refactor(ui): split render + connections
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
6
ui/src/ui/app-events.ts
Normal file
6
ui/src/ui/app-events.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type EventLogEntry = {
|
||||
ts: number;
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
223
ui/src/ui/app-render.helpers.ts
Normal file
223
ui/src/ui/app-render.helpers.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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
197
ui/src/ui/app-view-state.ts
Normal 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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
179
ui/src/ui/controllers/connections.save-discord.ts
Normal file
179
ui/src/ui/controllers/connections.save-discord.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
68
ui/src/ui/controllers/connections.save-imessage.ts
Normal file
68
ui/src/ui/controllers/connections.save-imessage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
89
ui/src/ui/controllers/connections.save-signal.ts
Normal file
89
ui/src/ui/controllers/connections.save-signal.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
143
ui/src/ui/controllers/connections.save-slack.ts
Normal file
143
ui/src/ui/controllers/connections.save-slack.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
43
ui/src/ui/controllers/connections.types.ts
Normal file
43
ui/src/ui/controllers/connections.types.ts
Normal 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;
|
||||
};
|
||||
|
||||
121
ui/src/ui/views/config-form.analyze.ts
Normal file
121
ui/src/ui/views/config-form.analyze.ts
Normal 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] };
|
||||
}
|
||||
|
||||
338
ui/src/ui/views/config-form.node.ts
Normal file
338
ui/src/ui/views/config-form.node.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
49
ui/src/ui/views/config-form.render.ts
Normal file
49
ui/src/ui/views/config-form.render.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
92
ui/src/ui/views/config-form.shared.ts
Normal file
92
ui/src/ui/views/config-form.shared.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
31
ui/src/ui/views/connections.discord.actions.ts
Normal file
31
ui/src/ui/views/connections.discord.actions.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
262
ui/src/ui/views/connections.discord.guilds.ts
Normal file
262
ui/src/ui/views/connections.discord.guilds.ts
Normal 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> </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>
|
||||
`;
|
||||
}
|
||||
|
||||
261
ui/src/ui/views/connections.discord.ts
Normal file
261
ui/src/ui/views/connections.discord.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
184
ui/src/ui/views/connections.imessage.ts
Normal file
184
ui/src/ui/views/connections.imessage.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
237
ui/src/ui/views/connections.signal.ts
Normal file
237
ui/src/ui/views/connections.signal.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
391
ui/src/ui/views/connections.slack.ts
Normal file
391
ui/src/ui/views/connections.slack.ts
Normal 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> </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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user