mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-31 03:22:19 +03:00
Compare commits
1 Commits
chore/1214
...
chore/1214
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b7c51321 |
@@ -961,57 +961,11 @@
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CliProfileAutoSyncToggles.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx": {
|
||||
@@ -1019,50 +973,14 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/GrokBuildToolCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx": {
|
||||
@@ -1298,21 +1216,64 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
|
||||
"react-hooks/refs": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderCcAliasSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportCodexAuthModal.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1331,11 +1292,57 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/hooks/useRiskAcknowledged.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/services/components/CliproxyAccountHealthCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/services/components/DarioAccountPanel.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/utils/buildCurl.ts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components";
|
||||
import { MITM_TOOL_HOSTS } from "@/shared/constants/mitmToolHosts";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -49,22 +49,12 @@ export default function AntigravityToolCard({
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to config files.
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKeyId) {
|
||||
setSelectedApiKeyId(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKeyId]);
|
||||
// resolve the real secret from DB before writing to config files. Default to
|
||||
// the first available key while the user hasn't picked one — derived during
|
||||
// render instead of synced through an effect (react-hooks/set-state-in-effect).
|
||||
const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
fetchStatus();
|
||||
loadSavedMappings();
|
||||
fetchModelAliases();
|
||||
}
|
||||
}, [isExpanded, status]);
|
||||
|
||||
const loadSavedMappings = async () => {
|
||||
const loadSavedMappings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`);
|
||||
if (res.ok) {
|
||||
@@ -78,9 +68,9 @@ export default function AntigravityToolCard({
|
||||
} catch (error) {
|
||||
console.log("Error loading saved mappings:", error);
|
||||
}
|
||||
};
|
||||
}, [tool.id]);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/antigravity-mitm");
|
||||
if (res.ok) {
|
||||
@@ -91,9 +81,9 @@ export default function AntigravityToolCard({
|
||||
console.log("Error fetching status:", error);
|
||||
setStatus({ running: false });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
@@ -101,7 +91,16 @@ export default function AntigravityToolCard({
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isExpanded && !status)) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([fetchStatus(), loadSavedMappings(), fetchModelAliases()]);
|
||||
})();
|
||||
}, [isExpanded, status, fetchStatus, loadSavedMappings, fetchModelAliases]);
|
||||
|
||||
// MITM elevation is decided by the *server* OS, not by this browser's user
|
||||
// agent. The server reports `isWin` and `needsSudoPassword` in GET status —
|
||||
@@ -135,7 +134,7 @@ export default function AntigravityToolCard({
|
||||
try {
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
const selectedKeyId =
|
||||
selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
effectiveApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
|
||||
const res = await fetch("/api/cli-tools/antigravity-mitm", {
|
||||
method: "POST",
|
||||
@@ -345,7 +344,7 @@ export default function AntigravityToolCard({
|
||||
</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
value={effectiveApiKeyId}
|
||||
onChange={(e) => setSelectedApiKeyId(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
|
||||
@@ -50,7 +50,11 @@ export default function ClaudeClassifierCompatToggle() {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await load();
|
||||
})();
|
||||
}, [load]);
|
||||
|
||||
const cycle = useCallback(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
@@ -64,23 +64,13 @@ export default function ClaudeToolCard({
|
||||
// Use batch status as fallback when card hasn't been expanded yet
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to settings.json.
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to settings.json. Default to
|
||||
// the first available key while the user hasn't picked one — derived during
|
||||
// render instead of synced through an effect (react-hooks/set-state-in-effect).
|
||||
const effectiveApiKey = selectedApiKey || (apiKeys?.length > 0 ? apiKeys[0].id : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !claudeStatus) {
|
||||
checkClaudeStatus();
|
||||
fetchModelAliases();
|
||||
fetchBackups();
|
||||
}
|
||||
}, [isExpanded, claudeStatus]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
@@ -88,46 +78,66 @@ export default function ClaudeToolCard({
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (claudeStatus?.installed && !hasInitializedModels.current) {
|
||||
hasInitializedModels.current = true;
|
||||
const env = claudeStatus.settings?.env || {};
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
if (model.envKey) {
|
||||
const value = env[model.envKey] || model.defaultValue || "";
|
||||
// Only sync initial values from file once
|
||||
if (value) {
|
||||
onModelMappingChange(model.alias, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Restore selected key from file: match token stored in file against known keys
|
||||
const tokenFromFile = getStoredClaudeAuthValue(env);
|
||||
if (tokenFromFile) {
|
||||
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
|
||||
// Mask the token from file to compare against the masked list.
|
||||
const maskedToken = tokenFromFile.slice(0, 8) + "****" + tokenFromFile.slice(-4);
|
||||
const matchedKey = apiKeys?.find((k) => k.key === maskedToken);
|
||||
if (matchedKey) setSelectedApiKey(matchedKey.id);
|
||||
}
|
||||
// ── Backups ──
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=claude");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
}, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]);
|
||||
}, []);
|
||||
|
||||
const checkClaudeStatus = async () => {
|
||||
const checkClaudeStatus = useCallback(async () => {
|
||||
setCheckingClaude(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/claude-settings");
|
||||
const data = await res.json();
|
||||
setClaudeStatus(data);
|
||||
// One-time form initialization from the settings file, right after the
|
||||
// fetch resolves (was a separate claudeStatus effect — moved here so no
|
||||
// setState runs synchronously inside an effect body).
|
||||
if (data?.installed && !hasInitializedModels.current) {
|
||||
hasInitializedModels.current = true;
|
||||
const env = data.settings?.env || {};
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
if (model.envKey) {
|
||||
const value = env[model.envKey] || model.defaultValue || "";
|
||||
// Only sync initial values from file once
|
||||
if (value) {
|
||||
onModelMappingChange(model.alias, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Restore selected key from file: match token stored in file against known keys
|
||||
const tokenFromFile = getStoredClaudeAuthValue(env);
|
||||
if (tokenFromFile) {
|
||||
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
|
||||
// Mask the token from file to compare against the masked list.
|
||||
const maskedToken = tokenFromFile.slice(0, 8) + "****" + tokenFromFile.slice(-4);
|
||||
const matchedKey = apiKeys?.find((k) => k.key === maskedToken);
|
||||
if (matchedKey) setSelectedApiKey(matchedKey.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setClaudeStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingClaude(false);
|
||||
}
|
||||
};
|
||||
}, [apiKeys, tool.defaultModels, onModelMappingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isExpanded && !claudeStatus)) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([checkClaudeStatus(), fetchModelAliases(), fetchBackups()]);
|
||||
})();
|
||||
}, [isExpanded, claudeStatus, checkClaudeStatus, fetchModelAliases, fetchBackups]);
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
@@ -148,7 +158,7 @@ export default function ClaudeToolCard({
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
// If no key is selected, leave auth unset so local installs can rely on
|
||||
// anonymous access instead of persisting a fake placeholder token.
|
||||
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
const selectedKeyId = effectiveApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias] || model.defaultValue || "";
|
||||
@@ -225,7 +235,7 @@ export default function ClaudeToolCard({
|
||||
// Generate settings.json content for manual copy
|
||||
const getManualConfigs = () => {
|
||||
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
|
||||
if (selectedApiKey && selectedApiKey.trim()) {
|
||||
if (effectiveApiKey && effectiveApiKey.trim()) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
|
||||
} else if (cloudEnabled) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
|
||||
@@ -244,17 +254,6 @@ export default function ClaudeToolCard({
|
||||
];
|
||||
};
|
||||
|
||||
// ── Backups ──
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=claude");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async (backupId) => {
|
||||
setRestoringBackup(backupId);
|
||||
setMessage(null);
|
||||
@@ -436,7 +435,7 @@ export default function ClaudeToolCard({
|
||||
</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
value={effectiveApiKey}
|
||||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
|
||||
@@ -45,7 +45,11 @@ export default function CliProfileAutoSyncToggles() {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await load();
|
||||
})();
|
||||
}, [load]);
|
||||
|
||||
const persist = useCallback(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
@@ -56,32 +56,12 @@ export default function ClineToolCard({
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to config files.
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKeyId) {
|
||||
setSelectedApiKeyId(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKeyId]);
|
||||
// resolve the real secret from DB before writing to config files. Default to
|
||||
// the first available key while the user hasn't picked one — derived during
|
||||
// render instead of synced through an effect (react-hooks/set-state-in-effect).
|
||||
const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !clineStatus) {
|
||||
checkClineStatus();
|
||||
fetchModelAliases();
|
||||
fetchBackups();
|
||||
}
|
||||
}, [isExpanded, clineStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (clineStatus?.settings && !hasInitializedModel.current) {
|
||||
const currentModel = clineStatus.settings.openAiModelId;
|
||||
if (currentModel) {
|
||||
setSelectedModel(currentModel);
|
||||
hasInitializedModel.current = true;
|
||||
}
|
||||
}
|
||||
}, [clineStatus]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
if (res.ok) {
|
||||
@@ -91,9 +71,9 @@ export default function ClineToolCard({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchBackups = async () => {
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=cline");
|
||||
if (res.ok) {
|
||||
@@ -103,7 +83,39 @@ export default function ClineToolCard({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkClineStatus = useCallback(async () => {
|
||||
setCheckingCline(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/cline-settings");
|
||||
const data = await res.json();
|
||||
setClineStatus(data);
|
||||
// One-time model initialization from the settings file, right after the
|
||||
// fetch resolves (was a separate clineStatus effect — moved here so no
|
||||
// setState runs synchronously inside an effect body).
|
||||
if (data?.settings && !hasInitializedModel.current) {
|
||||
const currentModel = data.settings.openAiModelId;
|
||||
if (currentModel) {
|
||||
setSelectedModel(currentModel);
|
||||
hasInitializedModel.current = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setClineStatus({ error: error.message });
|
||||
} finally {
|
||||
setCheckingCline(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isExpanded && !clineStatus)) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([checkClineStatus(), fetchModelAliases(), fetchBackups()]);
|
||||
})();
|
||||
}, [isExpanded, clineStatus, checkClineStatus, fetchModelAliases, fetchBackups]);
|
||||
|
||||
const handleRestoreBackup = async (backupId) => {
|
||||
setRestoringBackup(backupId);
|
||||
@@ -133,19 +145,6 @@ export default function ClineToolCard({
|
||||
}
|
||||
};
|
||||
|
||||
const checkClineStatus = async () => {
|
||||
setCheckingCline(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/cline-settings");
|
||||
const data = await res.json();
|
||||
setClineStatus(data);
|
||||
} catch (error) {
|
||||
setClineStatus({ error: error.message });
|
||||
} finally {
|
||||
setCheckingCline(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
if (customBaseUrl) return customBaseUrl;
|
||||
return baseUrl || DEFAULT_DISPLAY_BASE_URL;
|
||||
@@ -161,7 +160,7 @@ export default function ClineToolCard({
|
||||
: `${effectiveBaseUrl}/v1`;
|
||||
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
const selectedKeyId = selectedApiKeyId?.trim() || null;
|
||||
const selectedKeyId = effectiveApiKeyId?.trim() || null;
|
||||
|
||||
const res = await fetch("/api/cli-tools/cline-settings", {
|
||||
method: "POST",
|
||||
@@ -365,7 +364,7 @@ export default function ClineToolCard({
|
||||
<label className="text-sm text-text-muted">{t("apiKey")}</label>
|
||||
{apiKeys && apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
value={effectiveApiKeyId}
|
||||
onChange={(e) => setSelectedApiKeyId(e.target.value)}
|
||||
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
@@ -483,7 +482,7 @@ export default function ClineToolCard({
|
||||
onApply: handleManualConfig,
|
||||
currentConfig: {
|
||||
model: selectedModel,
|
||||
apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "",
|
||||
apiKey: apiKeys?.find((k) => k.id === effectiveApiKeyId)?.key || "",
|
||||
baseUrl: customBaseUrl || baseUrl,
|
||||
},
|
||||
} as any)}
|
||||
|
||||
@@ -56,10 +56,12 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
fetchStatus();
|
||||
fetchUpdateInfo();
|
||||
}
|
||||
if (!isExpanded) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([fetchStatus(), fetchUpdateInfo()]);
|
||||
})();
|
||||
}, [isExpanded, fetchStatus, fetchUpdateInfo]);
|
||||
|
||||
const apiCall = async (action: string, body?: Record<string, unknown>) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -50,23 +50,13 @@ export default function CodexToolCard({
|
||||
const [restoringBackup, setRestoringBackup] = useState(null);
|
||||
const cliReady = !!(codexStatus?.installed && codexStatus?.runnable);
|
||||
|
||||
useEffect(() => {
|
||||
// Store the key *id* so the backend can resolve the real secret from DB
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
// Store the key *id* so the backend can resolve the real secret from DB.
|
||||
// Default to the first available key while the user hasn't picked one —
|
||||
// derived during render instead of synced through an effect
|
||||
// (react-hooks/set-state-in-effect).
|
||||
const effectiveApiKey = selectedApiKey || (apiKeys?.length > 0 ? apiKeys[0].id : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !codexStatus) {
|
||||
checkCodexStatus();
|
||||
fetchModelAliases();
|
||||
fetchProfiles();
|
||||
fetchBackups();
|
||||
}
|
||||
}, [isExpanded, codexStatus]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
@@ -74,26 +64,50 @@ export default function CodexToolCard({
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Parse config content
|
||||
useEffect(() => {
|
||||
if (codexStatus && !codexStatus.config) {
|
||||
// ── Profiles ──
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-profiles");
|
||||
const data = await res.json();
|
||||
if (res.ok) setProfiles(data.profiles || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching profiles:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Backups ──
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=codex");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Parse config content and sync the form fields from a freshly fetched
|
||||
// status (was a separate codexStatus effect — runs right after each fetch
|
||||
// instead so no setState happens synchronously inside an effect body).
|
||||
const syncFormFromStatus = useCallback((status) => {
|
||||
if (status && !status.config) {
|
||||
setWireApi("responses");
|
||||
}
|
||||
|
||||
if (codexStatus?.config) {
|
||||
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/im);
|
||||
if (status?.config) {
|
||||
const modelMatch = status.config.match(/^model\s*=\s*"([^"]+)"/im);
|
||||
if (modelMatch) setSelectedModel(modelMatch[1]);
|
||||
|
||||
const effortMatch = codexStatus.config.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/im);
|
||||
const effortMatch = status.config.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/im);
|
||||
if (effortMatch) setReasoningEffort(effortMatch[1]);
|
||||
|
||||
const wireMatch = codexStatus.config.match(/^wire_api\s*=\s*"([^"]+)"/im);
|
||||
const wireMatch = status.config.match(/^wire_api\s*=\s*"([^"]+)"/im);
|
||||
setWireApi(wireMatch?.[1] || "responses");
|
||||
|
||||
const newMappings: Record<string, string> = {};
|
||||
const migrationsBlock = codexStatus.config.split("[notice.model_migrations]")[1];
|
||||
const migrationsBlock = status.config.split("[notice.model_migrations]")[1];
|
||||
if (migrationsBlock) {
|
||||
const nextSectionIdx = migrationsBlock.indexOf("[");
|
||||
const chunk =
|
||||
@@ -106,7 +120,32 @@ export default function CodexToolCard({
|
||||
}
|
||||
setModelMappings(newMappings);
|
||||
}
|
||||
}, [codexStatus]);
|
||||
}, []);
|
||||
|
||||
const checkCodexStatus = useCallback(async () => {
|
||||
setCheckingCodex(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-settings");
|
||||
const data = await res.json();
|
||||
setCodexStatus(data);
|
||||
syncFormFromStatus(data);
|
||||
} catch (error) {
|
||||
const fallback = { installed: false, error: error.message };
|
||||
setCodexStatus(fallback);
|
||||
syncFormFromStatus(fallback);
|
||||
} finally {
|
||||
setCheckingCodex(false);
|
||||
}
|
||||
}, [syncFormFromStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isExpanded && !codexStatus)) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([checkCodexStatus(), fetchModelAliases(), fetchProfiles(), fetchBackups()]);
|
||||
})();
|
||||
}, [isExpanded, codexStatus, checkCodexStatus, fetchModelAliases, fetchProfiles, fetchBackups]);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!cliReady) return null;
|
||||
@@ -127,30 +166,17 @@ export default function CodexToolCard({
|
||||
|
||||
const getDisplayUrl = () => normalizeCodexBaseUrl(customBaseUrl || baseUrl, wireApi);
|
||||
|
||||
const checkCodexStatus = async () => {
|
||||
setCheckingCodex(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-settings");
|
||||
const data = await res.json();
|
||||
setCodexStatus(data);
|
||||
} catch (error) {
|
||||
setCodexStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingCodex(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Use sk_omniroute for localhost if no key, otherwise use selected key
|
||||
const keyToUse =
|
||||
selectedApiKey && selectedApiKey.trim()
|
||||
? selectedApiKey
|
||||
effectiveApiKey && effectiveApiKey.trim()
|
||||
? effectiveApiKey
|
||||
: !cloudEnabled
|
||||
? "sk_omniroute"
|
||||
: selectedApiKey;
|
||||
: effectiveApiKey;
|
||||
|
||||
// Send both apiKey (as fallback) and keyId to look up the unmasked string natively
|
||||
const res = await fetch("/api/cli-tools/codex-settings", {
|
||||
@@ -159,7 +185,7 @@ export default function CodexToolCard({
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
keyId: selectedApiKey,
|
||||
keyId: effectiveApiKey,
|
||||
model: selectedModel || CODEX_DEFAULT_MODELS[0],
|
||||
reasoningEffort,
|
||||
wireApi,
|
||||
@@ -222,17 +248,6 @@ export default function CodexToolCard({
|
||||
setModalTarget(null);
|
||||
};
|
||||
|
||||
// ── Profiles ──
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-profiles");
|
||||
const data = await res.json();
|
||||
if (res.ok) setProfiles(data.profiles || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching profiles:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!newProfileName.trim()) return;
|
||||
setSavingProfile(true);
|
||||
@@ -305,17 +320,6 @@ export default function CodexToolCard({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Backups ──
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=codex");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async (backupId) => {
|
||||
setRestoringBackup(backupId);
|
||||
setMessage(null);
|
||||
@@ -554,7 +558,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
|
||||
</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
value={effectiveApiKey}
|
||||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
@@ -715,7 +719,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
|
||||
onClick={handleApplySettings}
|
||||
disabled={isApplyDisabled({
|
||||
selectedModel,
|
||||
selectedApiKey,
|
||||
selectedApiKey: effectiveApiKey,
|
||||
cloudEnabled,
|
||||
apiKeys,
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,6 +9,9 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
// (#618) Match any custom:OmniRoute-<i> entry (multi-model).
|
||||
const isOmniRouteEntry = (m) => typeof m?.id === "string" && m.id.startsWith("custom:OmniRoute");
|
||||
|
||||
export default function DroidToolCard({
|
||||
tool,
|
||||
isExpanded = false,
|
||||
@@ -45,9 +48,6 @@ export default function DroidToolCard({
|
||||
const [restoringBackup, setRestoringBackup] = useState(null);
|
||||
const cliReady = !!(droidStatus?.installed && droidStatus?.runnable);
|
||||
|
||||
// (#618) Match any custom:OmniRoute-<i> entry (multi-model).
|
||||
const isOmniRouteEntry = (m) => typeof m?.id === "string" && m.id.startsWith("custom:OmniRoute");
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!cliReady) return null;
|
||||
const currentConfig = droidStatus.settings?.customModels?.find(isOmniRouteEntry);
|
||||
@@ -65,22 +65,12 @@ export default function DroidToolCard({
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to config files.
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKeyId) {
|
||||
setSelectedApiKeyId(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKeyId]);
|
||||
// resolve the real secret from DB before writing to config files. Default to
|
||||
// the first available key while the user hasn't picked one — derived during
|
||||
// render instead of synced through an effect (react-hooks/set-state-in-effect).
|
||||
const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !droidStatus) {
|
||||
checkDroidStatus();
|
||||
fetchModelAliases();
|
||||
fetchBackups();
|
||||
}
|
||||
}, [isExpanded, droidStatus]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
@@ -88,34 +78,67 @@ export default function DroidToolCard({
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (droidStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
// (#618) Pre-fill the multi-model list from every custom:OmniRoute-<i>
|
||||
// entry, preserving the original index order.
|
||||
const existing = (droidStatus.settings?.customModels || [])
|
||||
.filter(isOmniRouteEntry)
|
||||
.slice()
|
||||
.sort((a, b) => (a.index || 0) - (b.index || 0));
|
||||
if (existing.length > 0) {
|
||||
setModelList(existing.map((m) => m.model).filter(Boolean));
|
||||
const first = existing[0];
|
||||
// apiKey may be a structured secret reference (object) rather than a
|
||||
// plaintext string. Only match on strings.
|
||||
if (typeof first?.apiKey === "string" && first.apiKey) {
|
||||
// (#523) Keys from /api/keys are masked. Match by prefix/suffix.
|
||||
const fileKeyPrefix = first.apiKey.slice(0, 8);
|
||||
const fileKeySuffix = first.apiKey.slice(-4);
|
||||
const matchedKey = apiKeys?.find(
|
||||
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
|
||||
);
|
||||
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
|
||||
// ── Backups ──
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=droid");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkDroidStatus = useCallback(async () => {
|
||||
setCheckingDroid(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/droid-settings");
|
||||
const data = await res.json();
|
||||
setDroidStatus(data);
|
||||
// One-time form initialization from the settings file, right after the
|
||||
// fetch resolves (was a separate droidStatus effect — moved here so no
|
||||
// setState runs synchronously inside an effect body).
|
||||
if (data?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
// (#618) Pre-fill the multi-model list from every custom:OmniRoute-<i>
|
||||
// entry, preserving the original index order.
|
||||
const existing = (data.settings?.customModels || [])
|
||||
.filter(isOmniRouteEntry)
|
||||
.slice()
|
||||
.sort((a, b) => (a.index || 0) - (b.index || 0));
|
||||
if (existing.length > 0) {
|
||||
setModelList(existing.map((m) => m.model).filter(Boolean));
|
||||
const first = existing[0];
|
||||
// apiKey may be a structured secret reference (object) rather than a
|
||||
// plaintext string. Only match on strings.
|
||||
if (typeof first?.apiKey === "string" && first.apiKey) {
|
||||
// (#523) Keys from /api/keys are masked. Match by prefix/suffix.
|
||||
const fileKeyPrefix = first.apiKey.slice(0, 8);
|
||||
const fileKeySuffix = first.apiKey.slice(-4);
|
||||
const matchedKey = apiKeys?.find(
|
||||
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
|
||||
);
|
||||
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setDroidStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingDroid(false);
|
||||
}
|
||||
}, [droidStatus, apiKeys]);
|
||||
}, [apiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isExpanded && !droidStatus)) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([checkDroidStatus(), fetchModelAliases(), fetchBackups()]);
|
||||
})();
|
||||
}, [isExpanded, droidStatus, checkDroidStatus, fetchModelAliases, fetchBackups]);
|
||||
|
||||
// (#618) Multi-model list manipulation helpers.
|
||||
const addModel = (value) => {
|
||||
@@ -126,19 +149,6 @@ export default function DroidToolCard({
|
||||
};
|
||||
const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id));
|
||||
|
||||
const checkDroidStatus = async () => {
|
||||
setCheckingDroid(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/droid-settings");
|
||||
const data = await res.json();
|
||||
setDroidStatus(data);
|
||||
} catch (error) {
|
||||
setDroidStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingDroid(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
@@ -155,7 +165,7 @@ export default function DroidToolCard({
|
||||
try {
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
const selectedKeyId =
|
||||
selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
effectiveApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
|
||||
const res = await fetch("/api/cli-tools/droid-settings", {
|
||||
method: "POST",
|
||||
@@ -227,17 +237,6 @@ export default function DroidToolCard({
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
// ── Backups ──
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=droid");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async (backupId) => {
|
||||
setRestoringBackup(backupId);
|
||||
setMessage(null);
|
||||
@@ -269,7 +268,7 @@ export default function DroidToolCard({
|
||||
|
||||
const getManualConfigs = () => {
|
||||
// (#523) Look up the key object by id to get the masked display value.
|
||||
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
|
||||
const selectedKeyObj = apiKeys?.find((k) => k.id === effectiveApiKeyId);
|
||||
const keyToDisplay =
|
||||
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
@@ -415,7 +414,7 @@ export default function DroidToolCard({
|
||||
</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
value={effectiveApiKeyId}
|
||||
onChange={(e) => setSelectedApiKeyId(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
|
||||
@@ -135,9 +135,10 @@ export default function GrokBuildToolCard({
|
||||
const [showBackups, setShowBackups] = useState(false);
|
||||
const [restoringBackup, setRestoringBackup] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedKeyId && apiKeys[0]?.id) setSelectedKeyId(apiKeys[0].id);
|
||||
}, [apiKeys, selectedKeyId]);
|
||||
// Default to the first available key while the user hasn't picked one —
|
||||
// derived during render instead of synced through an effect
|
||||
// (react-hooks/set-state-in-effect).
|
||||
const effectiveKeyId = selectedKeyId || apiKeys[0]?.id || "";
|
||||
|
||||
const hydrateStatus = useCallback((next: GrokStatus) => {
|
||||
setStatus(next);
|
||||
@@ -219,7 +220,11 @@ export default function GrokBuildToolCard({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
void Promise.all([refreshStatus(), refreshEndpoints(), refreshBackups()]);
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([refreshStatus(), refreshEndpoints(), refreshBackups()]);
|
||||
})();
|
||||
}, [isExpanded, refreshBackups, refreshEndpoints, refreshStatus]);
|
||||
|
||||
const baseUrl = useMemo(() => {
|
||||
@@ -277,7 +282,7 @@ export default function GrokBuildToolCard({
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl,
|
||||
keyId: selectedKeyId || null,
|
||||
keyId: effectiveKeyId || null,
|
||||
model,
|
||||
contextWindow: selectedContext(model),
|
||||
subagentModels: Object.fromEntries(
|
||||
@@ -468,7 +473,7 @@ export default function GrokBuildToolCard({
|
||||
<select
|
||||
id="grok-build-api-key"
|
||||
className={inputClass}
|
||||
value={selectedKeyId}
|
||||
value={effectiveKeyId}
|
||||
onChange={(event) => setSelectedKeyId(event.target.value)}
|
||||
>
|
||||
<option value="">Use the OmniRoute default key</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button, ModelSelectModal } from "@/shared/components";
|
||||
|
||||
@@ -109,12 +109,14 @@ export default function HermesAgentToolCard({
|
||||
// those providers never surface in the Hermes Agent role picker (#7151).
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
|
||||
// Track whether we have already seeded from batchStatus on this expand
|
||||
const seededFromBatchRef = useRef(false);
|
||||
// Render-stable "now" snapshot for the relative-time chip — Date.now() is
|
||||
// impure during render (react-hooks/purity), so capture it once via a lazy
|
||||
// state initializer. Minute-level granularity makes the frozen value fine.
|
||||
const [nowTs] = useState(() => Date.now());
|
||||
|
||||
function formatTimeSince(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diff = Date.now() - then;
|
||||
const diff = nowTs - then;
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days > 0) return t("daysAgoShort", { count: days });
|
||||
@@ -143,37 +145,7 @@ export default function HermesAgentToolCard({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
// Reset seed flag when collapsed so it can seed again on next expand
|
||||
seededFromBatchRef.current = false;
|
||||
setPreviewYaml(null);
|
||||
setFirstSetupAt(null);
|
||||
return;
|
||||
}
|
||||
// Phase 3: Seed from detector snapshot (batchStatus) for instant UI — once per expand.
|
||||
// NOTE: currentRoles is intentionally NOT a dependency. loadCurrentConfig() below sets
|
||||
// currentRoles to a fresh object on every fetch; if currentRoles were a dep, the effect
|
||||
// would re-fire → refetch → setCurrentRoles → re-fire … an infinite loop. On the detail
|
||||
// page isExpanded is hardcoded true, so that loop spun forever (the "loading forever" +
|
||||
// console spam of /api/cli-tools/hermes-agent-settings). We read currentRoles only via a
|
||||
// functional update so the emptiness guard sees the latest value without subscribing to it.
|
||||
if (!seededFromBatchRef.current && batchStatus?.hermesAgentRoles) {
|
||||
seededFromBatchRef.current = true;
|
||||
setCurrentRoles((prev) => {
|
||||
if (Object.keys(prev).length > 0) return prev;
|
||||
const seeded: Record<string, any> = {};
|
||||
Object.entries(batchStatus.hermesAgentRoles).forEach(([role, info]: [string, any]) => {
|
||||
seeded[role] = { model: info.model, provider: info.provider };
|
||||
});
|
||||
return seeded;
|
||||
});
|
||||
}
|
||||
loadCurrentConfig();
|
||||
fetchModelAliases();
|
||||
}, [isExpanded, batchStatus, loadCurrentConfig]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
@@ -181,6 +153,40 @@ export default function HermesAgentToolCard({
|
||||
} catch (error) {
|
||||
console.warn("Error fetching model aliases:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([loadCurrentConfig(), fetchModelAliases()]);
|
||||
})();
|
||||
}, [isExpanded, loadCurrentConfig, fetchModelAliases]);
|
||||
|
||||
// Phase 3: seed the visible role data from the detector snapshot
|
||||
// (batchStatus) for instant UI while /api/cli-tools/hermes-agent-settings is
|
||||
// in flight — derived during render instead of copied into state
|
||||
// (react-hooks/set-state-in-effect). Freshly loaded roles always win once
|
||||
// loadCurrentConfig() resolves and populates currentRoles.
|
||||
const seededRoles = useMemo(() => {
|
||||
const seeded: Record<string, any> = {};
|
||||
Object.entries(batchStatus?.hermesAgentRoles || {}).forEach(([role, info]: [string, any]) => {
|
||||
seeded[role] = { model: info.model, provider: info.provider };
|
||||
});
|
||||
return seeded;
|
||||
}, [batchStatus]);
|
||||
const displayRoles = Object.keys(currentRoles).length > 0 ? currentRoles : seededRoles;
|
||||
|
||||
const handleToggle = () => {
|
||||
// Collapsing: drop the stale preview and setup timestamp (was done by a
|
||||
// collapse effect — moved into the toggle handler so no setState runs
|
||||
// synchronously inside an effect body).
|
||||
if (isExpanded) {
|
||||
setPreviewYaml(null);
|
||||
setFirstSetupAt(null);
|
||||
}
|
||||
onToggle();
|
||||
};
|
||||
|
||||
const setRoleSelection = (roleId: string, model: string, provider = "OmniRoute") => {
|
||||
@@ -211,7 +217,7 @@ export default function HermesAgentToolCard({
|
||||
model: sel.model,
|
||||
}));
|
||||
} else {
|
||||
payloadSelections = Object.entries(currentRoles)
|
||||
payloadSelections = Object.entries(displayRoles)
|
||||
.filter(([_, info]) => info && info.model)
|
||||
.map(([role, info]) => ({ role, model: info.model }));
|
||||
}
|
||||
@@ -334,7 +340,10 @@ export default function HermesAgentToolCard({
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
{/* Collapsed header — exact match to OpenClaw / Kilo / other Auto-Configured entries */}
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div
|
||||
className="flex items-center justify-between hover:cursor-pointer"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<span className="material-symbols-outlined text-[22px] text-text-muted">terminal</span>
|
||||
@@ -355,9 +364,7 @@ export default function HermesAgentToolCard({
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{(Object.keys(currentRoles).length > 0 ||
|
||||
Object.keys(selections).length > 0 ||
|
||||
Object.keys(batchStatus?.hermesAgentRoles || {}).length > 0) && (
|
||||
{(Object.keys(displayRoles).length > 0 || Object.keys(selections).length > 0) && (
|
||||
<span className="text-[10px] px-1.5 py-px rounded bg-emerald-500/10 text-emerald-600">
|
||||
{t("hermesConfiguredRoles", {
|
||||
configured: configuredRolesCount,
|
||||
@@ -416,7 +423,7 @@ export default function HermesAgentToolCard({
|
||||
{/* Roles list — flat consistent rows (no nested Card.Section boxes) */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{HERMES_ROLES.map((role) => {
|
||||
const current = currentRoles[role.id];
|
||||
const current = displayRoles[role.id];
|
||||
const sel = selections[role.id];
|
||||
|
||||
// displayed model prefers pending user choice, falls back to real current from YAML
|
||||
@@ -550,7 +557,7 @@ export default function HermesAgentToolCard({
|
||||
disabled={
|
||||
isSaving ||
|
||||
isLoading ||
|
||||
(Object.keys(selections).length === 0 && Object.keys(currentRoles).length === 0)
|
||||
(Object.keys(selections).length === 0 && Object.keys(displayRoles).length === 0)
|
||||
}
|
||||
loading={isPreviewLoading}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
@@ -52,22 +52,12 @@ export default function KiloToolCard({
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to config files.
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKeyId) {
|
||||
setSelectedApiKeyId(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKeyId]);
|
||||
// resolve the real secret from DB before writing to config files. Default to
|
||||
// the first available key while the user hasn't picked one — derived during
|
||||
// render instead of synced through an effect (react-hooks/set-state-in-effect).
|
||||
const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !kiloStatus) {
|
||||
checkKiloStatus();
|
||||
fetchModelAliases();
|
||||
fetchBackups();
|
||||
}
|
||||
}, [isExpanded, kiloStatus]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
if (res.ok) {
|
||||
@@ -77,9 +67,9 @@ export default function KiloToolCard({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchBackups = async () => {
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=kilo");
|
||||
if (res.ok) {
|
||||
@@ -89,7 +79,29 @@ export default function KiloToolCard({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkKiloStatus = useCallback(async () => {
|
||||
setCheckingKilo(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/kilo-settings");
|
||||
const data = await res.json();
|
||||
setKiloStatus(data);
|
||||
} catch (error) {
|
||||
setKiloStatus({ error: error.message });
|
||||
} finally {
|
||||
setCheckingKilo(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isExpanded && !kiloStatus)) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([checkKiloStatus(), fetchModelAliases(), fetchBackups()]);
|
||||
})();
|
||||
}, [isExpanded, kiloStatus, checkKiloStatus, fetchModelAliases, fetchBackups]);
|
||||
|
||||
const handleRestoreBackup = async (backupId) => {
|
||||
setRestoringBackup(backupId);
|
||||
@@ -119,19 +131,6 @@ export default function KiloToolCard({
|
||||
}
|
||||
};
|
||||
|
||||
const checkKiloStatus = async () => {
|
||||
setCheckingKilo(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/kilo-settings");
|
||||
const data = await res.json();
|
||||
setKiloStatus(data);
|
||||
} catch (error) {
|
||||
setKiloStatus({ error: error.message });
|
||||
} finally {
|
||||
setCheckingKilo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
if (customBaseUrl) return customBaseUrl;
|
||||
return baseUrl || DEFAULT_DISPLAY_BASE_URL;
|
||||
@@ -147,7 +146,7 @@ export default function KiloToolCard({
|
||||
: `${effectiveBaseUrl}/v1`;
|
||||
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
const selectedKeyId = selectedApiKeyId?.trim() || null;
|
||||
const selectedKeyId = effectiveApiKeyId?.trim() || null;
|
||||
|
||||
const res = await fetch("/api/cli-tools/kilo-settings", {
|
||||
method: "POST",
|
||||
@@ -367,7 +366,7 @@ export default function KiloToolCard({
|
||||
<label className="text-sm text-text-muted">{t("apiKey")}</label>
|
||||
{apiKeys && apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
value={effectiveApiKeyId}
|
||||
onChange={(e) => setSelectedApiKeyId(e.target.value)}
|
||||
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
@@ -485,7 +484,7 @@ export default function KiloToolCard({
|
||||
onApply: handleManualConfig,
|
||||
currentConfig: {
|
||||
model: selectedModel,
|
||||
apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "",
|
||||
apiKey: apiKeys?.find((k) => k.id === effectiveApiKeyId)?.key || "",
|
||||
baseUrl: customBaseUrl || baseUrl,
|
||||
},
|
||||
} as any)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
@@ -57,22 +57,12 @@ export default function OpenClawToolCard({
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to config files.
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKeyId) {
|
||||
setSelectedApiKeyId(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKeyId]);
|
||||
// resolve the real secret from DB before writing to config files. Default to
|
||||
// the first available key while the user hasn't picked one — derived during
|
||||
// render instead of synced through an effect (react-hooks/set-state-in-effect).
|
||||
const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !openclawStatus) {
|
||||
checkOpenclawStatus();
|
||||
fetchModelAliases();
|
||||
fetchBackups();
|
||||
}
|
||||
}, [isExpanded, openclawStatus]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
const fetchModelAliases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
@@ -80,46 +70,66 @@ export default function OpenClawToolCard({
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (openclawStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const provider = openclawStatus.settings?.models?.providers?.["omniroute"];
|
||||
if (provider) {
|
||||
const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary;
|
||||
if (primaryModel) {
|
||||
const modelId = primaryModel.replace("omniroute/", "");
|
||||
setSelectedModel(modelId);
|
||||
}
|
||||
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
|
||||
// Match by prefix/suffix instead of exact comparison.
|
||||
// apiKey may be a structured secret reference (object) rather than a
|
||||
// plaintext string, e.g. OpenClaw SecretRefs. Only match on strings.
|
||||
if (typeof provider.apiKey === "string" && provider.apiKey) {
|
||||
const fileKeyPrefix = provider.apiKey.slice(0, 8);
|
||||
const fileKeySuffix = provider.apiKey.slice(-4);
|
||||
const matchedKey = apiKeys?.find(
|
||||
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
|
||||
);
|
||||
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
|
||||
}
|
||||
}
|
||||
// ── Backups ──
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=openclaw");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
}, [openclawStatus, apiKeys]);
|
||||
}, []);
|
||||
|
||||
const checkOpenclawStatus = async () => {
|
||||
const checkOpenclawStatus = useCallback(async () => {
|
||||
setCheckingOpenclaw(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/openclaw-settings");
|
||||
const data = await res.json();
|
||||
setOpenclawStatus(data);
|
||||
// One-time form initialization from the settings file, right after the
|
||||
// fetch resolves (was a separate openclawStatus effect — moved here so
|
||||
// no setState runs synchronously inside an effect body).
|
||||
if (data?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const provider = data.settings?.models?.providers?.["omniroute"];
|
||||
if (provider) {
|
||||
const primaryModel = data.settings?.agents?.defaults?.model?.primary;
|
||||
if (primaryModel) {
|
||||
const modelId = primaryModel.replace("omniroute/", "");
|
||||
setSelectedModel(modelId);
|
||||
}
|
||||
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
|
||||
// Match by prefix/suffix instead of exact comparison.
|
||||
// apiKey may be a structured secret reference (object) rather than a
|
||||
// plaintext string, e.g. OpenClaw SecretRefs. Only match on strings.
|
||||
if (typeof provider.apiKey === "string" && provider.apiKey) {
|
||||
const fileKeyPrefix = provider.apiKey.slice(0, 8);
|
||||
const fileKeySuffix = provider.apiKey.slice(-4);
|
||||
const matchedKey = apiKeys?.find(
|
||||
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
|
||||
);
|
||||
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setOpenclawStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingOpenclaw(false);
|
||||
}
|
||||
};
|
||||
}, [apiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isExpanded && !openclawStatus)) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([checkOpenclawStatus(), fetchModelAliases(), fetchBackups()]);
|
||||
})();
|
||||
}, [isExpanded, openclawStatus, checkOpenclawStatus, fetchModelAliases, fetchBackups]);
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
@@ -137,7 +147,7 @@ export default function OpenClawToolCard({
|
||||
try {
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
const selectedKeyId =
|
||||
selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
effectiveApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
|
||||
const res = await fetch("/api/cli-tools/openclaw-settings", {
|
||||
method: "POST",
|
||||
@@ -199,17 +209,6 @@ export default function OpenClawToolCard({
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
// ── Backups ──
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?tool=openclaw");
|
||||
const data = await res.json();
|
||||
if (res.ok) setBackups(data.backups || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching backups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async (backupId) => {
|
||||
setRestoringBackup(backupId);
|
||||
setMessage(null);
|
||||
@@ -241,7 +240,7 @@ export default function OpenClawToolCard({
|
||||
|
||||
const getManualConfigs = () => {
|
||||
// (#523) Look up the key object by id to get the masked display value.
|
||||
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
|
||||
const selectedKeyObj = apiKeys?.find((k) => k.id === effectiveApiKeyId);
|
||||
const keyToDisplay =
|
||||
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
@@ -408,7 +407,7 @@ export default function OpenClawToolCard({
|
||||
</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
value={effectiveApiKeyId}
|
||||
onChange={(e) => setSelectedApiKeyId(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
|
||||
@@ -91,25 +91,6 @@ function parseContextWindowOverrideInput(raw: string): { value: number | null; i
|
||||
return { value: Number(trimmed), invalid: false };
|
||||
}
|
||||
|
||||
// Fetch + parse extracted from the component so errors surface as a return
|
||||
// value (logged here) instead of state writes inside catch/finally blocks —
|
||||
// the load callback then only sets state after the await, which lets the
|
||||
// mount effect call it without a synchronous setState.
|
||||
async function fetchProviderModelsPayload(providerId: string): Promise<{
|
||||
models: CompatModelRow[];
|
||||
overrides: Array<CompatModelRow & { id: string }>;
|
||||
} | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return { models: data.models || [], overrides: data.modelCompatOverrides || [] };
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch custom models:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -160,28 +141,23 @@ export default function CustomModelsSection({
|
||||
const syncedModelIdSet = useMemo(() => new Set(syncedModelIds), [syncedModelIds]);
|
||||
|
||||
const fetchCustomModels = useCallback(async () => {
|
||||
const payload = await fetchProviderModelsPayload(providerId);
|
||||
if (payload) {
|
||||
setCustomModels(payload.models);
|
||||
setModelCompatOverrides(payload.overrides);
|
||||
try {
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCustomModels(data.models || []);
|
||||
setModelCompatOverrides(data.modelCompatOverrides || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch custom models:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [providerId]);
|
||||
|
||||
// Initial load: the async work is defined INSIDE the effect (calling the
|
||||
// component-scope fetchCustomModels callback synchronously from an effect is
|
||||
// rejected by the compiler rules); every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const payload = await fetchProviderModelsPayload(providerId);
|
||||
if (payload) {
|
||||
setCustomModels(payload.models);
|
||||
setModelCompatOverrides(payload.overrides);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
}, [providerId]);
|
||||
fetchCustomModels();
|
||||
}, [fetchCustomModels]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newModelId.trim() || adding) return;
|
||||
@@ -564,8 +540,8 @@ export default function CustomModelsSection({
|
||||
FREE
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
|
||||
@@ -168,10 +168,8 @@ export default function ModelCompatPopover({
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const headerRowIdRef = useRef(0);
|
||||
// Mirror of headerRows, kept in sync by applyHeaderRows below (every state
|
||||
// write goes through it), so blur/close commits read the freshest rows
|
||||
// without touching the ref during render.
|
||||
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
|
||||
headerRowsRef.current = headerRows;
|
||||
|
||||
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
|
||||
// latest typed values instead of the values captured when the handler was created (#8910).
|
||||
@@ -192,18 +190,13 @@ export default function ModelCompatPopover({
|
||||
providerId,
|
||||
modelId,
|
||||
});
|
||||
// Mirrored in an effect (never during render): the ref is only read from
|
||||
// event handlers and the save path, which run after this effect committed.
|
||||
useEffect(() => {
|
||||
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
|
||||
}, [paramTargetKey, providerId, modelId]);
|
||||
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
|
||||
|
||||
// Mirrors of the displayed text, so an edit can snapshot both fields
|
||||
// synchronously. applyParamFields below is the ONLY writer of
|
||||
// blockText/allowText and keeps these refs in sync itself, so no render-time
|
||||
// mirroring is needed (and none is allowed by the compiler's refs rule).
|
||||
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
|
||||
const blockTextRef = useRef("");
|
||||
const allowTextRef = useRef("");
|
||||
blockTextRef.current = blockText;
|
||||
allowTextRef.current = allowText;
|
||||
// Which target the values currently in the fields belong to. Guards the invariant that
|
||||
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
|
||||
// displayed — the desync that let one model's server values be saved under another (#8910).
|
||||
@@ -280,38 +273,14 @@ export default function ModelCompatPopover({
|
||||
};
|
||||
}, [open, tryCommitHeaderRows]);
|
||||
|
||||
// Rows (re)load from the parent when the popover opens or the protocol
|
||||
// switches — both user gestures — so the load lives in those handlers
|
||||
// (handleToggleOpen / handleProtocolChange) instead of an effect. This keeps
|
||||
// the old guarantee: a new inline parent callback on a re-render never wipes
|
||||
// in-progress edits.
|
||||
const applyHeaderRows = (rows: HeaderDraftRow[]) => {
|
||||
headerRowsRef.current = rows;
|
||||
setHeaderRows(rows);
|
||||
};
|
||||
|
||||
const loadHeaderRowsFor = (nextProtocol: string) => {
|
||||
const rec = getUpstreamHeadersRecord(nextProtocol);
|
||||
applyHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
|
||||
};
|
||||
|
||||
const resetValueVisibility = () => {
|
||||
setValuePeekRowId(null);
|
||||
setValueFocusRowId(null);
|
||||
};
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
resetValueVisibility();
|
||||
if (next) loadHeaderRowsFor(protocol);
|
||||
};
|
||||
|
||||
const handleProtocolChange = (nextProtocol: string) => {
|
||||
setProtocol(nextProtocol);
|
||||
resetValueVisibility();
|
||||
if (open) loadHeaderRowsFor(nextProtocol);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const rec = getUpstreamHeadersRecord(protocol);
|
||||
setHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
|
||||
// Only re-load rows when opening or switching protocol — not when the parent passes a new
|
||||
// inline callback every render (would wipe in-progress edits).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
|
||||
}, [open, protocol]);
|
||||
|
||||
// Load model-level block/allow from param-filters API
|
||||
useEffect(() => {
|
||||
@@ -451,23 +420,30 @@ export default function ModelCompatPopover({
|
||||
};
|
||||
}, [open, paramTargetKey, saveModelParamFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
setValuePeekRowId(null);
|
||||
setValueFocusRowId(null);
|
||||
}, [open, protocol]);
|
||||
|
||||
const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length;
|
||||
const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX;
|
||||
|
||||
const updateHeaderRow = (id: string, patch: Partial<Pick<HeaderDraftRow, "name" | "value">>) => {
|
||||
applyHeaderRows(headerRowsRef.current.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const addHeaderRow = () => {
|
||||
if (!canAddHeaderRow) return;
|
||||
applyHeaderRows([...headerRowsRef.current, { id: genHeaderRowId(), name: "", value: "" }]);
|
||||
setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]);
|
||||
};
|
||||
|
||||
const removeHeaderRow = (id: string) => {
|
||||
const next = headerRowsRef.current.filter((r) => r.id !== id);
|
||||
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
|
||||
applyHeaderRows(normalized);
|
||||
queueMicrotask(() => tryCommitHeaderRows(normalized));
|
||||
setHeaderRows((prev) => {
|
||||
const next = prev.filter((r) => r.id !== id);
|
||||
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
|
||||
queueMicrotask(() => tryCommitHeaderRows(normalized));
|
||||
return normalized;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -476,11 +452,7 @@ export default function ModelCompatPopover({
|
||||
const target = e.target as Node;
|
||||
const insideTrigger = ref.current?.contains(target);
|
||||
const insidePanel = panelRef.current?.contains(target);
|
||||
if (!insideTrigger && !insidePanel) {
|
||||
setOpen(false);
|
||||
setValuePeekRowId(null);
|
||||
setValueFocusRowId(null);
|
||||
}
|
||||
if (!insideTrigger && !insidePanel) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
return () => document.removeEventListener("mousedown", onDocClick);
|
||||
@@ -506,10 +478,10 @@ export default function ModelCompatPopover({
|
||||
}, [open]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// No rect reset on close: the portal render is gated on `open`, and
|
||||
// reopening recomputes the rect below before the browser paints, so a
|
||||
// stale rect is never visible.
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
setPortalPanelRect(null);
|
||||
return;
|
||||
}
|
||||
updatePortalPanelRect();
|
||||
window.addEventListener("resize", updatePortalPanelRect);
|
||||
window.addEventListener("scroll", updatePortalPanelRect, true);
|
||||
@@ -526,7 +498,7 @@ export default function ModelCompatPopover({
|
||||
<div className="relative inline-flex" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleOpen}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
|
||||
title={t("compatAdjustmentsTitle")}
|
||||
@@ -563,7 +535,7 @@ export default function ModelCompatPopover({
|
||||
</label>
|
||||
<select
|
||||
value={protocol}
|
||||
onChange={(e) => handleProtocolChange(e.target.value)}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="mb-4 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-2 text-xs text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
||||
>
|
||||
|
||||
@@ -77,47 +77,31 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Wraps the GET so a failure comes back as a value: the load callback then only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchCcAliasStateSafe(
|
||||
providerId: string
|
||||
): Promise<{ ok: boolean; state?: CcAliasState; error?: string }> {
|
||||
try {
|
||||
return { ok: true, state: await fetchCcAliasState(providerId) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the provider's alias settings once and reports a load failure to the operator. */
|
||||
function useCcAliasData(providerId: string, t: ProviderMessageTranslator) {
|
||||
const notify = useNotificationStore();
|
||||
const [state, setState] = useState<CcAliasState>(DEFAULT_STATE);
|
||||
// Loading is derived: true until a load attempt for the CURRENT provider
|
||||
// settles — this also re-shows the skeleton when providerId changes.
|
||||
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
|
||||
const loading = loadedProviderId !== providerId;
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// The async work is defined INSIDE the effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const outcome = await fetchCcAliasStateSafe(providerId);
|
||||
if (outcome.ok) {
|
||||
setState(outcome.state);
|
||||
} else {
|
||||
notify.error(
|
||||
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
|
||||
error: outcome.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
setLoadedProviderId(providerId);
|
||||
};
|
||||
void run();
|
||||
const loadState = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setState(await fetchCcAliasState(providerId));
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
|
||||
error: errorMessage(err),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadState();
|
||||
}, [loadState]);
|
||||
|
||||
return { state, setState, loading };
|
||||
}
|
||||
|
||||
|
||||
@@ -63,43 +63,27 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Wraps the GET so a failure comes back as a value: the load callback then only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchInterceptionTogglesSafe(
|
||||
providerId: string
|
||||
): Promise<{ ok: boolean; toggles?: InterceptionToggles; error?: string }> {
|
||||
try {
|
||||
return { ok: true, toggles: await fetchInterceptionToggles(providerId) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
function useProviderInterceptionToggles(providerId: string, t: Translate) {
|
||||
const notify = useNotificationStore();
|
||||
const [toggles, setToggles] = useState<InterceptionToggles>(DEFAULT_TOGGLES);
|
||||
// Loading is derived: true until a load attempt for the CURRENT provider
|
||||
// settles — this also re-shows the skeleton when providerId changes.
|
||||
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
|
||||
const loading = loadedProviderId !== providerId;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<keyof InterceptionToggles | null>(null);
|
||||
|
||||
// The async work is defined INSIDE the effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const outcome = await fetchInterceptionTogglesSafe(providerId);
|
||||
if (outcome.ok) {
|
||||
setToggles(outcome.toggles);
|
||||
} else {
|
||||
notify.error(t("interceptionLoadError", { error: outcome.error }));
|
||||
}
|
||||
setLoadedProviderId(providerId);
|
||||
};
|
||||
void run();
|
||||
const loadToggles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setToggles(await fetchInterceptionToggles(providerId));
|
||||
} catch (err) {
|
||||
notify.error(t("interceptionLoadError", { error: errorMessage(err) }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadToggles();
|
||||
}, [loadToggles]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (key: keyof InterceptionToggles, value: boolean) => {
|
||||
const next = { ...toggles, [key]: value };
|
||||
@@ -146,7 +130,9 @@ export default function ProviderInterceptionSection({
|
||||
<h2 className="text-base font-semibold text-text-main mb-1">
|
||||
{t("interceptionSectionTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mb-4 leading-relaxed">{t("interceptionSectionHint")}</p>
|
||||
<p className="text-xs text-text-muted mb-4 leading-relaxed">
|
||||
{t("interceptionSectionHint")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Toggle
|
||||
size="sm"
|
||||
|
||||
@@ -74,18 +74,6 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Wraps the GET so a failure comes back as a value: the load callback then only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchParamFilterConfigSafe(
|
||||
providerId: string
|
||||
): Promise<{ ok: boolean; config?: ParamFilterConfig; error?: string }> {
|
||||
try {
|
||||
return { ok: true, config: await fetchParamFilterConfig(providerId) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State hook — owns config load/save/reset so the component body stays JSX-only.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -107,10 +95,7 @@ function useDirtySetter<T>(setValue: (value: T) => void, setDirty: (value: boole
|
||||
function useProviderParamFilterConfig(providerId: string, t: Translate) {
|
||||
const notify = useNotificationStore();
|
||||
const [, setConfig] = useState<ParamFilterConfig>({ block: [], allow: [], autoLearn: false });
|
||||
// Loading is derived: true until a load attempt for the CURRENT provider
|
||||
// settles — this also re-shows the skeleton when providerId changes.
|
||||
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
|
||||
const loading = loadedProviderId !== providerId;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [blockText, setBlockTextState] = useState("");
|
||||
@@ -121,25 +106,25 @@ function useProviderParamFilterConfig(providerId: string, t: Translate) {
|
||||
const setAllowText = useDirtySetter(setAllowTextState, setDirty);
|
||||
const setAutoLearn = useDirtySetter(setAutoLearnState, setDirty);
|
||||
|
||||
// The async work is defined INSIDE the effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const outcome = await fetchParamFilterConfigSafe(providerId);
|
||||
if (outcome.ok) {
|
||||
setConfig(outcome.config);
|
||||
setBlockTextState(formatCommaList(outcome.config.block));
|
||||
setAllowTextState(formatCommaList(outcome.config.allow));
|
||||
setAutoLearnState(outcome.config.autoLearn);
|
||||
} else {
|
||||
notify.notify(t("paramFiltersLoadError", { error: outcome.error }), "error");
|
||||
}
|
||||
setLoadedProviderId(providerId);
|
||||
};
|
||||
void run();
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cfg = await fetchParamFilterConfig(providerId);
|
||||
setConfig(cfg);
|
||||
setBlockTextState(formatCommaList(cfg.block));
|
||||
setAllowTextState(formatCommaList(cfg.allow));
|
||||
setAutoLearnState(cfg.autoLearn);
|
||||
} catch (err) {
|
||||
notify.notify(t("paramFiltersLoadError", { error: errorMessage(err) }), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
|
||||
@@ -88,16 +88,7 @@ export default function VolcengineConnectModal({
|
||||
notify,
|
||||
t,
|
||||
}: VolcengineConnectModalProps) {
|
||||
// Prefilled from the last successful login via a lazy initializer — reading
|
||||
// localStorage inside the open effect required a synchronous setState there.
|
||||
const [phone, setPhone] = useState(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return localStorage.getItem(PHONE_STORAGE_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
const [phone, setPhone] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [session, setSession] = useState<SessionView | null>(null);
|
||||
@@ -108,12 +99,6 @@ export default function VolcengineConnectModal({
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Latest session, mirrored for the close/unmount cleanup below — that effect
|
||||
// only depends on isOpen, so reading the state directly would be stale.
|
||||
const sessionRef = useRef<SessionView | null>(null);
|
||||
useEffect(() => {
|
||||
sessionRef.current = session;
|
||||
}, [session]);
|
||||
|
||||
// ── lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -132,35 +117,22 @@ export default function VolcengineConnectModal({
|
||||
setResendCountdown(0);
|
||||
}, [stopTimers]);
|
||||
|
||||
// Leaving the modal cancels an in-flight session server-side and stops
|
||||
// polling. Runs as the cleanup of this open-scoped effect (no setState here).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
return () => {
|
||||
const current = sessionRef.current;
|
||||
const active = current && !isTerminal(current.phase) ? current : null;
|
||||
if (!isOpen) {
|
||||
// Leaving the modal cancels an in-flight session server-side.
|
||||
const active = session && !isTerminal(session.phase) ? session : null;
|
||||
if (active) {
|
||||
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
}).catch(() => {});
|
||||
}
|
||||
stopTimers();
|
||||
};
|
||||
}, [isOpen, stopTimers]);
|
||||
|
||||
// Local state reset on close — a render-phase adjustment guarded by the
|
||||
// previous isOpen value (react.dev "adjusting state when a prop changes")
|
||||
// instead of a synchronous setState inside an effect.
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSession(null);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
setResendCountdown(0);
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
|
||||
if (saved) setPhone(saved);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => stopTimers, [stopTimers]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
|
||||
@@ -62,23 +62,8 @@ export default function EditCompatibleNodeModal({
|
||||
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// Modal-open form initialization from the node being edited — applied as a
|
||||
// render-phase adjustment guarded by the previously initialized node
|
||||
// (react.dev "adjusting state when a prop changes") instead of a
|
||||
// synchronous-setState effect. Closing clears the marker so the next open
|
||||
// re-initializes again.
|
||||
const [initializedFor, setInitializedFor] = useState<{
|
||||
node: EditCompatibleNodeModalNode;
|
||||
isAnthropic?: boolean;
|
||||
isCcCompatible?: boolean;
|
||||
} | null>(null);
|
||||
if (isOpen && node) {
|
||||
if (
|
||||
initializedFor?.node !== node ||
|
||||
initializedFor.isAnthropic !== isAnthropic ||
|
||||
initializedFor.isCcCompatible !== isCcCompatible
|
||||
) {
|
||||
setInitializedFor({ node, isAnthropic, isCcCompatible });
|
||||
useEffect(() => {
|
||||
if (isOpen && node) {
|
||||
const psd = (node.providerSpecificData || {}) as Record<string, unknown>;
|
||||
setFormData({
|
||||
name: node.name || "",
|
||||
@@ -109,9 +94,7 @@ export default function EditCompatibleNodeModal({
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (initializedFor !== null) {
|
||||
setInitializedFor(null);
|
||||
}
|
||||
}, [isOpen, node, isAnthropic, isCcCompatible]);
|
||||
|
||||
const apiTypeOptions = [
|
||||
{ value: "chat", label: t("chatCompletions") },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
|
||||
import {
|
||||
@@ -262,19 +262,8 @@ export default function EditConnectionModal({
|
||||
: apiKeyOptional
|
||||
? t("apiKeyOptionalHint")
|
||||
: t("leaveBlankKeepCurrentApiKey");
|
||||
// Modal-open form initialization from the loaded connection — applied as a
|
||||
// render-phase adjustment guarded by the previously initialized connection
|
||||
// (react.dev "adjusting state when a prop changes") instead of the former
|
||||
// synchronous-setState effect. Remounting the 30+ field form per connection
|
||||
// id stays out of scope (#11251 follow-up, #9985); closing clears the marker
|
||||
// so the next open re-initializes again.
|
||||
const [initializedFor, setInitializedFor] = useState<{
|
||||
connection: EditConnectionModalConnection;
|
||||
providerId: string;
|
||||
} | null>(null);
|
||||
if (isOpen && connection) {
|
||||
if (initializedFor?.connection !== connection || initializedFor.providerId !== providerId) {
|
||||
setInitializedFor({ connection, providerId });
|
||||
useEffect(() => {
|
||||
if (isOpen && connection) {
|
||||
const effectiveProvider = connection.provider || providerId;
|
||||
const existingBaseUrl = stringField(connection.providerSpecificData?.baseUrl);
|
||||
const existingTargetFormat = stringField(connection.providerSpecificData?.targetFormat);
|
||||
@@ -304,6 +293,13 @@ export default function EditConnectionModal({
|
||||
connection.providerSpecificData?.quotaPerUnit != null
|
||||
? String(connection.providerSpecificData.quotaPerUnit)
|
||||
: "";
|
||||
// Modal-open form initialization from the loaded connection (sync with an
|
||||
// external system on `isOpen`); remounting the 30+ field form per
|
||||
// connection id is a behavior-risking restructure out of scope here
|
||||
// (#11251 follow-up, #9985).
|
||||
// NOTE: no react-hooks/set-state-in-effect suppression needed — the rule
|
||||
// only fires on unconditional synchronous setState, and this one is
|
||||
// guarded by the isOpen/connection condition above.
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
@@ -441,9 +437,15 @@ export default function EditConnectionModal({
|
||||
setValidatedProviderSpecificData(undefined);
|
||||
setSaveError(null);
|
||||
}
|
||||
} else if (initializedFor !== null) {
|
||||
setInitializedFor(null);
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
connection,
|
||||
providerId,
|
||||
defaultBaseUrl,
|
||||
showsRegion,
|
||||
defaultRegion,
|
||||
setOpenRouterPreset,
|
||||
]);
|
||||
const handleTest = async () => {
|
||||
if (!provider) return;
|
||||
setTesting(true);
|
||||
|
||||
@@ -42,95 +42,6 @@ import {
|
||||
const MAX_BULK_IDS = 100;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// ──── module-level fetch helpers ────────────────────────────────────────────
|
||||
// The network/parse/retry concerns live outside the hook so the callbacks
|
||||
// below only set state after the await — the mount effect can then call them
|
||||
// without a synchronous setState (errors come back as values, not as state
|
||||
// writes inside catch/finally blocks).
|
||||
|
||||
interface ProviderConnectionsFetchResult {
|
||||
connections: ConnectionRowConnection[] | null;
|
||||
node: any;
|
||||
nodeResolved: boolean;
|
||||
}
|
||||
|
||||
async function loadProviderConnectionsData(
|
||||
providerId: string,
|
||||
isCompatible: boolean
|
||||
): Promise<ProviderConnectionsFetchResult | null> {
|
||||
try {
|
||||
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
|
||||
const [connectionsRes, nodesRes] = await Promise.all([
|
||||
fetch(connectionsUrl, { cache: "no-store" }),
|
||||
fetch("/api/provider-nodes", { cache: "no-store" }),
|
||||
]);
|
||||
const connectionsData = await connectionsRes.json();
|
||||
const nodesData = await nodesRes.json();
|
||||
const connections = connectionsRes.ok
|
||||
? (connectionsData.connections || []).filter((c: any) =>
|
||||
connectionBelongsToProviderPage(c.provider, providerId)
|
||||
)
|
||||
: null;
|
||||
let node = null;
|
||||
let nodeResolved = false;
|
||||
if (nodesRes.ok) {
|
||||
nodeResolved = true;
|
||||
node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
|
||||
// Newly created compatible nodes can be briefly unavailable on one worker.
|
||||
if (!node && isCompatible) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
|
||||
if (!retryRes.ok) continue;
|
||||
const retryData = await retryRes.json();
|
||||
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
if (node) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { connections, node, nodeResolved };
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProxyConfigData(): Promise<{ config: any } | null> {
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
|
||||
if (res.ok) return { config: await res.json() };
|
||||
return { config: null };
|
||||
} catch {
|
||||
// Proxy indicators are best-effort — keep whatever is currently shown.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConnectionProxies(
|
||||
conns: { id?: string }[]
|
||||
): Promise<Record<string, { proxy: any; level: string } | null> | null> {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
conns
|
||||
.filter((c) => c.id)
|
||||
.map((c) =>
|
||||
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => [c.id!, data] as [string, any])
|
||||
.catch(() => [c.id!, null] as [string, any])
|
||||
)
|
||||
);
|
||||
const map: Record<string, { proxy: any; level: string } | null> = {};
|
||||
for (const [id, data] of results) {
|
||||
map[id] = data?.proxy ? data : null;
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──── types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -299,50 +210,93 @@ export function useProviderConnections(
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchProxyConfig = useCallback(async () => {
|
||||
const result = await loadProxyConfigData();
|
||||
if (result) setProxyConfig(result.config);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
setProxyConfig(await res.json());
|
||||
} else {
|
||||
setProxyConfig(null);
|
||||
}
|
||||
} catch {
|
||||
// Proxy indicators are best-effort.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
const result = await loadProviderConnectionsData(providerId, isCompatible);
|
||||
if (result) {
|
||||
if (result.connections) setConnections(result.connections);
|
||||
if (result.nodeResolved) setProviderNode(result.node);
|
||||
try {
|
||||
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
|
||||
const [connectionsRes, nodesRes] = await Promise.all([
|
||||
fetch(connectionsUrl, { cache: "no-store" }),
|
||||
fetch("/api/provider-nodes", { cache: "no-store" }),
|
||||
]);
|
||||
const connectionsData = await connectionsRes.json();
|
||||
const nodesData = await nodesRes.json();
|
||||
if (connectionsRes.ok) {
|
||||
const filtered = (connectionsData.connections || []).filter((c: any) =>
|
||||
connectionBelongsToProviderPage(c.provider, providerId)
|
||||
);
|
||||
setConnections(filtered);
|
||||
}
|
||||
if (nodesRes.ok) {
|
||||
let node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
|
||||
// Newly created compatible nodes can be briefly unavailable on one worker.
|
||||
if (!node && isCompatible) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
|
||||
if (!retryRes.ok) continue;
|
||||
const retryData = await retryRes.json();
|
||||
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
if (node) break;
|
||||
}
|
||||
}
|
||||
|
||||
setProviderNode(node);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [providerId, isCompatible]);
|
||||
|
||||
const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
|
||||
if (!conns.length) return;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
conns
|
||||
.filter((c) => c.id)
|
||||
.map((c) =>
|
||||
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => [c.id!, data] as [string, any])
|
||||
.catch(() => [c.id!, null] as [string, any])
|
||||
)
|
||||
);
|
||||
const map: Record<string, { proxy: any; level: string } | null> = {};
|
||||
for (const [id, data] of results) {
|
||||
map[id] = data?.proxy ? data : null;
|
||||
}
|
||||
setConnProxyMap(map);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── effects ──────────────────────────────────────────────────────────────
|
||||
// The async work is defined INSIDE each effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState below runs after an await.
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const result = await loadProviderConnectionsData(providerId, isCompatible);
|
||||
if (result) {
|
||||
if (result.connections) setConnections(result.connections);
|
||||
if (result.nodeResolved) setProviderNode(result.node);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
const runProxyConfig = async () => {
|
||||
const result = await loadProxyConfigData();
|
||||
if (result) setProxyConfig(result.config);
|
||||
};
|
||||
void runProxyConfig();
|
||||
}, [providerId, isCompatible]);
|
||||
fetchConnections();
|
||||
void fetchProxyConfig();
|
||||
}, [fetchConnections, fetchProxyConfig]);
|
||||
|
||||
// Per-connection proxy (handles registry assignments)
|
||||
useEffect(() => {
|
||||
if (loading || connections.length === 0) return;
|
||||
const run = async () => {
|
||||
const map = await resolveConnectionProxies(connections);
|
||||
if (map) setConnProxyMap(map);
|
||||
};
|
||||
void run();
|
||||
}, [loading, connections]);
|
||||
if (!loading && connections.length > 0) {
|
||||
void loadConnProxies(connections);
|
||||
}
|
||||
}, [loading, connections, loadConnProxies]);
|
||||
|
||||
// Upstream proxy routing config (native / CLIProxyAPI / Dario / fallback)
|
||||
useEffect(() => {
|
||||
@@ -592,11 +546,7 @@ export function useProviderConnections(
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateCliproxyRouting",
|
||||
"Failed to update upstream proxy routing"
|
||||
)
|
||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update upstream proxy routing")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,32 +26,6 @@ import {
|
||||
providerText,
|
||||
} from "../providerPageHelpers";
|
||||
|
||||
// Shared /api/settings fetch with error-as-value semantics so the loaders
|
||||
// below only touch state after the await (no synchronous setState reachable
|
||||
// from the load effects).
|
||||
async function fetchSettingsPayload(): Promise<{
|
||||
ok: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Settings request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Settings response was empty");
|
||||
}
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Failed to load settings",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──── types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseProviderSettingsReturn {
|
||||
@@ -99,19 +73,6 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
>(null);
|
||||
const [savingClaudeRoutingPreference, setSavingClaudeRoutingPreference] = useState(false);
|
||||
|
||||
// Reset the per-provider load flags when the provider changes — a
|
||||
// render-phase adjustment guarded by the previous providerId (react.dev
|
||||
// "adjusting state when a prop changes"), replacing the synchronous resets
|
||||
// that used to run inside the load effects.
|
||||
const [settingsProviderId, setSettingsProviderId] = useState(providerId);
|
||||
if (settingsProviderId !== providerId) {
|
||||
setSettingsProviderId(providerId);
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(null);
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
}
|
||||
|
||||
// ── derived ──────────────────────────────────────────────────────────────
|
||||
const codexGlobalServiceModeOptions = useMemo(
|
||||
() =>
|
||||
@@ -128,87 +89,76 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
codexSettingsRequestSeqRef.current = requestSeq;
|
||||
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
|
||||
|
||||
// Non-codex providers keep the initial false/null flags (also restored by
|
||||
// the render-phase reset above when providerId changes).
|
||||
if (providerId !== "codex") return;
|
||||
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!outcome.ok) {
|
||||
if (providerId !== "codex") {
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(outcome.message);
|
||||
setCodexSettingsLoadError(null);
|
||||
return;
|
||||
}
|
||||
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
|
||||
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
|
||||
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
|
||||
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(null);
|
||||
setCodexSettingsLoaded(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Settings request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Settings response was empty");
|
||||
}
|
||||
if (!isCurrentRequest()) return;
|
||||
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(data);
|
||||
setCodexGlobalServiceMode(getCodexGlobalServiceMode(data));
|
||||
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
|
||||
setCodexSettingsLoaded(true);
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest()) return;
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
// The async work is duplicated INSIDE the effect (calling the exposed
|
||||
// loadCodexSettings callback synchronously from an effect is rejected by the
|
||||
// compiler rules); every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
if (providerId !== "codex") return;
|
||||
const requestSeq = codexSettingsRequestSeqRef.current + 1;
|
||||
codexSettingsRequestSeqRef.current = requestSeq;
|
||||
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
|
||||
const run = async () => {
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!outcome.ok) {
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(outcome.message);
|
||||
return;
|
||||
}
|
||||
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
|
||||
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
|
||||
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
|
||||
setCodexSettingsLoadError(null);
|
||||
setCodexSettingsLoaded(true);
|
||||
};
|
||||
void run();
|
||||
}, [providerId]);
|
||||
void loadCodexSettings();
|
||||
}, [loadCodexSettings]);
|
||||
|
||||
// ── Claude routing settings loader ───────────────────────────────────────
|
||||
const loadClaudeRoutingSettings = useCallback(async () => {
|
||||
// Non-claude providers keep the initial false/null flags (also restored by
|
||||
// the render-phase reset above when providerId changes).
|
||||
if (providerId !== "claude") return;
|
||||
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!outcome.ok) {
|
||||
if (providerId !== "claude") {
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(outcome.message);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
return;
|
||||
}
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(
|
||||
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
|
||||
);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
setClaudeRoutingSettingsLoaded(true);
|
||||
}, [providerId]);
|
||||
|
||||
// Same inline-in-effect shape as the codex loader above.
|
||||
useEffect(() => {
|
||||
if (providerId !== "claude") return;
|
||||
const run = async () => {
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!outcome.ok) {
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(outcome.message);
|
||||
return;
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Settings request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Settings response was empty");
|
||||
}
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(
|
||||
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
|
||||
data.preferClaudeCodeForUnprefixedClaudeModels === true
|
||||
);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
setClaudeRoutingSettingsLoaded(true);
|
||||
};
|
||||
void run();
|
||||
} catch (error) {
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(
|
||||
error instanceof Error ? error.message : "Failed to load settings"
|
||||
);
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadClaudeRoutingSettings();
|
||||
}, [loadClaudeRoutingSettings]);
|
||||
|
||||
// ── Codex service mode handler ───────────────────────────────────────────
|
||||
const handleChangeCodexGlobalServiceMode = async (mode: CodexGlobalServiceMode) => {
|
||||
if (savingCodexGlobalServiceMode || !codexSettingsLoaded) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
@@ -133,25 +133,15 @@ export default function AddCompatibleProviderModal({
|
||||
[t]
|
||||
);
|
||||
|
||||
// Fresh form on every open (and on a mode switch while open) — applied as a
|
||||
// render-phase adjustment guarded by the previously initialized mode
|
||||
// (react.dev "adjusting state when a prop changes") instead of a
|
||||
// synchronous-setState effect. Closing clears the marker so the next open
|
||||
// re-initializes again.
|
||||
const [initializedFor, setInitializedFor] = useState<{ mode: CompatibleMode } | null>(null);
|
||||
if (isOpen) {
|
||||
if (initializedFor?.mode !== mode) {
|
||||
setInitializedFor({ mode });
|
||||
setFormData(createInitialForm(mode));
|
||||
setValidationResult(null);
|
||||
setCheckKey("");
|
||||
setShowAdvanced(false);
|
||||
setSaveError(null);
|
||||
setIconUrlError(null);
|
||||
}
|
||||
} else if (initializedFor !== null) {
|
||||
setInitializedFor(null);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData(createInitialForm(mode));
|
||||
setValidationResult(null);
|
||||
setCheckKey("");
|
||||
setShowAdvanced(false);
|
||||
setSaveError(null);
|
||||
setIconUrlError(null);
|
||||
}, [isOpen, mode]);
|
||||
|
||||
const modalTitle =
|
||||
title ||
|
||||
|
||||
@@ -133,7 +133,10 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
}, [providerId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerId) return;
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
return load();
|
||||
}, [providerId, load]);
|
||||
|
||||
@@ -149,7 +152,5 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
load();
|
||||
}, [providerId, load]);
|
||||
|
||||
// Without a providerId nothing ever loads, so the exposed loading flag is
|
||||
// derived instead of being reset synchronously inside the effect above.
|
||||
return { models, loading: providerId ? loading : false, error, retry };
|
||||
return { models, loading, error, retry };
|
||||
}
|
||||
|
||||
@@ -51,32 +51,26 @@ export function useProviderUrlFilters({
|
||||
activeServiceKind,
|
||||
setActiveServiceKind,
|
||||
}: UseProviderUrlFiltersArgs): { displayModePreferenceReady: boolean } {
|
||||
// Snapshot of the stored display-mode preference, read once via a lazy
|
||||
// initializer (localStorage must not be read during render). After the first
|
||||
// hydration the URL always carries the mode, so the fallback is mount-only.
|
||||
const [storedDisplayModePreference] = useState<ProviderDisplayMode>(() =>
|
||||
readProviderDisplayModePreference()
|
||||
);
|
||||
const [hydratedFromParams, setHydratedFromParams] = useState<ReadonlyURLSearchParams | null>(
|
||||
null
|
||||
);
|
||||
const [displayModePreferenceReady, setDisplayModePreferenceReady] = useState(false);
|
||||
const [filtersHydrated, setFiltersHydrated] = useState(false);
|
||||
|
||||
// URL → state hydration as a render-phase adjustment guarded by the
|
||||
// previously hydrated params object (react.dev "adjusting state when a prop
|
||||
// changes") — replaces the two synchronous setState effects keyed on
|
||||
// searchParams, and removes the transient default-state first paint.
|
||||
if (hydratedFromParams !== searchParams) {
|
||||
setHydratedFromParams(searchParams);
|
||||
useEffect(() => {
|
||||
const urlMode = readProviderFiltersFromUrl(searchParams).displayMode;
|
||||
setProviderDisplayMode(urlMode ?? readProviderDisplayModePreference());
|
||||
setDisplayModePreferenceReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const urlFilters = readProviderFiltersFromUrl(searchParams);
|
||||
setProviderDisplayMode(urlFilters.displayMode ?? storedDisplayModePreference);
|
||||
setSearchQuery(urlFilters.searchQuery ?? "");
|
||||
setModelSearchQuery(urlFilters.modelSearchQuery ?? "");
|
||||
setActiveCategory(urlFilters.category ?? null);
|
||||
setShowFreeOnly(urlFilters.showFreeOnly ?? false);
|
||||
setActiveServiceKind(urlFilters.mediaKind ?? null);
|
||||
}
|
||||
const displayModePreferenceReady = hydratedFromParams !== null;
|
||||
const filtersHydrated = displayModePreferenceReady;
|
||||
setFiltersHydrated(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersHydrated || !displayModePreferenceReady) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export const RISK_ACKNOWLEDGED_STORAGE_KEY = "omniroute-risk-acknowledged";
|
||||
|
||||
@@ -53,38 +53,22 @@ export function isRiskAcknowledged(providerId: string): boolean {
|
||||
return readRiskAcknowledgedMap()[providerId] === true;
|
||||
}
|
||||
|
||||
// localStorage is a mutable external store, so the hook below subscribes to it
|
||||
// through useSyncExternalStore instead of mirroring it into component state
|
||||
// with an effect (which required a synchronous setState on providerId change).
|
||||
const riskAcknowledgedListeners = new Set<() => void>();
|
||||
|
||||
function subscribeToRiskAcknowledged(listener: () => void): () => void {
|
||||
riskAcknowledgedListeners.add(listener);
|
||||
return () => {
|
||||
riskAcknowledgedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function emitRiskAcknowledgedChange(): void {
|
||||
for (const listener of riskAcknowledgedListeners) listener();
|
||||
}
|
||||
|
||||
export function acknowledgeProviderRisk(providerId: string): void {
|
||||
const map = readRiskAcknowledgedMap();
|
||||
map[providerId] = true;
|
||||
writeRiskAcknowledgedMap(map);
|
||||
emitRiskAcknowledgedChange();
|
||||
}
|
||||
|
||||
export function useRiskAcknowledged(providerId: string) {
|
||||
const acknowledged = useSyncExternalStore(
|
||||
subscribeToRiskAcknowledged,
|
||||
() => isRiskAcknowledged(providerId),
|
||||
() => false
|
||||
);
|
||||
const [acknowledged, setAcknowledged] = useState(() => isRiskAcknowledged(providerId));
|
||||
|
||||
useEffect(() => {
|
||||
setAcknowledged(isRiskAcknowledged(providerId));
|
||||
}, [providerId]);
|
||||
|
||||
const acknowledge = useCallback(() => {
|
||||
acknowledgeProviderRisk(providerId);
|
||||
setAcknowledged(true);
|
||||
}, [providerId]);
|
||||
|
||||
return { acknowledged, acknowledge };
|
||||
|
||||
@@ -54,7 +54,9 @@ const AddCompatibleProviderModal = dynamic(
|
||||
import { CategoryDot } from "./components/CategoryDot";
|
||||
const ImportProvidersFromFileModal = dynamic(
|
||||
() =>
|
||||
import("./components/ImportProvidersFromFileModal").then((m) => m.ImportProvidersFromFileModal),
|
||||
import("./components/ImportProvidersFromFileModal").then(
|
||||
(m) => m.ImportProvidersFromFileModal
|
||||
),
|
||||
{ ssr: false }
|
||||
);
|
||||
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
|
||||
@@ -190,27 +192,6 @@ function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
|
||||
return "ERR";
|
||||
}
|
||||
|
||||
// OAuth-env repair status fetch, extracted so the callback below only sets
|
||||
// state after the await (errors come back as `null` instead of a setState
|
||||
// inside the catch block, which the react-hooks compiler rules reject when the
|
||||
// callback is invoked from an effect).
|
||||
async function loadOauthEnvRepairStatus(): Promise<{
|
||||
available: boolean;
|
||||
missingCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) return null;
|
||||
return {
|
||||
available: Boolean(data.available),
|
||||
missingCount: Number(data.missingCount || 0),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const router = useRouter();
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
@@ -316,30 +297,33 @@ export default function ProvidersPage() {
|
||||
writeProviderDisplayModePreference(storedDisplayMode);
|
||||
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
|
||||
|
||||
// "No connections → fall back to the 'all' view" is a state adjustment
|
||||
// derived from other state, applied during render (self-invalidating guard,
|
||||
// converges in one extra pass) instead of a synchronous setState effect.
|
||||
if (
|
||||
shouldSyncProviderDisplayMode(displayModePreferenceReady, loading) &&
|
||||
connections.length === 0 &&
|
||||
providerDisplayMode === "configured"
|
||||
) {
|
||||
setProviderDisplayMode("all");
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!shouldSyncProviderDisplayMode(displayModePreferenceReady, loading)) return;
|
||||
if (connections.length === 0 && providerDisplayMode === "configured") {
|
||||
setProviderDisplayMode("all");
|
||||
}
|
||||
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
|
||||
|
||||
const fetchOauthEnvRepairStatus = useCallback(async () => {
|
||||
setOauthEnvRepairStatus(await loadOauthEnvRepairStatus());
|
||||
try {
|
||||
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setOauthEnvRepairStatus({
|
||||
available: Boolean(data.available),
|
||||
missingCount: Number(data.missingCount || 0),
|
||||
});
|
||||
} else {
|
||||
setOauthEnvRepairStatus(null);
|
||||
}
|
||||
} catch {
|
||||
setOauthEnvRepairStatus(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Inline-in-effect (calling the component-scope callback synchronously from
|
||||
// an effect is rejected by the compiler rules); setState runs after the await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const status = await loadOauthEnvRepairStatus();
|
||||
setOauthEnvRepairStatus(status);
|
||||
};
|
||||
void run();
|
||||
}, []);
|
||||
void fetchOauthEnvRepairStatus();
|
||||
}, [fetchOauthEnvRepairStatus]);
|
||||
|
||||
const handleRepairEnv = async () => {
|
||||
if (!oauthEnvRepairStatus?.available || repairingEnv) return;
|
||||
|
||||
@@ -18,11 +18,7 @@ const STATE_LABELS: Record<CliproxyAccountHealthResult["state"], string> = {
|
||||
};
|
||||
|
||||
function AccountRow({ account }: { account: CliproxyAccountHealth }) {
|
||||
const state = account.disabled
|
||||
? "Disabled"
|
||||
: account.unavailable
|
||||
? "Unavailable"
|
||||
: account.status;
|
||||
const state = account.disabled ? "Disabled" : account.unavailable ? "Unavailable" : account.status;
|
||||
return (
|
||||
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3 first:border-t-0">
|
||||
<div className="min-w-0">
|
||||
@@ -48,50 +44,33 @@ function AccountRow({ account }: { account: CliproxyAccountHealth }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Network + parse concerns live outside the component so the load callback only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchAccountHealth(): Promise<CliproxyAccountHealthResult> {
|
||||
try {
|
||||
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} catch {
|
||||
return { state: "unreachable", accounts: [], version: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function CliproxyAccountHealthCard() {
|
||||
const [result, setResult] = useState<CliproxyAccountHealthResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const next = await fetchAccountHealth();
|
||||
setResult(next);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Inline-in-effect (calling the component-scope `load` callback synchronously
|
||||
// from an effect is rejected by the compiler rules); setState is post-await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const next = await fetchAccountHealth();
|
||||
setResult(next);
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
setResult(await response.json());
|
||||
} catch {
|
||||
setResult({ state: "unreachable", accounts: [], version: null });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="CLIProxyAPI accounts"
|
||||
subtitle="Read-only status from the authenticated management API"
|
||||
action={
|
||||
<Button variant="secondary" size="sm" onClick={handleRefresh} loading={loading}>
|
||||
<Button variant="secondary" size="sm" onClick={() => void load()} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
@@ -108,9 +87,7 @@ export function CliproxyAccountHealthCard() {
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">
|
||||
{loading && !result
|
||||
? "Loading account health…"
|
||||
: STATE_LABELS[result?.state ?? "unreachable"]}
|
||||
{loading && !result ? "Loading account health…" : STATE_LABELS[result?.state ?? "unreachable"]}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -65,43 +65,9 @@ function formatExpiry(acc: DarioAccount): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Network + parse concerns extracted so the refresh callbacks only set state
|
||||
// after the await — the mount effect can then call them without a synchronous
|
||||
// setState (errors come back as values instead of catch-block state writes).
|
||||
async function fetchDarioAccounts(): Promise<{ accounts: DarioAccount[] } | { error: string }> {
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/accounts");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
accounts?: DarioAccount[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return { accounts: Array.isArray(json?.accounts) ? json!.accounts : [] };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOmniConnectionsList(): Promise<OmniConnection[] | null> {
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
connections?: OmniConnection[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!res.ok) return null;
|
||||
return Array.isArray(json?.connections) ? json!.connections : [];
|
||||
} catch {
|
||||
/* non-fatal — import section just stays empty */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function DarioAccountPanel() {
|
||||
const [accounts, setAccounts] = useState<DarioAccount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [pending, setPending] = useState<PendingLogin | null>(null);
|
||||
@@ -111,48 +77,51 @@ export function DarioAccountPanel() {
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const [omniConnections, setOmniConnections] = useState<OmniConnection[]>([]);
|
||||
const [omniLoading, setOmniLoading] = useState(true);
|
||||
const [omniLoading, setOmniLoading] = useState(false);
|
||||
const [importBusyId, setImportBusyId] = useState<string | null>(null);
|
||||
|
||||
const refreshAccounts = useCallback(async () => {
|
||||
const outcome = await fetchDarioAccounts();
|
||||
if ("accounts" in outcome) {
|
||||
setAccounts(outcome.accounts);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(outcome.error);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Inline-in-effect (calling the component-scope refresh callbacks
|
||||
// synchronously from an effect is rejected by the compiler rules); every
|
||||
// setState here runs after an await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const outcome = await fetchDarioAccounts();
|
||||
if ("accounts" in outcome) {
|
||||
setAccounts(outcome.accounts);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(outcome.error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
const runOmni = async () => {
|
||||
const list = await fetchOmniConnectionsList();
|
||||
if (list) setOmniConnections(list);
|
||||
setOmniLoading(false);
|
||||
};
|
||||
void runOmni();
|
||||
}, []);
|
||||
|
||||
const handleRefreshAccountsClick = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/accounts");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
accounts?: DarioAccount[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setAccounts(Array.isArray(json?.accounts) ? json!.accounts : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshOmniConnections = useCallback(async () => {
|
||||
setOmniLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
connections?: OmniConnection[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (res.ok) {
|
||||
setOmniConnections(Array.isArray(json?.connections) ? json!.connections : []);
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — import section just stays empty */
|
||||
} finally {
|
||||
setOmniLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAccounts();
|
||||
};
|
||||
void refreshOmniConnections();
|
||||
}, [refreshAccounts, refreshOmniConnections]);
|
||||
|
||||
async function importFromOmniroute(connectionId: string) {
|
||||
setImportBusyId(connectionId);
|
||||
@@ -375,7 +344,7 @@ export function DarioAccountPanel() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={handleRefreshAccountsClick}
|
||||
onClick={() => void refreshAccounts()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -32,22 +32,6 @@ export function paginateModels(
|
||||
return models.slice(start, start + pageSize);
|
||||
}
|
||||
|
||||
// Fetch + parse extracted from the component so errors surface as a return
|
||||
// value instead of state mutations inside catch/finally blocks.
|
||||
async function fetchServiceModels(
|
||||
refresh: boolean
|
||||
): Promise<{ ok: boolean; data?: ServiceModel[]; message?: string | null }> {
|
||||
try {
|
||||
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
return { ok: true, data: Array.isArray(body?.data) ? body.data : [] };
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : null };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function NinerouterModelList() {
|
||||
@@ -58,48 +42,35 @@ export function NinerouterModelList() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadModels = useCallback(
|
||||
const fetchModels = useCallback(
|
||||
async (refresh = false) => {
|
||||
// All setState calls stay after the await so the mount effect below can
|
||||
// call this loader without a synchronous setState inside the effect; the
|
||||
// refresh button sets its spinner flags in its own handler instead.
|
||||
const outcome = await fetchServiceModels(refresh);
|
||||
if (outcome.ok) {
|
||||
setModels(outcome.data);
|
||||
setPage(1);
|
||||
setError(null);
|
||||
if (refresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setError(outcome.message ?? t("modelsLoadFailed"));
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const data: ServiceModel[] = Array.isArray(body?.data) ? body.data : [];
|
||||
setModels(data);
|
||||
setPage(1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("modelsLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
// Inline-in-effect (calling the component-scope loadModels callback
|
||||
// synchronously from an effect is rejected by the compiler rules); every
|
||||
// setState here runs after the await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const outcome = await fetchServiceModels(false);
|
||||
if (outcome.ok) {
|
||||
setModels(outcome.data);
|
||||
setPage(1);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(outcome.message ?? t("modelsLoadFailed"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
}, [t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setError(null);
|
||||
void loadModels(true);
|
||||
};
|
||||
void fetchModels(false);
|
||||
}, [fetchModels]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(models.length / PAGE_SIZE));
|
||||
const visibleModels = paginateModels(models, page, PAGE_SIZE);
|
||||
@@ -121,7 +92,7 @@ export function NinerouterModelList() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
onClick={() => fetchModels(true)}
|
||||
disabled={loading || refreshing}
|
||||
className="shrink-0"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user