Merge branch 'main' into feat/mattermost-channel
This commit is contained in:
@@ -89,6 +89,13 @@
|
||||
color: rgba(134, 142, 150, 1);
|
||||
}
|
||||
|
||||
/* Image avatar support */
|
||||
img.chat-avatar {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
|
||||
@@ -4,6 +4,9 @@ import { generateUUID } from "./uuid";
|
||||
import { resetToolStream } from "./app-tool-stream";
|
||||
import { scheduleChatScroll } from "./app-scroll";
|
||||
import { setLastActiveSessionKey } from "./app-settings";
|
||||
import { normalizeBasePath } from "./navigation";
|
||||
import type { GatewayHelloOk } from "./gateway";
|
||||
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
|
||||
type ChatHost = {
|
||||
@@ -13,6 +16,9 @@ type ChatHost = {
|
||||
chatRunId: string | null;
|
||||
chatSending: boolean;
|
||||
sessionKey: string;
|
||||
basePath: string;
|
||||
hello: GatewayHelloOk | null;
|
||||
chatAvatarUrl: string | null;
|
||||
};
|
||||
|
||||
export function isChatBusy(host: ChatHost) {
|
||||
@@ -124,8 +130,53 @@ export async function refreshChat(host: ChatHost) {
|
||||
await Promise.all([
|
||||
loadChatHistory(host as unknown as ClawdbotApp),
|
||||
loadSessions(host as unknown as ClawdbotApp),
|
||||
refreshChatAvatar(host),
|
||||
]);
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
||||
}
|
||||
|
||||
export const flushChatQueueForEvent = flushChatQueue;
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
defaultAgentId?: string;
|
||||
};
|
||||
|
||||
function resolveAgentIdForSession(host: ChatHost): string | null {
|
||||
const parsed = parseAgentSessionKey(host.sessionKey);
|
||||
if (parsed?.agentId) return parsed.agentId;
|
||||
const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
|
||||
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
|
||||
return fallback || "main";
|
||||
}
|
||||
|
||||
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
|
||||
const base = normalizeBasePath(basePath);
|
||||
const encoded = encodeURIComponent(agentId);
|
||||
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
|
||||
}
|
||||
|
||||
export async function refreshChatAvatar(host: ChatHost) {
|
||||
if (!host.connected) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
const agentId = resolveAgentIdForSession(host);
|
||||
if (!agentId) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
host.chatAvatarUrl = null;
|
||||
const url = buildAvatarMetaUrl(host.basePath, agentId);
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
if (!res.ok) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { avatarUrl?: unknown };
|
||||
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
|
||||
host.chatAvatarUrl = avatarUrl || null;
|
||||
} catch {
|
||||
host.chatAvatarUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { loadDevices } from "./controllers/devices";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadAgents } from "./controllers/agents";
|
||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
||||
import { GatewayBrowserClient } from "./gateway";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import type { PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
|
||||
import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
|
||||
import type { Tab } from "./navigation";
|
||||
import type { UiSettings } from "./storage";
|
||||
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from "./controllers/exec-approval";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
import { loadAssistantIdentity } from "./controllers/assistant-identity";
|
||||
|
||||
type GatewayHost = {
|
||||
settings: UiSettings;
|
||||
@@ -38,7 +40,13 @@ type GatewayHost = {
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
presenceStatus: StatusSummary | null;
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
sessionKey: string;
|
||||
chatRunId: string | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
@@ -117,6 +125,8 @@ export function connectGateway(host: GatewayHost) {
|
||||
host.connected = true;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
void loadAssistantIdentity(host as unknown as ClawdbotApp);
|
||||
void loadAgents(host as unknown as ClawdbotApp);
|
||||
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
||||
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
startNodesPolling,
|
||||
stopLogsPolling,
|
||||
stopNodesPolling,
|
||||
startDebugPolling,
|
||||
stopDebugPolling,
|
||||
} from "./app-polling";
|
||||
|
||||
type LifecycleHost = {
|
||||
@@ -52,6 +54,9 @@ export function handleConnected(host: LifecycleHost) {
|
||||
if (host.tab === "logs") {
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
}
|
||||
if (host.tab === "debug") {
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFirstUpdated(host: LifecycleHost) {
|
||||
@@ -62,6 +67,7 @@ export function handleDisconnected(host: LifecycleHost) {
|
||||
window.removeEventListener("popstate", host.popStateHandler);
|
||||
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
|
||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
detachThemeListener(
|
||||
host as unknown as Parameters<typeof detachThemeListener>[0],
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadDebug } from "./controllers/debug";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
|
||||
type PollingHost = {
|
||||
nodesPollInterval: number | null;
|
||||
logsPollInterval: number | null;
|
||||
debugPollInterval: number | null;
|
||||
tab: string;
|
||||
};
|
||||
|
||||
@@ -35,3 +37,17 @@ export function stopLogsPolling(host: PollingHost) {
|
||||
clearInterval(host.logsPollInterval);
|
||||
host.logsPollInterval = null;
|
||||
}
|
||||
|
||||
export function startDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval != null) return;
|
||||
host.debugPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "debug") return;
|
||||
void loadDebug(host as unknown as ClawdbotApp);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function stopDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval == null) return;
|
||||
clearInterval(host.debugPollInterval);
|
||||
host.debugPollInterval = null;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function renderChatControls(state: AppViewState) {
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(state, next, true);
|
||||
void loadChatHistory(state);
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
||||
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||
import type { AppViewState } from "./app-view-state";
|
||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||
import {
|
||||
TAB_GROUPS,
|
||||
iconForTab,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import { refreshChatAvatar } from "./app-chat";
|
||||
import { renderChat } from "./views/chat";
|
||||
import { renderConfig } from "./views/config";
|
||||
import { renderChannels } from "./views/channels";
|
||||
@@ -79,6 +81,24 @@ import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } fr
|
||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
|
||||
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
const list = state.agentsList?.agents ?? [];
|
||||
const parsed = parseAgentSessionKey(state.sessionKey);
|
||||
const agentId =
|
||||
parsed?.agentId ??
|
||||
state.agentsList?.defaultId ??
|
||||
"main";
|
||||
const agent = list.find((entry) => entry.id === agentId);
|
||||
const identity = agent?.identity;
|
||||
const candidate = identity?.avatarUrl ?? identity?.avatar;
|
||||
if (!candidate) return undefined;
|
||||
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) return candidate;
|
||||
return identity?.avatarUrl;
|
||||
}
|
||||
|
||||
export function renderApp(state: AppViewState) {
|
||||
const presenceCount = state.presenceEntries.length;
|
||||
const sessionsCount = state.sessionsResult?.count ?? null;
|
||||
@@ -86,6 +106,8 @@ export function renderApp(state: AppViewState) {
|
||||
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
|
||||
const isChat = state.tab === "chat";
|
||||
const chatFocus = isChat && state.settings.chatFocusMode;
|
||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||
|
||||
return html`
|
||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""}">
|
||||
@@ -199,6 +221,7 @@ export function renderApp(state: AppViewState) {
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
@@ -412,12 +435,15 @@ export function renderApp(state: AppViewState) {
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking: state.settings.chatShowThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
@@ -432,7 +458,7 @@ export function renderApp(state: AppViewState) {
|
||||
focusMode: state.settings.chatFocusMode,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return loadChatHistory(state);
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () =>
|
||||
state.applySettings({
|
||||
@@ -455,6 +481,8 @@ export function renderApp(state: AppViewState) {
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
: nothing}
|
||||
|
||||
|
||||
91
ui/src/ui/app-settings.test.ts
Normal file
91
ui/src/ui/app-settings.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Tab } from "./navigation";
|
||||
|
||||
type SettingsHost = Parameters<typeof import("./app-settings").setTabFromRoute>[0];
|
||||
|
||||
const createHost = (tab: Tab): SettingsHost => ({
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
},
|
||||
theme: "system",
|
||||
themeResolved: "dark",
|
||||
applySessionKey: "main",
|
||||
sessionKey: "main",
|
||||
tab,
|
||||
connected: false,
|
||||
chatHasAutoScrolled: false,
|
||||
logsAtBottom: false,
|
||||
eventLog: [],
|
||||
eventLogBuffer: [],
|
||||
basePath: "",
|
||||
themeMedia: null,
|
||||
themeMediaHandler: null,
|
||||
});
|
||||
|
||||
describe("setTabFromRoute", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("starts and stops log polling based on the tab", async () => {
|
||||
const startLogsPolling = vi.fn();
|
||||
const stopLogsPolling = vi.fn();
|
||||
const startDebugPolling = vi.fn();
|
||||
const stopDebugPolling = vi.fn();
|
||||
|
||||
vi.doMock("./app-polling", () => ({
|
||||
startLogsPolling,
|
||||
stopLogsPolling,
|
||||
startDebugPolling,
|
||||
stopDebugPolling,
|
||||
}));
|
||||
|
||||
const { setTabFromRoute } = await import("./app-settings");
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "logs");
|
||||
expect(startLogsPolling).toHaveBeenCalledTimes(1);
|
||||
expect(stopLogsPolling).not.toHaveBeenCalled();
|
||||
expect(startDebugPolling).not.toHaveBeenCalled();
|
||||
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts and stops debug polling based on the tab", async () => {
|
||||
const startLogsPolling = vi.fn();
|
||||
const stopLogsPolling = vi.fn();
|
||||
const startDebugPolling = vi.fn();
|
||||
const stopDebugPolling = vi.fn();
|
||||
|
||||
vi.doMock("./app-polling", () => ({
|
||||
startLogsPolling,
|
||||
stopLogsPolling,
|
||||
startDebugPolling,
|
||||
stopDebugPolling,
|
||||
}));
|
||||
|
||||
const { setTabFromRoute } = await import("./app-settings");
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "debug");
|
||||
expect(startDebugPolling).toHaveBeenCalledTimes(1);
|
||||
expect(stopDebugPolling).not.toHaveBeenCalled();
|
||||
expect(startLogsPolling).not.toHaveBeenCalled();
|
||||
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import { saveSettings, type UiSettings } from "./storage";
|
||||
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
||||
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||
import { startLogsPolling, stopLogsPolling } from "./app-polling";
|
||||
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
|
||||
import { refreshChat } from "./app-chat";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
|
||||
@@ -116,6 +116,9 @@ export function setTab(host: SettingsHost, next: Tab) {
|
||||
if (next === "logs")
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
if (next === "debug")
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
void refreshActiveTab(host);
|
||||
syncUrlWithTab(host, next, false);
|
||||
}
|
||||
@@ -261,6 +264,9 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
||||
if (next === "logs")
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
if (next === "debug")
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
if (host.connected) void refreshActiveTab(host);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UiSettings } from "./storage";
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeTransitionContext } from "./theme-transition";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigSnapshot,
|
||||
CronJob,
|
||||
@@ -40,6 +41,9 @@ export type AppViewState = {
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
eventLog: EventLogEntry[];
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
sessionKey: string;
|
||||
chatLoading: boolean;
|
||||
chatSending: boolean;
|
||||
@@ -48,6 +52,7 @@ export type AppViewState = {
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
chatRunId: string | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
chatQueue: ChatQueueItem[];
|
||||
nodesLoading: boolean;
|
||||
@@ -94,6 +99,9 @@ export type AppViewState = {
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
presenceStatus: string | null;
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
sessionsLoading: boolean;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
sessionsError: string | null;
|
||||
@@ -139,6 +147,7 @@ export type AppViewState = {
|
||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
applySettings: (next: UiSettings) => void;
|
||||
loadOverview: () => Promise<void>;
|
||||
loadAssistantIdentity: () => Promise<void>;
|
||||
loadCron: () => Promise<void>;
|
||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||
handleWhatsAppWait: () => Promise<void>;
|
||||
|
||||
@@ -2,11 +2,13 @@ import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||
import { resolveInjectedAssistantIdentity } from "./assistant-identity";
|
||||
import { loadSettings, type UiSettings } from "./storage";
|
||||
import { renderApp } from "./app-render";
|
||||
import type { Tab } from "./navigation";
|
||||
import type { ResolvedTheme, ThemeMode } from "./theme";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
@@ -75,6 +77,7 @@ import {
|
||||
handleWhatsAppWait as handleWhatsAppWaitInternal,
|
||||
} from "./app-channels";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -82,6 +85,8 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const injectedAssistantIdentity = resolveInjectedAssistantIdentity();
|
||||
|
||||
@customElement("clawdbot-app")
|
||||
export class ClawdbotApp extends LitElement {
|
||||
@state() settings: UiSettings = loadSettings();
|
||||
@@ -97,6 +102,10 @@ export class ClawdbotApp extends LitElement {
|
||||
private toolStreamSyncTimer: number | null = null;
|
||||
private sidebarCloseTimer: number | null = null;
|
||||
|
||||
@state() assistantName = injectedAssistantIdentity.name;
|
||||
@state() assistantAvatar = injectedAssistantIdentity.avatar;
|
||||
@state() assistantAgentId = injectedAssistantIdentity.agentId ?? null;
|
||||
|
||||
@state() sessionKey = this.settings.sessionKey;
|
||||
@state() chatLoading = false;
|
||||
@state() chatSending = false;
|
||||
@@ -106,6 +115,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
// Sidebar state for tool output viewing
|
||||
@@ -168,6 +178,10 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() presenceError: string | null = null;
|
||||
@state() presenceStatus: string | null = null;
|
||||
|
||||
@state() agentsLoading = false;
|
||||
@state() agentsList: AgentsListResult | null = null;
|
||||
@state() agentsError: string | null = null;
|
||||
|
||||
@state() sessionsLoading = false;
|
||||
@state() sessionsResult: SessionsListResult | null = null;
|
||||
@state() sessionsError: string | null = null;
|
||||
@@ -226,6 +240,7 @@ export class ClawdbotApp extends LitElement {
|
||||
private chatUserNearBottom = true;
|
||||
private nodesPollInterval: number | null = null;
|
||||
private logsPollInterval: number | null = null;
|
||||
private debugPollInterval: number | null = null;
|
||||
private logsScrollFrame: number | null = null;
|
||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||
private toolStreamOrder: string[] = [];
|
||||
@@ -299,6 +314,10 @@ export class ClawdbotApp extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
async loadAssistantIdentity() {
|
||||
await loadAssistantIdentityInternal(this);
|
||||
}
|
||||
|
||||
applySettings(next: UiSettings) {
|
||||
applySettingsInternal(
|
||||
this as unknown as Parameters<typeof applySettingsInternal>[0],
|
||||
|
||||
49
ui/src/ui/assistant-identity.ts
Normal file
49
ui/src/ui/assistant-identity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
const MAX_ASSISTANT_NAME = 50;
|
||||
const MAX_ASSISTANT_AVATAR = 200;
|
||||
|
||||
export const DEFAULT_ASSISTANT_NAME = "Assistant";
|
||||
export const DEFAULT_ASSISTANT_AVATAR = "A";
|
||||
|
||||
export type AssistantIdentity = {
|
||||
agentId?: string | null;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CLAWDBOT_ASSISTANT_NAME__?: string;
|
||||
__CLAWDBOT_ASSISTANT_AVATAR__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
export function normalizeAssistantIdentity(
|
||||
input?: Partial<AssistantIdentity> | null,
|
||||
): AssistantIdentity {
|
||||
const name =
|
||||
coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
|
||||
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
|
||||
const agentId =
|
||||
typeof input?.agentId === "string" && input.agentId.trim()
|
||||
? input.agentId.trim()
|
||||
: null;
|
||||
return { agentId, name, avatar };
|
||||
}
|
||||
|
||||
export function resolveInjectedAssistantIdentity(): AssistantIdentity {
|
||||
if (typeof window === "undefined") {
|
||||
return normalizeAssistantIdentity({});
|
||||
}
|
||||
return normalizeAssistantIdentity({
|
||||
name: window.__CLAWDBOT_ASSISTANT_NAME__,
|
||||
avatar: window.__CLAWDBOT_ASSISTANT_AVATAR__,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import type { AssistantIdentity } from "../assistant-identity";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||
import type { MessageGroup } from "../types/chat-types";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
|
||||
@@ -12,10 +13,10 @@ import {
|
||||
} from "./message-extract";
|
||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
|
||||
|
||||
export function renderReadingIndicatorGroup() {
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant")}
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
@@ -31,15 +32,17 @@ export function renderStreamingGroup(
|
||||
text: string,
|
||||
startedAt: number,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
assistant?: AssistantIdentity,
|
||||
) {
|
||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const name = assistant?.name ?? "Assistant";
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant")}
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
@@ -51,7 +54,7 @@ export function renderStreamingGroup(
|
||||
onOpenSidebar,
|
||||
)}
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">Assistant</span>
|
||||
<span class="chat-sender-name">${name}</span>
|
||||
<span class="chat-group-timestamp">${timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,14 +64,20 @@ export function renderStreamingGroup(
|
||||
|
||||
export function renderMessageGroup(
|
||||
group: MessageGroup,
|
||||
opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean },
|
||||
opts: {
|
||||
onOpenSidebar?: (content: string) => void;
|
||||
showReasoning: boolean;
|
||||
assistantName?: string;
|
||||
assistantAvatar?: string | null;
|
||||
},
|
||||
) {
|
||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||
const assistantName = opts.assistantName ?? "Assistant";
|
||||
const who =
|
||||
normalizedRole === "user"
|
||||
? "You"
|
||||
: normalizedRole === "assistant"
|
||||
? "Assistant"
|
||||
? assistantName
|
||||
: normalizedRole;
|
||||
const roleClass =
|
||||
normalizedRole === "user"
|
||||
@@ -83,7 +92,10 @@ export function renderMessageGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
${renderAvatar(group.role)}
|
||||
${renderAvatar(group.role, {
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
})}
|
||||
<div class="chat-group-messages">
|
||||
${group.messages.map((item, index) =>
|
||||
renderGroupedMessage(
|
||||
@@ -105,13 +117,18 @@ export function renderMessageGroup(
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvatar(role: string) {
|
||||
function renderAvatar(
|
||||
role: string,
|
||||
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
||||
) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const initial =
|
||||
normalized === "user"
|
||||
? "U"
|
||||
: normalized === "assistant"
|
||||
? "A"
|
||||
? assistantName.charAt(0).toUpperCase() || "A"
|
||||
: normalized === "tool"
|
||||
? "⚙"
|
||||
: "?";
|
||||
@@ -120,12 +137,31 @@ function renderAvatar(role: string) {
|
||||
? "user"
|
||||
: normalized === "assistant"
|
||||
? "assistant"
|
||||
: normalized === "tool"
|
||||
: normalized === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
|
||||
if (assistantAvatar && normalized === "assistant") {
|
||||
if (isAvatarUrl(assistantAvatar)) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className}"
|
||||
src="${assistantAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
return (
|
||||
/^https?:\/\//i.test(value) ||
|
||||
/^data:image\//i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroupedMessage(
|
||||
message: unknown,
|
||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||
|
||||
25
ui/src/ui/controllers/agents.ts
Normal file
25
ui/src/ui/controllers/agents.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { AgentsListResult } from "../types";
|
||||
|
||||
export type AgentsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
agentsLoading: boolean;
|
||||
agentsError: string | null;
|
||||
agentsList: AgentsListResult | null;
|
||||
};
|
||||
|
||||
export async function loadAgents(state: AgentsState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.agentsLoading) return;
|
||||
state.agentsLoading = true;
|
||||
state.agentsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("agents.list", {})) as AgentsListResult | undefined;
|
||||
if (res) state.agentsList = res;
|
||||
} catch (err) {
|
||||
state.agentsError = String(err);
|
||||
} finally {
|
||||
state.agentsLoading = false;
|
||||
}
|
||||
}
|
||||
35
ui/src/ui/controllers/assistant-identity.ts
Normal file
35
ui/src/ui/controllers/assistant-identity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import {
|
||||
normalizeAssistantIdentity,
|
||||
type AssistantIdentity,
|
||||
} from "../assistant-identity";
|
||||
|
||||
export type AssistantIdentityState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
sessionKey: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
};
|
||||
|
||||
export async function loadAssistantIdentity(
|
||||
state: AssistantIdentityState,
|
||||
opts?: { sessionKey?: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim();
|
||||
const params = sessionKey ? { sessionKey } : {};
|
||||
try {
|
||||
const res = (await state.client.request("agent.identity.get", params)) as
|
||||
| Partial<AssistantIdentity>
|
||||
| undefined;
|
||||
if (!res) return;
|
||||
const normalized = normalizeAssistantIdentity(res);
|
||||
state.assistantName = normalized.name;
|
||||
state.assistantAvatar = normalized.avatar;
|
||||
state.assistantAgentId = normalized.agentId ?? null;
|
||||
} catch {
|
||||
// Ignore errors; keep last known identity.
|
||||
}
|
||||
}
|
||||
@@ -126,15 +126,27 @@ export class GatewayBrowserClient {
|
||||
window.clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
const deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
|
||||
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
|
||||
// Over plain HTTP, we skip device identity and fall back to token-only auth.
|
||||
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
|
||||
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
|
||||
|
||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||
const role = "operator";
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role,
|
||||
})?.token;
|
||||
const authToken = storedToken ?? this.opts.token;
|
||||
const canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||
let canFallbackToShared = false;
|
||||
let authToken = this.opts.token;
|
||||
|
||||
if (isSecureContext) {
|
||||
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role,
|
||||
})?.token;
|
||||
authToken = storedToken ?? this.opts.token;
|
||||
canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||
}
|
||||
const auth =
|
||||
authToken || this.opts.password
|
||||
? {
|
||||
@@ -142,19 +154,39 @@ export class GatewayBrowserClient {
|
||||
password: this.opts.password,
|
||||
}
|
||||
: undefined;
|
||||
const signedAtMs = Date.now();
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken ?? null,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||
|
||||
let device:
|
||||
| {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
signedAt: number;
|
||||
nonce: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (isSecureContext && deviceIdentity) {
|
||||
const signedAtMs = Date.now();
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken ?? null,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||
device = {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: deviceIdentity.publicKey,
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
const params = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
@@ -167,13 +199,7 @@ export class GatewayBrowserClient {
|
||||
},
|
||||
role,
|
||||
scopes,
|
||||
device: {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: deviceIdentity.publicKey,
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
},
|
||||
device,
|
||||
caps: [],
|
||||
auth,
|
||||
userAgent: navigator.userAgent,
|
||||
@@ -182,7 +208,7 @@ export class GatewayBrowserClient {
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
if (hello?.auth?.deviceToken) {
|
||||
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role: hello.auth.role ?? role,
|
||||
@@ -194,7 +220,7 @@ export class GatewayBrowserClient {
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch(() => {
|
||||
if (canFallbackToShared) {
|
||||
if (canFallbackToShared && deviceIdentity) {
|
||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||
}
|
||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||
|
||||
@@ -327,6 +327,25 @@ export type GatewaySessionsDefaults = {
|
||||
contextTokens: number | null;
|
||||
};
|
||||
|
||||
export type GatewayAgentRow = {
|
||||
id: string;
|
||||
name?: string;
|
||||
identity?: {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentsListResult = {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: string;
|
||||
agents: GatewayAgentRow[];
|
||||
};
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ChatProps = {
|
||||
toolMessages: unknown[];
|
||||
stream: string | null;
|
||||
streamStartedAt: number | null;
|
||||
assistantAvatarUrl?: string | null;
|
||||
draft: string;
|
||||
queue: ChatQueueItem[];
|
||||
connected: boolean;
|
||||
@@ -42,6 +43,8 @@ export type ChatProps = {
|
||||
sidebarContent?: string | null;
|
||||
sidebarError?: string | null;
|
||||
splitRatio?: number;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
// Event handlers
|
||||
onRefresh: () => void;
|
||||
onToggleFocusMode: () => void;
|
||||
@@ -64,6 +67,10 @@ export function renderChat(props: ChatProps) {
|
||||
);
|
||||
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
|
||||
const showReasoning = props.showThinking && reasoningLevel !== "off";
|
||||
const assistantIdentity = {
|
||||
name: props.assistantName,
|
||||
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
|
||||
};
|
||||
|
||||
const composePlaceholder = props.connected
|
||||
? "Message (↩ to send, Shift+↩ for line breaks)"
|
||||
@@ -114,7 +121,7 @@ export function renderChat(props: ChatProps) {
|
||||
: nothing}
|
||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||
if (item.kind === "reading-indicator") {
|
||||
return renderReadingIndicatorGroup();
|
||||
return renderReadingIndicatorGroup(assistantIdentity);
|
||||
}
|
||||
|
||||
if (item.kind === "stream") {
|
||||
@@ -122,6 +129,7 @@ export function renderChat(props: ChatProps) {
|
||||
item.text,
|
||||
item.startedAt,
|
||||
props.onOpenSidebar,
|
||||
assistantIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,6 +137,8 @@ export function renderChat(props: ChatProps) {
|
||||
return renderMessageGroup(item, {
|
||||
onOpenSidebar: props.onOpenSidebar,
|
||||
showReasoning,
|
||||
assistantName: props.assistantName,
|
||||
assistantAvatar: assistantIdentity.avatar,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ const sectionIcons = {
|
||||
};
|
||||
|
||||
// Section metadata
|
||||
const SECTION_META: Record<string, { label: string; description: string }> = {
|
||||
export const SECTION_META: Record<string, { label: string; description: string }> = {
|
||||
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
|
||||
update: { label: "Updates", description: "Auto-update settings and release channel" },
|
||||
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { renderConfigForm, type ConfigFormProps } from "./config-form.render";
|
||||
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
|
||||
export {
|
||||
analyzeConfigSchema,
|
||||
type ConfigSchemaAnalysis,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
|
||||
import {
|
||||
hintForPath,
|
||||
humanize,
|
||||
|
||||
@@ -77,6 +77,44 @@ export function renderOverview(props: OverviewProps) {
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
const insecureContextHint = (() => {
|
||||
if (props.connected || !props.lastError) return null;
|
||||
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
|
||||
if (isSecureContext !== false) return null;
|
||||
const lower = props.lastError.toLowerCase();
|
||||
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
|
||||
return null;
|
||||
}
|
||||
return html`
|
||||
<div class="muted" style="margin-top: 8px;">
|
||||
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
|
||||
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
|
||||
<div style="margin-top: 6px;">
|
||||
If you must stay on HTTP, set
|
||||
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
|
||||
</div>
|
||||
<div style="margin-top: 6px;">
|
||||
<a
|
||||
class="session-link"
|
||||
href="https://docs.clawd.bot/gateway/tailscale"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Tailscale Serve docs (opens in new tab)"
|
||||
>Docs: Tailscale Serve</a
|
||||
>
|
||||
<span class="muted"> · </span>
|
||||
<a
|
||||
class="session-link"
|
||||
href="https://docs.clawd.bot/web/control-ui#insecure-http"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Insecure HTTP docs (opens in new tab)"
|
||||
>Docs: Insecure HTTP</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
@@ -167,6 +205,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
? html`<div class="callout danger" style="margin-top: 14px;">
|
||||
<div>${props.lastError}</div>
|
||||
${authHint ?? ""}
|
||||
${insecureContextHint ?? ""}
|
||||
</div>`
|
||||
: html`<div class="callout" style="margin-top: 14px;">
|
||||
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||
|
||||
Reference in New Issue
Block a user