bosun 0.37.1 → 0.37.2

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.
@@ -349,12 +349,12 @@ async function configureMcpEnv(id, env) {
349
349
 
350
350
  async function fetchAvailableTools() {
351
351
  const res = await apiFetch("/api/agent-tools/available");
352
- return res?.data || { builtinTools: [], mcpServers: [] };
352
+ return res?.data || { builtinTools: [], bosunTools: [], mcpServers: [] };
353
353
  }
354
354
 
355
355
  async function fetchAgentToolConfig(agentId) {
356
356
  const res = await apiFetch(`/api/agent-tools/config?agentId=${encodeURIComponent(agentId)}`);
357
- return res?.data || { builtinTools: [], mcpServers: [] };
357
+ return res?.data || { builtinTools: [], bosunTools: [], mcpServers: [], enabledTools: null };
358
358
  }
359
359
 
360
360
  async function saveAgentToolConfig(agentId, config) {
@@ -377,6 +377,73 @@ async function fetchBuiltinToolDefaults() {
377
377
  const TYPE_ICONS = { prompt: ":edit:", agent: ":bot:", skill: ":cpu:", mcp: ":plug:" };
378
378
  const TYPE_LABELS = { prompt: "Prompt", agent: "Agent Profile", skill: "Skill", mcp: "MCP Server" };
379
379
  const TYPE_COLORS = { prompt: "#58a6ff", agent: "#af7bff", skill: "#3fb950", mcp: "#f59e0b" };
380
+ const AGENT_TYPE_OPTIONS = Object.freeze([
381
+ { value: "voice", label: "Voice" },
382
+ { value: "task", label: "Task" },
383
+ { value: "chat", label: "Chat" },
384
+ ]);
385
+
386
+ function normalizeAgentType(rawType) {
387
+ const value = String(rawType || "").trim().toLowerCase();
388
+ if (value === "voice" || value === "task" || value === "chat") return value;
389
+ return "task";
390
+ }
391
+
392
+ function inferAgentTypeFromEntry(entry, parsedContent) {
393
+ const explicit = normalizeAgentType(parsedContent?.agentType);
394
+ if (parsedContent?.agentType) return explicit;
395
+ if (parsedContent?.voiceAgent === true) return "voice";
396
+ const id = String(entry?.id || "").trim().toLowerCase();
397
+ const tags = Array.isArray(entry?.tags)
398
+ ? entry.tags.map((tag) => String(tag || "").trim().toLowerCase())
399
+ : [];
400
+ if (id.startsWith("voice-agent")) return "voice";
401
+ if (tags.includes("voice") || tags.includes("audio-agent") || tags.includes("realtime")) return "voice";
402
+ return "task";
403
+ }
404
+
405
+ const AUDIO_AGENT_TEMPLATES = Object.freeze({
406
+ female: {
407
+ type: "agent",
408
+ name: "Voice Agent (Female)",
409
+ description: "Conversational voice specialist with concise guidance and call-friendly pacing.",
410
+ tags: "voice,audio-agent,female,realtime",
411
+ scope: "global",
412
+ content: JSON.stringify({
413
+ name: "Voice Agent (Female)",
414
+ description: "Conversational voice specialist with concise guidance and call-friendly pacing.",
415
+ titlePatterns: ["\\bvoice\\b", "\\bcall\\b", "\\bmeeting\\b", "\\bassistant\\b"],
416
+ scopes: ["voice", "assistant"],
417
+ model: null,
418
+ promptOverride: null,
419
+ skills: ["concise-voice-guidance", "conversation-memory"],
420
+ agentType: "voice",
421
+ voiceAgent: true,
422
+ voicePersona: "female",
423
+ voiceInstructions: "You are Nova, a female voice agent. Be concise, warm, and practical. Use tools for facts and execution. Keep spoken responses short and clear.",
424
+ }, null, 2),
425
+ },
426
+ male: {
427
+ type: "agent",
428
+ name: "Voice Agent (Male)",
429
+ description: "Operational voice specialist focused on diagnostics and execution.",
430
+ tags: "voice,audio-agent,male,realtime",
431
+ scope: "global",
432
+ content: JSON.stringify({
433
+ name: "Voice Agent (Male)",
434
+ description: "Operational voice specialist focused on diagnostics and execution.",
435
+ titlePatterns: ["\\bvoice\\b", "\\bcall\\b", "\\bmeeting\\b", "\\bassistant\\b"],
436
+ scopes: ["voice", "assistant"],
437
+ model: null,
438
+ promptOverride: null,
439
+ skills: ["ops-diagnostics", "task-execution"],
440
+ agentType: "voice",
441
+ voiceAgent: true,
442
+ voicePersona: "male",
443
+ voiceInstructions: "You are Atlas, a male voice agent. Be direct and execution-oriented. Prefer actionable status updates. Use tools proactively for diagnostics.",
444
+ }, null, 2),
445
+ },
446
+ });
380
447
 
381
448
  /* ═══════════════════════════════════════════════════════════════
382
449
  * Sub-components
@@ -454,6 +521,9 @@ function LibraryCard({ entry, onSelect }) {
454
521
  <div class="library-card-desc">${entry.description}</div>
455
522
  `}
456
523
  <div class="library-card-meta">
524
+ ${entry.type === "agent" && entry.agentType && html`
525
+ <span class="library-card-tag">${String(entry.agentType).toUpperCase()}</span>
526
+ `}
457
527
  ${(entry.tags || []).slice(0, 5).map((tag) => html`
458
528
  <span class="library-card-tag" key=${tag}>${tag}</span>
459
529
  `)}
@@ -476,7 +546,8 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
476
546
  description: entry?.description || "",
477
547
  tags: (entry?.tags || []).join(", "),
478
548
  scope: entry?.scope || "global",
479
- content: "",
549
+ agentType: inferAgentTypeFromEntry(entry, null),
550
+ content: typeof entry?.content === "string" ? entry.content : "",
480
551
  };
481
552
  const [form, setForm] = useState(initialFormSnapshot);
482
553
  const [baseline, setBaseline] = useState(initialFormSnapshot);
@@ -496,6 +567,7 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
496
567
  description: entry?.description || "",
497
568
  tags: (entry?.tags || []).join(", "),
498
569
  scope: entry?.scope || "global",
570
+ agentType: inferAgentTypeFromEntry(entry, null),
499
571
  content: "",
500
572
  };
501
573
  setForm(next);
@@ -513,8 +585,13 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
513
585
  if (cancelled) return;
514
586
  let contentStr = detail?.content ?? "";
515
587
  if (typeof contentStr === "object") contentStr = JSON.stringify(contentStr, null, 2);
588
+ const parsed = detail?.content && typeof detail.content === "object" ? detail.content : null;
516
589
  setForm((f) => {
517
- const next = { ...f, content: contentStr };
590
+ const next = {
591
+ ...f,
592
+ content: contentStr,
593
+ agentType: inferAgentTypeFromEntry(detail || entry, parsed),
594
+ };
518
595
  setBaseline(next);
519
596
  return next;
520
597
  });
@@ -551,7 +628,19 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
551
628
  const tags = form.tags.split(/[,\s]+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
552
629
  let content = form.content;
553
630
  if (form.type === "agent") {
554
- try { content = JSON.parse(content); } catch { /* keep as string if invalid JSON */ }
631
+ try {
632
+ content = JSON.parse(content);
633
+ } catch {
634
+ showToast("Agent profile content must be valid JSON", "error");
635
+ return false;
636
+ }
637
+ const agentType = normalizeAgentType(form.agentType);
638
+ content.agentType = agentType;
639
+ if (agentType === "voice") {
640
+ content.voiceAgent = true;
641
+ } else if (content.voiceAgent === true) {
642
+ content.voiceAgent = false;
643
+ }
555
644
  }
556
645
  const res = await saveEntry({
557
646
  id: form.id || undefined,
@@ -611,6 +700,7 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
611
700
  model: null,
612
701
  promptOverride: null,
613
702
  skills: [],
703
+ agentType: "task",
614
704
  tags: [],
615
705
  }, null, 2)
616
706
  : "# Skill Title\n\n## Purpose\nDescribe what this skill teaches agents.\n\n## Instructions\n...";
@@ -661,6 +751,16 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
661
751
  <option value="workspace">Workspace</option>
662
752
  </select>
663
753
  </label>
754
+ ${form.type === "agent" && html`
755
+ <label>
756
+ Agent Type
757
+ <select value=${normalizeAgentType(form.agentType)} onChange=${updateField("agentType")}>
758
+ ${AGENT_TYPE_OPTIONS.map((opt) => html`
759
+ <option key=${opt.value} value=${opt.value}>${opt.label}</option>
760
+ `)}
761
+ </select>
762
+ </label>
763
+ `}
664
764
  <label>
665
765
  Content
666
766
  ${loadingContent
@@ -672,7 +772,7 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
672
772
  </label>
673
773
  <div style="font-size:0.78em;color:var(--text-tertiary,#666);margin-top:-8px;">
674
774
  ${form.type === "prompt" ? "Use {{VARIABLE_NAME}} for template variables. Reference in workflows as {{prompt:name}}."
675
- : form.type === "agent" ? "JSON format. Referenced in workflows as {{agent:name}}."
775
+ : form.type === "agent" ? "JSON format. Referenced in workflows as {{agent:name}}."
676
776
  : form.type === "mcp" ? "MCP server configuration. Managed via the MCP Servers panel."
677
777
  : "Markdown format. Referenced in workflows as {{skill:name}}."}
678
778
  </div>
@@ -727,8 +827,8 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
727
827
  /* ─ Agent Tool Configurator ─────────────────────────────── */
728
828
 
729
829
  function AgentToolConfigurator({ agentId, agentName }) {
730
- const [toolsTab, setToolsTab] = useState("builtin"); // "builtin" | "mcp"
731
- const [tools, setTools] = useState({ builtinTools: [], mcpServers: [] });
830
+ const [toolsTab, setToolsTab] = useState("builtin"); // "builtin" | "bosun" | "mcp"
831
+ const [tools, setTools] = useState({ builtinTools: [], bosunTools: [], mcpServers: [], enabledTools: null });
732
832
  const [installed, setInstalled] = useState([]);
733
833
  const [loading, setLoading] = useState(true);
734
834
  const [saving, setSaving] = useState(false);
@@ -756,11 +856,26 @@ function AgentToolConfigurator({ agentId, agentName }) {
756
856
  const newDisabled = enabled
757
857
  ? disabledList.filter((id) => id !== toolId)
758
858
  : [...disabledList, toolId];
859
+ const currentEnabledTools = Array.isArray(tools.enabledTools)
860
+ ? tools.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
861
+ : null;
862
+ const nextEnabledTools = currentEnabledTools
863
+ ? (() => {
864
+ const set = new Set(currentEnabledTools);
865
+ if (enabled) set.add(toolId);
866
+ else set.delete(toolId);
867
+ return [...set];
868
+ })()
869
+ : undefined;
759
870
  setSaving(true);
760
871
  try {
761
- await saveAgentToolConfig(agentId, { disabledBuiltinTools: newDisabled });
872
+ await saveAgentToolConfig(agentId, {
873
+ disabledBuiltinTools: newDisabled,
874
+ ...(nextEnabledTools !== undefined ? { enabledTools: nextEnabledTools } : {}),
875
+ });
762
876
  setTools((prev) => ({
763
877
  ...prev,
878
+ ...(nextEnabledTools !== undefined ? { enabledTools: nextEnabledTools } : {}),
764
879
  builtinTools: prev.builtinTools.map((t) =>
765
880
  t.id === toolId ? { ...t, enabled } : t
766
881
  ),
@@ -771,6 +886,34 @@ function AgentToolConfigurator({ agentId, agentName }) {
771
886
  setSaving(false);
772
887
  }, [agentId, tools]);
773
888
 
889
+ const toggleBosunTool = useCallback(async (toolId, enabled) => {
890
+ const bosunIds = (tools.bosunTools || []).map((tool) => String(tool?.id || "").trim()).filter(Boolean);
891
+ const bosunIdSet = new Set(bosunIds);
892
+ const currentEnabledTools = Array.isArray(tools.enabledTools)
893
+ ? tools.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
894
+ : [];
895
+ const currentSet = new Set(currentEnabledTools);
896
+ const hasBosunAllowlist = bosunIds.some((id) => currentSet.has(id));
897
+ const nextBosunSet = hasBosunAllowlist
898
+ ? new Set(currentEnabledTools.filter((id) => bosunIdSet.has(id)))
899
+ : new Set(bosunIds);
900
+ if (enabled) nextBosunSet.add(toolId);
901
+ else nextBosunSet.delete(toolId);
902
+ const preserved = currentEnabledTools.filter((id) => !bosunIdSet.has(id));
903
+ const nextEnabledTools = [...new Set([...preserved, ...nextBosunSet])];
904
+ setSaving(true);
905
+ try {
906
+ await saveAgentToolConfig(agentId, { enabledTools: nextEnabledTools });
907
+ setTools((prev) => ({
908
+ ...prev,
909
+ enabledTools: nextEnabledTools,
910
+ }));
911
+ } catch (err) {
912
+ showToast("Failed to save: " + err.message, "error");
913
+ }
914
+ setSaving(false);
915
+ }, [agentId, tools]);
916
+
774
917
  const toggleMcpServer = useCallback(async (serverId, enabled) => {
775
918
  const currentMcp = tools.mcpServers || [];
776
919
  const newMcp = enabled
@@ -787,6 +930,17 @@ function AgentToolConfigurator({ agentId, agentName }) {
787
930
  }, [agentId, tools]);
788
931
 
789
932
  const enabledMcpSet = new Set(tools.mcpServers || []);
933
+ const bosunTools = Array.isArray(tools.bosunTools) ? tools.bosunTools : [];
934
+ const bosunToolIds = bosunTools.map((tool) => String(tool?.id || "").trim()).filter(Boolean);
935
+ const rawEnabledTools = Array.isArray(tools.enabledTools)
936
+ ? tools.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
937
+ : null;
938
+ const hasBosunAllowlist = Boolean(rawEnabledTools && rawEnabledTools.some((id) => bosunToolIds.includes(id)));
939
+ const enabledBosunSet = new Set(
940
+ hasBosunAllowlist
941
+ ? rawEnabledTools.filter((id) => bosunToolIds.includes(id))
942
+ : bosunToolIds,
943
+ );
790
944
 
791
945
  if (loading) {
792
946
  return html`<div class="agent-tools-section">
@@ -806,6 +960,10 @@ function AgentToolConfigurator({ agentId, agentName }) {
806
960
  onClick=${() => setToolsTab("builtin")}>
807
961
  ${iconText(":cpu: Built-in Tools")} (${(tools.builtinTools || []).filter((t) => t.enabled).length}/${(tools.builtinTools || []).length})
808
962
  </button>
963
+ <button class=${`agent-tools-tab ${toolsTab === "bosun" ? "active" : ""}`}
964
+ onClick=${() => setToolsTab("bosun")}>
965
+ ${iconText(":zap: Bosun Tools")} (${enabledBosunSet.size}/${bosunTools.length})
966
+ </button>
809
967
  <button class=${`agent-tools-tab ${toolsTab === "mcp" ? "active" : ""}`}
810
968
  onClick=${() => setToolsTab("mcp")}>
811
969
  ${iconText(":plug: MCP Servers")} (${enabledMcpSet.size}/${installed.length})
@@ -832,6 +990,34 @@ function AgentToolConfigurator({ agentId, agentName }) {
832
990
  </div>
833
991
  `}
834
992
 
993
+ ${toolsTab === "bosun" && html`
994
+ <details open class="tool-config-group">
995
+ <summary class="tool-config-group-label">
996
+ Runtime Voice Tools (collapsible)
997
+ </summary>
998
+ ${bosunTools.length === 0 && html`
999
+ <div style="padding:12px;text-align:center;color:var(--text-secondary);font-size:0.85em;">
1000
+ No Bosun runtime tools were discovered.
1001
+ </div>
1002
+ `}
1003
+ ${bosunTools.map((tool) => html`
1004
+ <div class="tool-config-item" key=${tool.id}>
1005
+ <span class="tool-config-item-icon">${resolveIcon(":zap:") || iconText(":zap:")}</span>
1006
+ <div class="tool-config-item-info">
1007
+ <div class="tool-config-item-name">${tool.name}</div>
1008
+ <div class="tool-config-item-desc">${tool.description || "Bosun runtime tool"}</div>
1009
+ </div>
1010
+ <div class="tool-config-toggle">
1011
+ <${Toggle}
1012
+ checked=${enabledBosunSet.has(tool.id)}
1013
+ onChange=${(val) => toggleBosunTool(tool.id, val)}
1014
+ />
1015
+ </div>
1016
+ </div>
1017
+ `)}
1018
+ </details>
1019
+ `}
1020
+
835
1021
  ${toolsTab === "mcp" && html`
836
1022
  <div class="tool-config-group">
837
1023
  ${installed.length === 0 && html`
@@ -1350,6 +1536,17 @@ export function LibraryTab() {
1350
1536
 
1351
1537
  useEffect(() => { loadEntries(); }, [filterType.value]);
1352
1538
 
1539
+ useEffect(() => {
1540
+ const onWorkspaceSwitched = () => {
1541
+ setEditing(null);
1542
+ loadEntries();
1543
+ };
1544
+ window.addEventListener("ve:workspace-switched", onWorkspaceSwitched);
1545
+ return () => {
1546
+ window.removeEventListener("ve:workspace-switched", onWorkspaceSwitched);
1547
+ };
1548
+ }, [loadEntries]);
1549
+
1353
1550
  // Debounced search
1354
1551
  const searchTimer = useRef(null);
1355
1552
  const handleSearch = useCallback((value) => {
@@ -1391,6 +1588,13 @@ export function LibraryTab() {
1391
1588
  setEditing(entry);
1392
1589
  }, []);
1393
1590
 
1591
+ const handleCreateAudioAgent = useCallback((templateKey) => {
1592
+ const template = AUDIO_AGENT_TEMPLATES[templateKey];
1593
+ if (!template) return;
1594
+ haptic("light");
1595
+ setEditing({ ...template });
1596
+ }, []);
1597
+
1394
1598
  const handleSaved = useCallback(() => {
1395
1599
  setEditing(null);
1396
1600
  loadEntries();
@@ -1414,6 +1618,12 @@ export function LibraryTab() {
1414
1618
  <div class="library-root">
1415
1619
  <div class="library-header">
1416
1620
  <h2>${iconText(":book: Library")}</h2>
1621
+ <button class="library-type-pill" onClick=${() => handleCreateAudioAgent("female")}>
1622
+ ${iconText(":mic: New Female Audio Agent")}
1623
+ </button>
1624
+ <button class="library-type-pill" onClick=${() => handleCreateAudioAgent("male")}>
1625
+ ${iconText(":mic: New Male Audio Agent")}
1626
+ </button>
1417
1627
  <button class="library-type-pill" onClick=${handleRebuild}
1418
1628
  title="Rescan directories and rebuild manifest">
1419
1629
  ${iconText(":refresh: Rebuild")}
@@ -2114,25 +2114,36 @@ function VoiceEndpointsEditor() {
2114
2114
  ];
2115
2115
  }, []);
2116
2116
 
2117
- const normalizeEp = useCallback((ep = {}, idx = 0) => ({
2118
- _id: ep._id ?? `ep-${idx}-${Date.now()}`,
2119
- name: String(ep.name || `endpoint-${idx + 1}`),
2120
- provider: ["azure", "openai", "claude", "gemini", "custom"].includes(ep.provider) ? ep.provider : "azure",
2117
+ const normalizeEp = useCallback((ep = {}, idx = 0) => {
2118
+ const provider = ["azure", "openai", "claude", "gemini", "custom"].includes(ep.provider)
2119
+ ? ep.provider
2120
+ : "azure";
2121
+ const transcriptionEnabled = ep.transcriptionEnabled == null
2122
+ ? provider !== "azure"
2123
+ : ep.transcriptionEnabled !== false;
2124
+ return {
2125
+ _id: ep._id ?? `ep-${idx}-${Date.now()}`,
2126
+ name: String(ep.name || `endpoint-${idx + 1}`),
2127
+ provider,
2121
2128
  endpoint: (() => {
2122
- const p = ["azure", "openai", "claude", "gemini", "custom"].includes(ep.provider) ? ep.provider : "azure";
2123
2129
  const raw = String(ep.endpoint || "");
2124
- return (p === "azure" || p === "custom") ? raw : (raw || getDefaultEndpointUrl(p, ep.authSource));
2130
+ return (provider === "azure" || provider === "custom")
2131
+ ? raw
2132
+ : (raw || getDefaultEndpointUrl(provider, ep.authSource));
2125
2133
  })(),
2126
2134
  deployment: String(ep.deployment || ""),
2127
2135
  model: String(ep.model || ""),
2128
2136
  visionModel: String(ep.visionModel || ""),
2137
+ transcriptionModel: String(ep.transcriptionModel || ""),
2138
+ transcriptionEnabled,
2129
2139
  apiKey: String(ep.apiKey || ""),
2130
2140
  voiceId: String(ep.voiceId || ""),
2131
2141
  role: ["primary", "backup"].includes(ep.role) ? ep.role : "primary",
2132
2142
  weight: Number(ep.weight) > 0 ? Number(ep.weight) : 1,
2133
2143
  enabled: ep.enabled !== false,
2134
2144
  authSource: ["apiKey", "oauth"].includes(ep.authSource) ? ep.authSource : "apiKey",
2135
- }), [getDefaultEndpointUrl]);
2145
+ };
2146
+ }, [getDefaultEndpointUrl]);
2136
2147
 
2137
2148
  // Fetch OAuth status for all providers
2138
2149
  const fetchOAuthStatuses = useCallback(async () => {
@@ -2359,12 +2370,20 @@ function VoiceEndpointsEditor() {
2359
2370
  onInput=${(e) => updateEndpoint(ep._id, "endpoint", e.target.value)} />
2360
2371
  </div>
2361
2372
  <div style="grid-column:1/-1">
2362
- <div class="setting-row-label">Audio Model (Realtime)</div>
2363
- <input type="text" value=${ep.deployment} placeholder="gpt-realtime-1.5"
2373
+ <div class="setting-row-label">Deployment Name</div>
2374
+ <input type="text" value=${ep.deployment} placeholder="my-gpt-4o-realtime"
2364
2375
  onInput=${(e) => updateEndpoint(ep._id, "deployment", e.target.value)} />
2365
2376
  <div class="meta-text" style="margin-top:3px">
2366
- Connectivity tests use the endpoint URL exactly as entered.
2367
- If you enter only a host, Bosun appends the default Azure OpenAI probe route.
2377
+ The deployment name from Azure AI Foundry (not the model name).
2378
+ Find it under your resource Deployments. Leave empty to test credentials only.
2379
+ </div>
2380
+ </div>
2381
+ <div style="grid-column:1/-1">
2382
+ <div class="setting-row-label">Audio Model (Realtime)</div>
2383
+ <input type="text" value=${ep.model} placeholder="gpt-4o-realtime-preview"
2384
+ onInput=${(e) => updateEndpoint(ep._id, "model", e.target.value)} />
2385
+ <div class="meta-text" style="margin-top:3px">
2386
+ The underlying model name (e.g. gpt-4o-realtime-preview). Used at runtime.
2368
2387
  </div>
2369
2388
  </div>
2370
2389
  `}
@@ -2451,6 +2470,27 @@ function VoiceEndpointsEditor() {
2451
2470
  onInput=${(e) => updateEndpoint(ep._id, "visionModel", e.target.value)} />
2452
2471
  <div class="meta-text" style="margin-top:3px">Model used for screenshot / image analysis tasks.</div>
2453
2472
  </div>
2473
+ ${(ep.provider === "openai" || ep.provider === "azure") && html`
2474
+ <div style="grid-column:1/-1;display:grid;grid-template-columns:1fr auto;gap:8px;align-items:end">
2475
+ <div>
2476
+ <div class="setting-row-label">Transcription Model</div>
2477
+ <input type="text" value=${ep.transcriptionModel || ""}
2478
+ placeholder="gpt-4o-transcribe"
2479
+ onInput=${(e) => updateEndpoint(ep._id, "transcriptionModel", e.target.value)} />
2480
+ <div class="meta-text" style="margin-top:3px">
2481
+ Model used for input audio transcription. Leave blank for default (gpt-4o-transcribe).
2482
+ ${ep.provider === "azure" ? " Azure endpoints default transcription OFF unless enabled." : ""}
2483
+ </div>
2484
+ </div>
2485
+ <div style="display:flex;align-items:center;gap:6px;padding-bottom:22px">
2486
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
2487
+ <input type="checkbox" checked=${ep.transcriptionEnabled !== false}
2488
+ onChange=${(e) => updateEndpoint(ep._id, "transcriptionEnabled", e.target.checked)} />
2489
+ Enable
2490
+ </label>
2491
+ </div>
2492
+ </div>
2493
+ `}
2454
2494
  </div>
2455
2495
  <!-- Test Connection -->
2456
2496
  <div style="display:flex;align-items:center;gap:10px;margin-top:8px">