| "use client";
|
|
|
| import { useState, useEffect, useRef } from "react";
|
| import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
| import Image from "next/image";
|
| import CliStatusBadge from "./CliStatusBadge";
|
| import { useTranslations } from "next-intl";
|
| import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
|
|
|
| const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
|
|
| export default function KiloToolCard({
|
| tool,
|
| isExpanded = false,
|
| onToggle = () => {},
|
| baseUrl,
|
| hasActiveProviders,
|
| apiKeys,
|
| activeProviders,
|
| cloudEnabled,
|
| batchStatus,
|
| lastConfiguredAt,
|
| }) {
|
| const t = useTranslations("cliTools");
|
| const [kiloStatus, setKiloStatus] = useState(null);
|
| const [checkingKilo, setCheckingKilo] = useState(false);
|
| const [applying, setApplying] = useState(false);
|
| const [restoring, setRestoring] = useState(false);
|
| const [message, setMessage] = useState(null);
|
| const [selectedApiKeyId, setSelectedApiKeyId] = useState("");
|
| const [selectedModel, setSelectedModel] = useState("");
|
| const [modalOpen, setModalOpen] = useState(false);
|
| const [modelAliases, setModelAliases] = useState({});
|
| const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
| const [customBaseUrl, setCustomBaseUrl] = useState("");
|
| const hasInitializedModel = useRef(false);
|
|
|
| const [backups, setBackups] = useState([]);
|
| const [showBackups, setShowBackups] = useState(false);
|
| const [restoringBackup, setRestoringBackup] = useState(null);
|
| const cliReady = !!(kiloStatus?.installed && kiloStatus?.runnable);
|
|
|
| const getConfigStatus = () => {
|
| if (!cliReady) return null;
|
| if (!kiloStatus.hasOmniRoute) return "not_configured";
|
| return "configured";
|
| };
|
|
|
| const configStatus = getConfigStatus();
|
|
|
|
|
| const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
|
|
|
|
|
|
| useEffect(() => {
|
| if (apiKeys?.length > 0 && !selectedApiKeyId) {
|
| setSelectedApiKeyId(apiKeys[0].id);
|
| }
|
| }, [apiKeys, selectedApiKeyId]);
|
|
|
| useEffect(() => {
|
| if (isExpanded && !kiloStatus) {
|
| checkKiloStatus();
|
| fetchModelAliases();
|
| fetchBackups();
|
| }
|
| }, [isExpanded, kiloStatus]);
|
|
|
| const fetchModelAliases = async () => {
|
| try {
|
| const res = await fetch("/api/models/alias");
|
| if (res.ok) {
|
| const data = await res.json();
|
| setModelAliases(data.aliases || {});
|
| }
|
| } catch {
|
|
|
| }
|
| };
|
|
|
| const fetchBackups = async () => {
|
| try {
|
| const res = await fetch("/api/cli-tools/backups?tool=kilo");
|
| if (res.ok) {
|
| const data = await res.json();
|
| setBackups(data.backups || []);
|
| }
|
| } catch {
|
|
|
| }
|
| };
|
|
|
| const handleRestoreBackup = async (backupId) => {
|
| setRestoringBackup(backupId);
|
| try {
|
| const res = await fetch("/api/cli-tools/backups", {
|
| method: "POST",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ tool: "kilo", backupId }),
|
| });
|
| if (res.ok) {
|
| setMessage({ type: "success", text: t("backupRestoredReloading") });
|
| await checkKiloStatus();
|
| await fetchBackups();
|
| } else {
|
| const data = await res.json();
|
| setMessage({
|
| type: "error",
|
| text:
|
| (typeof data.error === "string" ? data.error : data.error?.message) ||
|
| t("failedRestoreBackup"),
|
| });
|
| }
|
| } catch (e) {
|
| setMessage({ type: "error", text: e.message });
|
| } finally {
|
| setRestoringBackup(null);
|
| }
|
| };
|
|
|
| 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;
|
| };
|
|
|
| const handleApply = async () => {
|
| setApplying(true);
|
| setMessage(null);
|
| try {
|
| const effectiveBaseUrl = getEffectiveBaseUrl();
|
| const normalizedBaseUrl = effectiveBaseUrl.endsWith("/v1")
|
| ? effectiveBaseUrl
|
| : `${effectiveBaseUrl}/v1`;
|
|
|
|
|
| const selectedKeyId = selectedApiKeyId?.trim() || null;
|
|
|
| const res = await fetch("/api/cli-tools/kilo-settings", {
|
| method: "POST",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({
|
| baseUrl: normalizedBaseUrl,
|
| apiKey: !cloudEnabled ? "sk_omniroute" : null,
|
| keyId: selectedKeyId,
|
| model: selectedModel,
|
| }),
|
| });
|
| const data = await res.json();
|
| if (res.ok) {
|
| setMessage({ type: "success", text: data.message || t("applied") });
|
| await checkKiloStatus();
|
| await fetchBackups();
|
| } else {
|
| setMessage({
|
| type: "error",
|
| text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failed"),
|
| });
|
| }
|
| } catch (error) {
|
| setMessage({ type: "error", text: error.message });
|
| } finally {
|
| setApplying(false);
|
| }
|
| };
|
|
|
| const handleReset = async () => {
|
| setRestoring(true);
|
| setMessage(null);
|
| try {
|
| const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" });
|
| const data = await res.json();
|
| if (res.ok) {
|
| setMessage({ type: "success", text: data.message || t("resetDone") });
|
| setSelectedModel("");
|
| hasInitializedModel.current = false;
|
| await checkKiloStatus();
|
| await fetchBackups();
|
| } else {
|
| setMessage({
|
| type: "error",
|
| text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failed"),
|
| });
|
| }
|
| } catch (error) {
|
| setMessage({ type: "error", text: error.message });
|
| } finally {
|
| setRestoring(false);
|
| }
|
| };
|
|
|
| const handleSelectModel = (model) => {
|
| setSelectedModel(model.value);
|
| setModalOpen(false);
|
| };
|
|
|
| const handleManualConfig = (config) => {
|
| if (config.model) setSelectedModel(config.model);
|
|
|
| if (config.apiKey && apiKeys?.length > 0) {
|
| const prefix = config.apiKey.slice(0, 8);
|
| const suffix = config.apiKey.slice(-4);
|
| const matchedKey = apiKeys.find(
|
| (k) => k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)
|
| );
|
| if (matchedKey) setSelectedApiKeyId(matchedKey.id);
|
| }
|
| if (config.baseUrl) setCustomBaseUrl(config.baseUrl);
|
| setShowManualConfigModal(false);
|
| };
|
|
|
| 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 rounded-lg flex items-center justify-center shrink-0">
|
| {tool.image ? (
|
| <Image
|
| src={tool.image}
|
| alt={tool.name}
|
| width={32}
|
| height={32}
|
| className="size-8 object-contain rounded-lg"
|
| sizes="32px"
|
| onError={(e) => {
|
| (e.currentTarget as HTMLElement).style.display = "none";
|
| }}
|
| />
|
| ) : (
|
| <span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>
|
| terminal
|
| </span>
|
| )}
|
| </div>
|
| <div className="min-w-0">
|
| <div className="flex items-center gap-2">
|
| <h3 className="font-medium text-sm">{tool.name}</h3>
|
| <CliStatusBadge
|
| effectiveConfigStatus={effectiveConfigStatus}
|
| batchStatus={batchStatus}
|
| lastConfiguredAt={lastConfiguredAt}
|
| />
|
| </div>
|
| <p className="text-xs text-text-muted truncate">{t("toolDescriptions.kilo")}</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-6 pt-6 border-t border-border">
|
| {checkingKilo && (
|
| <div className="flex items-center gap-2 text-text-muted text-sm">
|
| <span className="material-symbols-outlined animate-spin text-base">
|
| progress_activity
|
| </span>
|
| <span>{t("checkingCli", { tool: "Kilo Code" })}</span>
|
| </div>
|
| )}
|
|
|
| {kiloStatus && !checkingKilo && (
|
| <div className="flex flex-col gap-4">
|
| {/* Runtime status */}
|
| <div className="flex items-start gap-3 p-3 rounded-lg border bg-bg-secondary/50 border-border">
|
| <span
|
| className={`material-symbols-outlined text-lg ${cliReady ? "text-green-500" : "text-yellow-500"}`}
|
| >
|
| {cliReady ? "check_circle" : "warning"}
|
| </span>
|
| <div className="flex flex-col gap-1">
|
| <p className="text-sm font-medium">
|
| {cliReady
|
| ? t("cliDetectedReady", { tool: "Kilo Code" })
|
| : kiloStatus.installed
|
| ? t("cliNotRunnable", { tool: "Kilo Code" })
|
| : t("cliNotDetected", { tool: "Kilo Code" })}
|
| </p>
|
| {kiloStatus.commandPath && (
|
| <p className="text-xs text-text-muted">
|
| {t("binary")}:{" "}
|
| <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
| {kiloStatus.commandPath}
|
| </code>
|
| </p>
|
| )}
|
| {kiloStatus.authPath && (
|
| <p className="text-xs text-text-muted">
|
| {t("auth")}:{" "}
|
| <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
| {kiloStatus.authPath}
|
| </code>
|
| </p>
|
| )}
|
| </div>
|
| </div>
|
|
|
| {cliReady && (
|
| <>
|
| {/* Current config info */}
|
| {configStatus === "configured" && (
|
| <div className="flex items-start gap-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
| <span className="material-symbols-outlined text-green-500 text-lg">
|
| check_circle
|
| </span>
|
| <div className="flex flex-col gap-1">
|
| <p className="text-sm text-green-700 dark:text-green-300">
|
| {t("omnirouteConfiguredOpenAiCompatible")}
|
| </p>
|
| <p className="text-xs text-text-muted">
|
| {t("providers")}:{" "}
|
| <strong>{kiloStatus.settings?.auth?.join(", ") || "—"}</strong>
|
| </p>
|
| </div>
|
| </div>
|
| )}
|
|
|
| {/* Model selection */}
|
| <div className="flex flex-col gap-2">
|
| <label className="text-sm text-text-muted">{t("model")}</label>
|
| <div className="flex items-center gap-2">
|
| <input
|
| type="text"
|
| value={selectedModel}
|
| onChange={(e) => setSelectedModel(e.target.value)}
|
| placeholder={t("providerModelPlaceholder")}
|
| className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| />
|
| <Button
|
| variant="outline"
|
| size="sm"
|
| onClick={() => setModalOpen(true)}
|
| disabled={!hasActiveProviders}
|
| >
|
| {t("select")}
|
| </Button>
|
| <Button
|
| variant="ghost"
|
| size="sm"
|
| onClick={() => setShowManualConfigModal(true)}
|
| >
|
| <span className="material-symbols-outlined text-[16px]">edit</span>
|
| </Button>
|
| </div>
|
| </div>
|
|
|
| {/* API Key selection */}
|
| <div className="flex flex-col gap-2">
|
| <label className="text-sm text-text-muted">{t("apiKey")}</label>
|
| {apiKeys && apiKeys.length > 0 ? (
|
| <select
|
| value={selectedApiKeyId}
|
| 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"
|
| >
|
| {apiKeys.map((key) => (
|
| <option key={key.id} value={key.id}>
|
| {key.key}
|
| </option>
|
| ))}
|
| </select>
|
| ) : (
|
| <p className="text-sm text-text-muted">
|
| {cloudEnabled ? t("noApiKeysAvailable") : t("usingDefaultOmniroute")}
|
| </p>
|
| )}
|
| </div>
|
|
|
| {/* Action buttons */}
|
| <div className="flex items-center gap-2 pt-2">
|
| <Button
|
| variant="primary"
|
| size="sm"
|
| onClick={handleApply}
|
| disabled={!selectedModel}
|
| loading={applying}
|
| >
|
| <span className="material-symbols-outlined text-[14px] mr-1">save</span>
|
| {configStatus === "configured" ? t("updateConfig") : t("applyConfig")}
|
| </Button>
|
| {configStatus === "configured" && (
|
| <Button variant="outline" size="sm" onClick={handleReset} loading={restoring}>
|
| <span className="material-symbols-outlined text-[14px] mr-1">
|
| restart_alt
|
| </span>
|
| {t("reset")}
|
| </Button>
|
| )}
|
| </div>
|
|
|
| {/* Message */}
|
| {message && (
|
| <div
|
| className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}
|
| >
|
| <span className="material-symbols-outlined text-[16px]">
|
| {message.type === "success" ? "check_circle" : "error"}
|
| </span>
|
| <span>{message.text}</span>
|
| </div>
|
| )}
|
|
|
| {/* Backups section */}
|
| <div className="border-t border-border pt-3 mt-1">
|
| <button
|
| onClick={() => setShowBackups(!showBackups)}
|
| className="flex items-center gap-2 text-sm text-text-muted hover:text-text transition-colors"
|
| >
|
| <span
|
| className={`material-symbols-outlined text-[16px] transition-transform ${showBackups ? "rotate-90" : ""}`}
|
| >
|
| chevron_right
|
| </span>
|
| <span className="material-symbols-outlined text-[16px]">backup</span>
|
| {t("backups")} {backups.length > 0 && `(${backups.length})`}
|
| </button>
|
| {showBackups && backups.length > 0 && (
|
| <div className="mt-2 flex flex-col gap-1.5 pl-6">
|
| {backups.map((b) => (
|
| <div
|
| key={b.id}
|
| className="flex items-center justify-between gap-2 p-2 rounded bg-bg-secondary/50 text-xs"
|
| >
|
| <div className="flex flex-col">
|
| <span className="font-medium">{b.originalFile}</span>
|
| <span className="text-text-muted">
|
| {new Date(b.createdAt).toLocaleString()}
|
| </span>
|
| </div>
|
| <Button
|
| variant="ghost"
|
| size="sm"
|
| onClick={() => handleRestoreBackup(b.id)}
|
| loading={restoringBackup === b.id}
|
| >
|
| {t("restore")}
|
| </Button>
|
| </div>
|
| ))}
|
| </div>
|
| )}
|
| {showBackups && backups.length === 0 && (
|
| <p className="mt-2 pl-6 text-xs text-text-muted">{t("noBackupsAvailable")}</p>
|
| )}
|
| </div>
|
| </>
|
| )}
|
| </div>
|
| )}
|
| </div>
|
| )}
|
|
|
| <ModelSelectModal
|
| isOpen={modalOpen}
|
| onClose={() => setModalOpen(false)}
|
| onSelect={handleSelectModel}
|
| selectedModel={selectedModel}
|
| activeProviders={activeProviders}
|
| title={t("selectModelForTool", { tool: "Kilo Code" })}
|
| />
|
| {showManualConfigModal && (
|
| <ManualConfigModal
|
| isOpen={showManualConfigModal}
|
| onClose={() => setShowManualConfigModal(false)}
|
| title={t("kiloManualConfiguration")}
|
| {...({
|
| onApply: handleManualConfig,
|
| currentConfig: {
|
| model: selectedModel,
|
| apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "",
|
| baseUrl: customBaseUrl || baseUrl,
|
| },
|
| } as any)}
|
| />
|
| )}
|
| </Card>
|
| );
|
| }
|
|
|