refactor(ui): split render + connections

This commit is contained in:
Peter Steinberger
2026-01-14 05:40:14 +00:00
parent b11eea07b0
commit 8ba80d2dac
28 changed files with 2961 additions and 2891 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

6
ui/src/ui/app-events.ts Normal file
View File

@@ -0,0 +1,6 @@
export type EventLogEntry = {
ts: number;
event: string;
payload?: unknown;
};

View File

@@ -0,0 +1,223 @@
import { html } from "lit";
import type { AppViewState } from "./app-view-state";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
import { loadChatHistory } from "./controllers/chat";
import type { SessionsListResult } from "./types";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
export function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath);
return html`
<a
href=${href}
class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
state.setTab(tab);
}}
title=${titleForTab(tab)}
>
<span class="nav-item__icon" aria-hidden="true">${iconForTab(tab)}</span>
<span class="nav-item__text">${titleForTab(tab)}</span>
</a>
`;
}
export function renderChatControls(state: AppViewState) {
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
// Icon for list view (legacy)
const listIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`;
// Icon for grouped view
const groupIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`;
// Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
return html`
<div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void loadChatHistory(state);
}}
>
${sessionOptions.map(
(entry) =>
html`<option value=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
</select>
</label>
<button
class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected}
@click=${() => {
state.resetToolStream();
void loadChatHistory(state);
}}
title="Refresh chat history"
>
${refreshIcon}
</button>
<span class="chat-controls__separator">|</span>
<button
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
})}
aria-pressed=${state.settings.chatFocusMode}
title="Toggle focus mode (hide sidebar + page header)"
>
${focusIcon}
</button>
<button
class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
useNewChatLayout: !state.settings.useNewChatLayout,
})}
aria-pressed=${state.settings.useNewChatLayout}
title="${state.settings.useNewChatLayout ? "Switch to list view" : "Switch to grouped view"}"
>
${state.settings.useNewChatLayout ? groupIcon : listIcon}
</button>
</div>
`;
}
function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) {
const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = [];
// Add current session key first
seen.add(sessionKey);
options.push({ key: sessionKey });
// Add sessions from the result
if (sessions?.sessions) {
for (const s of sessions.sessions) {
if (!seen.has(s.key)) {
seen.add(s.key);
options.push({ key: s.key, displayName: s.displayName });
}
}
}
return options;
}
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
export function renderThemeToggle(state: AppViewState) {
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
if (event.clientX || event.clientY) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
}
state.setTheme(next, context);
};
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<div class="theme-toggle__track" role="group" aria-label="Theme">
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
>
${renderMonitorIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-label="Dark theme"
title="Dark"
>
${renderMoonIcon()}
</button>
</div>
</div>
`;
}
function renderSunIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
`;
}
function renderMoonIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
></path>
</svg>
`;
}
function renderMonitorIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
`;
}

View File

@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { AppViewState } from "./app-view-state";
import {
TAB_GROUPS,
iconForTab,
@@ -46,6 +47,7 @@ import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderSkills } from "./views/skills";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
import {
loadChannels,
updateDiscordForm,
@@ -77,137 +79,6 @@ import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } fr
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
export type EventLogEntry = {
ts: number;
event: string;
payload?: unknown;
};
export type AppViewState = {
settings: UiSettings;
password: string;
tab: Tab;
basePath: string;
connected: boolean;
theme: ThemeMode;
themeResolved: "light" | "dark";
hello: GatewayHelloOk | null;
lastError: string | null;
eventLog: EventLogEntry[];
sessionKey: string;
chatLoading: boolean;
chatSending: boolean;
chatMessage: string;
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;
chatRunId: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaLoading: boolean;
configUiHints: Record<string, unknown>;
configForm: Record<string, unknown> | null;
configFormMode: "form" | "raw";
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
presenceLoading: boolean;
presenceEntries: PresenceEntry[];
presenceError: string | null;
presenceStatus: string | null;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;
sessionsFilterActive: string;
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
cronLoading: boolean;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronRunsJobId: string | null;
cronRuns: CronRunLogEntry[];
cronBusy: boolean;
skillsLoading: boolean;
skillsReport: SkillStatusReport | null;
skillsError: string | null;
skillsFilter: string;
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown | null;
debugCallMethod: string;
debugCallParams: string;
debugCallResult: string | null;
debugCallError: string | null;
logsLoading: boolean;
logsError: string | null;
logsFile: string | null;
logsEntries: LogEntry[];
logsFilterText: string;
logsLevelFilters: Record<LogLevel, boolean>;
logsAutoFollow: boolean;
logsTruncated: boolean;
client: GatewayBrowserClient | null;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadCron: () => Promise<void>;
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>;
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
handleAbortChat: () => Promise<void>;
removeQueuedMessage: (id: string) => void;
resetToolStream: () => void;
handleLogsScroll: (event: Event) => void;
exportLogs: (lines: string[], label: string) => void;
};
export function renderApp(state: AppViewState) {
const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null;
@@ -589,218 +460,3 @@ export function renderApp(state: AppViewState) {
</div>
`;
}
function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath);
return html`
<a
href=${href}
class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
state.setTab(tab);
}}
title=${titleForTab(tab)}
>
<span class="nav-item__icon" aria-hidden="true">${iconForTab(tab)}</span>
<span class="nav-item__text">${titleForTab(tab)}</span>
</a>
`;
}
function renderChatControls(state: AppViewState) {
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
// Icon for list view (legacy)
const listIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`;
// Icon for grouped view
const groupIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`;
// Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
return html`
<div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void loadChatHistory(state);
}}
>
${sessionOptions.map(
(entry) =>
html`<option value=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
</select>
</label>
<button
class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected}
@click=${() => {
state.resetToolStream();
void loadChatHistory(state);
}}
title="Refresh chat history"
>
${refreshIcon}
</button>
<span class="chat-controls__separator">|</span>
<button
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
})}
aria-pressed=${state.settings.chatFocusMode}
title="Toggle focus mode (hide sidebar + page header)"
>
${focusIcon}
</button>
<button
class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
useNewChatLayout: !state.settings.useNewChatLayout,
})}
aria-pressed=${state.settings.useNewChatLayout}
title="${state.settings.useNewChatLayout ? "Switch to list view" : "Switch to grouped view"}"
>
${state.settings.useNewChatLayout ? groupIcon : listIcon}
</button>
</div>
`;
}
function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) {
const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = [];
// Add current session key first
seen.add(sessionKey);
options.push({ key: sessionKey });
// Add sessions from the result
if (sessions?.sessions) {
for (const s of sessions.sessions) {
if (!seen.has(s.key)) {
seen.add(s.key);
options.push({ key: s.key, displayName: s.displayName });
}
}
}
return options;
}
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
function renderThemeToggle(state: AppViewState) {
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
if (event.clientX || event.clientY) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
}
state.setTheme(next, context);
};
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<div class="theme-toggle__track" role="group" aria-label="Theme">
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
>
${renderMonitorIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-label="Dark theme"
title="Dark"
>
${renderMoonIcon()}
</button>
</div>
</div>
`;
}
function renderSunIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
`;
}
function renderMoonIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
></path>
</svg>
`;
}
function renderMonitorIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
`;
}

197
ui/src/ui/app-view-state.ts Normal file
View File

@@ -0,0 +1,197 @@
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type {
ChannelsStatusSnapshot,
ConfigSnapshot,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
LogEntry,
LogLevel,
PresenceEntry,
SessionsListResult,
SkillStatusReport,
StatusSummary,
} from "./types";
import type {
ChatQueueItem,
CronFormState,
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
export type AppViewState = {
settings: UiSettings;
password: string;
tab: Tab;
basePath: string;
connected: boolean;
theme: ThemeMode;
themeResolved: "light" | "dark";
hello: GatewayHelloOk | null;
lastError: string | null;
eventLog: EventLogEntry[];
sessionKey: string;
chatLoading: boolean;
chatSending: boolean;
chatMessage: string;
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;
chatRunId: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaLoading: boolean;
configUiHints: Record<string, unknown>;
configForm: Record<string, unknown> | null;
configFormMode: "form" | "raw";
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
presenceLoading: boolean;
presenceEntries: PresenceEntry[];
presenceError: string | null;
presenceStatus: string | null;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;
sessionsFilterActive: string;
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
cronLoading: boolean;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronRunsJobId: string | null;
cronRuns: CronRunLogEntry[];
cronBusy: boolean;
skillsLoading: boolean;
skillsReport: SkillStatusReport | null;
skillsError: string | null;
skillsFilter: string;
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown | null;
debugCallMethod: string;
debugCallParams: string;
debugCallResult: string | null;
debugCallError: string | null;
logsLoading: boolean;
logsError: string | null;
logsFile: string | null;
logsEntries: LogEntry[];
logsFilterText: string;
logsLevelFilters: Record<LogLevel, boolean>;
logsAutoFollow: boolean;
logsTruncated: boolean;
client: GatewayBrowserClient | null;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadCron: () => Promise<void>;
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>;
handleDiscordSave: () => Promise<void>;
handleSlackSave: () => Promise<void>;
handleSignalSave: () => Promise<void>;
handleIMessageSave: () => Promise<void>;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;
handleConfigFormUpdate: (path: string, value: unknown) => void;
handleConfigFormModeChange: (mode: "form" | "raw") => void;
handleConfigRawChange: (raw: string) => void;
handleInstallSkill: (key: string) => Promise<void>;
handleUpdateSkill: (key: string) => Promise<void>;
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
handleUpdateSkillEdit: (key: string, value: string) => void;
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
handleCronRun: (jobId: string) => Promise<void>;
handleCronRemove: (jobId: string) => Promise<void>;
handleCronAdd: () => Promise<void>;
handleCronRunsLoad: (jobId: string) => Promise<void>;
handleCronFormUpdate: (path: string, value: unknown) => void;
handleSessionsLoad: () => Promise<void>;
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
handleLoadNodes: () => Promise<void>;
handleLoadPresence: () => Promise<void>;
handleLoadSkills: () => Promise<void>;
handleLoadDebug: () => Promise<void>;
handleLoadLogs: () => Promise<void>;
handleDebugCall: () => Promise<void>;
handleRunUpdate: () => Promise<void>;
setPassword: (next: string) => void;
setSessionKey: (next: string) => void;
setChatMessage: (next: string) => void;
handleChatSend: () => Promise<void>;
handleChatAbort: () => Promise<void>;
handleChatSelectQueueItem: (id: string) => void;
handleChatDropQueueItem: (id: string) => void;
handleChatClearQueue: () => void;
handleLogsFilterChange: (next: string) => void;
handleLogsLevelFilterToggle: (level: LogLevel) => void;
handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
handleUpdateDiscordForm: (path: string, value: unknown) => void;
handleUpdateSlackForm: (path: string, value: unknown) => void;
handleUpdateSignalForm: (path: string, value: unknown) => void;
handleUpdateTelegramForm: (path: string, value: unknown) => void;
handleUpdateIMessageForm: (path: string, value: unknown) => void;
};

View File

@@ -85,12 +85,7 @@ import {
} from "./controllers/skills";
import { loadDebug } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
type EventLogEntry = {
ts: number;
event: string;
payload?: unknown;
};
import type { EventLogEntry } from "./app-events";
const TOOL_STREAM_LIMIT = 50;
const TOOL_STREAM_THROTTLE_MS = 80;

View File

@@ -0,0 +1,179 @@
import { parseList } from "../format";
import {
defaultDiscordActions,
type DiscordActionForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
} from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export async function saveDiscordConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.discordSaving) return;
state.discordSaving = true;
state.discordConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const discord = { ...(config.discord ?? {}) } as Record<string, unknown>;
const form = state.discordForm;
if (form.enabled) {
delete discord.enabled;
} else {
discord.enabled = false;
}
if (!state.discordTokenLocked) {
const token = form.token.trim();
if (token) discord.token = token;
else delete discord.token;
}
const allowFrom = parseList(form.allowFrom);
const groupChannels = parseList(form.groupChannels);
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>;
if (form.dmEnabled) delete dm.enabled;
else dm.enabled = false;
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
else delete dm.allowFrom;
if (form.groupEnabled) dm.groupEnabled = true;
else delete dm.groupEnabled;
if (groupChannels.length > 0) dm.groupChannels = groupChannels;
else delete dm.groupChannels;
if (Object.keys(dm).length > 0) discord.dm = dm;
else delete discord.dm;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
discord.mediaMaxMb = mediaMaxMb;
} else {
delete discord.mediaMaxMb;
}
const historyLimitRaw = form.historyLimit.trim();
if (historyLimitRaw.length === 0) {
delete discord.historyLimit;
} else {
const historyLimit = Number(historyLimitRaw);
if (Number.isFinite(historyLimit) && historyLimit >= 0) {
discord.historyLimit = historyLimit;
} else {
delete discord.historyLimit;
}
}
const chunkLimitRaw = form.textChunkLimit.trim();
if (chunkLimitRaw.length === 0) {
delete discord.textChunkLimit;
} else {
const chunkLimit = Number(chunkLimitRaw);
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
discord.textChunkLimit = chunkLimit;
} else {
delete discord.textChunkLimit;
}
}
if (form.replyToMode === "off") {
delete discord.replyToMode;
} else {
discord.replyToMode = form.replyToMode;
}
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
const guilds: Record<string, unknown> = {};
guildsForm.forEach((guild: DiscordGuildForm) => {
const key = String(guild.key ?? "").trim();
if (!key) return;
const entry: Record<string, unknown> = {};
const slug = String(guild.slug ?? "").trim();
if (slug) entry.slug = slug;
if (guild.requireMention) entry.requireMention = true;
if (
guild.reactionNotifications === "off" ||
guild.reactionNotifications === "all" ||
guild.reactionNotifications === "own" ||
guild.reactionNotifications === "allowlist"
) {
entry.reactionNotifications = guild.reactionNotifications;
}
const users = parseList(guild.users);
if (users.length > 0) entry.users = users;
const channels: Record<string, unknown> = {};
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
channelForms.forEach((channel: DiscordGuildChannelForm) => {
const channelKey = String(channel.key ?? "").trim();
if (!channelKey) return;
const channelEntry: Record<string, unknown> = {};
if (channel.allow === false) channelEntry.allow = false;
if (channel.requireMention) channelEntry.requireMention = true;
channels[channelKey] = channelEntry;
});
if (Object.keys(channels).length > 0) entry.channels = channels;
guilds[key] = entry;
});
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
else delete discord.guilds;
const actions: Partial<DiscordActionForm> = {};
const applyAction = (key: keyof DiscordActionForm) => {
const value = form.actions[key];
if (value !== defaultDiscordActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("stickers");
applyAction("polls");
applyAction("permissions");
applyAction("messages");
applyAction("threads");
applyAction("pins");
applyAction("search");
applyAction("memberInfo");
applyAction("roleInfo");
applyAction("channelInfo");
applyAction("voiceStatus");
applyAction("events");
applyAction("roles");
applyAction("moderation");
if (Object.keys(actions).length > 0) {
discord.actions = actions;
} else {
delete discord.actions;
}
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
if (form.slashEnabled) {
slash.enabled = true;
} else {
delete slash.enabled;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else delete slash.name;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else delete slash.sessionPrefix;
if (form.slashEphemeral) {
delete slash.ephemeral;
} else {
slash.ephemeral = false;
}
if (Object.keys(slash).length > 0) discord.slashCommand = slash;
else delete discord.slashCommand;
if (Object.keys(discord).length > 0) {
config.discord = discord;
} else {
delete config.discord;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.discordConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.discordConfigStatus = String(err);
} finally {
state.discordSaving = false;
}
}

View File

@@ -0,0 +1,68 @@
import { parseList } from "../format";
import type { ConnectionsState } from "./connections.types";
export async function saveIMessageConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.imessageSaving) return;
state.imessageSaving = true;
state.imessageConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const imessage = { ...(config.imessage ?? {}) } as Record<string, unknown>;
const form = state.imessageForm;
if (form.enabled) {
delete imessage.enabled;
} else {
imessage.enabled = false;
}
const cliPath = form.cliPath.trim();
if (cliPath) imessage.cliPath = cliPath;
else delete imessage.cliPath;
const dbPath = form.dbPath.trim();
if (dbPath) imessage.dbPath = dbPath;
else delete imessage.dbPath;
if (form.service === "auto") {
delete imessage.service;
} else {
imessage.service = form.service;
}
const region = form.region.trim();
if (region) imessage.region = region;
else delete imessage.region;
const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) imessage.allowFrom = allowFrom;
else delete imessage.allowFrom;
if (form.includeAttachments) imessage.includeAttachments = true;
else delete imessage.includeAttachments;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
imessage.mediaMaxMb = mediaMaxMb;
} else {
delete imessage.mediaMaxMb;
}
if (Object.keys(imessage).length > 0) {
config.imessage = imessage;
} else {
delete config.imessage;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.imessageConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.imessageConfigStatus = String(err);
} finally {
state.imessageSaving = false;
}
}

View File

@@ -0,0 +1,89 @@
import { parseList } from "../format";
import type { ConnectionsState } from "./connections.types";
export async function saveSignalConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.signalSaving) return;
state.signalSaving = true;
state.signalConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const signal = { ...(config.signal ?? {}) } as Record<string, unknown>;
const form = state.signalForm;
if (form.enabled) {
delete signal.enabled;
} else {
signal.enabled = false;
}
const account = form.account.trim();
if (account) signal.account = account;
else delete signal.account;
const httpUrl = form.httpUrl.trim();
if (httpUrl) signal.httpUrl = httpUrl;
else delete signal.httpUrl;
const httpHost = form.httpHost.trim();
if (httpHost) signal.httpHost = httpHost;
else delete signal.httpHost;
const httpPort = Number(form.httpPort);
if (Number.isFinite(httpPort) && httpPort > 0) {
signal.httpPort = httpPort;
} else {
delete signal.httpPort;
}
const cliPath = form.cliPath.trim();
if (cliPath) signal.cliPath = cliPath;
else delete signal.cliPath;
if (form.autoStart) {
delete signal.autoStart;
} else {
signal.autoStart = false;
}
if (form.receiveMode === "on-start" || form.receiveMode === "manual") {
signal.receiveMode = form.receiveMode;
} else {
delete signal.receiveMode;
}
if (form.ignoreAttachments) signal.ignoreAttachments = true;
else delete signal.ignoreAttachments;
if (form.ignoreStories) signal.ignoreStories = true;
else delete signal.ignoreStories;
if (form.sendReadReceipts) signal.sendReadReceipts = true;
else delete signal.sendReadReceipts;
const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) signal.allowFrom = allowFrom;
else delete signal.allowFrom;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
signal.mediaMaxMb = mediaMaxMb;
} else {
delete signal.mediaMaxMb;
}
if (Object.keys(signal).length > 0) {
config.signal = signal;
} else {
delete config.signal;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.signalConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.signalConfigStatus = String(err);
} finally {
state.signalSaving = false;
}
}

View File

@@ -0,0 +1,143 @@
import { parseList } from "../format";
import { defaultSlackActions, type SlackActionForm } from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export async function saveSlackConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.slackSaving) return;
state.slackSaving = true;
state.slackConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const slack = { ...(config.slack ?? {}) } as Record<string, unknown>;
const form = state.slackForm;
if (form.enabled) {
delete slack.enabled;
} else {
slack.enabled = false;
}
if (!state.slackTokenLocked) {
const token = form.botToken.trim();
if (token) slack.botToken = token;
else delete slack.botToken;
}
if (!state.slackAppTokenLocked) {
const token = form.appToken.trim();
if (token) slack.appToken = token;
else delete slack.appToken;
}
const dm = { ...(slack.dm ?? {}) } as Record<string, unknown>;
dm.enabled = form.dmEnabled;
const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
else delete dm.allowFrom;
if (form.groupEnabled) {
dm.groupEnabled = true;
} else {
delete dm.groupEnabled;
}
const groupChannels = parseList(form.groupChannels);
if (groupChannels.length > 0) dm.groupChannels = groupChannels;
else delete dm.groupChannels;
if (Object.keys(dm).length > 0) slack.dm = dm;
else delete slack.dm;
const mediaMaxMb = Number.parseFloat(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
slack.mediaMaxMb = mediaMaxMb;
} else {
delete slack.mediaMaxMb;
}
const textChunkLimit = Number.parseInt(form.textChunkLimit, 10);
if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) {
slack.textChunkLimit = textChunkLimit;
} else {
delete slack.textChunkLimit;
}
if (form.reactionNotifications === "own") {
delete slack.reactionNotifications;
} else {
slack.reactionNotifications = form.reactionNotifications;
}
const reactionAllowlist = parseList(form.reactionAllowlist);
if (reactionAllowlist.length > 0) {
slack.reactionAllowlist = reactionAllowlist;
} else {
delete slack.reactionAllowlist;
}
const slash = { ...(slack.slashCommand ?? {}) } as Record<string, unknown>;
if (form.slashEnabled) {
slash.enabled = true;
} else {
delete slash.enabled;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else delete slash.name;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else delete slash.sessionPrefix;
if (form.slashEphemeral) {
delete slash.ephemeral;
} else {
slash.ephemeral = false;
}
if (Object.keys(slash).length > 0) slack.slashCommand = slash;
else delete slack.slashCommand;
const actions: Partial<SlackActionForm> = {};
const applyAction = (key: keyof SlackActionForm) => {
const value = form.actions[key];
if (value !== defaultSlackActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("messages");
applyAction("pins");
applyAction("memberInfo");
applyAction("emojiList");
if (Object.keys(actions).length > 0) {
slack.actions = actions;
} else {
delete slack.actions;
}
const channels = form.channels
.map((entry): [string, Record<string, unknown>] | null => {
const key = entry.key.trim();
if (!key) return null;
const record: Record<string, unknown> = {
allow: entry.allow,
requireMention: entry.requireMention,
};
return [key, record];
})
.filter((value): value is [string, Record<string, unknown>] =>
Boolean(value),
);
if (channels.length > 0) {
slack.channels = Object.fromEntries(channels);
} else {
delete slack.channels;
}
if (Object.keys(slack).length > 0) {
config.slack = slack;
} else {
delete config.slack;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.slackConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.slackConfigStatus = String(err);
} finally {
state.slackSaving = false;
}
}

View File

@@ -1,52 +1,20 @@
import type { GatewayBrowserClient } from "../gateway";
import { parseList } from "../format";
import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types";
import type { ChannelsStatusSnapshot } from "../types";
import {
defaultDiscordActions,
defaultSlackActions,
type DiscordActionForm,
type DiscordForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
type IMessageForm,
type SlackActionForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export type ConnectionsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
configSnapshot: ConfigSnapshot | null;
};
export { saveDiscordConfig } from "./connections.save-discord";
export { saveIMessageConfig } from "./connections.save-imessage";
export { saveSlackConfig } from "./connections.save-slack";
export { saveSignalConfig } from "./connections.save-signal";
export type { ConnectionsState };
export async function loadChannels(state: ConnectionsState, probe: boolean) {
if (!state.client || !state.connected) return;
@@ -254,462 +222,3 @@ export async function saveTelegramConfig(state: ConnectionsState) {
state.telegramSaving = false;
}
}
export async function saveDiscordConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.discordSaving) return;
state.discordSaving = true;
state.discordConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const discord = { ...(config.discord ?? {}) } as Record<string, unknown>;
const form = state.discordForm;
if (form.enabled) {
delete discord.enabled;
} else {
discord.enabled = false;
}
if (!state.discordTokenLocked) {
const token = form.token.trim();
if (token) discord.token = token;
else delete discord.token;
}
const allowFrom = parseList(form.allowFrom);
const groupChannels = parseList(form.groupChannels);
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>;
if (form.dmEnabled) delete dm.enabled;
else dm.enabled = false;
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
else delete dm.allowFrom;
if (form.groupEnabled) dm.groupEnabled = true;
else delete dm.groupEnabled;
if (groupChannels.length > 0) dm.groupChannels = groupChannels;
else delete dm.groupChannels;
if (Object.keys(dm).length > 0) discord.dm = dm;
else delete discord.dm;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
discord.mediaMaxMb = mediaMaxMb;
} else {
delete discord.mediaMaxMb;
}
const historyLimitRaw = form.historyLimit.trim();
if (historyLimitRaw.length === 0) {
delete discord.historyLimit;
} else {
const historyLimit = Number(historyLimitRaw);
if (Number.isFinite(historyLimit) && historyLimit >= 0) {
discord.historyLimit = historyLimit;
} else {
delete discord.historyLimit;
}
}
const chunkLimitRaw = form.textChunkLimit.trim();
if (chunkLimitRaw.length === 0) {
delete discord.textChunkLimit;
} else {
const chunkLimit = Number(chunkLimitRaw);
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
discord.textChunkLimit = chunkLimit;
} else {
delete discord.textChunkLimit;
}
}
if (form.replyToMode === "off") {
delete discord.replyToMode;
} else {
discord.replyToMode = form.replyToMode;
}
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
const guilds: Record<string, unknown> = {};
guildsForm.forEach((guild: DiscordGuildForm) => {
const key = String(guild.key ?? "").trim();
if (!key) return;
const entry: Record<string, unknown> = {};
const slug = String(guild.slug ?? "").trim();
if (slug) entry.slug = slug;
if (guild.requireMention) entry.requireMention = true;
if (
guild.reactionNotifications === "off" ||
guild.reactionNotifications === "all" ||
guild.reactionNotifications === "own" ||
guild.reactionNotifications === "allowlist"
) {
entry.reactionNotifications = guild.reactionNotifications;
}
const users = parseList(guild.users);
if (users.length > 0) entry.users = users;
const channels: Record<string, unknown> = {};
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
channelForms.forEach((channel: DiscordGuildChannelForm) => {
const channelKey = String(channel.key ?? "").trim();
if (!channelKey) return;
const channelEntry: Record<string, unknown> = {};
if (channel.allow === false) channelEntry.allow = false;
if (channel.requireMention) channelEntry.requireMention = true;
channels[channelKey] = channelEntry;
});
if (Object.keys(channels).length > 0) entry.channels = channels;
guilds[key] = entry;
});
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
else delete discord.guilds;
const actions: Partial<DiscordActionForm> = {};
const applyAction = (key: keyof DiscordActionForm) => {
const value = form.actions[key];
if (value !== defaultDiscordActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("stickers");
applyAction("polls");
applyAction("permissions");
applyAction("messages");
applyAction("threads");
applyAction("pins");
applyAction("search");
applyAction("memberInfo");
applyAction("roleInfo");
applyAction("channelInfo");
applyAction("voiceStatus");
applyAction("events");
applyAction("roles");
applyAction("moderation");
if (Object.keys(actions).length > 0) {
discord.actions = actions;
} else {
delete discord.actions;
}
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
if (form.slashEnabled) {
slash.enabled = true;
} else {
delete slash.enabled;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else delete slash.name;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else delete slash.sessionPrefix;
if (form.slashEphemeral) {
delete slash.ephemeral;
} else {
slash.ephemeral = false;
}
if (Object.keys(slash).length > 0) discord.slashCommand = slash;
else delete discord.slashCommand;
if (Object.keys(discord).length > 0) {
config.discord = discord;
} else {
delete config.discord;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.discordConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.discordConfigStatus = String(err);
} finally {
state.discordSaving = false;
}
}
export async function saveSlackConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.slackSaving) return;
state.slackSaving = true;
state.slackConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const slack = { ...(config.slack ?? {}) } as Record<string, unknown>;
const form = state.slackForm;
if (form.enabled) {
delete slack.enabled;
} else {
slack.enabled = false;
}
if (!state.slackTokenLocked) {
const token = form.botToken.trim();
if (token) slack.botToken = token;
else delete slack.botToken;
}
if (!state.slackAppTokenLocked) {
const token = form.appToken.trim();
if (token) slack.appToken = token;
else delete slack.appToken;
}
const dm = { ...(slack.dm ?? {}) } as Record<string, unknown>;
dm.enabled = form.dmEnabled;
const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
else delete dm.allowFrom;
if (form.groupEnabled) {
dm.groupEnabled = true;
} else {
delete dm.groupEnabled;
}
const groupChannels = parseList(form.groupChannels);
if (groupChannels.length > 0) dm.groupChannels = groupChannels;
else delete dm.groupChannels;
if (Object.keys(dm).length > 0) slack.dm = dm;
else delete slack.dm;
const mediaMaxMb = Number.parseFloat(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
slack.mediaMaxMb = mediaMaxMb;
} else {
delete slack.mediaMaxMb;
}
const textChunkLimit = Number.parseInt(form.textChunkLimit, 10);
if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) {
slack.textChunkLimit = textChunkLimit;
} else {
delete slack.textChunkLimit;
}
if (form.reactionNotifications === "own") {
delete slack.reactionNotifications;
} else {
slack.reactionNotifications = form.reactionNotifications;
}
const reactionAllowlist = parseList(form.reactionAllowlist);
if (reactionAllowlist.length > 0) {
slack.reactionAllowlist = reactionAllowlist;
} else {
delete slack.reactionAllowlist;
}
const slash = { ...(slack.slashCommand ?? {}) } as Record<string, unknown>;
if (form.slashEnabled) {
slash.enabled = true;
} else {
delete slash.enabled;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else delete slash.name;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else delete slash.sessionPrefix;
if (form.slashEphemeral) {
delete slash.ephemeral;
} else {
slash.ephemeral = false;
}
if (Object.keys(slash).length > 0) slack.slashCommand = slash;
else delete slack.slashCommand;
const actions: Partial<SlackActionForm> = {};
const applyAction = (key: keyof SlackActionForm) => {
const value = form.actions[key];
if (value !== defaultSlackActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("messages");
applyAction("pins");
applyAction("memberInfo");
applyAction("emojiList");
if (Object.keys(actions).length > 0) {
slack.actions = actions;
} else {
delete slack.actions;
}
const channels = form.channels
.map((entry): [string, Record<string, unknown>] | null => {
const key = entry.key.trim();
if (!key) return null;
const record: Record<string, unknown> = {
allow: entry.allow,
requireMention: entry.requireMention,
};
return [key, record];
})
.filter((value): value is [string, Record<string, unknown>] => Boolean(value));
if (channels.length > 0) {
slack.channels = Object.fromEntries(channels);
} else {
delete slack.channels;
}
if (Object.keys(slack).length > 0) {
config.slack = slack;
} else {
delete config.slack;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.slackConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.slackConfigStatus = String(err);
} finally {
state.slackSaving = false;
}
}
export async function saveSignalConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.signalSaving) return;
state.signalSaving = true;
state.signalConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const signal = { ...(config.signal ?? {}) } as Record<string, unknown>;
const form = state.signalForm;
if (form.enabled) {
delete signal.enabled;
} else {
signal.enabled = false;
}
const account = form.account.trim();
if (account) signal.account = account;
else delete signal.account;
const httpUrl = form.httpUrl.trim();
if (httpUrl) signal.httpUrl = httpUrl;
else delete signal.httpUrl;
const httpHost = form.httpHost.trim();
if (httpHost) signal.httpHost = httpHost;
else delete signal.httpHost;
const httpPort = Number(form.httpPort);
if (Number.isFinite(httpPort) && httpPort > 0) {
signal.httpPort = httpPort;
} else {
delete signal.httpPort;
}
const cliPath = form.cliPath.trim();
if (cliPath) signal.cliPath = cliPath;
else delete signal.cliPath;
if (form.autoStart) {
delete signal.autoStart;
} else {
signal.autoStart = false;
}
if (form.receiveMode === "on-start" || form.receiveMode === "manual") {
signal.receiveMode = form.receiveMode;
} else {
delete signal.receiveMode;
}
if (form.ignoreAttachments) signal.ignoreAttachments = true;
else delete signal.ignoreAttachments;
if (form.ignoreStories) signal.ignoreStories = true;
else delete signal.ignoreStories;
if (form.sendReadReceipts) signal.sendReadReceipts = true;
else delete signal.sendReadReceipts;
const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) signal.allowFrom = allowFrom;
else delete signal.allowFrom;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
signal.mediaMaxMb = mediaMaxMb;
} else {
delete signal.mediaMaxMb;
}
if (Object.keys(signal).length > 0) {
config.signal = signal;
} else {
delete config.signal;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.signalConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.signalConfigStatus = String(err);
} finally {
state.signalSaving = false;
}
}
export async function saveIMessageConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.imessageSaving) return;
state.imessageSaving = true;
state.imessageConfigStatus = null;
try {
const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>;
const imessage = { ...(config.imessage ?? {}) } as Record<string, unknown>;
const form = state.imessageForm;
if (form.enabled) {
delete imessage.enabled;
} else {
imessage.enabled = false;
}
const cliPath = form.cliPath.trim();
if (cliPath) imessage.cliPath = cliPath;
else delete imessage.cliPath;
const dbPath = form.dbPath.trim();
if (dbPath) imessage.dbPath = dbPath;
else delete imessage.dbPath;
if (form.service === "auto") {
delete imessage.service;
} else {
imessage.service = form.service;
}
const region = form.region.trim();
if (region) imessage.region = region;
else delete imessage.region;
const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) imessage.allowFrom = allowFrom;
else delete imessage.allowFrom;
if (form.includeAttachments) imessage.includeAttachments = true;
else delete imessage.includeAttachments;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
imessage.mediaMaxMb = mediaMaxMb;
} else {
delete imessage.mediaMaxMb;
}
if (Object.keys(imessage).length > 0) {
config.imessage = imessage;
} else {
delete config.imessage;
}
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.imessageConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.imessageConfigStatus = String(err);
} finally {
state.imessageSaving = false;
}
}

View File

@@ -0,0 +1,43 @@
import type { GatewayBrowserClient } from "../gateway";
import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types";
import type {
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "../ui-types";
export type ConnectionsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
configSnapshot: ConfigSnapshot | null;
};

View File

@@ -0,0 +1,121 @@
import { pathKey, schemaType, type JsonSchema } from "./config-form.shared";
export type ConfigSchemaAnalysis = {
schema: JsonSchema | null;
unsupportedPaths: string[];
};
export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
if (!raw || typeof raw !== "object") {
return { schema: null, unsupportedPaths: ["<root>"] };
}
return normalizeSchemaNode(raw as JsonSchema, []);
}
function normalizeSchemaNode(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis {
const unsupportedPaths: string[] = [];
const normalized: JsonSchema = { ...schema };
const pathLabel = pathKey(path) || "<root>";
if (schema.anyOf || schema.oneOf || schema.allOf) {
const union = normalizeUnion(schema, path);
if (union) return union;
unsupportedPaths.push(pathLabel);
return { schema, unsupportedPaths };
}
const nullable = Array.isArray(schema.type) && schema.type.includes("null");
const type =
schemaType(schema) ??
(schema.properties || schema.additionalProperties ? "object" : undefined);
normalized.type = type ?? schema.type;
if (nullable && !normalized.nullable) {
normalized.nullable = true;
}
if (type === "object") {
const properties = schema.properties ?? {};
const normalizedProps: Record<string, JsonSchema> = {};
for (const [key, value] of Object.entries(properties)) {
const res = normalizeSchemaNode(value, [...path, key]);
normalizedProps[key] = res.schema ?? value;
unsupportedPaths.push(...res.unsupportedPaths);
}
normalized.properties = normalizedProps;
if (
schema.additionalProperties &&
typeof schema.additionalProperties === "object"
) {
const res = normalizeSchemaNode(
schema.additionalProperties as JsonSchema,
[...path, "*"],
);
normalized.additionalProperties =
res.schema ?? schema.additionalProperties;
unsupportedPaths.push(...res.unsupportedPaths);
}
}
if (type === "array" && schema.items && !Array.isArray(schema.items)) {
const res = normalizeSchemaNode(schema.items, [...path, 0]);
normalized.items = res.schema ?? schema.items;
unsupportedPaths.push(...res.unsupportedPaths);
}
return { schema: normalized, unsupportedPaths };
}
function normalizeUnion(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis | null {
const union = schema.anyOf ?? schema.oneOf ?? schema.allOf ?? [];
const pathLabel = pathKey(path) || "<root>";
if (union.length === 0) return null;
const nonNull = union.filter(
(v) =>
!(
v.type === "null" ||
(Array.isArray(v.type) && v.type.includes("null"))
),
);
if (nonNull.length === 1) {
const res = normalizeSchemaNode(nonNull[0], path);
return {
schema: { ...(res.schema ?? nonNull[0]), nullable: true },
unsupportedPaths: res.unsupportedPaths,
};
}
const literals = nonNull
.map((v) => {
if (v.const !== undefined) return v.const;
if (v.enum && v.enum.length === 1) return v.enum[0];
return undefined;
})
.filter((v) => v !== undefined);
if (literals.length === nonNull.length) {
return {
schema: {
...schema,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
type: "string",
enum: literals as unknown[],
},
unsupportedPaths: [],
};
}
return { schema, unsupportedPaths: [pathLabel] };
}

View File

@@ -0,0 +1,338 @@
import { html, nothing, type TemplateResult } from "lit";
import type { ConfigUiHints } from "../types";
import {
defaultValue,
hintForPath,
humanize,
isSensitivePath,
pathKey,
schemaType,
type JsonSchema,
} from "./config-form.shared";
export function renderNode(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const key = pathKey(path);
if (unsupported.has(key)) {
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
}
if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(v) =>
!(
v.type === "null" ||
(Array.isArray(v.type) && v.type.includes("null"))
),
);
if (nonNull.length === 1) {
return renderNode({ ...params, schema: nonNull[0] });
}
const extractLiteral = (v: JsonSchema): unknown | undefined => {
if (v.const !== undefined) return v.const;
if (v.enum && v.enum.length === 1) return v.enum[0];
return undefined;
};
const literals = nonNull.map(extractLiteral);
const allLiterals = literals.every((v) => v !== undefined);
if (allLiterals && literals.length > 0) {
const currentIndex = literals.findIndex(
(lit) => lit === value || String(lit) === String(value),
);
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${currentIndex >= 0 ? String(currentIndex) : ""}
?disabled=${disabled}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === "" ? undefined : literals[Number(idx)]);
}}
>
<option value="">Select…</option>
${literals.map(
(lit, idx) =>
html`<option value=${String(idx)}>${String(lit)}</option>`,
)}
</select>
</label>
`;
}
}
if (type === "object") {
const obj = (value ?? {}) as Record<string, unknown>;
const props = schema.properties ?? {};
const entries = Object.entries(props);
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
const reserved = new Set(Object.keys(props));
const additional = schema.additionalProperties;
const allowExtra = Boolean(additional) && typeof additional === "object";
return html`
<div class="fieldset">
${showLabel ? html`<div class="legend">${label}</div>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
${sorted.map(([propKey, node]) =>
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
unsupported,
disabled,
onPatch,
}),
)}
${allowExtra
? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
})
: nothing}
</div>
`;
}
if (type === "array") {
const itemsSchema = Array.isArray(schema.items)
? schema.items[0]
: schema.items;
if (!itemsSchema) {
return html`<div class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
<div class="muted">Unsupported array schema. Use Raw.</div>
</div>`;
}
const arr = Array.isArray(value) ? value : [];
return html`
<div class="field" style="margin-top: 12px;">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<div class="array">
${arr.map((item, idx) => {
const itemPath = [...path, idx];
return html`<div class="array-item">
<div style="flex: 1;">
${renderNode({
schema: itemsSchema,
value: item,
path: itemPath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
</div>
<button
class="btn danger"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
Remove
</button>
</div>`;
})}
<button
class="btn"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.push(defaultValue(itemsSchema));
onPatch(path, next);
}}
>
Add
</button>
</div>
</div>
`;
}
if (type === "boolean") {
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="checkbox"
.checked=${Boolean(value)}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
/>
</label>
`;
}
if (type === "number" || type === "integer") {
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="number"
.value=${value == null ? "" : String(value)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
</label>
`;
}
if (type === "string") {
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type=${isSensitive ? "password" : "text"}
placeholder=${placeholder}
.value=${value == null ? "" : String(value)}
?disabled=${disabled}
@input=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).value)}
/>
</label>
`;
}
return html`<div class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
<div class="muted">Unsupported type. Use Raw.</div>
</div>`;
}
function renderMapField(params: {
schema: JsonSchema;
value: Record<string, unknown>;
path: Array<string | number>;
hints: ConfigUiHints;
unsupported: Set<string>;
disabled: boolean;
reservedKeys: Set<string>;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } =
params;
const entries = Object.entries(value ?? {}).filter(
([key]) => !reservedKeys.has(key),
);
return html`
<div class="field" style="margin-top: 12px;">
<div class="row" style="justify-content: space-between;">
<span class="muted">Extra entries</span>
<button
class="btn"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
let index = 1;
let key = `new-${index}`;
while (key in next) {
index += 1;
key = `new-${index}`;
}
next[key] = defaultValue(schema);
onPatch(path, next);
}}
>
Add
</button>
</div>
${entries.length === 0
? html`<div class="muted">No entries yet.</div>`
: entries.map(([key, entryValue]) => {
const valuePath = [...path, key];
return html`<div class="array-item" style="gap: 8px;">
<input
class="mono"
style="min-width: 140px;"
?disabled=${disabled}
.value=${key}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) };
if (nextKey in next) return;
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
<div style="flex: 1;">
${renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
</div>
<button
class="btn danger"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
delete next[key];
onPatch(path, next);
}}
>
Remove
</button>
</div>`;
})}
</div>
`;
}

View File

@@ -0,0 +1,49 @@
import { html } from "lit";
import type { ConfigUiHints } from "../types";
import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared";
import { renderNode } from "./config-form.node";
export type ConfigFormProps = {
schema: JsonSchema | null;
uiHints: ConfigUiHints;
value: Record<string, unknown> | null;
disabled?: boolean;
unsupportedPaths?: string[];
onPatch: (path: Array<string | number>, value: unknown) => void;
};
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`;
}
const schema = props.schema;
const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) {
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
}
const unsupported = new Set(props.unsupportedPaths ?? []);
const entries = Object.entries(schema.properties);
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0;
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
return html`
<div class="config-form">
${sorted.map(([key, node]) =>
renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
onPatch: props.onPatch,
}),
)}
</div>
`;
}

View File

@@ -0,0 +1,92 @@
import type { ConfigUiHints } from "../types";
export type JsonSchema = {
type?: string | string[];
title?: string;
description?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema | JsonSchema[];
additionalProperties?: JsonSchema | boolean;
enum?: unknown[];
const?: unknown;
default?: unknown;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
allOf?: JsonSchema[];
nullable?: boolean;
};
export function schemaType(schema: JsonSchema): string | undefined {
if (!schema) return undefined;
if (Array.isArray(schema.type)) {
const filtered = schema.type.filter((t) => t !== "null");
return filtered[0] ?? schema.type[0];
}
return schema.type;
}
export function defaultValue(schema?: JsonSchema): unknown {
if (!schema) return "";
if (schema.default !== undefined) return schema.default;
const type = schemaType(schema);
switch (type) {
case "object":
return {};
case "array":
return [];
case "boolean":
return false;
case "number":
case "integer":
return 0;
case "string":
return "";
default:
return "";
}
}
export function pathKey(path: Array<string | number>): string {
return path.filter((segment) => typeof segment === "string").join(".");
}
export function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
const key = pathKey(path);
const direct = hints[key];
if (direct) return direct;
const segments = key.split(".");
for (const [hintKey, hint] of Object.entries(hints)) {
if (!hintKey.includes("*")) continue;
const hintSegments = hintKey.split(".");
if (hintSegments.length !== segments.length) continue;
let match = true;
for (let i = 0; i < segments.length; i += 1) {
if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) {
match = false;
break;
}
}
if (match) return hint;
}
return undefined;
}
export function humanize(raw: string) {
return raw
.replace(/_/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.replace(/^./, (m) => m.toUpperCase());
}
export function isSensitivePath(path: Array<string | number>): boolean {
const key = pathKey(path).toLowerCase();
return (
key.includes("token") ||
key.includes("password") ||
key.includes("secret") ||
key.includes("apikey") ||
key.endsWith("key")
);
}

View File

@@ -1,711 +1,7 @@
import { html, nothing, type TemplateResult } from "lit";
import type { ConfigUiHint, ConfigUiHints } from "../types";
export { renderConfigForm, type ConfigFormProps } from "./config-form.render";
export {
analyzeConfigSchema,
type ConfigSchemaAnalysis,
} from "./config-form.analyze";
export type { JsonSchema } from "./config-form.shared";
export type ConfigFormProps = {
schema: JsonSchema | null;
uiHints: ConfigUiHints;
value: Record<string, unknown> | null;
disabled?: boolean;
unsupportedPaths?: string[];
onPatch: (path: Array<string | number>, value: unknown) => void;
};
type JsonSchema = {
type?: string | string[];
title?: string;
description?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema | JsonSchema[];
additionalProperties?: JsonSchema | boolean;
enum?: unknown[];
const?: unknown;
default?: unknown;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
allOf?: JsonSchema[];
nullable?: boolean;
};
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`;
}
const schema = props.schema;
const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) {
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
}
const unsupported = new Set(props.unsupportedPaths ?? []);
const entries = Object.entries(schema.properties);
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0;
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
return html`
<div class="config-form">
${sorted.map(([key, node]) =>
renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
onPatch: props.onPatch,
}),
)}
</div>
`;
}
function renderNode(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const key = pathKey(path);
if (unsupported.has(key)) {
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
}
if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
);
if (nonNull.length === 1) {
return renderNode({ ...params, schema: nonNull[0] });
}
const extractLiteral = (v: JsonSchema): unknown | undefined => {
if (v.const !== undefined) return v.const;
if (v.enum && v.enum.length === 1) return v.enum[0];
return undefined;
};
const literals = nonNull.map(extractLiteral);
const allLiterals = literals.every((v) => v !== undefined);
if (allLiterals && literals.length > 0) {
const currentIndex = literals.findIndex(
(lit) => lit === value || String(lit) === String(value),
);
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${currentIndex >= 0 ? String(currentIndex) : ""}
?disabled=${disabled}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === "" ? undefined : literals[Number(idx)]);
}}
>
<option value="">—</option>
${literals.map(
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
)}
</select>
</label>
`;
}
const primitiveTypes = ["string", "number", "integer", "boolean"];
const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type)));
if (allPrimitive) {
const typeHint = nonNull.map((v) => v.type).join(" | ");
const hasBoolean = nonNull.some((v) => v.type === "boolean");
const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer");
const isInteger = nonNull.every((v) => v.type !== "number");
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="text"
placeholder=${typeHint}
.value=${value == null ? "" : String(value)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
if (raw === "") {
onPatch(path, undefined);
return;
}
if (hasBoolean && (raw === "true" || raw === "false")) {
onPatch(path, raw === "true");
return;
}
if (hasNumber && /^-?\d+(\.\d+)?$/.test(raw)) {
const num = Number(raw);
if (Number.isFinite(num) && (!isInteger || Number.isInteger(num))) {
onPatch(path, num);
return;
}
}
onPatch(path, raw);
}}
/>
</label>
`;
}
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
}
if (schema.allOf) {
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
}
if (type === "object") {
const props = schema.properties ?? {};
const entries = Object.entries(props);
const hasMap =
schema.additionalProperties &&
typeof schema.additionalProperties === "object";
if (entries.length === 0 && !hasMap) return nothing;
const reservedKeys = new Set(entries.map(([key]) => key));
return html`
<fieldset class="field-group">
<legend>${label}</legend>
${help ? html`<div class="muted">${help}</div>` : nothing}
${entries.map(([key, node]) =>
renderNode({
schema: node,
value: value && typeof value === "object" ? (value as any)[key] : undefined,
path: [...path, key],
hints,
unsupported,
onPatch,
disabled,
}),
)}
${hasMap
? renderMapField({
schema: schema.additionalProperties as JsonSchema,
value: value && typeof value === "object" ? (value as any) : {},
path,
hints,
unsupported,
disabled,
reservedKeys,
onPatch,
})
: nothing}
</fieldset>
`;
}
if (type === "array") {
const itemSchema = Array.isArray(schema.items)
? schema.items[0]
: schema.items;
const arr = Array.isArray(value) ? value : [];
return html`
<div class="field">
<div class="row" style="justify-content: space-between;">
${showLabel ? html`<span>${label}</span>` : nothing}
<button
class="btn"
?disabled=${disabled}
@click=${() => {
const next = [...arr, defaultValue(itemSchema)];
onPatch(path, next);
}}
>
Add
</button>
</div>
${help ? html`<div class="muted">${help}</div>` : nothing}
${arr.map((entry, index) =>
html`<div class="array-item">
${itemSchema
? renderNode({
schema: itemSchema,
value: entry,
path: [...path, index],
hints,
unsupported,
disabled,
onPatch,
})
: nothing}
<button
class="btn danger"
?disabled=${disabled}
@click=${() => {
const next = arr.slice();
next.splice(index, 1);
onPatch(path, next);
}}
>
Remove
</button>
</div>`,
)}
</div>
`;
}
if (schema.enum) {
const enumValues = schema.enum;
const currentIndex = enumValues.findIndex(
(v) => v === value || String(v) === String(value),
);
const unsetValue = "__unset__";
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${currentIndex >= 0 ? String(currentIndex) : unsetValue}
?disabled=${disabled}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === unsetValue ? undefined : enumValues[Number(idx)]);
}}
>
<option value=${unsetValue}>—</option>
${enumValues.map(
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
)}
</select>
</label>
`;
}
if (type === "boolean") {
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="checkbox"
.checked=${Boolean(value)}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
/>
</label>
`;
}
if (type === "number" || type === "integer") {
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="number"
.value=${value == null ? "" : String(value)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
</label>
`;
}
if (type === "string") {
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type=${isSensitive ? "password" : "text"}
placeholder=${placeholder}
.value=${value == null ? "" : String(value)}
?disabled=${disabled}
@input=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).value)}
/>
</label>
`;
}
return html`<div class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
<div class="muted">Unsupported type. Use Raw.</div>
</div>`;
}
function schemaType(schema: JsonSchema): string | undefined {
if (!schema) return undefined;
if (Array.isArray(schema.type)) {
const filtered = schema.type.filter((t) => t !== "null");
return filtered[0] ?? schema.type[0];
}
return schema.type;
}
function defaultValue(schema?: JsonSchema): unknown {
if (!schema) return "";
if (schema.default !== undefined) return schema.default;
const type = schemaType(schema);
switch (type) {
case "object":
return {};
case "array":
return [];
case "boolean":
return false;
case "number":
case "integer":
return 0;
case "string":
return "";
default:
return "";
}
}
function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
const key = pathKey(path);
const direct = hints[key];
if (direct) return direct;
const segments = key.split(".");
for (const [hintKey, hint] of Object.entries(hints)) {
if (!hintKey.includes("*")) continue;
const hintSegments = hintKey.split(".");
if (hintSegments.length !== segments.length) continue;
let match = true;
for (let i = 0; i < segments.length; i += 1) {
if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) {
match = false;
break;
}
}
if (match) return hint;
}
return undefined;
}
function pathKey(path: Array<string | number>): string {
return path.filter((segment) => typeof segment === "string").join(".");
}
function humanize(raw: string) {
return raw
.replace(/_/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.replace(/^./, (m) => m.toUpperCase());
}
function isSensitivePath(path: Array<string | number>): boolean {
const key = pathKey(path).toLowerCase();
return (
key.includes("token") ||
key.includes("password") ||
key.includes("secret") ||
key.includes("apikey") ||
key.endsWith("key")
);
}
function renderMapField(params: {
schema: JsonSchema;
value: Record<string, unknown>;
path: Array<string | number>;
hints: ConfigUiHints;
unsupported: Set<string>;
disabled: boolean;
reservedKeys: Set<string>;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
schema,
value,
path,
hints,
unsupported,
disabled,
reservedKeys,
onPatch,
} = params;
const entries = Object.entries(value ?? {}).filter(
([key]) => !reservedKeys.has(key),
);
return html`
<div class="field" style="margin-top: 12px;">
<div class="row" style="justify-content: space-between;">
<span class="muted">Extra entries</span>
<button
class="btn"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
let index = 1;
let key = `new-${index}`;
while (key in next) {
index += 1;
key = `new-${index}`;
}
next[key] = defaultValue(schema);
onPatch(path, next);
}}
>
Add
</button>
</div>
${entries.length === 0
? html`<div class="muted">No entries yet.</div>`
: entries.map(([key, entryValue]) => {
const valuePath = [...path, key];
return html`<div class="array-item" style="gap: 8px;">
<input
class="mono"
style="min-width: 140px;"
?disabled=${disabled}
.value=${key}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) };
if (nextKey in next) return;
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
<div style="flex: 1;">
${renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
</div>
<button
class="btn danger"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
delete next[key];
onPatch(path, next);
}}
>
Remove
</button>
</div>`;
})}
</div>
`;
}
export type ConfigSchemaAnalysis = {
schema: JsonSchema | null;
unsupportedPaths: string[];
};
export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
if (!raw || typeof raw !== "object") {
return { schema: null, unsupportedPaths: ["<root>"] };
}
const result = normalizeSchemaNode(raw as JsonSchema, []);
return result;
}
function normalizeSchemaNode(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis {
const unsupportedPaths: string[] = [];
const normalized = { ...schema };
const pathLabel = pathKey(path) || "<root>";
if (schema.anyOf || schema.oneOf || schema.allOf) {
const union = normalizeUnion(schema, path);
if (union) return union;
unsupportedPaths.push(pathLabel);
return { schema, unsupportedPaths };
}
const nullable =
Array.isArray(schema.type) && schema.type.includes("null");
const type =
schemaType(schema) ??
(schema.properties || schema.additionalProperties ? "object" : undefined);
normalized.type = type ?? schema.type;
normalized.nullable = nullable || schema.nullable;
if (normalized.enum) {
const { enumValues, nullable: enumNullable } = normalizeEnumValues(
normalized.enum,
);
normalized.enum = enumValues;
if (enumNullable) normalized.nullable = true;
if (enumValues.length === 0) {
unsupportedPaths.push(pathLabel);
}
}
if (type === "object") {
const props = schema.properties ?? {};
const normalizedProps: Record<string, JsonSchema> = {};
for (const [key, child] of Object.entries(props)) {
const result = normalizeSchemaNode(child, [...path, key]);
if (result.schema) normalizedProps[key] = result.schema;
unsupportedPaths.push(...result.unsupportedPaths);
}
normalized.properties = normalizedProps;
if (schema.additionalProperties === true) {
unsupportedPaths.push(pathLabel);
} else if (schema.additionalProperties === false) {
normalized.additionalProperties = false;
} else if (schema.additionalProperties) {
const result = normalizeSchemaNode(
schema.additionalProperties,
[...path, "*"],
);
normalized.additionalProperties = result.schema ?? schema.additionalProperties;
if (result.unsupportedPaths.length > 0) {
unsupportedPaths.push(pathLabel);
}
}
} else if (type === "array") {
const itemSchema = Array.isArray(schema.items)
? schema.items[0]
: schema.items;
if (!itemSchema) {
unsupportedPaths.push(pathLabel);
} else {
const result = normalizeSchemaNode(itemSchema, [...path, "*"]);
normalized.items = result.schema ?? itemSchema;
if (result.unsupportedPaths.length > 0) {
unsupportedPaths.push(pathLabel);
}
}
} else if (
type === "string" ||
type === "number" ||
type === "integer" ||
type === "boolean"
) {
// ok
} else if (!normalized.enum) {
unsupportedPaths.push(pathLabel);
}
return {
schema: normalized,
unsupportedPaths: Array.from(new Set(unsupportedPaths)),
};
}
function normalizeUnion(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis | null {
if (schema.allOf) return null;
const variants = schema.anyOf ?? schema.oneOf;
if (!variants) return null;
const values: unknown[] = [];
const nonLiteral: JsonSchema[] = [];
let nullable = false;
for (const variant of variants) {
if (!variant || typeof variant !== "object") return null;
if (Array.isArray(variant.enum)) {
const { enumValues, nullable: enumNullable } = normalizeEnumValues(
variant.enum,
);
values.push(...enumValues);
if (enumNullable) nullable = true;
continue;
}
if ("const" in variant) {
if (variant.const === null || variant.const === undefined) {
nullable = true;
continue;
}
values.push(variant.const);
continue;
}
if (schemaType(variant) === "null") {
nullable = true;
continue;
}
nonLiteral.push(variant);
}
if (values.length > 0 && nonLiteral.length === 0) {
const unique: unknown[] = [];
for (const value of values) {
if (!unique.some((entry) => Object.is(entry, value))) unique.push(value);
}
return {
schema: {
...schema,
enum: unique,
nullable,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
},
unsupportedPaths: [],
};
}
if (nonLiteral.length === 1) {
const result = normalizeSchemaNode(nonLiteral[0], path);
if (result.schema) {
result.schema.nullable = nullable || result.schema.nullable;
}
return result;
}
const primitiveTypes = ["string", "number", "integer", "boolean"];
const allPrimitive = nonLiteral.every(
(v) => v.type && primitiveTypes.includes(String(v.type)),
);
if (allPrimitive && nonLiteral.length > 0 && values.length === 0) {
return {
schema: { ...schema, nullable },
unsupportedPaths: [],
};
}
return null;
}
function normalizeEnumValues(values: unknown[]) {
const filtered = values.filter((value) => value !== null && value !== undefined);
const nullable = filtered.length !== values.length;
const unique: unknown[] = [];
for (const value of filtered) {
if (!unique.some((entry) => Object.is(entry, value))) unique.push(value);
}
return { enumValues: unique, nullable };
}

View File

@@ -0,0 +1,31 @@
import { html } from "lit";
import type { ConnectionsProps } from "./connections.types";
import { discordActionOptions } from "./connections.action-options";
export function renderDiscordActionsSection(props: ConnectionsProps) {
return html`
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${discordActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.discordForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
actions: {
...props.discordForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
`;
}

View File

@@ -0,0 +1,262 @@
import { html, nothing } from "lit";
import type { ConnectionsProps } from "./connections.types";
export function renderDiscordGuildsEditor(props: ConnectionsProps) {
return html`
<div class="field full">
<span>Guilds</span>
<div class="card-sub">
Add each guild (id or slug) and optional channel rules. Empty channel
entries still allow that channel.
</div>
<div class="list">
${props.discordForm.guilds.map(
(guild, guildIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Guild id / slug</span>
<input
.value=${guild.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
key: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Slug</span>
<input
.value=${guild.slug}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
slug: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${guild.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${guild.reactionNotifications}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all" | "allowlist",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field">
<span>Users allowlist</span>
<input
.value=${guild.users}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
users: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
placeholder="123456789, username#1234"
/>
</label>
</div>
${guild.channels.length
? html`
<div class="form-grid" style="margin-top: 8px;">
${guild.channels.map(
(channel, channelIndex) => html`
<label class="field">
<span>Channel id / slug</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
key: (e.target as HTMLInputElement).value,
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
allow:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels.splice(channelIndex, 1);
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Remove
</button>
</label>
`,
)}
</div>
`
: nothing}
</div>
<div class="list-meta">
<span>Channels</span>
<button
class="btn"
@click=${() => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
{ key: "", allow: true, requireMention: false },
];
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Add channel
</button>
<button
class="btn danger"
@click=${() => {
const next = [...props.discordForm.guilds];
next.splice(guildIndex, 1);
props.onDiscordChange({ guilds: next });
}}
>
Remove guild
</button>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onDiscordChange({
guilds: [
...props.discordForm.guilds,
{
key: "",
slug: "",
requireMention: false,
reactionNotifications: "own",
users: "",
channels: [],
},
],
})}
>
Add guild
</button>
</div>
`;
}

View File

@@ -0,0 +1,261 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
import { renderDiscordActionsSection } from "./connections.discord.actions";
import { renderDiscordGuildsEditor } from "./connections.discord.guilds";
export function renderDiscordCard(params: {
props: ConnectionsProps;
discord: DiscordStatus | null;
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
const botName = discord?.probe?.bot?.username;
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot connection and probe status.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Bot</span>
<span>${botName ? `@${botName}` : "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.discordForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.discordForm.token}
?disabled=${props.discordTokenLocked}
@input=${(e: Event) =>
props.onDiscordChange({
token: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>Allow DMs from</span>
<input
.value=${props.discordForm.allowFrom}
@input=${(e: Event) =>
props.onDiscordChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="123456789, username#1234"
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.discordForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DMs</span>
<select
.value=${props.discordForm.groupEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group channels</span>
<input
.value=${props.discordForm.groupChannels}
@input=${(e: Event) =>
props.onDiscordChange({
groupChannels: (e.target as HTMLInputElement).value,
})}
placeholder="channelId1, channelId2"
/>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.discordForm.mediaMaxMb}
@input=${(e: Event) =>
props.onDiscordChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="8"
/>
</label>
<label class="field">
<span>History limit</span>
<input
.value=${props.discordForm.historyLimit}
@input=${(e: Event) =>
props.onDiscordChange({
historyLimit: (e.target as HTMLInputElement).value,
})}
placeholder="20"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.discordForm.textChunkLimit}
@input=${(e: Event) =>
props.onDiscordChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="2000"
/>
</label>
<label class="field">
<span>Reply to mode</span>
<select
.value=${props.discordForm.replyToMode}
@change=${(e: Event) =>
props.onDiscordChange({
replyToMode: (e.target as HTMLSelectElement).value as
| "off"
| "first"
| "all",
})}
>
<option value="off">Off</option>
<option value="first">First</option>
<option value="all">All</option>
</select>
</label>
${renderDiscordGuildsEditor(props)}
<label class="field">
<span>Slash command</span>
<select
.value=${props.discordForm.slashEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash name</span>
<input
.value=${props.discordForm.slashName}
@input=${(e: Event) =>
props.onDiscordChange({
slashName: (e.target as HTMLInputElement).value,
})}
placeholder="clawd"
/>
</label>
<label class="field">
<span>Slash session prefix</span>
<input
.value=${props.discordForm.slashSessionPrefix}
@input=${(e: Event) =>
props.onDiscordChange({
slashSessionPrefix: (e.target as HTMLInputElement).value,
})}
placeholder="discord:slash"
/>
</label>
<label class="field">
<span>Slash ephemeral</span>
<select
.value=${props.discordForm.slashEphemeral ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
</div>
${renderDiscordActionsSection(props)}
${props.discordTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
DISCORD_BOT_TOKEN is set in the environment. Config edits will not
override it.
</div>`
: nothing}
${props.discordStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.discordStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.discordSaving}
@click=${() => props.onDiscordSave()}
>
${props.discordSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,184 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderIMessageCard(params: {
props: ConnectionsProps;
imessage: IMessageStatus | null;
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">imsg CLI and database availability.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">CLI</span>
<span>${imessage?.cliPath ?? "n/a"}</span>
</div>
<div>
<span class="label">DB</span>
<span>${imessage?.dbPath ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>
${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Last probe</span>
<span>
${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
</span>
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe && !imessage.probe.ok
? html`<div class="callout" style="margin-top: 12px;">
Probe failed · ${imessage.probe.error ?? "unknown error"}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.imessageForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onIMessageChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>CLI path</span>
<input
.value=${props.imessageForm.cliPath}
@input=${(e: Event) =>
props.onIMessageChange({
cliPath: (e.target as HTMLInputElement).value,
})}
placeholder="imsg"
/>
</label>
<label class="field">
<span>DB path</span>
<input
.value=${props.imessageForm.dbPath}
@input=${(e: Event) =>
props.onIMessageChange({
dbPath: (e.target as HTMLInputElement).value,
})}
placeholder="~/Library/Messages/chat.db"
/>
</label>
<label class="field">
<span>Service</span>
<select
.value=${props.imessageForm.service}
@change=${(e: Event) =>
props.onIMessageChange({
service: (e.target as HTMLSelectElement).value as
| "auto"
| "imessage"
| "sms",
})}
>
<option value="auto">Auto</option>
<option value="imessage">iMessage</option>
<option value="sms">SMS</option>
</select>
</label>
<label class="field">
<span>Region</span>
<input
.value=${props.imessageForm.region}
@input=${(e: Event) =>
props.onIMessageChange({
region: (e.target as HTMLInputElement).value,
})}
placeholder="US"
/>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.imessageForm.allowFrom}
@input=${(e: Event) =>
props.onIMessageChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="chat_id:101, +1555"
/>
</label>
<label class="field">
<span>Include attachments</span>
<select
.value=${props.imessageForm.includeAttachments ? "yes" : "no"}
@change=${(e: Event) =>
props.onIMessageChange({
includeAttachments:
(e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.imessageForm.mediaMaxMb}
@input=${(e: Event) =>
props.onIMessageChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="16"
/>
</label>
</div>
${props.imessageStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.imessageStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.imessageSaving}
@click=${() => props.onIMessageSave()}
>
${props.imessageSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,237 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderSignalCard(params: {
props: ConnectionsProps;
signal: SignalStatus | null;
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">REST daemon status and probe details.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.signalForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Account</span>
<input
.value=${props.signalForm.account}
@input=${(e: Event) =>
props.onSignalChange({
account: (e.target as HTMLInputElement).value,
})}
placeholder="+15551234567"
/>
</label>
<label class="field">
<span>HTTP URL</span>
<input
.value=${props.signalForm.httpUrl}
@input=${(e: Event) =>
props.onSignalChange({
httpUrl: (e.target as HTMLInputElement).value,
})}
placeholder="http://127.0.0.1:8080"
/>
</label>
<label class="field">
<span>HTTP host</span>
<input
.value=${props.signalForm.httpHost}
@input=${(e: Event) =>
props.onSignalChange({
httpHost: (e.target as HTMLInputElement).value,
})}
placeholder="127.0.0.1"
/>
</label>
<label class="field">
<span>HTTP port</span>
<input
.value=${props.signalForm.httpPort}
@input=${(e: Event) =>
props.onSignalChange({
httpPort: (e.target as HTMLInputElement).value,
})}
placeholder="8080"
/>
</label>
<label class="field">
<span>CLI path</span>
<input
.value=${props.signalForm.cliPath}
@input=${(e: Event) =>
props.onSignalChange({
cliPath: (e.target as HTMLInputElement).value,
})}
placeholder="signal-cli"
/>
</label>
<label class="field">
<span>Auto start</span>
<select
.value=${props.signalForm.autoStart ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
autoStart: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Receive mode</span>
<select
.value=${props.signalForm.receiveMode}
@change=${(e: Event) =>
props.onSignalChange({
receiveMode: (e.target as HTMLSelectElement).value as
| "on-start"
| "manual"
| "",
})}
>
<option value="">Default</option>
<option value="on-start">on-start</option>
<option value="manual">manual</option>
</select>
</label>
<label class="field">
<span>Ignore attachments</span>
<select
.value=${props.signalForm.ignoreAttachments ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
ignoreAttachments: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Ignore stories</span>
<select
.value=${props.signalForm.ignoreStories ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
ignoreStories: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Send read receipts</span>
<select
.value=${props.signalForm.sendReadReceipts ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
sendReadReceipts: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.signalForm.allowFrom}
@input=${(e: Event) =>
props.onSignalChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="12345, +1555"
/>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.signalForm.mediaMaxMb}
@input=${(e: Event) =>
props.onSignalChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="8"
/>
</label>
</div>
${props.signalStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.signalStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.signalSaving}
@click=${() => props.onSignalSave()}
>
${props.signalSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,391 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
import { slackActionOptions } from "./connections.action-options";
export function renderSlackCard(params: {
props: ConnectionsProps;
slack: SlackStatus | null;
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
const botName = slack?.probe?.bot?.name;
const teamName = slack?.probe?.team?.name;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and bot details.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Bot</span>
<span>${botName ? botName : "n/a"}</span>
</div>
<div>
<span class="label">Team</span>
<span>${teamName ? teamName : "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""}
${slack.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.slackForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.slackForm.botToken}
?disabled=${props.slackTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
botToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>App token</span>
<input
type="password"
.value=${props.slackForm.appToken}
?disabled=${props.slackAppTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
appToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.slackForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Allow DMs from</span>
<input
.value=${props.slackForm.allowFrom}
@input=${(e: Event) =>
props.onSlackChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456, *"
/>
</label>
<label class="field">
<span>Group DMs enabled</span>
<select
.value=${props.slackForm.groupEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DM channels</span>
<input
.value=${props.slackForm.groupChannels}
@input=${(e: Event) =>
props.onSlackChange({
groupChannels: (e.target as HTMLInputElement).value,
})}
placeholder="G123, #team"
/>
</label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${props.slackForm.reactionNotifications}
@change=${(e: Event) =>
props.onSlackChange({
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all" | "allowlist",
})}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field">
<span>Reaction allowlist</span>
<input
.value=${props.slackForm.reactionAllowlist}
@input=${(e: Event) =>
props.onSlackChange({
reactionAllowlist: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.slackForm.textChunkLimit}
@input=${(e: Event) =>
props.onSlackChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="4000"
/>
</label>
<label class="field">
<span>Media max (MB)</span>
<input
.value=${props.slackForm.mediaMaxMb}
@input=${(e: Event) =>
props.onSlackChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="20"
/>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Slash command</div>
<div class="form-grid" style="margin-top: 8px;">
<label class="field">
<span>Slash enabled</span>
<select
.value=${props.slackForm.slashEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash name</span>
<input
.value=${props.slackForm.slashName}
@input=${(e: Event) =>
props.onSlackChange({
slashName: (e.target as HTMLInputElement).value,
})}
placeholder="clawd"
/>
</label>
<label class="field">
<span>Slash session prefix</span>
<input
.value=${props.slackForm.slashSessionPrefix}
@input=${(e: Event) =>
props.onSlackChange({
slashSessionPrefix: (e.target as HTMLInputElement).value,
})}
placeholder="slack:slash"
/>
</label>
<label class="field">
<span>Slash ephemeral</span>
<select
.value=${props.slackForm.slashEphemeral ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Channels</div>
<div class="card-sub">Add channel ids or #names and optionally require mentions.</div>
<div class="list">
${props.slackForm.channels.map(
(channel, channelIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Channel id / name</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
key: (e.target as HTMLInputElement).value,
};
props.onSlackChange({ channels: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
allow: (e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [...props.slackForm.channels];
next.splice(channelIndex, 1);
props.onSlackChange({ channels: next });
}}
>
Remove
</button>
</label>
</div>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onSlackChange({
channels: [
...props.slackForm.channels,
{ key: "", allow: true, requireMention: false },
],
})}
>
Add channel
</button>
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${slackActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.slackForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
actions: {
...props.slackForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
${props.slackTokenLocked || props.slackAppTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""}
${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the
environment. Config edits will not override it.
</div>`
: nothing}
${props.slackStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.slackStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.slackSaving}
@click=${() => props.onSlackSave()}
>
${props.slackSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,7 @@
import { html, nothing } from "lit";
import { formatEventPayload } from "../presenter";
type EventLogEntry = {
ts: number;
event: string;
payload?: unknown;
};
import type { EventLogEntry } from "../app-events";
export type DebugProps = {
loading: boolean;
@@ -126,4 +121,3 @@ export function renderDebug(props: DebugProps) {
</section>
`;
}