feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+540 -6
View File
@@ -6,7 +6,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, Sun } from "lucide-react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
@@ -20,7 +20,9 @@ import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState";
import { useSkills } from "@/hooks/useSkills";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { logoFallbackUrls } from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import {
BootstrapAuthRequiredError,
@@ -38,6 +40,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
ChatSummary,
RuntimeSurface,
PairingRequestInfo,
SessionAutomationJob,
SettingsPayload,
WorkspaceScopePayload,
@@ -45,7 +48,12 @@ import type {
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
fetchPairingRequests,
fetchSettings,
fetchWorkspaces,
runPairingAction,
} from "@/lib/api";
import {
createRuntimeHost,
getHostApi,
@@ -70,11 +78,15 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const RESTART_ROUTE_KEY = "nanobot-webui.restartRoute";
const RESTART_ROUTE_TTL_MS = 5 * 60 * 1000;
const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
const PAIRING_POLL_INTERVAL_MS = 5_000;
const PAIRING_DISMISS_SNOOZE_MS = 30_000;
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
type ShellRoute = {
view: ShellView;
@@ -82,6 +94,106 @@ type ShellRoute = {
settingsSection: SettingsSectionKey;
};
type PairingChannelPresentation = {
label: string;
initials: string;
color: string;
logoUrl?: string;
};
const PAIRING_CHANNEL_PRESENTATION: Record<string, PairingChannelPresentation> = {
dingtalk: {
label: "DingTalk",
initials: "DT",
color: "#FF6A00",
logoUrl: "https://www.dingtalk.com/favicon.ico",
},
discord: {
label: "Discord",
initials: "DC",
color: "#5865F2",
logoUrl: "https://discord.com/favicon.ico",
},
email: {
label: "Email",
initials: "EM",
color: "#EA4335",
logoUrl: "https://gmail.com/favicon.ico",
},
feishu: {
label: "Feishu",
initials: "FS",
color: "#3370FF",
logoUrl: "https://www.feishu.cn/favicon.ico",
},
lark: {
label: "Lark",
initials: "LK",
color: "#3370FF",
logoUrl: "https://www.larksuite.com/favicon.ico",
},
matrix: {
label: "Matrix",
initials: "M",
color: "#111827",
logoUrl: "https://matrix.org/favicon.ico",
},
msteams: {
label: "Microsoft Teams",
initials: "MT",
color: "#6264A7",
logoUrl: "https://www.microsoft.com/favicon.ico",
},
napcat: {
label: "NapCat",
initials: "NC",
color: "#7C3AED",
logoUrl: "https://napneko.github.io/favicon.ico",
},
qq: {
label: "QQ",
initials: "QQ",
color: "#12B7F5",
logoUrl: "https://im.qq.com/favicon.ico",
},
signal: {
label: "Signal",
initials: "SG",
color: "#3A76F0",
logoUrl: "https://signal.org/favicon.ico",
},
slack: {
label: "Slack",
initials: "SL",
color: "#611F69",
logoUrl: "https://slack.com/favicon.ico",
},
telegram: {
label: "Telegram",
initials: "TG",
color: "#229ED9",
logoUrl: "https://telegram.org/favicon.ico",
},
wecom: {
label: "WeCom",
initials: "WC",
color: "#2F7DFF",
logoUrl: "https://work.weixin.qq.com/favicon.ico",
},
weixin: {
label: "WeChat",
initials: "WX",
color: "#07C160",
logoUrl: "https://weixin.qq.com/favicon.ico",
},
whatsapp: {
label: "WhatsApp",
initials: "WA",
color: "#25D366",
logoUrl: "https://www.whatsapp.com/favicon.ico",
},
};
const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"overview",
"appearance",
@@ -89,6 +201,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"image",
"voice",
"browser",
"channels",
"apps",
"automations",
"skills",
@@ -109,11 +222,47 @@ function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
return "settings";
}
function fallbackRestartHash(hash: string): boolean {
return !hash || hash === "/" || hash === "/new";
}
function rememberRestartRoute(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(RESTART_ROUTE_KEY, window.location.hash || "#/new");
} catch {
// ignore storage errors
}
}
function maybeRestoreRestartHash(hash: string): string {
if (typeof window === "undefined" || !fallbackRestartHash(hash)) return hash;
try {
const startedAt = Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0");
const storedHash = window.localStorage.getItem(RESTART_ROUTE_KEY);
if (!startedAt || !storedHash || Date.now() - startedAt > RESTART_ROUTE_TTL_MS) {
window.localStorage.removeItem(RESTART_ROUTE_KEY);
return hash;
}
window.localStorage.removeItem(RESTART_ROUTE_KEY);
const nextHash = storedHash.startsWith("#") ? storedHash : `#${storedHash}`;
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${nextHash}`,
);
return nextHash.slice(1);
} catch {
return hash;
}
}
function readShellRoute(): ShellRoute {
if (typeof window === "undefined") return defaultShellRoute();
const hash = window.location.hash.startsWith("#")
const currentHash = window.location.hash.startsWith("#")
? window.location.hash.slice(1)
: window.location.hash;
const hash = maybeRestoreRestartHash(currentHash);
if (!hash || hash === "/" || hash === "/new") return defaultShellRoute();
const [path, query = ""] = hash.split("?", 2);
@@ -346,6 +495,286 @@ function HostChrome({
);
}
function PairingCodePopup({
requests,
total,
busyCode,
error,
onApprove,
onDismiss,
}: {
requests: PairingRequestInfo[];
total: number;
busyCode: string | null;
error: string | null;
onApprove: (code: string) => void;
onDismiss: (code: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const normalizedCode = normalizePairingCode(value);
const matchedRequest = useMemo(
() => requests.find((request) => request.code === normalizedCode) ?? null,
[normalizedCode, requests],
);
const firstRequest = requests[0] ?? null;
const displayRequest = matchedRequest ?? firstRequest;
const expires = formatPairingExpiry(firstRequest?.expires_in_seconds);
const isCompleteCode = normalizedCode.length === 9;
const showNoMatch = isCompleteCode && !matchedRequest && !busyCode;
useEffect(() => {
if (!matchedRequest || busyCode) return;
onApprove(matchedRequest.code);
}, [busyCode, matchedRequest, onApprove]);
useEffect(() => {
if (!requests.length) setValue("");
}, [requests.length]);
if (!firstRequest) return null;
return (
<div
role="dialog"
aria-live="polite"
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
className={cn(
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
"border border-border/70 bg-popover/95 p-4 text-popover-foreground",
"shadow-[0_24px_70px_rgba(15,23,42,0.20)] backdrop-blur-xl",
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
)}
>
<div className="flex items-start gap-3">
<PairingChannelBadge channel={displayRequest.channel} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[15px] font-semibold tracking-[-0.01em]">
{t("app.pairing.title", { defaultValue: "Pair a chat user" })}
</p>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{t("app.pairing.description", {
defaultValue: "Enter the pairing code shown in the chat.",
})}
</p>
</div>
<button
type="button"
aria-label={t("common.close", { defaultValue: "Close" })}
onClick={() => onDismiss(firstRequest.code)}
className="rounded-full p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<label className="mt-4 block text-[12.5px] font-medium text-foreground">
{t("app.pairing.code", { defaultValue: "Pairing code" })}
</label>
<PairingCodeSlots
value={value}
disabled={Boolean(busyCode)}
matched={Boolean(matchedRequest)}
invalid={showNoMatch}
ariaLabel={t("app.pairing.code", { defaultValue: "Pairing code" })}
onChange={(next) => setValue(formatPairingCodeInput(next))}
/>
<div className="mt-3 flex items-center justify-between gap-3 text-[12.5px] text-muted-foreground">
<span>
{matchedRequest
? t("app.pairing.matched", {
defaultValue: "Matched {{channel}}. Connecting...",
channel: channelLabel(matchedRequest.channel),
})
: t("app.pairing.expiresInline", {
defaultValue: "Code expires {{expires}}.",
expires,
})}
</span>
{total > 1 ? (
<span className="shrink-0">
{t("app.pairing.queueCount", {
defaultValue: "{{count}} pending",
count: total,
})}
</span>
) : null}
</div>
{showNoMatch ? (
<p className="mt-2 text-[12px] leading-5 text-destructive">
{t("app.pairing.noMatch", {
defaultValue: "No pending request matches this code.",
})}
</p>
) : null}
{error ? (
<p className="mt-2 text-[12px] leading-5 text-destructive">{error}</p>
) : null}
</div>
</div>
</div>
);
}
function PairingChannelBadge({ channel }: { channel: string }) {
const key = pairingChannelKey(channel);
const presentation = PAIRING_CHANNEL_PRESENTATION[key];
const label = presentation?.label ?? channelLabel(channel);
const initials = presentation?.initials ?? label.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#10B981";
const logoUrls = useMemo(
() => logoFallbackUrls(presentation?.logoUrl),
[presentation?.logoUrl],
);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
return (
<div
className="mt-0.5 grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-2xl border bg-background shadow-sm"
style={{
borderColor: `${color}30`,
boxShadow: `inset 0 0 0 1px ${color}14, 0 1px 2px rgba(15,23,42,0.06)`,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-6 w-6 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : presentation ? (
<span className="text-[11px] font-bold tracking-[-0.02em]" style={{ color }}>
{initials}
</span>
) : (
<ShieldCheck className="h-5 w-5" style={{ color }} />
)}
</div>
);
}
function PairingCodeSlots({
value,
disabled,
matched,
invalid,
ariaLabel,
onChange,
}: {
value: string;
disabled: boolean;
matched: boolean;
invalid: boolean;
ariaLabel: string;
onChange: (value: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState(false);
const compact = compactPairingCode(value);
const activeIndex = Math.min(compact.length, 7);
const slots = Array.from({ length: 8 }, (_, index) => compact[index] ?? "");
const renderSlot = (char: string, index: number) => {
const highlighted = focused && index === activeIndex && !matched && !invalid;
return (
<div
key={index}
className={cn(
"grid h-10 w-7 place-items-center rounded-xl border",
"bg-background/80 font-mono text-[16px] font-semibold uppercase",
"text-foreground shadow-[0_1px_1px_rgba(15,23,42,0.04)] transition",
matched
? "border-emerald-500/45 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: invalid
? "border-destructive/55 bg-destructive/5 text-destructive"
: highlighted
? "border-foreground/30 bg-background text-foreground"
: char
? "border-border/80 bg-background text-foreground"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
{char || " "}
</div>
);
};
return (
<div
className={cn(
"relative mt-2 rounded-2xl border border-transparent p-1",
"transition duration-150",
focused && !disabled ? "border-ring/20 bg-muted/35" : "bg-transparent",
)}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef}
value={value}
aria-label={ariaLabel}
inputMode="text"
autoCapitalize="characters"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
maxLength={9}
disabled={disabled}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(event) => onChange(event.target.value)}
className="absolute inset-0 z-10 h-full w-full cursor-text opacity-0 disabled:cursor-default"
/>
<div className="pointer-events-none flex items-center gap-1.5">
{slots.slice(0, 4).map((char, index) => renderSlot(char, index))}
<div className="mx-0.5 h-px w-2.5 rounded-full bg-muted-foreground/35" />
{slots.slice(4).map((char, index) => renderSlot(char, index + 4))}
</div>
</div>
);
}
function compactPairingCode(raw: string): string {
return raw.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8).toUpperCase();
}
function formatPairingCodeInput(raw: string): string {
const compact = compactPairingCode(raw);
if (compact.length <= 4) return compact;
return `${compact.slice(0, 4)}-${compact.slice(4)}`;
}
function normalizePairingCode(raw: string): string {
return formatPairingCodeInput(raw);
}
function pairingChannelKey(channel: string): string {
const raw = channel.trim().toLowerCase();
if (!raw) return "";
return raw.split(/[.:]/)[0] ?? raw;
}
function channelLabel(channel: string): string {
const key = pairingChannelKey(channel);
return PAIRING_CHANNEL_PRESENTATION[key]?.label ?? channel;
}
function formatPairingExpiry(seconds: number | null | undefined): string {
if (seconds == null) return "soon";
if (seconds <= 0) return "expired";
if (seconds < 60) return `${seconds}s`;
return `${Math.ceil(seconds / 60)} min`;
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
@@ -510,9 +939,24 @@ export default function App() {
if (!hostApi?.restartEngine) {
throw new Error("native engine restart is unavailable");
}
await hostApi.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token;
rememberRestartRoute();
try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch {
// ignore storage errors
}
try {
await hostApi.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token;
} finally {
try {
window.localStorage.removeItem(RESTART_STARTED_KEY);
window.localStorage.removeItem(RESTART_ROUTE_KEY);
} catch {
// ignore storage errors
}
}
};
return (
@@ -585,6 +1029,12 @@ function Shell({
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
const [pairingError, setPairingError] = useState<string | null>(null);
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
() => new Map(),
);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
@@ -657,6 +1107,36 @@ function Shell({
writeSessionUpdateChatIds(updatedChatIds);
}, [updatedChatIds]);
const refreshPairingRequests = useCallback(async () => {
try {
const payload = await fetchPairingRequests(token);
const requests = Array.isArray(payload.requests) ? payload.requests : [];
setPairingRequests(requests);
setSnoozedPairingCodes((current) => {
if (current.size === 0) return current;
const activeCodes = new Set(requests.map((request) => request.code));
const now = Date.now();
const next = new Map(
Array.from(current).filter(
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
),
);
return next.size === current.size ? current : next;
});
} catch {
// Pairing is an opportunistic WebUI affordance. The slash command path
// remains available if this polling request fails.
}
}, [token]);
useEffect(() => {
void refreshPairingRequests();
const timer = window.setInterval(() => {
void refreshPairingRequests();
}, PAIRING_POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [refreshPairingRequests]);
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
@@ -1237,6 +1717,7 @@ function Shell({
if (!chatId) return;
restartSawDisconnectRef.current = false;
setIsRestarting(true);
rememberRestartRoute();
try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch {
@@ -1302,6 +1783,7 @@ function Shell({
if (!restartSawDisconnectRef.current && elapsedMs < 1500) return;
try {
window.localStorage.removeItem(RESTART_STARTED_KEY);
window.localStorage.removeItem(RESTART_ROUTE_KEY);
} catch {
// ignore storage errors
}
@@ -1357,6 +1839,50 @@ function Shell({
setPendingDelete({ key, label, automations });
}, [getSessionAutomations]);
const visiblePairingRequests = useMemo(
() => {
const now = Date.now();
return pairingRequests.filter((request) => {
const snoozedUntil = snoozedPairingCodes.get(request.code);
return !snoozedUntil || snoozedUntil <= now;
});
},
[pairingRequests, snoozedPairingCodes],
);
const onPairingAction = useCallback(
async (action: "approve" | "deny", code: string) => {
setPairingBusyCode(code);
setPairingError(null);
try {
const payload = await runPairingAction(token, action, code);
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
setSnoozedPairingCodes((current) => {
if (!current.has(code)) return current;
const next = new Map(current);
next.delete(code);
return next;
});
} catch (e) {
setPairingError((e as Error).message);
void refreshPairingRequests();
} finally {
setPairingBusyCode(null);
}
},
[refreshPairingRequests, token],
);
const onDismissPairingRequest = useCallback((code: string) => {
setSnoozedPairingCodes((current) => {
const snoozedUntil = Date.now() + PAIRING_DISMISS_SNOOZE_MS;
if (current.get(code) === snoozedUntil) return current;
const next = new Map(current);
next.set(code, snoozedUntil);
return next;
});
}, []);
const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
@@ -1653,6 +2179,14 @@ function Shell({
{restartToast}
</div>
) : null}
<PairingCodePopup
requests={visiblePairingRequests}
total={visiblePairingRequests.length}
busyCode={pairingBusyCode}
error={pairingError}
onApprove={(code) => void onPairingAction("approve", code)}
onDismiss={onDismissPairingRequest}
/>
</div>
</ThemeProvider>
);