@tokagent/tokagentos 2.0.29 → 2.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,7 @@ import {
23
23
  } from "@elizaos/ui";
24
24
  import {
25
25
  Calendar,
26
+ Check,
26
27
  CheckCircle2,
27
28
  ChevronDown,
28
29
  ChevronRight,
@@ -3045,6 +3046,356 @@ function WorkflowDataFlowStrip({
3045
3046
  );
3046
3047
  }
3047
3048
 
3049
+ /**
3050
+ * Single row in the Run history list. Click to expand → lazy-fetches the
3051
+ * agent output for this run via /api/triggers/:triggerId/runs/:runId/output
3052
+ * and renders it inline. Caches per-row, so toggling collapse/expand
3053
+ * doesn't re-fetch. Errors are rendered in place, never crash the list.
3054
+ */
3055
+ function TriggerRunRow({
3056
+ run,
3057
+ triggerId,
3058
+ triggerInstructions,
3059
+ expanded,
3060
+ onToggle,
3061
+ uiLanguage,
3062
+ t,
3063
+ }: {
3064
+ run: import("../../api").TriggerRunRecord;
3065
+ triggerId: string;
3066
+ triggerInstructions: string;
3067
+ expanded: boolean;
3068
+ onToggle: () => void;
3069
+ uiLanguage: string;
3070
+ t: (key: string, opts?: Record<string, unknown>) => string;
3071
+ }) {
3072
+ type OutputState =
3073
+ | { phase: "idle" }
3074
+ | { phase: "loading" }
3075
+ | {
3076
+ phase: "ready";
3077
+ text: string;
3078
+ truncated: boolean;
3079
+ messageCount?: number;
3080
+ segments?: Array<{
3081
+ stage: "action" | "final";
3082
+ action?: string;
3083
+ text: string;
3084
+ }>;
3085
+ }
3086
+ | {
3087
+ phase: "pending";
3088
+ kind: "still_processing" | "skipped" | "no_output" | "no_autonomy_room";
3089
+ message?: string;
3090
+ diagnostics?: {
3091
+ memoriesInWindow: number;
3092
+ agentId: string;
3093
+ roomId: string;
3094
+ windowStart: number;
3095
+ windowEnd: number;
3096
+ peek: Array<{
3097
+ entityIdMatches: boolean;
3098
+ source: string | null;
3099
+ metadataType: unknown;
3100
+ textPreview: string | null;
3101
+ createdAt: number | null;
3102
+ }>;
3103
+ };
3104
+ }
3105
+ | { phase: "error"; message: string };
3106
+
3107
+ const [output, setOutput] = useState<OutputState>({ phase: "idle" });
3108
+
3109
+ const fetchOutput = useCallback(async () => {
3110
+ setOutput({ phase: "loading" });
3111
+ try {
3112
+ const res = await client.getTriggerRunOutput(triggerId, run.triggerRunId);
3113
+ if (res.output && res.status === "ready") {
3114
+ setOutput({
3115
+ phase: "ready",
3116
+ text: res.output.text,
3117
+ truncated: res.output.truncated,
3118
+ messageCount: res.messageCount,
3119
+ segments: res.segments,
3120
+ });
3121
+ return;
3122
+ }
3123
+ if (
3124
+ res.status === "still_processing" ||
3125
+ res.status === "skipped" ||
3126
+ res.status === "no_output" ||
3127
+ res.status === "no_autonomy_room"
3128
+ ) {
3129
+ setOutput({
3130
+ phase: "pending",
3131
+ kind: res.status,
3132
+ message: res.message,
3133
+ diagnostics: res.diagnostics,
3134
+ });
3135
+ return;
3136
+ }
3137
+ setOutput({
3138
+ phase: "error",
3139
+ message: res.message ?? `Lookup returned status: ${res.status}`,
3140
+ });
3141
+ } catch (err) {
3142
+ setOutput({
3143
+ phase: "error",
3144
+ message: err instanceof Error ? err.message : String(err),
3145
+ });
3146
+ }
3147
+ }, [triggerId, run.triggerRunId]);
3148
+
3149
+ // Lazy: fetch the first time the row is expanded, not on render.
3150
+ useEffect(() => {
3151
+ if (expanded && output.phase === "idle") {
3152
+ void fetchOutput();
3153
+ }
3154
+ }, [expanded, output.phase, fetchOutput]);
3155
+
3156
+ const ChevronIcon = expanded ? ChevronDown : ChevronRight;
3157
+ const startedAt = new Date(run.startedAt);
3158
+ const finishedAt = new Date(run.finishedAt);
3159
+ const fullStarted = `${startedAt.toLocaleString(uiLanguage || undefined)} (${startedAt.toISOString()})`;
3160
+ const fullFinished = finishedAt.toISOString();
3161
+
3162
+ const pendingCopy =
3163
+ output.phase === "pending"
3164
+ ? output.kind === "still_processing"
3165
+ ? "Agent is still working on this run. Try refresh in a moment."
3166
+ : output.kind === "skipped"
3167
+ ? "This run was skipped — no output to show."
3168
+ : output.kind === "no_output"
3169
+ ? "No agent reply was recorded in the autonomy room for this run's time window."
3170
+ : output.kind === "no_autonomy_room"
3171
+ ? "Autonomy room not configured — cannot resolve outputs for past runs."
3172
+ : null
3173
+ : null;
3174
+
3175
+ return (
3176
+ <div className="border-b border-border/20 last:border-b-0">
3177
+ <button
3178
+ type="button"
3179
+ onClick={onToggle}
3180
+ aria-expanded={expanded}
3181
+ className={`flex w-full flex-wrap items-center gap-2 px-3 py-2 text-left text-xs-tight transition-colors hover:bg-muted/30 ${
3182
+ expanded ? "bg-muted/20" : ""
3183
+ }`}
3184
+ >
3185
+ <ChevronIcon className="h-3 w-3 shrink-0 text-muted/60" />
3186
+ <StatusBadge
3187
+ label={localizedExecutionStatus(run.status, t)}
3188
+ variant={toneForLastStatus(run.status)}
3189
+ />
3190
+ <span className="text-muted/70 tabular-nums">
3191
+ {formatDateTime(run.startedAt, { locale: uiLanguage })}
3192
+ </span>
3193
+ <span className="text-muted/60">
3194
+ {formatDurationMs(run.latencyMs, { t })}
3195
+ </span>
3196
+ <span className="ml-auto rounded bg-bg/40 px-1 py-0.5 font-mono text-[10px] text-muted/60">
3197
+ {run.source}
3198
+ </span>
3199
+ </button>
3200
+
3201
+ {expanded ? (
3202
+ <div className="border-t border-border/20 bg-bg/20 px-3 py-3 text-xs-tight space-y-3">
3203
+ {/* Timestamps + IDs */}
3204
+ <div className="grid grid-cols-1 gap-1 text-[11px] text-muted/70 sm:grid-cols-2">
3205
+ <div>
3206
+ <span className="text-muted/50">Started:</span> {fullStarted}
3207
+ </div>
3208
+ <div>
3209
+ <span className="text-muted/50">Finished:</span> {fullFinished}
3210
+ </div>
3211
+ <div className="font-mono text-[10px] text-muted/50 truncate">
3212
+ run {run.triggerRunId}
3213
+ </div>
3214
+ <div className="font-mono text-[10px] text-muted/50 truncate">
3215
+ task {run.taskId}
3216
+ </div>
3217
+ </div>
3218
+
3219
+ {/* Instruction */}
3220
+ {triggerInstructions ? (
3221
+ <div>
3222
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted/60">
3223
+ Instruction
3224
+ </div>
3225
+ <div className="whitespace-pre-wrap rounded border border-border/30 bg-bg/40 px-2 py-1.5 text-[11px] text-muted/90">
3226
+ {triggerInstructions}
3227
+ </div>
3228
+ </div>
3229
+ ) : null}
3230
+
3231
+ {/* Output */}
3232
+ <div>
3233
+ <div className="mb-1 flex items-center justify-between gap-2">
3234
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted/60">
3235
+ Output
3236
+ </span>
3237
+ {output.phase === "ready" || output.phase === "pending" || output.phase === "error" ? (
3238
+ <button
3239
+ type="button"
3240
+ className="text-[10px] text-muted/60 underline-offset-2 hover:text-txt hover:underline"
3241
+ onClick={(e) => {
3242
+ e.stopPropagation();
3243
+ void fetchOutput();
3244
+ }}
3245
+ >
3246
+ {t("common.refresh")}
3247
+ </button>
3248
+ ) : null}
3249
+ </div>
3250
+ {output.phase === "idle" || output.phase === "loading" ? (
3251
+ <div className="flex items-center gap-2 rounded border border-border/30 bg-bg/40 px-2 py-2 text-[11px] text-muted/60">
3252
+ <div className="h-3 w-3 animate-spin rounded-full border-2 border-muted/30 border-t-muted/80" />
3253
+ Loading output…
3254
+ </div>
3255
+ ) : output.phase === "ready" ? (
3256
+ <div className="space-y-2">
3257
+ {output.segments && output.segments.length > 0 ? (
3258
+ output.segments.map((seg, i) => {
3259
+ const isAction = seg.stage === "action";
3260
+ return (
3261
+ <div
3262
+ key={`${seg.stage}-${i}`}
3263
+ className={`rounded border px-2 py-1.5 text-[11px] ${
3264
+ isAction
3265
+ ? "border-accent/30 bg-accent/5"
3266
+ : "border-border/30 bg-bg/40 text-txt/85"
3267
+ }`}
3268
+ >
3269
+ <div
3270
+ className={`mb-1 text-[9px] font-semibold uppercase tracking-wider ${
3271
+ isAction ? "text-accent" : "text-muted/60"
3272
+ }`}
3273
+ >
3274
+ {isAction
3275
+ ? `Tool · ${seg.action ?? "action"}`
3276
+ : "Agent reply"}
3277
+ </div>
3278
+ <div className="whitespace-pre-wrap font-mono text-[11px]">
3279
+ {seg.text}
3280
+ </div>
3281
+ </div>
3282
+ );
3283
+ })
3284
+ ) : (
3285
+ <div className="whitespace-pre-wrap rounded border border-border/30 bg-bg/40 px-2 py-1.5 text-[11px] text-txt/85">
3286
+ {output.text}
3287
+ </div>
3288
+ )}
3289
+ {output.truncated ? (
3290
+ <div className="text-[10px] text-muted/50">
3291
+ Output truncated — see autonomy room for full reply.
3292
+ </div>
3293
+ ) : null}
3294
+ </div>
3295
+ ) : output.phase === "pending" ? (
3296
+ <div className="space-y-2">
3297
+ <div className="rounded border border-border/30 bg-bg/40 px-2 py-1.5 text-[11px] italic text-muted/70">
3298
+ {pendingCopy}
3299
+ </div>
3300
+ {output.diagnostics ? (
3301
+ <details className="text-[10px] text-muted/60">
3302
+ <summary className="cursor-pointer select-none hover:text-txt">
3303
+ Diagnostics ({output.diagnostics.memoriesInWindow}{" "}
3304
+ memor{output.diagnostics.memoriesInWindow === 1 ? "y" : "ies"}{" "}
3305
+ in window)
3306
+ </summary>
3307
+ <div className="mt-1.5 space-y-1.5 rounded border border-border/30 bg-bg/40 px-2 py-1.5 font-mono">
3308
+ <div>
3309
+ agentId:{" "}
3310
+ <span className="text-muted/80">
3311
+ {output.diagnostics.agentId}
3312
+ </span>
3313
+ </div>
3314
+ <div>
3315
+ roomId:{" "}
3316
+ <span className="text-muted/80">
3317
+ {output.diagnostics.roomId}
3318
+ </span>
3319
+ </div>
3320
+ <div>
3321
+ window:{" "}
3322
+ <span className="text-muted/80">
3323
+ {new Date(output.diagnostics.windowStart).toISOString()}{" "}
3324
+ → {new Date(output.diagnostics.windowEnd).toISOString()}
3325
+ </span>
3326
+ </div>
3327
+ {output.diagnostics.peek.length === 0 ? (
3328
+ <div className="italic">
3329
+ No memories at all in window — autonomy loop never
3330
+ wrote to the room.
3331
+ </div>
3332
+ ) : (
3333
+ <div>
3334
+ <div className="mb-1">First {output.diagnostics.peek.length} memories:</div>
3335
+ <ul className="space-y-1 pl-3">
3336
+ {output.diagnostics.peek.map((p, i) => (
3337
+ <li
3338
+ key={`${p.createdAt ?? ""}-${i}`}
3339
+ className="border-l border-border/40 pl-2"
3340
+ >
3341
+ <div>
3342
+ entityIdMatches:{" "}
3343
+ <span
3344
+ className={
3345
+ p.entityIdMatches
3346
+ ? "text-ok/80"
3347
+ : "text-warning/80"
3348
+ }
3349
+ >
3350
+ {String(p.entityIdMatches)}
3351
+ </span>
3352
+ </div>
3353
+ <div>
3354
+ source: {p.source ?? "(none)"}
3355
+ </div>
3356
+ <div>
3357
+ metadata.type: {String(p.metadataType ?? "(none)")}
3358
+ </div>
3359
+ {p.textPreview ? (
3360
+ <div className="text-muted/80">
3361
+ text: "{p.textPreview}"
3362
+ </div>
3363
+ ) : (
3364
+ <div className="italic">no text</div>
3365
+ )}
3366
+ </li>
3367
+ ))}
3368
+ </ul>
3369
+ </div>
3370
+ )}
3371
+ </div>
3372
+ </details>
3373
+ ) : null}
3374
+ </div>
3375
+ ) : (
3376
+ <div className="whitespace-pre-wrap rounded border border-danger/20 bg-danger/10 px-2 py-1.5 font-mono text-[11px] text-danger/90">
3377
+ Couldn't load output: {output.message}
3378
+ </div>
3379
+ )}
3380
+ </div>
3381
+
3382
+ {/* Error trace */}
3383
+ {run.error ? (
3384
+ <div>
3385
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-danger/70">
3386
+ Error
3387
+ </div>
3388
+ <div className="whitespace-pre-wrap rounded border border-danger/20 bg-danger/10 px-2 py-1.5 font-mono text-[11px] text-danger/90">
3389
+ {run.error}
3390
+ </div>
3391
+ </div>
3392
+ ) : null}
3393
+ </div>
3394
+ ) : null}
3395
+ </div>
3396
+ );
3397
+ }
3398
+
3048
3399
  function TriggerAutomationDetailPane({
3049
3400
  automation,
3050
3401
  onPromoteToWorkflow,
@@ -3073,6 +3424,67 @@ function TriggerAutomationDetailPane({
3073
3424
  ? Object.hasOwn(triggerRunsById, triggerId)
3074
3425
  : false;
3075
3426
 
3427
+ // "Run now" UX — local state so the button gives feedback instead of
3428
+ // looking like a static link. While the dispatch is in flight: button
3429
+ // disabled + spinner. After it resolves: brief "Triggered" green tick,
3430
+ // toast in the global notice tray, and the run-history list refreshes
3431
+ // so the new run row appears immediately (no manual refresh needed).
3432
+ const { setActionNotice } = useApp();
3433
+ const [runNowState, setRunNowState] = useState<
3434
+ "idle" | "running" | "fired"
3435
+ >("idle");
3436
+ const runNowFiredTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
3437
+ null,
3438
+ );
3439
+ useEffect(() => {
3440
+ return () => {
3441
+ if (runNowFiredTimeoutRef.current) {
3442
+ clearTimeout(runNowFiredTimeoutRef.current);
3443
+ }
3444
+ };
3445
+ }, []);
3446
+ const handleRunNow = useCallback(async () => {
3447
+ if (!triggerId || runNowState === "running") return;
3448
+ setRunNowState("running");
3449
+ try {
3450
+ await onRunSelectedTrigger(triggerId);
3451
+ setActionNotice?.(
3452
+ "Trigger fired. Watch the Run history for the agent's output.",
3453
+ "success",
3454
+ 4000,
3455
+ );
3456
+ // Refresh runs so the new row pops in without a manual refresh.
3457
+ void loadTriggerRuns(triggerId);
3458
+ setRunNowState("fired");
3459
+ runNowFiredTimeoutRef.current = setTimeout(() => {
3460
+ setRunNowState("idle");
3461
+ }, 2500);
3462
+ } catch (err) {
3463
+ setActionNotice?.(
3464
+ err instanceof Error
3465
+ ? `Trigger dispatch failed: ${err.message}`
3466
+ : "Trigger dispatch failed.",
3467
+ "error",
3468
+ 5000,
3469
+ );
3470
+ setRunNowState("idle");
3471
+ }
3472
+ }, [
3473
+ triggerId,
3474
+ runNowState,
3475
+ onRunSelectedTrigger,
3476
+ setActionNotice,
3477
+ loadTriggerRuns,
3478
+ ]);
3479
+
3480
+ // Accordion: at most one run row is open at a time. Reset when the
3481
+ // user switches to a different trigger (different triggerId) so the
3482
+ // state doesn't leak across automations.
3483
+ const [openRunId, setOpenRunId] = useState<string | null>(null);
3484
+ useEffect(() => {
3485
+ setOpenRunId(null);
3486
+ }, [triggerId]);
3487
+
3076
3488
  useEffect(() => {
3077
3489
  if (triggerId && !hasLoadedRuns) {
3078
3490
  void loadTriggerRuns(triggerId);
@@ -3132,9 +3544,26 @@ function TriggerAutomationDetailPane({
3132
3544
  tone={trigger.enabled ? "warning" : "ok"}
3133
3545
  />
3134
3546
  <IconAction
3135
- label={t("triggersview.RunNow")}
3136
- onClick={() => void onRunSelectedTrigger(trigger.id)}
3137
- icon={<Zap className="h-3.5 w-3.5" />}
3547
+ label={
3548
+ runNowState === "running"
3549
+ ? "Firing trigger…"
3550
+ : runNowState === "fired"
3551
+ ? "Trigger fired"
3552
+ : t("triggersview.RunNow")
3553
+ }
3554
+ onClick={() => void handleRunNow()}
3555
+ disabled={runNowState === "running"}
3556
+ ariaBusy={runNowState === "running"}
3557
+ tone={runNowState === "fired" ? "ok" : undefined}
3558
+ icon={
3559
+ runNowState === "running" ? (
3560
+ <div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
3561
+ ) : runNowState === "fired" ? (
3562
+ <Check className="h-3.5 w-3.5" />
3563
+ ) : (
3564
+ <Zap className="h-3.5 w-3.5" />
3565
+ )
3566
+ }
3138
3567
  />
3139
3568
  <IconAction
3140
3569
  label={t("triggersview.Edit")}
@@ -3241,31 +3670,22 @@ function TriggerAutomationDetailPane({
3241
3670
  No runs yet.
3242
3671
  </div>
3243
3672
  ) : (
3244
- <div className="divide-y divide-border/20">
3673
+ <div>
3245
3674
  {selectedRuns.map((run) => (
3246
- <div
3675
+ <TriggerRunRow
3247
3676
  key={run.triggerRunId}
3248
- className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs-tight"
3249
- >
3250
- <StatusBadge
3251
- label={localizedExecutionStatus(run.status, t)}
3252
- variant={toneForLastStatus(run.status)}
3253
- />
3254
- <span className="text-muted/70 tabular-nums">
3255
- {formatDateTime(run.startedAt, { locale: uiLanguage })}
3256
- </span>
3257
- <span className="text-muted/60">
3258
- {formatDurationMs(run.latencyMs, { t })}
3259
- </span>
3260
- <span className="ml-auto rounded bg-bg/40 px-1 py-0.5 font-mono text-[10px] text-muted/60">
3261
- {run.source}
3262
- </span>
3263
- {run.error ? (
3264
- <div className="basis-full whitespace-pre-wrap rounded border border-danger/20 bg-danger/10 px-2 py-1 font-mono text-[11px] text-danger/90">
3265
- {run.error}
3266
- </div>
3267
- ) : null}
3268
- </div>
3677
+ run={run}
3678
+ triggerId={trigger.id}
3679
+ triggerInstructions={trigger.instructions ?? ""}
3680
+ expanded={openRunId === run.triggerRunId}
3681
+ onToggle={() =>
3682
+ setOpenRunId((current) =>
3683
+ current === run.triggerRunId ? null : run.triggerRunId,
3684
+ )
3685
+ }
3686
+ uiLanguage={uiLanguage}
3687
+ t={t}
3688
+ />
3269
3689
  ))}
3270
3690
  </div>
3271
3691
  )}
@@ -55,6 +55,7 @@ import {
55
55
  useRef,
56
56
  useState,
57
57
  } from "react";
58
+ import { client } from "../../api/client";
58
59
  import { CodingAgentSettingsSection } from "../../app-shell/task-coordinator-slots.js";
59
60
  import { useApp } from "../../state";
60
61
  import { WidgetHost } from "../../widgets";
@@ -174,6 +175,25 @@ const SETTINGS_SECTIONS: SettingsSectionDef[] = [
174
175
  keywordKeys: ["settings.keyword.voice"],
175
176
  level: "simple",
176
177
  },
178
+ {
179
+ // Tavily API key for the WEB_SEARCH action (plugin-web-fetch). Lives
180
+ // outside ai-model because Tavily isn't a model provider — it's a
181
+ // search backend the agent calls when a trigger or chat instruction
182
+ // asks for web search.
183
+ id: "web-search",
184
+ label: "settings.sections.webSearch.label",
185
+ description: "settings.sections.webSearch.desc",
186
+ keywords: [
187
+ "web search",
188
+ "tavily",
189
+ "search",
190
+ "internet",
191
+ "online",
192
+ "trends",
193
+ "api key",
194
+ ],
195
+ level: "simple",
196
+ },
177
197
  {
178
198
  // Cloud and direct-provider model routing. Local model runtime controls are
179
199
  // split into the advanced Local Models section below.
@@ -665,6 +685,125 @@ function AdvancedSection() {
665
685
  );
666
686
  }
667
687
 
688
+ /* ── WebSearchKeySection ──────────────────────────────────────────────── */
689
+
690
+ /**
691
+ * Single-purpose Tavily API key input. Calls a dedicated endpoint
692
+ * (`PUT /api/integrations/tavily-key`) instead of the plugin-config-save
693
+ * pipeline because the web-fetch plugin is a workspace plugin not in the
694
+ * bundled plugin-discovery manifest — its `TAVILY_API_KEY` parameter is
695
+ * invisible to the generic plugin-config validator, which rejects with
696
+ * 422 ("not a declared config key").
697
+ *
698
+ * The endpoint writes to config.env, mirrors to process.env (so the next
699
+ * WEB_SEARCH call sees the key immediately), and triggers a runtime
700
+ * restart so any cached `runtime.getSetting` value refreshes.
701
+ */
702
+ function WebSearchKeySection() {
703
+ const app = useApp();
704
+ const setActionNotice = app.setActionNotice;
705
+ const [value, setValue] = useState("");
706
+ const [revealed, setRevealed] = useState(false);
707
+ const [saving, setSaving] = useState(false);
708
+ const [savedAt, setSavedAt] = useState<number | null>(null);
709
+ const saved = savedAt != null && Date.now() - savedAt < 4000;
710
+
711
+ const trimmed = value.trim();
712
+ const canSave =
713
+ !saving && /^tvly-[A-Za-z0-9_-]{8,}$/.test(trimmed);
714
+
715
+ const onSave = useCallback(async () => {
716
+ if (!canSave) return;
717
+ setSaving(true);
718
+ try {
719
+ const result = await client.setTavilyApiKey(trimmed);
720
+ setActionNotice?.(
721
+ result.restartScheduled
722
+ ? "Tavily key saved. Web search is active; agent restart scheduled."
723
+ : "Tavily key saved. Web search is active.",
724
+ "success",
725
+ 4000,
726
+ );
727
+ setValue("");
728
+ setSavedAt(Date.now());
729
+ } catch (err) {
730
+ setActionNotice?.(
731
+ err instanceof Error ? `Save failed: ${err.message}` : "Save failed.",
732
+ "error",
733
+ 5000,
734
+ );
735
+ } finally {
736
+ setSaving(false);
737
+ }
738
+ }, [canSave, setActionNotice, trimmed]);
739
+
740
+ const formatHint =
741
+ value.length > 0 && !canSave && !saving
742
+ ? "Expected format: tvly- followed by 8+ alphanumeric characters."
743
+ : null;
744
+
745
+ return (
746
+ <div className="space-y-3">
747
+ <p className="text-sm text-muted">
748
+ Tavily powers the agent's <code className="text-txt">WEB_SEARCH</code>{" "}
749
+ action — needed for triggers like "fetch daily AI trends" and any
750
+ chat instruction that asks the agent to search the web. Get a free
751
+ key (1,000 searches/month, no credit card) at{" "}
752
+ <a
753
+ href="https://app.tavily.com/sign-in"
754
+ target="_blank"
755
+ rel="noopener noreferrer"
756
+ className="text-accent underline-offset-2 hover:underline"
757
+ >
758
+ app.tavily.com
759
+ </a>
760
+ . Saving here writes to <code className="text-txt">config.env</code>{" "}
761
+ and restarts the runtime so the key takes effect.
762
+ </p>
763
+
764
+ <div className="space-y-2">
765
+ <Label htmlFor="settings-tavily-key" className="text-txt-strong">
766
+ Tavily API key
767
+ </Label>
768
+ <div className="flex gap-2">
769
+ <Input
770
+ id="settings-tavily-key"
771
+ type={revealed ? "text" : "password"}
772
+ value={value}
773
+ onChange={(e) => setValue(e.target.value)}
774
+ placeholder="tvly-…"
775
+ autoComplete="off"
776
+ spellCheck={false}
777
+ className="rounded-lg bg-bg font-mono text-sm"
778
+ />
779
+ <Button
780
+ type="button"
781
+ variant="outline"
782
+ size="sm"
783
+ onClick={() => setRevealed((v) => !v)}
784
+ aria-label={revealed ? "Hide key" : "Show key"}
785
+ >
786
+ {revealed ? "Hide" : "Show"}
787
+ </Button>
788
+ <Button
789
+ type="button"
790
+ variant="default"
791
+ size="sm"
792
+ onClick={() => void onSave()}
793
+ disabled={!canSave}
794
+ >
795
+ {saving && <Spinner size={14} />}
796
+ {saving ? "Saving" : saved ? "Saved" : "Save"}
797
+ </Button>
798
+ </div>
799
+ {formatHint && (
800
+ <p className="text-xs text-danger">{formatHint}</p>
801
+ )}
802
+ </div>
803
+ </div>
804
+ );
805
+ }
806
+
668
807
  /* ── SettingsView ─────────────────────────────────────────────────────── */
669
808
 
670
809
  export function SettingsView({
@@ -951,6 +1090,22 @@ export function SettingsView({
951
1090
  </SettingsSection>
952
1091
  )}
953
1092
 
1093
+ {visibleSectionIds.has("web-search") && (
1094
+ <SettingsSection
1095
+ id="web-search"
1096
+ title={t("settings.sections.webSearch.label", {
1097
+ defaultValue: "Web search",
1098
+ })}
1099
+ description={t("settings.sections.webSearch.desc", {
1100
+ defaultValue:
1101
+ "Configure the Tavily API key the agent uses to search the web.",
1102
+ })}
1103
+ ref={registerContentItem("web-search")}
1104
+ >
1105
+ <WebSearchKeySection />
1106
+ </SettingsSection>
1107
+ )}
1108
+
954
1109
  {visibleSectionIds.has("ai-model") && (
955
1110
  <div className="grid gap-5 xl:grid-cols-2 items-start">
956
1111
  <div className="flex flex-col gap-5 min-w-0">