Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useState, useEffect } 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"; | |
| import ProviderIcon from "@/shared/components/ProviderIcon"; | |
| export default function AntigravityToolCard({ | |
| tool, | |
| isExpanded = false, | |
| onToggle = () => {}, | |
| baseUrl, | |
| apiKeys, | |
| activeProviders, | |
| hasActiveProviders, | |
| cloudEnabled, | |
| }) { | |
| const t = useTranslations("cliTools"); | |
| const [status, setStatus] = useState(null); | |
| const [loading, setLoading] = useState(false); | |
| const [showPasswordModal, setShowPasswordModal] = useState(false); | |
| const [sudoPassword, setSudoPassword] = useState(""); | |
| const [selectedApiKeyId, setSelectedApiKeyId] = useState(""); | |
| const [message, setMessage] = useState(null); | |
| const [modelMappings, setModelMappings] = useState({}); | |
| const [modalOpen, setModalOpen] = useState(false); | |
| const [currentEditingAlias, setCurrentEditingAlias] = useState(null); | |
| // Model aliases drive the API-Key-compatible / passthrough provider groups in | |
| // ModelSelectModal — without them, custom OpenAI/Anthropic-compatible | |
| // providers don't surface in the picker even when active. | |
| 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]); | |
| useEffect(() => { | |
| if (isExpanded && !status) { | |
| fetchStatus(); | |
| loadSavedMappings(); | |
| fetchModelAliases(); | |
| } | |
| }, [isExpanded, status]); | |
| const loadSavedMappings = async () => { | |
| try { | |
| const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| const aliases = data.aliases || {}; | |
| if (Object.keys(aliases).length > 0) { | |
| setModelMappings(aliases); | |
| } | |
| } | |
| } catch (error) { | |
| console.log("Error loading saved mappings:", error); | |
| } | |
| }; | |
| const fetchStatus = async () => { | |
| try { | |
| const res = await fetch("/api/cli-tools/antigravity-mitm"); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setStatus(data); | |
| } | |
| } catch (error) { | |
| console.log("Error fetching status:", error); | |
| setStatus({ running: false }); | |
| } | |
| }; | |
| 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); | |
| } | |
| }; | |
| // MITM elevation is decided by the *server* OS, not by this browser's user | |
| // agent. The server reports `isWin` and `needsSudoPassword` in GET status — | |
| // a Windows browser hitting a Linux server still needs sudo, and a Linux | |
| // browser hitting a Windows server does not (#822). | |
| const serverIsWindows = status?.isWin === true; | |
| const canRunWithoutPassword = | |
| serverIsWindows || | |
| status?.hasCachedPassword === true || | |
| status?.needsSudoPassword === false; | |
| const handleStart = () => { | |
| if (canRunWithoutPassword) { | |
| doStart(""); | |
| } else { | |
| setShowPasswordModal(true); | |
| setMessage(null); | |
| } | |
| }; | |
| const handleStop = () => { | |
| if (canRunWithoutPassword) { | |
| doStop(""); | |
| } else { | |
| setShowPasswordModal(true); | |
| setMessage(null); | |
| } | |
| }; | |
| const doStart = async (password) => { | |
| setLoading(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/antigravity-mitm", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| apiKey: !cloudEnabled ? "sk_omniroute" : null, | |
| keyId: selectedKeyId, | |
| sudoPassword: password, | |
| }), | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| setMessage({ type: "success", text: t("mitmStarted") }); | |
| setShowPasswordModal(false); | |
| setSudoPassword(""); | |
| fetchStatus(); | |
| } else { | |
| setMessage({ | |
| type: "error", | |
| text: | |
| (typeof data.error === "string" ? data.error : data.error?.message) || t("failedStart"), | |
| }); | |
| } | |
| } catch (error) { | |
| setMessage({ type: "error", text: error.message }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const doStop = async (password) => { | |
| setLoading(true); | |
| setMessage(null); | |
| try { | |
| const res = await fetch("/api/cli-tools/antigravity-mitm", { | |
| method: "DELETE", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ sudoPassword: password }), | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| setMessage({ type: "success", text: t("mitmStopped") }); | |
| setShowPasswordModal(false); | |
| setSudoPassword(""); | |
| fetchStatus(); | |
| } else { | |
| setMessage({ | |
| type: "error", | |
| text: | |
| (typeof data.error === "string" ? data.error : data.error?.message) || t("failedStop"), | |
| }); | |
| } | |
| } catch (error) { | |
| setMessage({ type: "error", text: error.message }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleConfirmPassword = () => { | |
| if (!sudoPassword.trim()) { | |
| setMessage({ type: "error", text: t("sudoPasswordRequiredError") }); | |
| return; | |
| } | |
| if (status?.running) { | |
| doStop(sudoPassword); | |
| } else { | |
| doStart(sudoPassword); | |
| } | |
| }; | |
| const openModelSelector = (alias) => { | |
| setCurrentEditingAlias(alias); | |
| setModalOpen(true); | |
| }; | |
| const handleModelSelect = (model) => { | |
| if (currentEditingAlias) { | |
| setModelMappings((prev) => ({ | |
| ...prev, | |
| [currentEditingAlias]: model.value, | |
| })); | |
| } | |
| }; | |
| const handleModelMappingChange = (alias, value) => { | |
| setModelMappings((prev) => ({ | |
| ...prev, | |
| [alias]: value, | |
| })); | |
| }; | |
| const handleSaveMappings = async () => { | |
| setLoading(true); | |
| setMessage(null); | |
| try { | |
| const res = await fetch("/api/cli-tools/antigravity-mitm/alias", { | |
| method: "PUT", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ tool: tool.id, mappings: modelMappings }), | |
| }); | |
| if (!res.ok) { | |
| const data = await res.json(); | |
| throw new Error( | |
| (typeof data.error === "string" ? data.error : data.error?.message) || | |
| t("failedSaveMappings") | |
| ); | |
| } | |
| setMessage({ type: "success", text: t("mappingsSaved") }); | |
| } catch (error) { | |
| setMessage({ type: "error", text: error.message }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const isRunning = status?.running; | |
| return ( | |
| <Card padding="sm" className="overflow-hidden"> | |
| <div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}> | |
| <div className="flex items-center gap-3"> | |
| <div className="size-8 flex items-center justify-center shrink-0"> | |
| <ProviderIcon providerId={tool.id || "antigravity"} size={32} type="color" /> | |
| </div> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <h3 className="font-medium text-sm">{tool.name}</h3> | |
| {isRunning ? ( | |
| <Badge variant="success" size="sm"> | |
| {t("active")} | |
| </Badge> | |
| ) : ( | |
| <Badge variant="default" size="sm"> | |
| {t("inactive")} | |
| </Badge> | |
| )} | |
| </div> | |
| <p className="text-xs text-text-muted truncate">{tool.description}</p> | |
| </div> | |
| </div> | |
| <span | |
| className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`} | |
| > | |
| expand_more | |
| </span> | |
| </div> | |
| {isExpanded && ( | |
| <div className="mt-4 pt-4 border-t border-border flex flex-col gap-4"> | |
| {/* Start/Stop Button - always on top */} | |
| <div className="flex items-center gap-2"> | |
| {isRunning ? ( | |
| <button | |
| onClick={handleStop} | |
| disabled={loading} | |
| className="px-4 py-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 font-medium text-sm flex items-center gap-2 hover:bg-red-500/20 transition-colors disabled:opacity-50" | |
| > | |
| <span className="material-symbols-outlined text-[18px]">stop_circle</span> | |
| {t("stopMitm")} | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={handleStart} | |
| disabled={loading || !hasActiveProviders} | |
| className="px-4 py-2 rounded-lg bg-primary/10 border border-primary/30 text-primary font-medium text-sm flex items-center gap-2 hover:bg-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| <span className="material-symbols-outlined text-[18px]">play_circle</span> | |
| {t("startMitm")} | |
| </button> | |
| )} | |
| </div> | |
| {message?.type === "error" && ( | |
| <div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600"> | |
| <span className="material-symbols-outlined text-[14px]">error</span> | |
| <span>{message.text}</span> | |
| </div> | |
| )} | |
| {/* When running: API Key + Model Mappings */} | |
| {isRunning && ( | |
| <> | |
| <div className="flex items-center gap-2"> | |
| <span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right"> | |
| {t("apiKey")} | |
| </span> | |
| <span className="material-symbols-outlined text-text-muted text-[14px]"> | |
| arrow_forward | |
| </span> | |
| {apiKeys.length > 0 ? ( | |
| <select | |
| value={selectedApiKeyId} | |
| 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" | |
| > | |
| {apiKeys.map((key) => ( | |
| <option key={key.id} value={key.id}> | |
| {key.key} | |
| </option> | |
| ))} | |
| </select> | |
| ) : ( | |
| <span className="flex-1 text-xs text-text-muted px-2 py-1.5"> | |
| {cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")} | |
| </span> | |
| )} | |
| </div> | |
| {(tool.defaultModels || []).map((model) => ( | |
| <div key={model.alias} className="flex items-center gap-2"> | |
| <span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right"> | |
| {model.name} | |
| </span> | |
| <span className="material-symbols-outlined text-text-muted text-[14px]"> | |
| arrow_forward | |
| </span> | |
| <input | |
| type="text" | |
| value={modelMappings[model.alias] || ""} | |
| onChange={(e) => handleModelMappingChange(model.alias, e.target.value)} | |
| placeholder={t("modelPlaceholder")} | |
| 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" | |
| /> | |
| <button | |
| onClick={() => openModelSelector(model.alias)} | |
| disabled={!hasActiveProviders} | |
| className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`} | |
| > | |
| {t("select")} | |
| </button> | |
| {modelMappings[model.alias] && ( | |
| <button | |
| onClick={() => handleModelMappingChange(model.alias, "")} | |
| className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" | |
| title={t("clear")} | |
| > | |
| <span className="material-symbols-outlined text-[14px]">close</span> | |
| </button> | |
| )} | |
| </div> | |
| ))} | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="primary" | |
| size="sm" | |
| onClick={handleSaveMappings} | |
| disabled={loading || Object.keys(modelMappings).length === 0} | |
| > | |
| <span className="material-symbols-outlined text-[14px] mr-1">save</span> | |
| {t("saveMappings")} | |
| </Button> | |
| </div> | |
| </> | |
| )} | |
| {/* When stopped: how it works */} | |
| {!isRunning && | |
| (() => { | |
| // Per-tool MITM hosts redirected to 127.0.0.1 (#505, 9router#788). List every | |
| // host so users on locked-down machines can add the entries to their hosts file | |
| // manually when the automatic (sudo-gated) edit isn't available. | |
| const toolName = tool.name || tool.id; | |
| const hosts = MITM_TOOL_HOSTS[tool.id] ?? MITM_TOOL_HOSTS.antigravity; | |
| return ( | |
| <div className="flex flex-col gap-1.5 px-1"> | |
| <p className="text-xs text-text-muted"> | |
| <span className="font-medium text-text-main">{t("howItWorks")}</span>{" "} | |
| {t("mitmHowWorksDesc", { toolName })} | |
| </p> | |
| <div className="flex flex-col gap-0.5 text-[11px] text-text-muted"> | |
| <span>{t("mitmStep1")}</span> | |
| <span>{t("mitmStep2Prefix")}</span> | |
| <ul className="list-none my-0.5 flex flex-col gap-0.5 font-mono text-[10px] text-text-muted break-all"> | |
| {hosts.map((host) => ( | |
| <li key={host}> | |
| <span className="text-primary">127.0.0.1</span> {host} | |
| </li> | |
| ))} | |
| </ul> | |
| <span>{t("mitmStep2Suffix")}</span> | |
| <span>{t("mitmStep3", { toolName })}</span> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| )} | |
| {/* Password Modal */} | |
| <Modal | |
| isOpen={showPasswordModal} | |
| onClose={() => { | |
| setShowPasswordModal(false); | |
| setSudoPassword(""); | |
| setMessage(null); | |
| }} | |
| title={t("sudoPasswordRequiredTitle")} | |
| size="sm" | |
| > | |
| <div className="flex flex-col gap-4"> | |
| <div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"> | |
| <span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span> | |
| <p className="text-xs text-text-muted">{t("sudoPasswordHint")}</p> | |
| </div> | |
| <Input | |
| type="password" | |
| placeholder={t("enterSudoPassword")} | |
| value={sudoPassword} | |
| onChange={(e) => setSudoPassword(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !loading) handleConfirmPassword(); | |
| }} | |
| /> | |
| {message && ( | |
| <div | |
| className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`} | |
| > | |
| <span className="material-symbols-outlined text-[14px]"> | |
| {message.type === "success" ? "check_circle" : "error"} | |
| </span> | |
| <span>{message.text}</span> | |
| </div> | |
| )} | |
| <div className="flex items-center justify-end gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => { | |
| setShowPasswordModal(false); | |
| setSudoPassword(""); | |
| setMessage(null); | |
| }} | |
| disabled={loading} | |
| > | |
| {t("cancel")} | |
| </Button> | |
| <Button variant="primary" size="sm" onClick={handleConfirmPassword} loading={loading}> | |
| {t("confirm")} | |
| </Button> | |
| </div> | |
| </div> | |
| </Modal> | |
| {/* Model Select Modal */} | |
| <ModelSelectModal | |
| isOpen={modalOpen} | |
| onClose={() => setModalOpen(false)} | |
| onSelect={handleModelSelect} | |
| selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} | |
| activeProviders={activeProviders} | |
| modelAliases={modelAliases} | |
| title={t("selectModelForAlias", { alias: currentEditingAlias || "" })} | |
| /> | |
| </Card> | |
| ); | |
| } | |