bosun 0.36.1 → 0.36.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.
- package/package.json +1 -1
- package/setup-web-server.mjs +128 -0
- package/telegram-bot.mjs +84 -1
- package/ui/app.js +32 -1
- package/ui/app.monolith.js +1 -1
- package/ui/components/chat-view.js +89 -16
- package/ui/components/session-list.js +123 -20
- package/ui/modules/voice-client.js +13 -0
- package/ui/setup.html +239 -14
- package/ui/styles/sessions.css +75 -0
- package/ui/tabs/agents.js +42 -12
- package/ui-server.mjs +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.2",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/setup-web-server.mjs
CHANGED
|
@@ -434,6 +434,18 @@ function buildStableSetupDefaults({
|
|
|
434
434
|
workflowRunStuckThresholdMs: 300000,
|
|
435
435
|
workflowMaxPersistedRuns: 200,
|
|
436
436
|
workflowMaxConcurrentBranches: 8,
|
|
437
|
+
voiceEnabled: true,
|
|
438
|
+
voiceProvider: "auto",
|
|
439
|
+
voiceModel: "gpt-4o-realtime-preview-2024-12-17",
|
|
440
|
+
voiceVisionModel: "gpt-4.1-mini",
|
|
441
|
+
voiceId: "alloy",
|
|
442
|
+
voiceTurnDetection: "server_vad",
|
|
443
|
+
voiceFallbackMode: "browser",
|
|
444
|
+
voiceDelegateExecutor: "codex-sdk",
|
|
445
|
+
openaiRealtimeApiKey: "",
|
|
446
|
+
azureOpenaiRealtimeEndpoint: "",
|
|
447
|
+
azureOpenaiRealtimeApiKey: "",
|
|
448
|
+
azureOpenaiRealtimeDeployment: "gpt-4o-realtime-preview",
|
|
437
449
|
copilotEnableAllMcpTools: false,
|
|
438
450
|
// Backward-compatible fields consumed by older setup UI revisions.
|
|
439
451
|
distribution: "primary-only",
|
|
@@ -818,6 +830,122 @@ function applyNonBlockingSetupEnvDefaults(envMap, env = {}, sourceEnv = process.
|
|
|
818
830
|
["workspace-write", "danger-full-access", "read-only"],
|
|
819
831
|
"workspace-write",
|
|
820
832
|
);
|
|
833
|
+
envMap.VOICE_ENABLED = toBooleanEnvString(
|
|
834
|
+
pickNonEmptyValue(
|
|
835
|
+
env.voiceEnabled,
|
|
836
|
+
env.VOICE_ENABLED,
|
|
837
|
+
envMap.VOICE_ENABLED,
|
|
838
|
+
sourceEnv.VOICE_ENABLED,
|
|
839
|
+
),
|
|
840
|
+
true,
|
|
841
|
+
);
|
|
842
|
+
envMap.VOICE_PROVIDER = normalizeEnumValue(
|
|
843
|
+
pickNonEmptyValue(
|
|
844
|
+
env.voiceProvider,
|
|
845
|
+
env.VOICE_PROVIDER,
|
|
846
|
+
envMap.VOICE_PROVIDER,
|
|
847
|
+
sourceEnv.VOICE_PROVIDER,
|
|
848
|
+
),
|
|
849
|
+
["auto", "openai", "azure", "claude", "gemini", "fallback"],
|
|
850
|
+
"auto",
|
|
851
|
+
);
|
|
852
|
+
envMap.VOICE_MODEL = String(
|
|
853
|
+
pickNonEmptyValue(
|
|
854
|
+
env.voiceModel,
|
|
855
|
+
env.VOICE_MODEL,
|
|
856
|
+
envMap.VOICE_MODEL,
|
|
857
|
+
sourceEnv.VOICE_MODEL,
|
|
858
|
+
) || "gpt-4o-realtime-preview-2024-12-17",
|
|
859
|
+
).trim() || "gpt-4o-realtime-preview-2024-12-17";
|
|
860
|
+
envMap.VOICE_VISION_MODEL = String(
|
|
861
|
+
pickNonEmptyValue(
|
|
862
|
+
env.voiceVisionModel,
|
|
863
|
+
env.VOICE_VISION_MODEL,
|
|
864
|
+
envMap.VOICE_VISION_MODEL,
|
|
865
|
+
sourceEnv.VOICE_VISION_MODEL,
|
|
866
|
+
) || "gpt-4.1-mini",
|
|
867
|
+
).trim() || "gpt-4.1-mini";
|
|
868
|
+
envMap.VOICE_ID = normalizeEnumValue(
|
|
869
|
+
pickNonEmptyValue(
|
|
870
|
+
env.voiceId,
|
|
871
|
+
env.VOICE_ID,
|
|
872
|
+
envMap.VOICE_ID,
|
|
873
|
+
sourceEnv.VOICE_ID,
|
|
874
|
+
),
|
|
875
|
+
["alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"],
|
|
876
|
+
"alloy",
|
|
877
|
+
);
|
|
878
|
+
envMap.VOICE_TURN_DETECTION = normalizeEnumValue(
|
|
879
|
+
pickNonEmptyValue(
|
|
880
|
+
env.voiceTurnDetection,
|
|
881
|
+
env.VOICE_TURN_DETECTION,
|
|
882
|
+
envMap.VOICE_TURN_DETECTION,
|
|
883
|
+
sourceEnv.VOICE_TURN_DETECTION,
|
|
884
|
+
),
|
|
885
|
+
["server_vad", "semantic_vad", "none"],
|
|
886
|
+
"server_vad",
|
|
887
|
+
);
|
|
888
|
+
envMap.VOICE_FALLBACK_MODE = normalizeEnumValue(
|
|
889
|
+
pickNonEmptyValue(
|
|
890
|
+
env.voiceFallbackMode,
|
|
891
|
+
env.VOICE_FALLBACK_MODE,
|
|
892
|
+
envMap.VOICE_FALLBACK_MODE,
|
|
893
|
+
sourceEnv.VOICE_FALLBACK_MODE,
|
|
894
|
+
),
|
|
895
|
+
["browser", "disabled"],
|
|
896
|
+
"browser",
|
|
897
|
+
);
|
|
898
|
+
envMap.VOICE_DELEGATE_EXECUTOR = normalizeEnumValue(
|
|
899
|
+
pickNonEmptyValue(
|
|
900
|
+
env.voiceDelegateExecutor,
|
|
901
|
+
env.VOICE_DELEGATE_EXECUTOR,
|
|
902
|
+
envMap.VOICE_DELEGATE_EXECUTOR,
|
|
903
|
+
sourceEnv.VOICE_DELEGATE_EXECUTOR,
|
|
904
|
+
),
|
|
905
|
+
["codex-sdk", "copilot-sdk", "claude-sdk", "gemini-sdk", "opencode-sdk"],
|
|
906
|
+
"codex-sdk",
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
const openaiRealtimeApiKey = pickNonEmptyValue(
|
|
910
|
+
env.openaiRealtimeApiKey,
|
|
911
|
+
env.OPENAI_REALTIME_API_KEY,
|
|
912
|
+
envMap.OPENAI_REALTIME_API_KEY,
|
|
913
|
+
sourceEnv.OPENAI_REALTIME_API_KEY,
|
|
914
|
+
);
|
|
915
|
+
if (openaiRealtimeApiKey !== undefined) {
|
|
916
|
+
envMap.OPENAI_REALTIME_API_KEY = String(openaiRealtimeApiKey).trim();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const azureRealtimeEndpoint = pickNonEmptyValue(
|
|
920
|
+
env.azureOpenaiRealtimeEndpoint,
|
|
921
|
+
env.AZURE_OPENAI_REALTIME_ENDPOINT,
|
|
922
|
+
envMap.AZURE_OPENAI_REALTIME_ENDPOINT,
|
|
923
|
+
sourceEnv.AZURE_OPENAI_REALTIME_ENDPOINT,
|
|
924
|
+
sourceEnv.AZURE_OPENAI_ENDPOINT,
|
|
925
|
+
);
|
|
926
|
+
if (azureRealtimeEndpoint !== undefined) {
|
|
927
|
+
envMap.AZURE_OPENAI_REALTIME_ENDPOINT = String(azureRealtimeEndpoint).trim();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const azureRealtimeApiKey = pickNonEmptyValue(
|
|
931
|
+
env.azureOpenaiRealtimeApiKey,
|
|
932
|
+
env.AZURE_OPENAI_REALTIME_API_KEY,
|
|
933
|
+
envMap.AZURE_OPENAI_REALTIME_API_KEY,
|
|
934
|
+
sourceEnv.AZURE_OPENAI_REALTIME_API_KEY,
|
|
935
|
+
sourceEnv.AZURE_OPENAI_API_KEY,
|
|
936
|
+
);
|
|
937
|
+
if (azureRealtimeApiKey !== undefined) {
|
|
938
|
+
envMap.AZURE_OPENAI_REALTIME_API_KEY = String(azureRealtimeApiKey).trim();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
envMap.AZURE_OPENAI_REALTIME_DEPLOYMENT = String(
|
|
942
|
+
pickNonEmptyValue(
|
|
943
|
+
env.azureOpenaiRealtimeDeployment,
|
|
944
|
+
env.AZURE_OPENAI_REALTIME_DEPLOYMENT,
|
|
945
|
+
envMap.AZURE_OPENAI_REALTIME_DEPLOYMENT,
|
|
946
|
+
sourceEnv.AZURE_OPENAI_REALTIME_DEPLOYMENT,
|
|
947
|
+
) || "gpt-4o-realtime-preview",
|
|
948
|
+
).trim() || "gpt-4o-realtime-preview";
|
|
821
949
|
|
|
822
950
|
envMap.CONTAINER_ENABLED = toBooleanEnvString(
|
|
823
951
|
pickNonEmptyValue(env.containerEnabled, envMap.CONTAINER_ENABLED, sourceEnv.CONTAINER_ENABLED),
|
package/telegram-bot.mjs
CHANGED
|
@@ -2178,6 +2178,83 @@ const TELEGRAM_ICON_TOKEN_LABELS = Object.freeze({
|
|
|
2178
2178
|
cloud: "Cloud",
|
|
2179
2179
|
});
|
|
2180
2180
|
|
|
2181
|
+
const TELEGRAM_ICON_TOKEN_EMOJI = Object.freeze({
|
|
2182
|
+
check: "✅",
|
|
2183
|
+
close: "❌",
|
|
2184
|
+
alert: "⚠️",
|
|
2185
|
+
pause: "⏸️",
|
|
2186
|
+
play: "▶️",
|
|
2187
|
+
stop: "⏹️",
|
|
2188
|
+
refresh: "🔄",
|
|
2189
|
+
chart: "📊",
|
|
2190
|
+
clipboard: "📋",
|
|
2191
|
+
bot: "🤖",
|
|
2192
|
+
git: "🌿",
|
|
2193
|
+
settings: "⚙️",
|
|
2194
|
+
server: "🖧",
|
|
2195
|
+
folder: "📁",
|
|
2196
|
+
file: "📄",
|
|
2197
|
+
phone: "📱",
|
|
2198
|
+
globe: "🌐",
|
|
2199
|
+
heart: "❤️",
|
|
2200
|
+
cpu: "🧠",
|
|
2201
|
+
chat: "💬",
|
|
2202
|
+
hash: "#️⃣",
|
|
2203
|
+
repeat: "🔁",
|
|
2204
|
+
beaker: "🧪",
|
|
2205
|
+
compass: "🧭",
|
|
2206
|
+
target: "🎯",
|
|
2207
|
+
workflow: "🧩",
|
|
2208
|
+
arrowright: "➡️",
|
|
2209
|
+
plus: "➕",
|
|
2210
|
+
menu: "☰",
|
|
2211
|
+
lock: "🔒",
|
|
2212
|
+
unlock: "🔓",
|
|
2213
|
+
search: "🔍",
|
|
2214
|
+
link: "🔗",
|
|
2215
|
+
upload: "📤",
|
|
2216
|
+
download: "📥",
|
|
2217
|
+
box: "📦",
|
|
2218
|
+
bell: "🔔",
|
|
2219
|
+
lightbulb: "💡",
|
|
2220
|
+
rocket: "🚀",
|
|
2221
|
+
home: "🏠",
|
|
2222
|
+
pin: "📌",
|
|
2223
|
+
star: "⭐",
|
|
2224
|
+
help: "❓",
|
|
2225
|
+
cloud: "☁️",
|
|
2226
|
+
monitor: "🖥️",
|
|
2227
|
+
eye: "👁️",
|
|
2228
|
+
edit: "✏️",
|
|
2229
|
+
trash: "🗑️",
|
|
2230
|
+
dot: "•",
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
function decodeUnicodeIconToken(name) {
|
|
2234
|
+
const raw = String(name || "").trim();
|
|
2235
|
+
const match = raw.match(/^u([0-9a-f]{4,6})$/i);
|
|
2236
|
+
if (!match) return "";
|
|
2237
|
+
const codePoint = Number.parseInt(match[1], 16);
|
|
2238
|
+
if (!Number.isFinite(codePoint)) return "";
|
|
2239
|
+
try {
|
|
2240
|
+
return String.fromCodePoint(codePoint);
|
|
2241
|
+
} catch {
|
|
2242
|
+
return "";
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function resolveTelegramIconTokenGlyph(name) {
|
|
2247
|
+
const raw = String(name || "").trim();
|
|
2248
|
+
if (!raw) return "";
|
|
2249
|
+
const lowered = raw.toLowerCase();
|
|
2250
|
+
const squashed = lowered.replace(/[_-]+/g, "");
|
|
2251
|
+
const glyph = TELEGRAM_ICON_TOKEN_EMOJI[lowered]
|
|
2252
|
+
|| TELEGRAM_ICON_TOKEN_EMOJI[squashed]
|
|
2253
|
+
|| decodeUnicodeIconToken(lowered)
|
|
2254
|
+
|| decodeUnicodeIconToken(squashed);
|
|
2255
|
+
return glyph || "";
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2181
2258
|
function humanizeIconTokenName(name) {
|
|
2182
2259
|
const spaced = String(name || "")
|
|
2183
2260
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
@@ -2195,6 +2272,8 @@ function formatTelegramIconTokens(value, { button = false } = {}) {
|
|
|
2195
2272
|
const str = String(value);
|
|
2196
2273
|
return str.replace(/:([a-zA-Z][a-zA-Z0-9_-]*):/g, (_match, rawName) => {
|
|
2197
2274
|
const tokenName = String(rawName || "");
|
|
2275
|
+
const glyph = resolveTelegramIconTokenGlyph(tokenName);
|
|
2276
|
+
if (glyph) return glyph;
|
|
2198
2277
|
const key = tokenName.toLowerCase();
|
|
2199
2278
|
const label = TELEGRAM_ICON_TOKEN_LABELS[tokenName]
|
|
2200
2279
|
|| TELEGRAM_ICON_TOKEN_LABELS[key]
|
|
@@ -4005,8 +4084,12 @@ function appendRefreshRow(keyboard, screenId, params = {}) {
|
|
|
4005
4084
|
)
|
|
4006
4085
|
: uiGoAction(screenId, params.page);
|
|
4007
4086
|
const rows = keyboard.inline_keyboard || [];
|
|
4087
|
+
const refreshLabel = formatTelegramIconTokens(":refresh: Refresh", { button: true });
|
|
4008
4088
|
const hasRefresh = rows.some((row) =>
|
|
4009
|
-
row.some((btn) =>
|
|
4089
|
+
row.some((btn) =>
|
|
4090
|
+
btn?.text === ":refresh: Refresh"
|
|
4091
|
+
|| btn?.text === refreshLabel
|
|
4092
|
+
|| btn?.callback_data === action),
|
|
4010
4093
|
);
|
|
4011
4094
|
if (hasRefresh) return keyboard;
|
|
4012
4095
|
return {
|
package/ui/app.js
CHANGED
|
@@ -61,6 +61,7 @@ const TABLET_MIN_WIDTH = 768;
|
|
|
61
61
|
const COMPACT_NAV_MAX_WIDTH = 520;
|
|
62
62
|
const RAIL_ICON_WIDTH = 54;
|
|
63
63
|
const SIDEBAR_ICON_WIDTH = 54;
|
|
64
|
+
const APP_LOGO_SOURCES = ["/logo.png", "/logo.svg", "/favicon.png"];
|
|
64
65
|
const VOICE_LAUNCH_QUERY_KEYS = [
|
|
65
66
|
"launch",
|
|
66
67
|
"call",
|
|
@@ -74,6 +75,30 @@ const VOICE_LAUNCH_QUERY_KEYS = [
|
|
|
74
75
|
"chat_id",
|
|
75
76
|
];
|
|
76
77
|
|
|
78
|
+
function getAppLogoSource(index = 0) {
|
|
79
|
+
const safeIndex = Number.isFinite(index) ? Math.trunc(index) : 0;
|
|
80
|
+
if (safeIndex <= 0) return APP_LOGO_SOURCES[0];
|
|
81
|
+
if (safeIndex >= APP_LOGO_SOURCES.length) {
|
|
82
|
+
return APP_LOGO_SOURCES[APP_LOGO_SOURCES.length - 1];
|
|
83
|
+
}
|
|
84
|
+
return APP_LOGO_SOURCES[safeIndex];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleAppLogoLoadError(event) {
|
|
88
|
+
const target = event?.currentTarget;
|
|
89
|
+
if (!target) return;
|
|
90
|
+
|
|
91
|
+
const currentIndex = Number.parseInt(
|
|
92
|
+
String(target.dataset?.logoFallbackIndex || "0"),
|
|
93
|
+
10,
|
|
94
|
+
);
|
|
95
|
+
const nextIndex = Number.isFinite(currentIndex) ? currentIndex + 1 : 1;
|
|
96
|
+
if (nextIndex >= APP_LOGO_SOURCES.length) return;
|
|
97
|
+
|
|
98
|
+
target.dataset.logoFallbackIndex = String(nextIndex);
|
|
99
|
+
target.src = getAppLogoSource(nextIndex);
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
function parseVoiceLaunchFromUrl() {
|
|
78
103
|
if (typeof window === "undefined") return null;
|
|
79
104
|
const params = new URLSearchParams(window.location.search || "");
|
|
@@ -586,7 +611,13 @@ function SidebarNav({ collapsed = false, onToggle }) {
|
|
|
586
611
|
<div class="sidebar-brand-row">
|
|
587
612
|
<div class="sidebar-brand">
|
|
588
613
|
<div class="sidebar-logo">
|
|
589
|
-
<img
|
|
614
|
+
<img
|
|
615
|
+
src=${getAppLogoSource(0)}
|
|
616
|
+
alt="Bosun"
|
|
617
|
+
class="app-logo-img"
|
|
618
|
+
data-logo-fallback-index="0"
|
|
619
|
+
onError=${handleAppLogoLoadError}
|
|
620
|
+
/>
|
|
590
621
|
</div>
|
|
591
622
|
${!collapsed && html`<div class="sidebar-title">Bosun</div>`}
|
|
592
623
|
</div>
|
package/ui/app.monolith.js
CHANGED
|
@@ -2398,7 +2398,7 @@ function Header() {
|
|
|
2398
2398
|
return html`
|
|
2399
2399
|
<header class="app-header">
|
|
2400
2400
|
<div class="app-header-brand">
|
|
2401
|
-
<img src="logo.png" alt="Bosun" class="app-logo-img" />
|
|
2401
|
+
<img src="/logo.png" alt="Bosun" class="app-logo-img" />
|
|
2402
2402
|
<div class="app-header-title">Bosun</div>
|
|
2403
2403
|
</div>
|
|
2404
2404
|
<div class="connection-pill ${isConn ? "connected" : "disconnected"}">
|
|
@@ -438,6 +438,42 @@ const TraceEvent = memo(function TraceEvent({ msg }) {
|
|
|
438
438
|
`;
|
|
439
439
|
}, (prev, next) => prev.msg === next.msg);
|
|
440
440
|
|
|
441
|
+
/* ─── ThinkingGroup — collapses consecutive trace events into one row ─── */
|
|
442
|
+
const ThinkingGroup = memo(function ThinkingGroup({ msgs }) {
|
|
443
|
+
const hasErrors = msgs.some((m) => m.type === "error" || m.type === "stream_error");
|
|
444
|
+
const [expanded, setExpanded] = useState(hasErrors);
|
|
445
|
+
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (hasErrors) setExpanded(true);
|
|
448
|
+
}, [msgs.length, hasErrors]);
|
|
449
|
+
|
|
450
|
+
const toolCount = msgs.filter((m) => m.type === "tool_call").length;
|
|
451
|
+
const stepCount = msgs.filter((m) => {
|
|
452
|
+
const t = (m.type || "").toLowerCase();
|
|
453
|
+
return !["tool_call", "tool_result", "tool_output", "error", "stream_error"].includes(t);
|
|
454
|
+
}).length;
|
|
455
|
+
|
|
456
|
+
const parts = [];
|
|
457
|
+
if (toolCount) parts.push(`${toolCount} tool call${toolCount !== 1 ? "s" : ""}`);
|
|
458
|
+
if (stepCount) parts.push(`${stepCount} step${stepCount !== 1 ? "s" : ""}`);
|
|
459
|
+
const label = parts.join(", ") || `${msgs.length} step${msgs.length !== 1 ? "s" : ""}`;
|
|
460
|
+
|
|
461
|
+
return html`
|
|
462
|
+
<div class="thinking-group ${expanded ? "expanded" : ""} ${hasErrors ? "has-errors" : ""}">
|
|
463
|
+
<button class="thinking-group-head" type="button" onClick=${() => setExpanded((p) => !p)}>
|
|
464
|
+
<span class="thinking-group-badge">${iconText(":cpu: Thinking")}</span>
|
|
465
|
+
<span class="thinking-group-label">${label}</span>
|
|
466
|
+
<span class="thinking-group-chevron">${expanded ? "▾" : "▸"}</span>
|
|
467
|
+
</button>
|
|
468
|
+
${expanded && html`
|
|
469
|
+
<div class="thinking-group-body">
|
|
470
|
+
${msgs.map((m, idx) => html`<${TraceEvent} key=${m.id || m.timestamp || idx} msg=${m} />`)}
|
|
471
|
+
</div>
|
|
472
|
+
`}
|
|
473
|
+
</div>
|
|
474
|
+
`;
|
|
475
|
+
}, (prev, next) => prev.msgs === next.msgs);
|
|
476
|
+
|
|
441
477
|
/* ─── Chat View component ─── */
|
|
442
478
|
|
|
443
479
|
export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
@@ -541,12 +577,28 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
541
577
|
return messageIdentity(latest);
|
|
542
578
|
}, [filteredMessages]);
|
|
543
579
|
|
|
580
|
+
// Count only real (non-trace) messages toward the visible limit so trace
|
|
581
|
+
// events don't consume the page budget.
|
|
582
|
+
const realMessageCount = useMemo(
|
|
583
|
+
() => filteredMessages.filter((msg) => !isTraceEventMessage(msg)).length,
|
|
584
|
+
[filteredMessages],
|
|
585
|
+
);
|
|
586
|
+
|
|
544
587
|
const visibleMessages = useMemo(() => {
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
588
|
+
if (realMessageCount <= visibleCount) return filteredMessages;
|
|
589
|
+
// Walk backwards counting only real messages; include all trace events
|
|
590
|
+
// that fall between them so groups stay intact.
|
|
591
|
+
let realCount = 0;
|
|
592
|
+
for (let i = filteredMessages.length - 1; i >= 0; i--) {
|
|
593
|
+
if (!isTraceEventMessage(filteredMessages[i])) {
|
|
594
|
+
realCount++;
|
|
595
|
+
if (realCount >= visibleCount) return filteredMessages.slice(i);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return filteredMessages;
|
|
599
|
+
}, [filteredMessages, visibleCount, realMessageCount]);
|
|
548
600
|
|
|
549
|
-
const hasMoreMessages =
|
|
601
|
+
const hasMoreMessages = realMessageCount > visibleCount;
|
|
550
602
|
|
|
551
603
|
const streamActivityKey = useMemo(() => {
|
|
552
604
|
if (filteredMessages.length === 0) return "empty";
|
|
@@ -602,16 +654,37 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
602
654
|
}
|
|
603
655
|
|
|
604
656
|
const renderItems = useMemo(() => {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
657
|
+
const items = [];
|
|
658
|
+
let i = 0;
|
|
659
|
+
while (i < visibleMessages.length) {
|
|
660
|
+
const msg = visibleMessages[i];
|
|
661
|
+
if (isTraceEventMessage(msg)) {
|
|
662
|
+
// Collect consecutive trace events; discard completely empty ones.
|
|
663
|
+
const group = [];
|
|
664
|
+
let groupKey = null;
|
|
665
|
+
while (i < visibleMessages.length && isTraceEventMessage(visibleMessages[i])) {
|
|
666
|
+
const m = visibleMessages[i];
|
|
667
|
+
if (messageText(m).trim()) {
|
|
668
|
+
group.push(m);
|
|
669
|
+
if (!groupKey) groupKey = m.id || m.timestamp || `trace-${i}`;
|
|
670
|
+
}
|
|
671
|
+
i++;
|
|
672
|
+
}
|
|
673
|
+
if (group.length > 0) {
|
|
674
|
+
items.push({ kind: "thinking-group", key: `thinking-group-${groupKey}`, msgs: group });
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
const baseKey = msg.id || msg.timestamp || `msg-${i}`;
|
|
678
|
+
items.push({
|
|
679
|
+
kind: "message",
|
|
680
|
+
key: `message-${baseKey}-${i}`,
|
|
681
|
+
messageKey: messageIdentity(msg),
|
|
682
|
+
msg,
|
|
683
|
+
});
|
|
684
|
+
i++;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return items;
|
|
615
688
|
}, [visibleMessages]);
|
|
616
689
|
|
|
617
690
|
const refreshMessages = useCallback(async () => {
|
|
@@ -1173,8 +1246,8 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
1173
1246
|
</button>
|
|
1174
1247
|
</div>
|
|
1175
1248
|
`}
|
|
1176
|
-
${renderItems.map((item) => item.kind === "
|
|
1177
|
-
? html`<${
|
|
1249
|
+
${renderItems.map((item) => item.kind === "thinking-group"
|
|
1250
|
+
? html`<${ThinkingGroup} key=${item.key} msgs=${item.msgs} />`
|
|
1178
1251
|
: html`<${ChatBubble}
|
|
1179
1252
|
key=${item.key}
|
|
1180
1253
|
msg=${item.msg}
|
|
@@ -413,6 +413,32 @@ const STATUS_COLOR_MAP = {
|
|
|
413
413
|
archived: "var(--text-hint)",
|
|
414
414
|
};
|
|
415
415
|
|
|
416
|
+
const SESSION_VIEW_FILTER = Object.freeze({
|
|
417
|
+
all: "all",
|
|
418
|
+
active: "active",
|
|
419
|
+
historic: "historic",
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
function normalizeSessionViewFilter(value) {
|
|
423
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
424
|
+
if (normalized === SESSION_VIEW_FILTER.active) return SESSION_VIEW_FILTER.active;
|
|
425
|
+
if (normalized === SESSION_VIEW_FILTER.historic) return SESSION_VIEW_FILTER.historic;
|
|
426
|
+
return SESSION_VIEW_FILTER.all;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function getSessionStatusKey(session) {
|
|
430
|
+
return String(session?.status || "idle").trim().toLowerCase();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isActiveSession(session) {
|
|
434
|
+
const status = getSessionStatusKey(session);
|
|
435
|
+
return status === "active" || status === "running";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isHistoricSession(session) {
|
|
439
|
+
return !isActiveSession(session);
|
|
440
|
+
}
|
|
441
|
+
|
|
416
442
|
/* ─── Swipeable Session Item ─── */
|
|
417
443
|
function SwipeableSessionItem({
|
|
418
444
|
session: s,
|
|
@@ -639,6 +665,8 @@ export function SessionList({
|
|
|
639
665
|
onSelect,
|
|
640
666
|
showArchived = true,
|
|
641
667
|
onToggleArchived,
|
|
668
|
+
sessionView = SESSION_VIEW_FILTER.all,
|
|
669
|
+
onSessionViewChange,
|
|
642
670
|
defaultType = null,
|
|
643
671
|
renamingSessionId = null,
|
|
644
672
|
onStartRename,
|
|
@@ -647,9 +675,36 @@ export function SessionList({
|
|
|
647
675
|
}) {
|
|
648
676
|
const [search, setSearch] = useState("");
|
|
649
677
|
const [revealedActions, setRevealedActions] = useState(null);
|
|
678
|
+
const [uncontrolledSessionView, setUncontrolledSessionView] = useState(
|
|
679
|
+
normalizeSessionViewFilter(sessionView),
|
|
680
|
+
);
|
|
650
681
|
const allSessions = sessionsData.value || [];
|
|
651
682
|
const error = sessionsError.value;
|
|
652
683
|
const hasSearch = search.trim().length > 0;
|
|
684
|
+
const resolvedSessionView =
|
|
685
|
+
typeof onSessionViewChange === "function"
|
|
686
|
+
? normalizeSessionViewFilter(sessionView)
|
|
687
|
+
: uncontrolledSessionView;
|
|
688
|
+
|
|
689
|
+
useEffect(() => {
|
|
690
|
+
if (typeof onSessionViewChange === "function") return;
|
|
691
|
+
const normalized = normalizeSessionViewFilter(sessionView);
|
|
692
|
+
if (normalized !== uncontrolledSessionView) {
|
|
693
|
+
setUncontrolledSessionView(normalized);
|
|
694
|
+
}
|
|
695
|
+
}, [onSessionViewChange, sessionView, uncontrolledSessionView]);
|
|
696
|
+
|
|
697
|
+
const setSessionView = useCallback(
|
|
698
|
+
(nextFilter) => {
|
|
699
|
+
const normalized = normalizeSessionViewFilter(nextFilter);
|
|
700
|
+
if (typeof onSessionViewChange === "function") {
|
|
701
|
+
onSessionViewChange(normalized);
|
|
702
|
+
} else {
|
|
703
|
+
setUncontrolledSessionView(normalized);
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
[onSessionViewChange],
|
|
707
|
+
);
|
|
653
708
|
|
|
654
709
|
// Filter by defaultType to exclude ghost sessions (e.g. task sessions in Chat tab)
|
|
655
710
|
const typeFiltered = defaultType
|
|
@@ -666,30 +721,39 @@ export function SessionList({
|
|
|
666
721
|
})
|
|
667
722
|
: allSessions;
|
|
668
723
|
|
|
669
|
-
const
|
|
724
|
+
const archivedFiltered = showArchived
|
|
670
725
|
? typeFiltered
|
|
671
|
-
: typeFiltered.filter((s) => s
|
|
726
|
+
: typeFiltered.filter((s) => getSessionStatusKey(s) !== "archived");
|
|
727
|
+
|
|
728
|
+
const viewFiltered = archivedFiltered.filter((s) => {
|
|
729
|
+
if (resolvedSessionView === SESSION_VIEW_FILTER.active) {
|
|
730
|
+
return isActiveSession(s);
|
|
731
|
+
}
|
|
732
|
+
if (resolvedSessionView === SESSION_VIEW_FILTER.historic) {
|
|
733
|
+
return isHistoricSession(s);
|
|
734
|
+
}
|
|
735
|
+
return true;
|
|
736
|
+
});
|
|
672
737
|
|
|
673
738
|
const filtered = search
|
|
674
|
-
?
|
|
739
|
+
? viewFiltered.filter(
|
|
675
740
|
(s) =>
|
|
676
741
|
(s.title || "").toLowerCase().includes(search.toLowerCase()) ||
|
|
677
742
|
(s.taskId || "").toLowerCase().includes(search.toLowerCase()),
|
|
678
743
|
)
|
|
679
|
-
:
|
|
744
|
+
: viewFiltered;
|
|
680
745
|
|
|
681
|
-
const active = filtered.filter(
|
|
682
|
-
|
|
683
|
-
);
|
|
684
|
-
const archived = filtered.filter((s) => s.status === "archived");
|
|
746
|
+
const active = filtered.filter((s) => isActiveSession(s));
|
|
747
|
+
const archived = filtered.filter((s) => getSessionStatusKey(s) === "archived");
|
|
685
748
|
const recent = filtered.filter(
|
|
686
749
|
(s) =>
|
|
687
|
-
s
|
|
688
|
-
s.status !== "running" &&
|
|
689
|
-
s.status !== "archived",
|
|
750
|
+
!isActiveSession(s) && getSessionStatusKey(s) !== "archived",
|
|
690
751
|
);
|
|
691
752
|
|
|
692
|
-
const archivedCount = typeFiltered.filter((s) => s
|
|
753
|
+
const archivedCount = typeFiltered.filter((s) => getSessionStatusKey(s) === "archived").length;
|
|
754
|
+
const allCount = archivedFiltered.length;
|
|
755
|
+
const activeCount = archivedFiltered.filter((s) => isActiveSession(s)).length;
|
|
756
|
+
const historicCount = archivedFiltered.filter((s) => isHistoricSession(s)).length;
|
|
693
757
|
|
|
694
758
|
const handleSelect = useCallback(
|
|
695
759
|
(id) => {
|
|
@@ -705,6 +769,13 @@ export function SessionList({
|
|
|
705
769
|
loadSessions(_lastLoadFilter);
|
|
706
770
|
}, []);
|
|
707
771
|
|
|
772
|
+
const handleCreateSession = useCallback(() => {
|
|
773
|
+
if (resolvedSessionView === SESSION_VIEW_FILTER.historic) {
|
|
774
|
+
setSessionView(SESSION_VIEW_FILTER.all);
|
|
775
|
+
}
|
|
776
|
+
createSession(defaultType ? { type: defaultType } : {});
|
|
777
|
+
}, [defaultType, resolvedSessionView, setSessionView]);
|
|
778
|
+
|
|
708
779
|
const handleArchive = useCallback(async (id) => {
|
|
709
780
|
setRevealedActions(null);
|
|
710
781
|
await archiveSession(id);
|
|
@@ -726,6 +797,21 @@ export function SessionList({
|
|
|
726
797
|
setRevealedActions(null);
|
|
727
798
|
}, []);
|
|
728
799
|
|
|
800
|
+
const emptyTitle = hasSearch
|
|
801
|
+
? "No matching sessions"
|
|
802
|
+
: resolvedSessionView === SESSION_VIEW_FILTER.active
|
|
803
|
+
? "No active sessions"
|
|
804
|
+
: resolvedSessionView === SESSION_VIEW_FILTER.historic
|
|
805
|
+
? "No historic sessions"
|
|
806
|
+
: "No sessions yet";
|
|
807
|
+
const emptyHint = hasSearch
|
|
808
|
+
? "Try a different keyword or clear the search."
|
|
809
|
+
: resolvedSessionView === SESSION_VIEW_FILTER.active
|
|
810
|
+
? "Start a new session or switch to All."
|
|
811
|
+
: resolvedSessionView === SESSION_VIEW_FILTER.historic
|
|
812
|
+
? "Historic sessions appear after they finish."
|
|
813
|
+
: "Create a session to get started.";
|
|
814
|
+
|
|
729
815
|
/* ── Render session items ── */
|
|
730
816
|
function renderSessionItem(s) {
|
|
731
817
|
return html`
|
|
@@ -783,8 +869,7 @@ export function SessionList({
|
|
|
783
869
|
`}
|
|
784
870
|
<button
|
|
785
871
|
class="btn btn-primary btn-sm"
|
|
786
|
-
onClick=${
|
|
787
|
-
createSession(defaultType ? { type: defaultType } : {})}
|
|
872
|
+
onClick=${handleCreateSession}
|
|
788
873
|
>
|
|
789
874
|
+ New
|
|
790
875
|
</button>
|
|
@@ -800,6 +885,27 @@ export function SessionList({
|
|
|
800
885
|
/>
|
|
801
886
|
</div>
|
|
802
887
|
|
|
888
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap;padding:0 10px 8px;">
|
|
889
|
+
<button
|
|
890
|
+
class="btn btn-sm ${resolvedSessionView === SESSION_VIEW_FILTER.all ? "btn-primary" : "btn-ghost"}"
|
|
891
|
+
onClick=${() => setSessionView(SESSION_VIEW_FILTER.all)}
|
|
892
|
+
>
|
|
893
|
+
All (${allCount})
|
|
894
|
+
</button>
|
|
895
|
+
<button
|
|
896
|
+
class="btn btn-sm ${resolvedSessionView === SESSION_VIEW_FILTER.active ? "btn-primary" : "btn-ghost"}"
|
|
897
|
+
onClick=${() => setSessionView(SESSION_VIEW_FILTER.active)}
|
|
898
|
+
>
|
|
899
|
+
Active (${activeCount})
|
|
900
|
+
</button>
|
|
901
|
+
<button
|
|
902
|
+
class="btn btn-sm ${resolvedSessionView === SESSION_VIEW_FILTER.historic ? "btn-primary" : "btn-ghost"}"
|
|
903
|
+
onClick=${() => setSessionView(SESSION_VIEW_FILTER.historic)}
|
|
904
|
+
>
|
|
905
|
+
Historic (${historicCount})
|
|
906
|
+
</button>
|
|
907
|
+
</div>
|
|
908
|
+
|
|
803
909
|
<div class="session-list-scroll">
|
|
804
910
|
${active.length > 0 &&
|
|
805
911
|
html`
|
|
@@ -821,18 +927,15 @@ export function SessionList({
|
|
|
821
927
|
<div class="session-empty">
|
|
822
928
|
<div class="session-empty-icon">${resolveIcon(":chat:")}</div>
|
|
823
929
|
<div class="session-empty-text">
|
|
824
|
-
${
|
|
930
|
+
${emptyTitle}
|
|
825
931
|
<div class="session-empty-subtext">
|
|
826
|
-
${
|
|
827
|
-
? "Try a different keyword or clear the search."
|
|
828
|
-
: "Create a session to get started."}
|
|
932
|
+
${emptyHint}
|
|
829
933
|
</div>
|
|
830
934
|
</div>
|
|
831
935
|
<div class="session-empty-actions">
|
|
832
936
|
<button
|
|
833
937
|
class="btn btn-primary btn-sm"
|
|
834
|
-
onClick=${
|
|
835
|
-
createSession(defaultType ? { type: defaultType } : {})}
|
|
938
|
+
onClick=${handleCreateSession}
|
|
836
939
|
>
|
|
837
940
|
+ New Session
|
|
838
941
|
</button>
|
|
@@ -140,6 +140,19 @@ export async function startVoiceSession(options = {}) {
|
|
|
140
140
|
const tokenData = await tokenRes.json();
|
|
141
141
|
|
|
142
142
|
// 2. Get microphone
|
|
143
|
+
const mediaDevices = navigator?.mediaDevices;
|
|
144
|
+
if (!mediaDevices?.getUserMedia) {
|
|
145
|
+
const host = String(globalThis.location?.hostname || "").toLowerCase();
|
|
146
|
+
const localhostLike =
|
|
147
|
+
host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
148
|
+
if (!globalThis.isSecureContext && !localhostLike) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"Microphone access requires HTTPS (or localhost). Open the UI via the Cloudflare HTTPS URL or localhost.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
throw new Error("Microphone API unavailable in this browser/runtime.");
|
|
154
|
+
}
|
|
155
|
+
|
|
143
156
|
_mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
144
157
|
audio: {
|
|
145
158
|
echoCancellation: true,
|
package/ui/setup.html
CHANGED
|
@@ -344,6 +344,28 @@
|
|
|
344
344
|
.success-banner h2 { color: var(--success); margin-bottom: 8px; }
|
|
345
345
|
.success-banner p { color: var(--text-secondary); font-size: 0.9rem; }
|
|
346
346
|
|
|
347
|
+
/* ── Inline icon helpers (shared with iconText output) ───────────── */
|
|
348
|
+
.icon-text {
|
|
349
|
+
display: inline-flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
gap: 0.35em;
|
|
352
|
+
flex-wrap: wrap;
|
|
353
|
+
}
|
|
354
|
+
.icon-inline {
|
|
355
|
+
display: inline-flex;
|
|
356
|
+
align-items: center;
|
|
357
|
+
justify-content: center;
|
|
358
|
+
width: 1em;
|
|
359
|
+
height: 1em;
|
|
360
|
+
line-height: 1;
|
|
361
|
+
vertical-align: middle;
|
|
362
|
+
}
|
|
363
|
+
.icon-inline svg {
|
|
364
|
+
width: 1em;
|
|
365
|
+
height: 1em;
|
|
366
|
+
display: block;
|
|
367
|
+
}
|
|
368
|
+
|
|
347
369
|
/* ── Profile Cards ───────────────────────────────────────────────── */
|
|
348
370
|
.profile-cards {
|
|
349
371
|
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
|
@@ -489,6 +511,7 @@
|
|
|
489
511
|
import { h, render } from "preact";
|
|
490
512
|
import { useState, useEffect, useRef } from "preact/hooks";
|
|
491
513
|
import htm from "htm";
|
|
514
|
+
import { iconText } from "./modules/icon-utils.js";
|
|
492
515
|
|
|
493
516
|
const html = htm.bind(h);
|
|
494
517
|
|
|
@@ -679,6 +702,19 @@ function App() {
|
|
|
679
702
|
const [whatsappEnabled, setWhatsappEnabled] = useState(false);
|
|
680
703
|
const [telegramIntervalMin, setTelegramIntervalMin] = useState(10);
|
|
681
704
|
const [orchestratorScript, setOrchestratorScript] = useState("");
|
|
705
|
+
// Voice assistant
|
|
706
|
+
const [voiceEnabled, setVoiceEnabled] = useState(true);
|
|
707
|
+
const [voiceProvider, setVoiceProvider] = useState("auto");
|
|
708
|
+
const [voiceModel, setVoiceModel] = useState("gpt-4o-realtime-preview-2024-12-17");
|
|
709
|
+
const [voiceVisionModel, setVoiceVisionModel] = useState("gpt-4.1-mini");
|
|
710
|
+
const [voiceId, setVoiceId] = useState("alloy");
|
|
711
|
+
const [voiceTurnDetection, setVoiceTurnDetection] = useState("server_vad");
|
|
712
|
+
const [voiceFallbackMode, setVoiceFallbackMode] = useState("browser");
|
|
713
|
+
const [voiceDelegateExecutor, setVoiceDelegateExecutor] = useState("codex-sdk");
|
|
714
|
+
const [openaiRealtimeApiKey, setOpenaiRealtimeApiKey] = useState("");
|
|
715
|
+
const [azureOpenaiRealtimeEndpoint, setAzureOpenaiRealtimeEndpoint] = useState("");
|
|
716
|
+
const [azureOpenaiRealtimeApiKey, setAzureOpenaiRealtimeApiKey] = useState("");
|
|
717
|
+
const [azureOpenaiRealtimeDeployment, setAzureOpenaiRealtimeDeployment] = useState("gpt-4o-realtime-preview");
|
|
682
718
|
|
|
683
719
|
const getWorkflowProfileById = (profileId, profileList = workflowProfiles) =>
|
|
684
720
|
(profileList || []).find((profile_) => profile_.id === profileId) || null;
|
|
@@ -866,6 +902,18 @@ function App() {
|
|
|
866
902
|
}
|
|
867
903
|
if (d.bosunHome) setBosunHome(d.bosunHome);
|
|
868
904
|
if (d.workspacesDir) setWorkspacesDir(d.workspacesDir);
|
|
905
|
+
if (d.voiceEnabled !== undefined) { setVoiceEnabled(d.voiceEnabled !== false); }
|
|
906
|
+
if (d.voiceProvider) { setVoiceProvider(d.voiceProvider); }
|
|
907
|
+
if (d.voiceModel) { setVoiceModel(d.voiceModel); }
|
|
908
|
+
if (d.voiceVisionModel) { setVoiceVisionModel(d.voiceVisionModel); }
|
|
909
|
+
if (d.voiceId) { setVoiceId(d.voiceId); }
|
|
910
|
+
if (d.voiceTurnDetection) { setVoiceTurnDetection(d.voiceTurnDetection); }
|
|
911
|
+
if (d.voiceFallbackMode) { setVoiceFallbackMode(d.voiceFallbackMode); }
|
|
912
|
+
if (d.voiceDelegateExecutor) { setVoiceDelegateExecutor(d.voiceDelegateExecutor); }
|
|
913
|
+
if (d.openaiRealtimeApiKey) { setOpenaiRealtimeApiKey(d.openaiRealtimeApiKey); }
|
|
914
|
+
if (d.azureOpenaiRealtimeEndpoint) { setAzureOpenaiRealtimeEndpoint(d.azureOpenaiRealtimeEndpoint); }
|
|
915
|
+
if (d.azureOpenaiRealtimeApiKey) { setAzureOpenaiRealtimeApiKey(d.azureOpenaiRealtimeApiKey); }
|
|
916
|
+
if (d.azureOpenaiRealtimeDeployment) { setAzureOpenaiRealtimeDeployment(d.azureOpenaiRealtimeDeployment); }
|
|
869
917
|
|
|
870
918
|
// Pre-fill repos from existing config (slugs preferred)
|
|
871
919
|
if (statusData.existingConfig?.repos?.length) {
|
|
@@ -877,6 +925,19 @@ function App() {
|
|
|
877
925
|
// ── Pre-fill from existing .env ──────────────────────────────────────
|
|
878
926
|
const env = statusData.existingEnv || {};
|
|
879
927
|
let envLoaded = false;
|
|
928
|
+
const existingVoice = statusData.existingConfig?.voice || {};
|
|
929
|
+
if (existingVoice.enabled != null) { setVoiceEnabled(existingVoice.enabled !== false); envLoaded = true; }
|
|
930
|
+
if (existingVoice.provider) { setVoiceProvider(String(existingVoice.provider)); envLoaded = true; }
|
|
931
|
+
if (existingVoice.model) { setVoiceModel(String(existingVoice.model)); envLoaded = true; }
|
|
932
|
+
if (existingVoice.visionModel) { setVoiceVisionModel(String(existingVoice.visionModel)); envLoaded = true; }
|
|
933
|
+
if (existingVoice.voiceId) { setVoiceId(String(existingVoice.voiceId)); envLoaded = true; }
|
|
934
|
+
if (existingVoice.turnDetection) { setVoiceTurnDetection(String(existingVoice.turnDetection)); envLoaded = true; }
|
|
935
|
+
if (existingVoice.fallbackMode) { setVoiceFallbackMode(String(existingVoice.fallbackMode)); envLoaded = true; }
|
|
936
|
+
if (existingVoice.delegateExecutor) { setVoiceDelegateExecutor(String(existingVoice.delegateExecutor)); envLoaded = true; }
|
|
937
|
+
if (existingVoice.openaiApiKey) { setOpenaiRealtimeApiKey(String(existingVoice.openaiApiKey)); envLoaded = true; }
|
|
938
|
+
if (existingVoice.azureEndpoint) { setAzureOpenaiRealtimeEndpoint(String(existingVoice.azureEndpoint)); envLoaded = true; }
|
|
939
|
+
if (existingVoice.azureApiKey) { setAzureOpenaiRealtimeApiKey(String(existingVoice.azureApiKey)); envLoaded = true; }
|
|
940
|
+
if (existingVoice.azureDeployment) { setAzureOpenaiRealtimeDeployment(String(existingVoice.azureDeployment)); envLoaded = true; }
|
|
880
941
|
if (env.BOSUN_HOME) { setBosunHome(env.BOSUN_HOME); envLoaded = true; }
|
|
881
942
|
if (env.BOSUN_WORKSPACES_DIR) { setWorkspacesDir(env.BOSUN_WORKSPACES_DIR); setWorkspacesDirCustomized(true); envLoaded = true; }
|
|
882
943
|
if (env.PROJECT_NAME) { setProjectName(env.PROJECT_NAME); envLoaded = true; }
|
|
@@ -981,6 +1042,19 @@ function App() {
|
|
|
981
1042
|
if (env.WHATSAPP_ENABLED) { setWhatsappEnabled(env.WHATSAPP_ENABLED === "true"); envLoaded = true; }
|
|
982
1043
|
if (env.TELEGRAM_INTERVAL_MIN) { setTelegramIntervalMin(Number(env.TELEGRAM_INTERVAL_MIN) || 10); envLoaded = true; }
|
|
983
1044
|
if (env.ORCHESTRATOR_SCRIPT) { setOrchestratorScript(env.ORCHESTRATOR_SCRIPT); envLoaded = true; }
|
|
1045
|
+
// Voice settings
|
|
1046
|
+
if (env.VOICE_ENABLED !== undefined) { setVoiceEnabled(env.VOICE_ENABLED !== "false"); envLoaded = true; }
|
|
1047
|
+
if (env.VOICE_PROVIDER) { setVoiceProvider(env.VOICE_PROVIDER); envLoaded = true; }
|
|
1048
|
+
if (env.VOICE_MODEL) { setVoiceModel(env.VOICE_MODEL); envLoaded = true; }
|
|
1049
|
+
if (env.VOICE_VISION_MODEL) { setVoiceVisionModel(env.VOICE_VISION_MODEL); envLoaded = true; }
|
|
1050
|
+
if (env.VOICE_ID) { setVoiceId(env.VOICE_ID); envLoaded = true; }
|
|
1051
|
+
if (env.VOICE_TURN_DETECTION) { setVoiceTurnDetection(env.VOICE_TURN_DETECTION); envLoaded = true; }
|
|
1052
|
+
if (env.VOICE_FALLBACK_MODE) { setVoiceFallbackMode(env.VOICE_FALLBACK_MODE); envLoaded = true; }
|
|
1053
|
+
if (env.VOICE_DELEGATE_EXECUTOR) { setVoiceDelegateExecutor(env.VOICE_DELEGATE_EXECUTOR); envLoaded = true; }
|
|
1054
|
+
if (env.OPENAI_REALTIME_API_KEY) { setOpenaiRealtimeApiKey(env.OPENAI_REALTIME_API_KEY); envLoaded = true; }
|
|
1055
|
+
if (env.AZURE_OPENAI_REALTIME_ENDPOINT) { setAzureOpenaiRealtimeEndpoint(env.AZURE_OPENAI_REALTIME_ENDPOINT); envLoaded = true; }
|
|
1056
|
+
if (env.AZURE_OPENAI_REALTIME_API_KEY) { setAzureOpenaiRealtimeApiKey(env.AZURE_OPENAI_REALTIME_API_KEY); envLoaded = true; }
|
|
1057
|
+
if (env.AZURE_OPENAI_REALTIME_DEPLOYMENT) { setAzureOpenaiRealtimeDeployment(env.AZURE_OPENAI_REALTIME_DEPLOYMENT); envLoaded = true; }
|
|
984
1058
|
// Multi-workspace: load workspaces[] from existing config
|
|
985
1059
|
if (statusData.existingConfig?.workspaces?.length > 0) {
|
|
986
1060
|
setMultiWorkspaceEnabled(true);
|
|
@@ -1235,6 +1309,19 @@ function App() {
|
|
|
1235
1309
|
copilotEnableAskUser,
|
|
1236
1310
|
copilotEnableAllMcpTools,
|
|
1237
1311
|
copilotMcpConfig,
|
|
1312
|
+
// Voice assistant
|
|
1313
|
+
voiceEnabled,
|
|
1314
|
+
voiceProvider,
|
|
1315
|
+
voiceModel,
|
|
1316
|
+
voiceVisionModel,
|
|
1317
|
+
voiceId,
|
|
1318
|
+
voiceTurnDetection,
|
|
1319
|
+
voiceFallbackMode,
|
|
1320
|
+
voiceDelegateExecutor,
|
|
1321
|
+
openaiRealtimeApiKey,
|
|
1322
|
+
azureOpenaiRealtimeEndpoint,
|
|
1323
|
+
azureOpenaiRealtimeApiKey,
|
|
1324
|
+
azureOpenaiRealtimeDeployment,
|
|
1238
1325
|
// Infrastructure
|
|
1239
1326
|
containerEnabled,
|
|
1240
1327
|
containerRuntime,
|
|
@@ -1531,7 +1618,7 @@ function App() {
|
|
|
1531
1618
|
<div class="setup-container">
|
|
1532
1619
|
<div class="step-panel">
|
|
1533
1620
|
<div class="success-banner">
|
|
1534
|
-
<div class="icon"
|
|
1621
|
+
<div class="icon">${iconText(":star:")}</div>
|
|
1535
1622
|
<h2>Setup Complete!</h2>
|
|
1536
1623
|
<p>Bosun is configured and ready to go.</p>
|
|
1537
1624
|
<p style="margin-top:12px;font-size:0.8rem;color:var(--text-dim)">
|
|
@@ -1596,7 +1683,7 @@ function App() {
|
|
|
1596
1683
|
const icon = item.installed ? ":check:" : item.required ? ":close:" : ":alert:";
|
|
1597
1684
|
return html`
|
|
1598
1685
|
<li class="prereq-item ${statusClass}">
|
|
1599
|
-
<span class="icon">${icon}</span>
|
|
1686
|
+
<span class="icon">${iconText(icon)}</span>
|
|
1600
1687
|
<span class="name">${item.label}${!item.required ? " (optional)" : ""}</span>
|
|
1601
1688
|
<span class="version">${item.version || (item.installed ? "found" : "not found")}</span>
|
|
1602
1689
|
</li>
|
|
@@ -1604,7 +1691,7 @@ function App() {
|
|
|
1604
1691
|
})}
|
|
1605
1692
|
${prereqs.gh && !prereqs.gh.authenticated && prereqs.gh.installed ? html`
|
|
1606
1693
|
<li class="prereq-item warn">
|
|
1607
|
-
<span class="icon"
|
|
1694
|
+
<span class="icon">${iconText(":alert:")}</span>
|
|
1608
1695
|
<span class="name">GitHub CLI not authenticated</span>
|
|
1609
1696
|
<span class="version">Run: gh auth login</span>
|
|
1610
1697
|
</li>
|
|
@@ -1655,12 +1742,12 @@ function App() {
|
|
|
1655
1742
|
|
|
1656
1743
|
<div class="profile-cards">
|
|
1657
1744
|
<div class="profile-card ${profile === "standard" ? "selected" : ""}" onclick=${() => setProfile("standard")}>
|
|
1658
|
-
<div class="icon"
|
|
1745
|
+
<div class="icon">${iconText(":zap:")}</div>
|
|
1659
1746
|
<h4>Standard</h4>
|
|
1660
1747
|
<p>Sensible defaults with primary & backup executors. Best for most users.</p>
|
|
1661
1748
|
</div>
|
|
1662
1749
|
<div class="profile-card ${profile === "advanced" ? "selected" : ""}" onclick=${() => setProfile("advanced")}>
|
|
1663
|
-
<div class="icon"
|
|
1750
|
+
<div class="icon">${iconText(":settings:")}</div>
|
|
1664
1751
|
<h4>Advanced</h4>
|
|
1665
1752
|
<p>Full control over executors, failover, distribution weights, and all settings.</p>
|
|
1666
1753
|
</div>
|
|
@@ -1690,7 +1777,7 @@ function App() {
|
|
|
1690
1777
|
<p class="step-desc">Configure which AI coding agents bosun will use. You can add multiple executors with weighted distribution.</p>
|
|
1691
1778
|
|
|
1692
1779
|
<div style="background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.25);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:0.8rem;line-height:1.6">
|
|
1693
|
-
<strong
|
|
1780
|
+
<strong>${iconText(":lock: No API key required in most cases.")}</strong>
|
|
1694
1781
|
GitHub Copilot, Codex CLI, and Claude Code all support OAuth — just run
|
|
1695
1782
|
<code style="font-family:var(--font-mono);color:var(--accent-light)">gh auth login</code>,
|
|
1696
1783
|
<code style="font-family:var(--font-mono);color:var(--accent-light)">codex auth login</code>, or
|
|
@@ -1710,10 +1797,11 @@ function App() {
|
|
|
1710
1797
|
? configuredModelOptions
|
|
1711
1798
|
: getModelsForExecutor(ex.executor);
|
|
1712
1799
|
const authMode = ex.authMode || "oauth";
|
|
1800
|
+
const executorHeading = `${ex.enabled === false ? ":dot:" : ex.role === "primary" ? ":dot:" : ":dot:"} Executor ${i + 1}: ${ex.name}`;
|
|
1713
1801
|
return html`
|
|
1714
1802
|
<div class="executor-card">
|
|
1715
1803
|
<div class="executor-card-header">
|
|
1716
|
-
<h4>${
|
|
1804
|
+
<h4>${iconText(executorHeading)}</h4>
|
|
1717
1805
|
${executors.length > 1 && html`
|
|
1718
1806
|
<button class="btn btn-sm btn-danger" onclick=${() => removeExecutor(i)}>Remove</button>
|
|
1719
1807
|
`}
|
|
@@ -1798,7 +1886,7 @@ function App() {
|
|
|
1798
1886
|
return html`
|
|
1799
1887
|
<div class="connection-card">
|
|
1800
1888
|
<div class="connection-card-header">
|
|
1801
|
-
<span class="connection-label">${isPrimary ? ":star: Primary" : `:globe: Profile ${ci + 1}`}: ${conn.name || (isPrimary ? "default" : "(unnamed)")}</span>
|
|
1889
|
+
<span class="connection-label">${iconText(`${isPrimary ? ":star: Primary" : `:globe: Profile ${ci + 1}`}: ${conn.name || (isPrimary ? "default" : "(unnamed)")}`)}</span>
|
|
1802
1890
|
<button class="btn btn-sm btn-danger"
|
|
1803
1891
|
onclick=${() => removeConnection(i, ci)}>✕ Remove</button>
|
|
1804
1892
|
</div>
|
|
@@ -1945,7 +2033,7 @@ function App() {
|
|
|
1945
2033
|
|
|
1946
2034
|
${!multiWorkspaceEnabled && html`
|
|
1947
2035
|
<div style="background:var(--bg-input);border:1px solid var(--border-primary);border-radius:var(--radius-sm);padding:10px 14px;margin-bottom:16px;font-size:0.78rem;color:var(--text-secondary)">
|
|
1948
|
-
:folder: Repos will be cloned into:
|
|
2036
|
+
${iconText(":folder: Repos will be cloned into:")}
|
|
1949
2037
|
<code style="font-family:var(--font-mono);color:var(--accent-light);margin-left:4px">${cloneRoot}/${"<repo-name>"}</code>
|
|
1950
2038
|
</div>
|
|
1951
2039
|
`}
|
|
@@ -2211,12 +2299,12 @@ function App() {
|
|
|
2211
2299
|
${/* ── GitHub App installation callout ── */ ""}
|
|
2212
2300
|
<div style="background:rgba(56,139,253,.07);border:1px solid rgba(56,139,253,.25);border-radius:var(--radius-sm);padding:14px 16px;margin-bottom:20px">
|
|
2213
2301
|
<div style="display:flex;align-items:flex-start;gap:10px">
|
|
2214
|
-
<span style="font-size:1.3em;line-height:1.2;flex-shrink:0"
|
|
2302
|
+
<span style="font-size:1.3em;line-height:1.2;flex-shrink:0">${iconText(":box:")}</span>
|
|
2215
2303
|
<div style="flex:1;min-width:0">
|
|
2216
2304
|
<div style="font-weight:600;font-size:0.9rem;color:var(--accent-light);margin-bottom:4px">GitHub App — Bosun[VE]</div>
|
|
2217
2305
|
${oauthStatus === "received"
|
|
2218
2306
|
? html`<div style="color:#4ade80;font-size:0.82rem">
|
|
2219
|
-
:check: GitHub App authorized!
|
|
2307
|
+
${iconText(":check: GitHub App authorized!")}
|
|
2220
2308
|
${oauthInstallationId ? html` (Installation <code style="font-family:var(--font-mono)">${oauthInstallationId}</code>)` : null}
|
|
2221
2309
|
</div>`
|
|
2222
2310
|
: html`<div style="font-size:0.82rem;color:var(--text-secondary);line-height:1.5">
|
|
@@ -2226,7 +2314,7 @@ function App() {
|
|
|
2226
2314
|
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2227
2315
|
<a href="https://github.com/apps/bosun-ve" target="_blank" rel="noopener"
|
|
2228
2316
|
style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--accent);color:#fff;border-radius:5px;font-size:0.8rem;font-weight:600;text-decoration:none">
|
|
2229
|
-
:link: Install from GitHub Marketplace
|
|
2317
|
+
${iconText(":link: Install from GitHub Marketplace")}
|
|
2230
2318
|
</a>
|
|
2231
2319
|
<span style="font-size:0.75rem;color:var(--text-dim)">
|
|
2232
2320
|
<span class="spinner" style="width:10px;height:10px;border-width:2px;vertical-align:middle;margin-right:4px"></span>
|
|
@@ -2278,7 +2366,7 @@ function App() {
|
|
|
2278
2366
|
<div style="border:1px solid var(--border-primary);border-radius:var(--radius-sm);margin-bottom:10px;overflow:hidden">
|
|
2279
2367
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;cursor:pointer;background:var(--bg-input);user-select:none"
|
|
2280
2368
|
onclick=${() => setOpen((v) => !v)}>
|
|
2281
|
-
<span style="font-weight:600;font-size:0.88rem">${title}</span>
|
|
2369
|
+
<span style="font-weight:600;font-size:0.88rem">${iconText(title)}</span>
|
|
2282
2370
|
<span style="color:var(--text-dim);font-size:0.8rem">${open ? "▲" : "▼"}</span>
|
|
2283
2371
|
</div>
|
|
2284
2372
|
${open && html`<div style="padding:16px">${children}</div>`}
|
|
@@ -2594,6 +2682,135 @@ function App() {
|
|
|
2594
2682
|
</div>
|
|
2595
2683
|
<//>
|
|
2596
2684
|
|
|
2685
|
+
<${Section} title=":mic: Voice Assistant">
|
|
2686
|
+
<div class="form-group">
|
|
2687
|
+
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
|
|
2688
|
+
<input type="checkbox" checked=${voiceEnabled}
|
|
2689
|
+
onchange=${(e) => setVoiceEnabled(e.target.checked)}
|
|
2690
|
+
style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer" />
|
|
2691
|
+
Enable Voice Mode in the UI
|
|
2692
|
+
</label>
|
|
2693
|
+
<div class="hint">Allows live voice/video calls from chat. Tier 2 browser fallback works without cloud keys.</div>
|
|
2694
|
+
</div>
|
|
2695
|
+
${voiceEnabled && html`
|
|
2696
|
+
<div class="executor-grid">
|
|
2697
|
+
<div class="form-group">
|
|
2698
|
+
<label>Voice Provider</label>
|
|
2699
|
+
<select value=${voiceProvider} onchange=${(e) => setVoiceProvider(e.target.value)}>
|
|
2700
|
+
<option value="auto">Auto Detect</option>
|
|
2701
|
+
<option value="openai">OpenAI Realtime</option>
|
|
2702
|
+
<option value="azure">Azure OpenAI Realtime</option>
|
|
2703
|
+
<option value="claude">Claude (fallback + Claude vision)</option>
|
|
2704
|
+
<option value="gemini">Gemini (fallback + Gemini vision)</option>
|
|
2705
|
+
<option value="fallback">Browser Fallback Only</option>
|
|
2706
|
+
</select>
|
|
2707
|
+
</div>
|
|
2708
|
+
<div class="form-group">
|
|
2709
|
+
<label>Voice Persona</label>
|
|
2710
|
+
<select value=${voiceId} onchange=${(e) => setVoiceId(e.target.value)}>
|
|
2711
|
+
<option value="alloy">alloy</option>
|
|
2712
|
+
<option value="ash">ash</option>
|
|
2713
|
+
<option value="ballad">ballad</option>
|
|
2714
|
+
<option value="coral">coral</option>
|
|
2715
|
+
<option value="echo">echo</option>
|
|
2716
|
+
<option value="fable">fable</option>
|
|
2717
|
+
<option value="nova">nova</option>
|
|
2718
|
+
<option value="onyx">onyx</option>
|
|
2719
|
+
<option value="sage">sage</option>
|
|
2720
|
+
<option value="shimmer">shimmer</option>
|
|
2721
|
+
<option value="verse">verse</option>
|
|
2722
|
+
</select>
|
|
2723
|
+
</div>
|
|
2724
|
+
<div class="form-group">
|
|
2725
|
+
<label>Realtime Voice Model</label>
|
|
2726
|
+
<input
|
|
2727
|
+
type="text"
|
|
2728
|
+
value=${voiceModel}
|
|
2729
|
+
oninput=${(e) => setVoiceModel(e.target.value)}
|
|
2730
|
+
placeholder="gpt-4o-realtime-preview-2024-12-17"
|
|
2731
|
+
/>
|
|
2732
|
+
</div>
|
|
2733
|
+
<div class="form-group">
|
|
2734
|
+
<label>Vision Model</label>
|
|
2735
|
+
<input
|
|
2736
|
+
type="text"
|
|
2737
|
+
value=${voiceVisionModel}
|
|
2738
|
+
oninput=${(e) => setVoiceVisionModel(e.target.value)}
|
|
2739
|
+
placeholder="gpt-4.1-mini"
|
|
2740
|
+
/>
|
|
2741
|
+
</div>
|
|
2742
|
+
<div class="form-group">
|
|
2743
|
+
<label>Turn Detection</label>
|
|
2744
|
+
<select value=${voiceTurnDetection} onchange=${(e) => setVoiceTurnDetection(e.target.value)}>
|
|
2745
|
+
<option value="server_vad">server_vad</option>
|
|
2746
|
+
<option value="semantic_vad">semantic_vad</option>
|
|
2747
|
+
<option value="none">none</option>
|
|
2748
|
+
</select>
|
|
2749
|
+
</div>
|
|
2750
|
+
<div class="form-group">
|
|
2751
|
+
<label>Fallback Mode</label>
|
|
2752
|
+
<select value=${voiceFallbackMode} onchange=${(e) => setVoiceFallbackMode(e.target.value)}>
|
|
2753
|
+
<option value="browser">browser</option>
|
|
2754
|
+
<option value="disabled">disabled</option>
|
|
2755
|
+
</select>
|
|
2756
|
+
</div>
|
|
2757
|
+
<div class="form-group">
|
|
2758
|
+
<label>Delegate Executor</label>
|
|
2759
|
+
<select value=${voiceDelegateExecutor} onchange=${(e) => setVoiceDelegateExecutor(e.target.value)}>
|
|
2760
|
+
<option value="codex-sdk">codex-sdk</option>
|
|
2761
|
+
<option value="copilot-sdk">copilot-sdk</option>
|
|
2762
|
+
<option value="claude-sdk">claude-sdk</option>
|
|
2763
|
+
<option value="gemini-sdk">gemini-sdk</option>
|
|
2764
|
+
<option value="opencode-sdk">opencode-sdk</option>
|
|
2765
|
+
</select>
|
|
2766
|
+
</div>
|
|
2767
|
+
</div>
|
|
2768
|
+
${(voiceProvider === "auto" || voiceProvider === "openai") && html`
|
|
2769
|
+
<div class="form-group">
|
|
2770
|
+
<label>OpenAI Realtime API Key</label>
|
|
2771
|
+
<input
|
|
2772
|
+
type="password"
|
|
2773
|
+
value=${openaiRealtimeApiKey}
|
|
2774
|
+
oninput=${(e) => setOpenaiRealtimeApiKey(e.target.value)}
|
|
2775
|
+
placeholder="Optional - defaults to OPENAI_API_KEY"
|
|
2776
|
+
/>
|
|
2777
|
+
<div class="hint">Leave blank to use OPENAI_API_KEY.</div>
|
|
2778
|
+
</div>
|
|
2779
|
+
`}
|
|
2780
|
+
${(voiceProvider === "auto" || voiceProvider === "azure") && html`
|
|
2781
|
+
<div class="executor-grid">
|
|
2782
|
+
<div class="form-group">
|
|
2783
|
+
<label>Azure Realtime Endpoint</label>
|
|
2784
|
+
<input
|
|
2785
|
+
type="text"
|
|
2786
|
+
value=${azureOpenaiRealtimeEndpoint}
|
|
2787
|
+
oninput=${(e) => setAzureOpenaiRealtimeEndpoint(e.target.value)}
|
|
2788
|
+
placeholder="https://<resource>.openai.azure.com"
|
|
2789
|
+
/>
|
|
2790
|
+
</div>
|
|
2791
|
+
<div class="form-group">
|
|
2792
|
+
<label>Azure Realtime Deployment</label>
|
|
2793
|
+
<input
|
|
2794
|
+
type="text"
|
|
2795
|
+
value=${azureOpenaiRealtimeDeployment}
|
|
2796
|
+
oninput=${(e) => setAzureOpenaiRealtimeDeployment(e.target.value)}
|
|
2797
|
+
placeholder="gpt-4o-realtime-preview"
|
|
2798
|
+
/>
|
|
2799
|
+
</div>
|
|
2800
|
+
</div>
|
|
2801
|
+
<div class="form-group">
|
|
2802
|
+
<label>Azure Realtime API Key</label>
|
|
2803
|
+
<input
|
|
2804
|
+
type="password"
|
|
2805
|
+
value=${azureOpenaiRealtimeApiKey}
|
|
2806
|
+
oninput=${(e) => setAzureOpenaiRealtimeApiKey(e.target.value)}
|
|
2807
|
+
placeholder="Optional - defaults to AZURE_OPENAI_API_KEY"
|
|
2808
|
+
/>
|
|
2809
|
+
</div>
|
|
2810
|
+
`}
|
|
2811
|
+
`}
|
|
2812
|
+
<//>
|
|
2813
|
+
|
|
2597
2814
|
<${Section} title=":hammer: Infrastructure">
|
|
2598
2815
|
<div class="form-group">
|
|
2599
2816
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
|
|
@@ -2698,6 +2915,14 @@ function App() {
|
|
|
2698
2915
|
</tr>
|
|
2699
2916
|
<tr><th>Workflow Auto-Install</th><td>${workflowAutoInstall ? "Enabled" : "Disabled"}</td></tr>
|
|
2700
2917
|
<tr><th>Telegram</th><td>${telegramEnabled && telegramToken ? "Configured" : "Skipped"}</td></tr>
|
|
2918
|
+
<tr>
|
|
2919
|
+
<th>Voice</th>
|
|
2920
|
+
<td>
|
|
2921
|
+
${voiceEnabled
|
|
2922
|
+
? `${voiceProvider} (${voiceFallbackMode} fallback)`
|
|
2923
|
+
: "Disabled"}
|
|
2924
|
+
</td>
|
|
2925
|
+
</tr>
|
|
2701
2926
|
${profile === "advanced" ? html`
|
|
2702
2927
|
<tr><th>Max Parallel</th><td>${maxParallel}</td></tr>
|
|
2703
2928
|
<tr><th>Max Retries</th><td>${maxRetries}</td></tr>
|
|
@@ -2765,7 +2990,7 @@ function App() {
|
|
|
2765
2990
|
`}
|
|
2766
2991
|
${oauthStatus === "received" && html`
|
|
2767
2992
|
<div style="background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.35);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:0.85rem;color:#4ade80;display:flex;align-items:flex-start;gap:10px">
|
|
2768
|
-
<span style="font-size:1.2em;line-height:1"
|
|
2993
|
+
<span style="font-size:1.2em;line-height:1">${iconText(":check:")}</span>
|
|
2769
2994
|
<div>
|
|
2770
2995
|
<strong>GitHub App authorized!</strong>
|
|
2771
2996
|
${oauthInstallationId && html` Installation ID: <code style="font-family:var(--font-mono);font-size:0.8em;color:#86efac">${oauthInstallationId}</code>.`}
|
package/ui/styles/sessions.css
CHANGED
|
@@ -1218,6 +1218,81 @@
|
|
|
1218
1218
|
background: rgba(255, 255, 255, 0.2);
|
|
1219
1219
|
}
|
|
1220
1220
|
|
|
1221
|
+
/* ─── Thinking Group — collapsed view of consecutive trace events ─── */
|
|
1222
|
+
.thinking-group {
|
|
1223
|
+
align-self: stretch;
|
|
1224
|
+
border: 1px solid var(--border);
|
|
1225
|
+
border-radius: 12px;
|
|
1226
|
+
background: rgba(255, 255, 255, 0.02);
|
|
1227
|
+
margin: 2px 0 4px;
|
|
1228
|
+
overflow: hidden;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.thinking-group.has-errors {
|
|
1232
|
+
border-color: rgba(239, 68, 68, 0.35);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
.thinking-group-head {
|
|
1236
|
+
display: flex;
|
|
1237
|
+
align-items: center;
|
|
1238
|
+
gap: 8px;
|
|
1239
|
+
padding: 7px 10px;
|
|
1240
|
+
width: 100%;
|
|
1241
|
+
border: 0;
|
|
1242
|
+
background: transparent;
|
|
1243
|
+
color: inherit;
|
|
1244
|
+
cursor: pointer;
|
|
1245
|
+
text-align: left;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
.thinking-group-head:hover {
|
|
1249
|
+
background: rgba(255, 255, 255, 0.03);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
.thinking-group-badge {
|
|
1253
|
+
display: inline-flex;
|
|
1254
|
+
align-items: center;
|
|
1255
|
+
gap: 4px;
|
|
1256
|
+
padding: 2px 7px;
|
|
1257
|
+
border-radius: 999px;
|
|
1258
|
+
border: 1px solid rgba(245, 158, 11, 0.35);
|
|
1259
|
+
background: rgba(245, 158, 11, 0.12);
|
|
1260
|
+
font-size: 9px;
|
|
1261
|
+
font-weight: 700;
|
|
1262
|
+
letter-spacing: 0.07em;
|
|
1263
|
+
text-transform: uppercase;
|
|
1264
|
+
color: #fde68a;
|
|
1265
|
+
flex-shrink: 0;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
.thinking-group-badge svg {
|
|
1269
|
+
width: 10px;
|
|
1270
|
+
height: 10px;
|
|
1271
|
+
opacity: 0.85;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.thinking-group-label {
|
|
1275
|
+
flex: 1;
|
|
1276
|
+
min-width: 0;
|
|
1277
|
+
font-size: 11px;
|
|
1278
|
+
font-weight: 400;
|
|
1279
|
+
color: var(--text-secondary);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
.thinking-group-chevron {
|
|
1283
|
+
flex-shrink: 0;
|
|
1284
|
+
font-size: 13px;
|
|
1285
|
+
color: var(--text-hint);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.thinking-group-body {
|
|
1289
|
+
border-top: 1px solid var(--border);
|
|
1290
|
+
padding: 6px;
|
|
1291
|
+
display: flex;
|
|
1292
|
+
flex-direction: column;
|
|
1293
|
+
gap: 2px;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1221
1296
|
/* Subtle entrance for bubbles — only the last few get the animation
|
|
1222
1297
|
(content-visibility: auto on older bubbles skips them for free) */
|
|
1223
1298
|
.chat-bubble:last-child,
|
package/ui/tabs/agents.js
CHANGED
|
@@ -87,6 +87,28 @@ function formatTaskOptionLabel(task) {
|
|
|
87
87
|
return `#${numberToken} ${task?.title || "(untitled task)"}`;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function normalizeDispatchTaskChoices(tasks) {
|
|
91
|
+
if (!Array.isArray(tasks)) return [];
|
|
92
|
+
const deduped = [];
|
|
93
|
+
const seenTaskIds = new Set();
|
|
94
|
+
for (const task of tasks) {
|
|
95
|
+
if (!task || typeof task !== "object") continue;
|
|
96
|
+
const status = String(task?.status || "").toLowerCase();
|
|
97
|
+
const dispatchable =
|
|
98
|
+
task?.draft === true || status === "draft" || status === "todo";
|
|
99
|
+
if (!dispatchable) continue;
|
|
100
|
+
const taskId = String(task?.id ?? task?.taskId ?? "").trim();
|
|
101
|
+
if (!taskId || seenTaskIds.has(taskId)) continue;
|
|
102
|
+
seenTaskIds.add(taskId);
|
|
103
|
+
deduped.push({ ...task, id: taskId });
|
|
104
|
+
}
|
|
105
|
+
return deduped.sort((a, b) => taskSortScore(b) - taskSortScore(a));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fleetSlotKey(index) {
|
|
109
|
+
return `slot-${index}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
/* ─── Workspace Viewer Modal ─── */
|
|
91
113
|
function WorkspaceViewer({ agent, onClose }) {
|
|
92
114
|
const [logText, setLogText] = useState("Loading…");
|
|
@@ -958,26 +980,34 @@ function DispatchSection({ freeSlots, inputRef, className = "" }) {
|
|
|
958
980
|
const [dispatching, setDispatching] = useState(false);
|
|
959
981
|
const [taskChoices, setTaskChoices] = useState([]);
|
|
960
982
|
const [tasksLoading, setTasksLoading] = useState(false);
|
|
983
|
+
const latestTaskRequestRef = useRef(0);
|
|
984
|
+
const mountedRef = useRef(true);
|
|
961
985
|
|
|
962
986
|
const canDispatch = Boolean(taskId.trim() || prompt.trim());
|
|
963
987
|
|
|
988
|
+
useEffect(() => {
|
|
989
|
+
mountedRef.current = true;
|
|
990
|
+
return () => {
|
|
991
|
+
mountedRef.current = false;
|
|
992
|
+
};
|
|
993
|
+
}, []);
|
|
994
|
+
|
|
964
995
|
const loadDispatchTasks = useCallback(() => {
|
|
996
|
+
const requestId = latestTaskRequestRef.current + 1;
|
|
997
|
+
latestTaskRequestRef.current = requestId;
|
|
965
998
|
setTasksLoading(true);
|
|
966
999
|
apiFetch("/api/tasks?limit=1000", { _silent: true })
|
|
967
1000
|
.then((res) => {
|
|
968
|
-
|
|
969
|
-
const choices =
|
|
970
|
-
.filter((task) => {
|
|
971
|
-
const status = String(task?.status || "").toLowerCase();
|
|
972
|
-
return task?.draft === true || status === "draft" || status === "todo";
|
|
973
|
-
})
|
|
974
|
-
.sort((a, b) => taskSortScore(b) - taskSortScore(a));
|
|
1001
|
+
if (!mountedRef.current || latestTaskRequestRef.current !== requestId) return;
|
|
1002
|
+
const choices = normalizeDispatchTaskChoices(res?.data);
|
|
975
1003
|
setTaskChoices(choices);
|
|
976
1004
|
})
|
|
977
1005
|
.catch(() => {
|
|
1006
|
+
if (!mountedRef.current || latestTaskRequestRef.current !== requestId) return;
|
|
978
1007
|
setTaskChoices([]);
|
|
979
1008
|
})
|
|
980
1009
|
.finally(() => {
|
|
1010
|
+
if (!mountedRef.current || latestTaskRequestRef.current !== requestId) return;
|
|
981
1011
|
setTasksLoading(false);
|
|
982
1012
|
});
|
|
983
1013
|
}, []);
|
|
@@ -1045,8 +1075,8 @@ function DispatchSection({ freeSlots, inputRef, className = "" }) {
|
|
|
1045
1075
|
<option value="">
|
|
1046
1076
|
${tasksLoading ? "Loading tasks…" : "Select backlog or draft task"}
|
|
1047
1077
|
</option>
|
|
1048
|
-
${taskChoices.map((task) => html`
|
|
1049
|
-
<option key=${task.id} value=${task.id}>
|
|
1078
|
+
${taskChoices.map((task, i) => html`
|
|
1079
|
+
<option key=${`${task.id}-${i}`} value=${task.id}>
|
|
1050
1080
|
${formatTaskOptionLabel(task)}
|
|
1051
1081
|
</option>
|
|
1052
1082
|
`)}
|
|
@@ -1355,7 +1385,7 @@ export function AgentsTab() {
|
|
|
1355
1385
|
const st = slot ? slot.status || "busy" : "idle";
|
|
1356
1386
|
return html`
|
|
1357
1387
|
<div
|
|
1358
|
-
key=${i}
|
|
1388
|
+
key=${fleetSlotKey(i)}
|
|
1359
1389
|
class="slot-cell slot-${st}"
|
|
1360
1390
|
title=${slot
|
|
1361
1391
|
? `${slot.taskTitle || slot.taskId} (${st})`
|
|
@@ -1387,8 +1417,8 @@ export function AgentsTab() {
|
|
|
1387
1417
|
: "No active slots"}
|
|
1388
1418
|
</div>
|
|
1389
1419
|
${slots.length
|
|
1390
|
-
|
|
1391
|
-
|
|
1420
|
+
? slots.map(
|
|
1421
|
+
(slot, i) => html`
|
|
1392
1422
|
<div
|
|
1393
1423
|
key=${slot?.taskId || slot?.sessionId || `slot-${i}`}
|
|
1394
1424
|
class="task-card fleet-agent-card ${expandedSlot === i
|
package/ui-server.mjs
CHANGED
|
@@ -9022,6 +9022,7 @@ async function handleApi(req, res, url) {
|
|
|
9022
9022
|
available: availability.available,
|
|
9023
9023
|
tier: availability.tier,
|
|
9024
9024
|
provider: availability.provider,
|
|
9025
|
+
reason: availability.reason || "",
|
|
9025
9026
|
voiceId: config.voiceId,
|
|
9026
9027
|
turnDetection: config.turnDetection,
|
|
9027
9028
|
model: config.model,
|