File size: 20,625 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
"use client";

import React, { useState, useEffect, useRef, useCallback } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";

interface Role {
  id: string;
  label: string;
  description: string;
}

const HERMES_ROLES: Role[] = [
  { id: "default", label: "Default (main)", description: "Primary conversation model" },
  {
    id: "delegation",
    label: "Delegation (subagents)",
    description: "Orchestrator and sub-agent model",
  },
  { id: "vision", label: "Vision", description: "Image and screenshot understanding" },
  { id: "compression", label: "Compression", description: "Prompt compression & summarization" },
  { id: "web_extract", label: "Web Extract", description: "Web page content extraction" },
  { id: "skills_hub", label: "Skills Hub", description: "Skills and tool-use reasoning" },
  { id: "approval", label: "Approval", description: "Safety and approval decisions" },
];

const HERMES_AGENT_ZERO_CONFIG_PROVIDERS = ["opencode"];

export default function HermesAgentToolCard({
  tool,
  isExpanded = false,
  onToggle = () => {},
  baseUrl,
  apiKeys,
  activeProviders = [],
  hasActiveProviders,
  cloudEnabled,
  batchStatus,
}: any) {
  type RoleSelection = { model: string; provider: string };

  const [selections, setSelections] = useState<Record<string, RoleSelection>>({});
  const [currentRoles, setCurrentRoles] = useState<Record<string, any>>({});
  const [isLoading, setIsLoading] = useState(false);
  const [isSaving, setIsSaving] = useState(false);
  const [message, setMessage] = useState<string | null>(null);
  const [modalRole, setModalRole] = useState<string | null>(null);
  const [previewYaml, setPreviewYaml] = useState<string | null>(null);
  const [isPreviewLoading, setIsPreviewLoading] = useState(false);
  const [firstSetupAt, setFirstSetupAt] = useState<string | null>(null);

  // Track whether we have already seeded from batchStatus on this expand
  const seededFromBatchRef = useRef(false);

  function formatTimeSince(iso: string): string {
    const then = new Date(iso).getTime();
    const diff = Date.now() - then;

    const days = Math.floor(diff / (1000 * 60 * 60 * 24));
    if (days > 0) return `${days}d`;
    const hours = Math.floor(diff / (1000 * 60 * 60));
    if (hours > 0) return `${hours}h`;
    const minutes = Math.floor(diff / (1000 * 60));
    return `${minutes}m`;
  }

  const loadCurrentConfig = useCallback(async () => {
    setIsLoading(true);
    try {
      const res = await fetch("/api/cli-tools/hermes-agent-settings");
      const data = await res.json();
      if (data.success && data.roles) {
        setCurrentRoles(data.roles);
        if (data.firstSetupAt) setFirstSetupAt(data.firstSetupAt);
        // Do NOT seed selections from disk data.
        // selections only holds explicit user choices made in this session.
        // Display falls back to currentRoles for unchanged roles.
      }
    } catch (e) {
      console.warn("Could not load current Hermes Agent config", e);
    } finally {
      setIsLoading(false);
    }
  }, []);

  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();
  }, [isExpanded, batchStatus, loadCurrentConfig]);

  const setRoleSelection = (roleId: string, model: string, provider = "OmniRoute") => {
    setSelections((prev) => ({ ...prev, [roleId]: { model, provider } }));
  };

  const applyToAll = (model: string) => {
    const newSel: Record<string, RoleSelection> = {};
    HERMES_ROLES.forEach((r) => (newSel[r.id] = { model, provider: "OmniRoute" }));
    setSelections(newSel);
  };

  const handleTogglePreview = async () => {
    setMessage(null);

    // If preview is currently visible, hide it (toggle behavior)
    if (previewYaml) {
      setPreviewYaml(null);
      return;
    }

    // Build payload: prefer pending selections, fall back to currently loaded roles
    let payloadSelections: Array<{ role: string; model: string }>;

    if (Object.keys(selections).length > 0) {
      payloadSelections = Object.entries(selections).map(([role, sel]) => ({
        role,
        model: sel.model,
      }));
    } else {
      payloadSelections = Object.entries(currentRoles)
        .filter(([_, info]) => info && info.model)
        .map(([role, info]) => ({ role, model: info.model }));
    }

    if (payloadSelections.length === 0) {
      setMessage("Select models for roles (or ensure roles are loaded) before previewing.");
      return;
    }

    setIsPreviewLoading(true);
    try {
      const res = await fetch("/api/cli-tools/hermes-agent-settings", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          baseUrl,
          keyId: apiKeys?.[0]?.id,
          selections: payloadSelections,
          preview: true,
        }),
      });

      const data = await res.json();
      if (res.ok && data.yaml) {
        setPreviewYaml(data.yaml);
      } else {
        setMessage(data.error || "Failed to generate preview");
      }
    } catch {
      setMessage("Failed to generate preview");
    } finally {
      setIsPreviewLoading(false);
    }
  };

  const handleSave = async () => {
    setIsSaving(true);
    setMessage(null);

    const payloadSelections = Object.entries(selections).map(([role, sel]) => ({
      role,
      model: sel.model,
    }));

    try {
      const res = await fetch("/api/cli-tools/hermes-agent-settings", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          baseUrl,
          keyId: apiKeys?.[0]?.id,
          selections: payloadSelections,
        }),
      });

      const data = await res.json();
      if (res.ok) {
        setMessage(`Saved to ${data.configPath}`);
        setSelections({}); // clear pending user choices after successful save
        setPreviewYaml(null); // hide any open preview after apply
        await loadCurrentConfig();
      } else {
        setMessage(data.error || "Failed to save");
      }
    } catch {
      setMessage("Network error");
    } finally {
      setIsSaving(false);
    }
  };

  const isLoadingAny = isLoading || isSaving;

  // Effective per-role data for count + collapsed status.
  // Priority: pending selections > freshly loaded currentRoles > batchStatus from detector (phase 3)
  const effectiveRoles = React.useMemo(() => {
    // If user has pending changes, treat selected roles as OmniRoute
    if (Object.keys(selections).length > 0) {
      const map: Record<string, any> = {};
      HERMES_ROLES.forEach((r) => {
        if (selections[r.id]) {
          map[r.id] = { usingOmniRoute: true };
        } else if (currentRoles[r.id]) {
          map[r.id] = currentRoles[r.id];
        } else if (batchStatus?.hermesAgentRoles?.[r.id]) {
          map[r.id] = batchStatus.hermesAgentRoles[r.id];
        }
      });
      return map;
    }

    // Prefer fresh loaded data
    if (Object.keys(currentRoles).length > 0) {
      return currentRoles;
    }

    // Fall back to detector snapshot (this is what finishes phase 3)
    return batchStatus?.hermesAgentRoles || {};
  }, [selections, currentRoles, batchStatus]);

  // Count of roles that are (or will be) routed via OmniRoute
  const configuredRolesCount = HERMES_ROLES.filter((role) => {
    // Pending selection always counts as OmniRoute intent
    if (selections[role.id]) return true;

    const info = effectiveRoles[role.id];
    if (!info) return false;

    // Support both shapes: detector shape (usingOmniRoute) and settings shape (provider + base_url)
    if (typeof info.usingOmniRoute === "boolean") {
      return info.usingOmniRoute;
    }
    return (
      info?.provider === "omniroute" ||
      (info?.base_url || "").includes("20128") ||
      (info?.base_url || "").includes("localhost")
    );
  }).length;

  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 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>
          </div>
          <div className="min-w-0">
            <div className="flex items-center gap-2">
              <h3 className="font-medium text-sm flex items-center gap-2">
                {tool?.name || "Hermes Agent"}
                {firstSetupAt && (
                  <span
                    className="text-[10px] text-text-muted flex items-center gap-0.5 font-normal"
                    title={`First set up via OmniRoute on ${new Date(firstSetupAt).toLocaleDateString()}`}
                  >
                    <span className="material-symbols-outlined text-[11px]">schedule</span>
                    {formatTimeSince(firstSetupAt)} since setup
                  </span>
                )}
              </h3>
              {(Object.keys(currentRoles).length > 0 ||
                Object.keys(selections).length > 0 ||
                Object.keys(batchStatus?.hermesAgentRoles || {}).length > 0) && (
                <span className="text-[10px] px-1.5 py-px rounded bg-emerald-500/10 text-emerald-600">
                  {configuredRolesCount}/{HERMES_ROLES.length} roles
                </span>
              )}
            </div>
            <p className="text-xs text-text-muted truncate">
              {tool?.description || "Advanced multi-role terminal agent (by Nousresearch)"}
            </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">
          {/* Right-aligned Refresh button (no counter) */}
          <div className="flex justify-end">
            <Button
              variant="ghost"
              size="sm"
              onClick={loadCurrentConfig}
              disabled={isLoading}
              loading={isLoading}
            >
              <span className="material-symbols-outlined text-[14px] mr-1">refresh</span>
              Refresh all
            </Button>
          </div>

          {/* Quick apply row β€” consistent small action pills */}
          {activeProviders?.[0]?.models?.length > 0 && (
            <div className="flex flex-wrap items-center gap-2 text-xs">
              <span className="text-text-muted">Quick apply same model to all roles:</span>
              {activeProviders[0].models.slice(0, 6).map((m: any) => {
                const modelValue = typeof m === "string" ? m : m?.value || m?.name;
                if (!modelValue) return null;
                return (
                  <button
                    key={modelValue}
                    onClick={() => applyToAll(modelValue)}
                    className="px-2 py-0.5 rounded border border-border bg-surface hover:bg-bg-secondary text-text-main transition-colors"
                    title={`Apply ${modelValue} to every role`}
                  >
                    {modelValue}
                  </button>
                );
              })}
            </div>
          )}

          {/* 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 sel = selections[role.id];

              // displayed model prefers pending user choice, falls back to real current from YAML
              const displayedModel = sel?.model || current?.model;

              // Badge logic per user's spec:
              // - If user has selected something in this session (pending): show as via OmniRoute
              // - Else if current from disk: show real provider name + "(not OmniRoute)" or "OmniRoute"
              let badge: { label: string; pending: boolean } | null = null;

              if (sel) {
                // pending change made via the Select modal / quick apply β†’ will be routed via OmniRoute
                const prov = sel.provider || "OmniRoute";
                badge = { label: `${prov} (via OmniRoute)`, pending: true };
              } else if (current) {
                const isOmni =
                  current?.provider === "omniroute" ||
                  (current?.base_url || "").includes("20128") ||
                  (current?.base_url || "").includes("localhost");

                if (isOmni) {
                  badge = { label: "OmniRoute", pending: false };
                } else {
                  const realProvider = current.provider || "Other";
                  badge = { label: `${realProvider} (not OmniRoute)`, pending: false };
                }
              }

              return (
                <div key={role.id} className="flex items-start justify-between gap-3 py-1">
                  {/* Left: role label + subtitle (now has room so long descriptions stay on one line) */}
                  <div className="min-w-0 pr-3">
                    <div className="font-medium text-sm text-text-main">{role.label}</div>
                    <div className="text-[10px] leading-tight text-text-muted">
                      {role.description}
                    </div>
                  </div>

                  {/* Right cluster: model name + status badge + actions (pushed to the right) */}
                  <div className="flex items-center gap-2 shrink-0">
                    {displayedModel ? (
                      <code
                        className="px-1.5 py-0.5 rounded bg-black/5 dark:bg-white/10 font-mono text-text-main text-[10px] max-w-[200px] truncate"
                        title={displayedModel}
                      >
                        {displayedModel}
                      </code>
                    ) : (
                      <span className="text-text-muted text-[10px]">β€”</span>
                    )}

                    {badge && (
                      <div
                        className={`text-[10px] px-1.5 py-px rounded shrink-0 ${
                          badge.label.includes("not OmniRoute")
                            ? "bg-amber-500/10 text-amber-600"
                            : "bg-emerald-500/10 text-emerald-600"
                        }`}
                      >
                        {badge.label}
                        {badge.pending ? " *" : ""}
                      </div>
                    )}

                    <Button
                      variant="secondary"
                      size="sm"
                      onClick={() => setModalRole(role.id)}
                      disabled={isLoadingAny}
                    >
                      Select
                    </Button>

                    {sel && (
                      <Button
                        variant="ghost"
                        size="sm"
                        onClick={() => {
                          setSelections((prev) => {
                            const next = { ...prev };
                            delete next[role.id];
                            return next;
                          });
                        }}
                        disabled={isLoadingAny}
                        title="Remove this role from pending changes"
                      >
                        Clear
                      </Button>
                    )}
                  </div>
                </div>
              );
            })}
          </div>

          {/* Message (standard colored info bar like other cards) */}
          {message && (
            <div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-green-500/10 text-green-600">
              <span className="material-symbols-outlined text-[14px]">check_circle</span>
              <span>{message}</span>
            </div>
          )}

          {/* Action row β€” primary Apply + right spacer for future actions */}
          <div className="flex items-center gap-2">
            <Button
              onClick={handleSave}
              disabled={isSaving || isLoading || Object.keys(selections).length === 0}
              variant="primary"
              size="sm"
              loading={isSaving}
            >
              <span className="material-symbols-outlined text-[14px] mr-1">save</span>
              Apply to Hermes Agent
            </Button>

            <Button
              variant="ghost"
              size="sm"
              onClick={handleTogglePreview}
              disabled={
                isSaving ||
                isLoading ||
                (Object.keys(selections).length === 0 && Object.keys(currentRoles).length === 0)
              }
              loading={isPreviewLoading}
            >
              <span className="material-symbols-outlined text-[14px] mr-1">visibility</span>
              Preview
            </Button>

            {Object.keys(selections).length > 0 && (
              <span className="text-xs text-text-muted ml-1">
                {Object.keys(selections).length} role
                {Object.keys(selections).length === 1 ? "" : "s"} will be updated
              </span>
            )}

            <div className="flex-1" />

            {/* Optional future: Reset or Manual config could go here, right-aligned */}
          </div>

          {/* Inline YAML preview β€” toggled by the Preview button, styled like other config previews on the CLI tools page */}
          {previewYaml && (
            <div className="mt-2">
              <div className="text-[10px] font-medium text-text-muted mb-1.5 flex items-center gap-1.5">
                <span>Preview β€” will write to ~/.hermes/config.yaml</span>
              </div>
              <pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-auto max-h-80 text-xs">
                <code className="font-mono whitespace-pre text-text-main">{previewYaml}</code>
              </pre>
            </div>
          )}

          <p className="text-xs text-text-muted -mt-2">
            Saves the selected models for each role into <code>~/.hermes/config.yaml</code>.
          </p>
        </div>
      )}

      <ModelSelectModal
        isOpen={!!modalRole}
        onClose={() => setModalRole(null)}
        onSelect={(model: any) => {
          if (modalRole) {
            const modelValue = typeof model === "string" ? model : model?.value || model?.name;
            if (modelValue) {
              // Capture a useful provider label from the modal selection when available
              const prov =
                (model && (model.provider || model.providerId || model.group)) || "OmniRoute";
              setRoleSelection(modalRole, modelValue, prov);
            }
          }
          setModalRole(null);
        }}
        showCombos={true}
        activeProviders={activeProviders}
        alwaysIncludeProviders={HERMES_AGENT_ZERO_CONFIG_PROVIDERS}
      />
    </Card>
  );
}