"use client"; import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; import ProviderIcon from "@/shared/components/ProviderIcon"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function DroidToolCard({ tool, isExpanded = false, onToggle = () => {}, baseUrl, hasActiveProviders, apiKeys, activeProviders, cloudEnabled, batchStatus, lastConfiguredAt, }) { const t = useTranslations("cliTools"); const [droidStatus, setDroidStatus] = useState(null); const [checkingDroid, setCheckingDroid] = useState(false); const [applying, setApplying] = useState(false); const [restoring, setRestoring] = useState(false); const [message, setMessage] = useState(null); const [selectedApiKeyId, setSelectedApiKeyId] = useState(""); // (#618) Multi-model support: list of model ids + input box for the next entry. // `selectedModel` is derived as the first entry so existing call sites // (manual-config preview, ModelSelectModal) continue to work. const [modelList, setModelList] = useState([]); const [modelInput, setModelInput] = useState(""); const selectedModel = modelList[0] || ""; const [modalOpen, setModalOpen] = useState(false); const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); // Backups state const [backups, setBackups] = useState([]); const [showBackups, setShowBackups] = useState(false); const [restoringBackup, setRestoringBackup] = useState(null); const cliReady = !!(droidStatus?.installed && droidStatus?.runnable); // (#618) Match any custom:OmniRoute- 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); if (!currentConfig) return "not_configured"; const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1"); const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL); if (localMatch || cloudMatch) return "configured"; return "other"; }; const configStatus = getConfigStatus(); // Use batch status as fallback when card hasn't been expanded yet 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]); useEffect(() => { if (isExpanded && !droidStatus) { checkDroidStatus(); fetchModelAliases(); fetchBackups(); } }, [isExpanded, droidStatus]); const fetchModelAliases = async () => { try { const res = await fetch("/api/models/alias"); const data = await res.json(); if (res.ok) setModelAliases(data.aliases || {}); } 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- // 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]; if (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); } } } }, [droidStatus, apiKeys]); // (#618) Multi-model list manipulation helpers. const addModel = (value) => { const v = (value ?? modelInput).trim(); if (!v || modelList.includes(v)) return; setModelList((prev) => [...prev, v]); setModelInput(""); }; 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`; }; const getDisplayUrl = () => { const url = customBaseUrl || baseUrl; return url.endsWith("/v1") ? url : `${url}/v1`; }; const handleApplySettings = async () => { setApplying(true); setMessage(null); 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); const res = await fetch("/api/cli-tools/droid-settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: !cloudEnabled ? "sk_omniroute" : null, keyId: selectedKeyId, // (#618) Send both `model` (legacy, first entry) and `models` (array). // Backend prefers `models` when present; `model` keeps Zod happy // for callers still on the single-model contract. model: selectedModel, models: modelList, activeModel: selectedModel, }), }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: t("settingsApplied") }); checkDroidStatus(); } else { setMessage({ type: "error", text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedApplySettings"), }); } } catch (error) { setMessage({ type: "error", text: error.message }); } finally { setApplying(false); } }; const handleResetSettings = async () => { setRestoring(true); setMessage(null); try { const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: t("settingsReset") }); setModelList([]); setSelectedApiKeyId(""); checkDroidStatus(); } else { setMessage({ type: "error", text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedResetSettings"), }); } } catch (error) { setMessage({ type: "error", text: error.message }); } finally { setRestoring(false); } }; const handleModelSelect = (model) => { // (#618) Append to the model list rather than replacing the single slot. if (!model?.value || modelList.includes(model.value)) { setModalOpen(false); return; } setModelList((prev) => [...prev, model.value]); 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); try { const res = await fetch("/api/cli-tools/backups", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tool: "droid", backupId }), }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: t("backupRestored") }); checkDroidStatus(); fetchBackups(); } else { setMessage({ type: "error", text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedRestore"), }); } } catch (error) { setMessage({ type: "error", text: error.message }); } finally { setRestoringBackup(null); } }; 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 keyToDisplay = selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : ""); // (#618) Render one entry per requested model; fall back to a placeholder // when the list is empty so manual-config preview still shows the shape. const modelsForPreview = modelList.length > 0 ? modelList : ["provider/model-id"]; const settingsContent = { customModels: modelsForPreview.map((m, i) => ({ model: m, id: `custom:OmniRoute-${i}`, index: i, baseUrl: getEffectiveBaseUrl(), apiKey: keyToDisplay, displayName: m, maxOutputTokens: 131072, noImageSupport: false, provider: "openai", })), }; const platform = typeof navigator !== "undefined" && navigator.platform; // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil const isWindows = platform?.toLowerCase().includes("win"); const settingsPath = isWindows ? "%USERPROFILE%\\.factory\\settings.json" : "~/.factory/settings.json"; return [ { filename: settingsPath, content: JSON.stringify(settingsContent, null, 2), }, ]; }; return (

{tool.name}

{t("toolDescriptions.droid")}

expand_more
{isExpanded && (
{checkingDroid && (
progress_activity {t("checkingCli", { tool: "Factory Droid" })}
)} {!checkingDroid && droidStatus && !cliReady && (
warning

{droidStatus.installed ? t("cliNotRunnable", { tool: "Factory Droid" }) : t("cliNotInstalled", { tool: "Factory Droid" })}

{droidStatus.installed ? t("cliFoundFailedHealthcheck", { tool: "Factory Droid", reason: droidStatus.reason ? ` (${droidStatus.reason})` : "", }) : t("installCliPrompt", { tool: "Factory Droid" })}

)} {!checkingDroid && cliReady && ( <>
{/* Current Base URL — first OmniRoute entry, any index (#618) */} {droidStatus?.settings?.customModels?.find(isOmniRouteEntry)?.baseUrl && (
{t("current")} arrow_forward {droidStatus.settings.customModels.find(isOmniRouteEntry).baseUrl}
)} {/* Base URL */}
{t("baseUrl")} arrow_forward setCustomBaseUrl(e.target.value)} placeholder={t("baseUrlPlaceholder")} className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> {customBaseUrl && customBaseUrl !== baseUrl && ( )}
{/* API Key */}
{t("apiKey")} arrow_forward {apiKeys.length > 0 ? ( ) : ( {cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")} )}
{/* Models — multi-model support (#618) */}
{t("model")} {modelList.length > 0 && ( ({modelList.length}) )} arrow_forward
{modelList.length > 0 && (
{modelList.map((id) => (
{id}
))}
)}
setModelInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }} placeholder={t("providerModelPlaceholder")} className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
{message && (
{message.type === "success" ? "check_circle" : "error"} {message.text}
)}
{showBackups && (

history {t("configBackups")}

{backups.length === 0 ? (

{t("noBackupsYet")}

) : (
{backups.map((b) => (
description {b.id} {new Date(b.createdAt).toLocaleString()}
))}
)}
)} )}
)} setModalOpen(false)} onSelect={handleModelSelect} selectedModel={selectedModel} activeProviders={activeProviders} modelAliases={modelAliases} title={t("selectModelForTool", { tool: "Factory Droid" })} /> setShowManualConfigModal(false)} title={t("droidManualConfiguration")} configs={getManualConfigs()} /> ); }