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:
+540
-6
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -137,16 +138,13 @@ export function CliAppMentionToken({
|
||||
variant: "composer" | "message";
|
||||
isHero?: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const color = app.brand_color || "hsl(var(--primary))";
|
||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [app.logo_url]);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
|
||||
@@ -175,7 +173,10 @@ export function CliAppMentionToken({
|
||||
src={logoUrl ?? ""}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
@@ -196,16 +197,13 @@ export function McpPresetMentionToken({
|
||||
variant: "composer" | "message";
|
||||
isHero?: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const color = preset.brand_color || "hsl(var(--primary))";
|
||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [preset.logo_url]);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
|
||||
@@ -234,7 +232,10 @@ export function McpPresetMentionToken({
|
||||
src={logoUrl ?? ""}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
|
||||
@@ -22,6 +19,7 @@ import {
|
||||
isFilePatternReference,
|
||||
isLikelyFilePath,
|
||||
} from "@/components/FileReferenceChip";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { inferMediaKind } from "@/lib/media";
|
||||
import { faviconUrls } from "@/lib/provider-brand";
|
||||
import { remarkTexMath } from "@/lib/remark-tex-math";
|
||||
@@ -304,7 +302,7 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
|
||||
}
|
||||
|
||||
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
const { favicon, onFaviconError } = useFaviconFallback(link.host);
|
||||
const { favicon, onFaviconError, onFaviconLoad } = useFaviconFallback(link.host);
|
||||
const label = link.prefix
|
||||
? `${link.prefix} — ${link.title}`
|
||||
: link.title;
|
||||
@@ -332,7 +330,9 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
src={favicon}
|
||||
alt=""
|
||||
className="h-3 w-3 rounded-[2px] object-contain"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onFaviconLoad}
|
||||
onError={onFaviconError}
|
||||
/>
|
||||
) : (
|
||||
@@ -348,19 +348,12 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
|
||||
function useFaviconFallback(host: string) {
|
||||
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
|
||||
const [faviconIndex, setFaviconIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFaviconIndex(0);
|
||||
}, [host]);
|
||||
|
||||
const onFaviconError = useCallback(() => {
|
||||
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
|
||||
}, [faviconCandidates.length]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(faviconCandidates);
|
||||
|
||||
return {
|
||||
favicon: faviconCandidates[faviconIndex] ?? null,
|
||||
onFaviconError,
|
||||
favicon: logoUrl ?? null,
|
||||
onFaviconError: onLogoError,
|
||||
onFaviconLoad: onLogoLoad,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ToggleButton({
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel ?? label}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) onChange(!checked);
|
||||
}}
|
||||
className={cn(
|
||||
"relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full p-[2px]",
|
||||
"transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
checked
|
||||
? "bg-[#2997FF] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]"
|
||||
: "bg-muted shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] hover:bg-muted/80",
|
||||
disabled && "cursor-default opacity-60",
|
||||
disabled && checked && "hover:bg-[#2997FF]",
|
||||
disabled && !checked && "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-[18px] w-[18px] rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.18),0_2px_7px_rgba(0,0,0,0.11)]",
|
||||
"transition-transform duration-200 ease-out",
|
||||
checked ? "translate-x-[16px]" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
CHANNEL_PRESENTATION,
|
||||
type ChannelSetupPresentation,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type { NanobotFeatureInfo } from "@/lib/types";
|
||||
|
||||
export type ChannelFilter = "all" | "on" | "off";
|
||||
|
||||
export function channelSetup(feature: NanobotFeatureInfo): ChannelSetupPresentation {
|
||||
return CHANNEL_PRESENTATION[feature.name]?.setup ?? {
|
||||
summary:
|
||||
"Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.",
|
||||
steps: [
|
||||
`Open ~/.nanobot/config.json and find channels.${feature.name}.`,
|
||||
"Add the credentials required by that platform, using the channel documentation as the source of truth.",
|
||||
"Restart nanobot, then send a small test message from that platform.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function ChannelLogo({
|
||||
feature,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#6B7280";
|
||||
const Icon = presentation?.icon;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
|
||||
if (showBrandLogos && logoUrl) {
|
||||
return (
|
||||
<span
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] border border-border/45 bg-background"
|
||||
style={{ boxShadow: `inset 0 0 0 1px ${color}22` }}
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-5.5 w-5.5 max-h-6 max-w-6 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (Icon) {
|
||||
return (
|
||||
<span
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background"
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon className="h-5 w-5" strokeWidth={2.25} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background text-[11px] font-bold"
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function channelDisplayName(feature: NanobotFeatureInfo): string {
|
||||
return CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name;
|
||||
}
|
||||
|
||||
export function channelDescription(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||
const fallback =
|
||||
CHANNEL_PRESENTATION[feature.name]?.description ??
|
||||
`Use nanobot from ${channelDisplayName(feature)}.`;
|
||||
return t(`settings.channels.items.${feature.name}.description`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
export function channelRequirements(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||
const fallback =
|
||||
CHANNEL_PRESENTATION[feature.name]?.requirements ??
|
||||
"Channel credentials and gateway settings";
|
||||
return t(`settings.channels.items.${feature.name}.requirements`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean {
|
||||
if (filter === "on") return feature.enabled;
|
||||
if (filter === "off") return !feature.enabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function channelStatusLabel(
|
||||
feature: NanobotFeatureInfo,
|
||||
tx: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
if (feature.enabled) return tx("settings.values.on", "On");
|
||||
return tx("settings.values.off", "Off");
|
||||
}
|
||||
|
||||
export function channelSearchText(feature: NanobotFeatureInfo): string {
|
||||
return [
|
||||
channelDisplayName(feature),
|
||||
feature.display_name,
|
||||
feature.name,
|
||||
feature.status,
|
||||
CHANNEL_PRESENTATION[feature.name]?.description,
|
||||
CHANNEL_PRESENTATION[feature.name]?.requirements,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
export function ChannelStatusBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-full bg-muted/75 px-2 py-0.5 text-[11px] font-medium leading-4 text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Check, Loader2, Network, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
cancelChannelConnect,
|
||||
pollChannelConnect,
|
||||
startChannelConnect,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
ChannelConnectPayload,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
export type ChannelQrConnectLabels = {
|
||||
qrAlt: string;
|
||||
scanTitle: string;
|
||||
scanDescription: string;
|
||||
waiting: string;
|
||||
connected: string;
|
||||
stopped: string;
|
||||
connecting: string;
|
||||
scanAgain: string;
|
||||
connect: string;
|
||||
};
|
||||
|
||||
export function ChannelQrConnectFlow({
|
||||
token,
|
||||
channelName,
|
||||
startOptions = {},
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
labels,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
channelName: "feishu" | "weixin";
|
||||
startOptions?: {
|
||||
domain?: "feishu" | "lark";
|
||||
instanceId?: string;
|
||||
mode?: "replace" | "create";
|
||||
force?: boolean;
|
||||
};
|
||||
idleLabel?: string;
|
||||
connectRequestId?: number;
|
||||
labels: ChannelQrConnectLabels;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [connect, setConnect] = useState<ChannelConnectPayload | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||
const pollInFlight = useRef(false);
|
||||
const startDomain = startOptions.domain;
|
||||
const startInstanceId = startOptions.instanceId;
|
||||
const startMode = startOptions.mode;
|
||||
const startForce = startOptions.force;
|
||||
|
||||
const pending = connect?.status === "pending";
|
||||
const succeeded = connect?.status === "succeeded";
|
||||
const canStart = !pending && !busy;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connect?.qr_url) {
|
||||
setQrDataUrl("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void QRCode.toDataURL(connect.qr_url, {
|
||||
width: 184,
|
||||
margin: 1,
|
||||
color: { dark: "#111827", light: "#ffffff" },
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setQrDataUrl("");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connect?.qr_url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connect?.session_id || connect.status !== "pending") return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
if (pollInFlight.current) return;
|
||||
pollInFlight.current = true;
|
||||
try {
|
||||
const payload = await pollChannelConnect(token, channelName, connect.session_id);
|
||||
if (cancelled) return;
|
||||
setConnect((current) => ({
|
||||
...(current ?? payload),
|
||||
...payload,
|
||||
qr_url: payload.qr_url ?? current?.qr_url,
|
||||
}));
|
||||
if (payload.nanobot_features) {
|
||||
onFeaturesUpdate(payload.nanobot_features);
|
||||
}
|
||||
if (payload.status !== "pending") {
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
} finally {
|
||||
pollInFlight.current = false;
|
||||
}
|
||||
};
|
||||
const initial = window.setTimeout(() => void poll(), 900);
|
||||
const interval = window.setInterval(
|
||||
() => void poll(),
|
||||
Math.max(2500, connect.interval_ms ?? 5000),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(initial);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, token]);
|
||||
|
||||
const start = useCallback(async (force = false) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await startChannelConnect(token, channelName, {
|
||||
domain: startDomain,
|
||||
instanceId: startInstanceId,
|
||||
mode: startMode,
|
||||
force: force || startForce,
|
||||
});
|
||||
setConnect(payload);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [channelName, startDomain, startForce, startInstanceId, startMode, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||
setHandledRequestId(connectRequestId);
|
||||
void start();
|
||||
}, [connectRequestId, handledRequestId, start]);
|
||||
|
||||
const cancel = async () => {
|
||||
if (!connect?.session_id) {
|
||||
setConnect(null);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await cancelChannelConnect(token, channelName, connect.session_id);
|
||||
setConnect(payload);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{pending ? (
|
||||
<div className="grid gap-4 rounded-[14px] border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<div className="grid h-[196px] w-[196px] place-items-center rounded-[14px] border border-border/60 bg-background">
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={labels.qrAlt}
|
||||
className="h-[184px] w-[184px]"
|
||||
/>
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{labels.scanTitle}
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
|
||||
{labels.scanDescription}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{labels.waiting}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
onClick={() => void cancel()}
|
||||
disabled={busy}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{succeeded ? (
|
||||
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
{connect.message ?? labels.connected}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
|
||||
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
|
||||
{connect.message || labels.stopped}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void start(channelName === "weixin" && succeeded)}
|
||||
disabled={!canStart}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : succeeded ? (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Network className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{pending
|
||||
? labels.connecting
|
||||
: succeeded
|
||||
? labels.scanAgain
|
||||
: idleLabel ?? labels.connect}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function FeishuConnectFlow({
|
||||
token,
|
||||
instanceId = "default",
|
||||
mode = "replace",
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
instanceId?: string;
|
||||
mode?: "replace" | "create";
|
||||
idleLabel?: string;
|
||||
connectRequestId?: number;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<ChannelQrConnectFlow
|
||||
token={token}
|
||||
channelName="feishu"
|
||||
startOptions={{ domain: "feishu", instanceId, mode }}
|
||||
idleLabel={idleLabel}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
labels={{
|
||||
qrAlt: tx("settings.channels.feishuQrAlt", "Feishu connection QR code"),
|
||||
scanTitle: tx("settings.channels.feishuScanTitle", "Scan with Feishu"),
|
||||
scanDescription: tx(
|
||||
"settings.channels.feishuScanDescription",
|
||||
"Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
|
||||
),
|
||||
waiting: tx("settings.channels.feishuWaiting", "Waiting for authorization..."),
|
||||
connected: tx("settings.channels.feishuConnected", "Feishu is connected."),
|
||||
stopped: tx("settings.channels.feishuConnectStopped", "Connection stopped."),
|
||||
connecting: tx("settings.channels.feishuConnecting", "Connecting..."),
|
||||
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
|
||||
connect: tx("settings.channels.connect", "Connect"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeixinConnectFlow({
|
||||
token,
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
idleLabel?: string;
|
||||
connectRequestId?: number;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<ChannelQrConnectFlow
|
||||
token={token}
|
||||
channelName="weixin"
|
||||
idleLabel={idleLabel}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
labels={{
|
||||
qrAlt: tx("settings.channels.weixinQrAlt", "WeChat login QR code"),
|
||||
scanTitle: tx("settings.channels.weixinScanTitle", "Scan with WeChat"),
|
||||
scanDescription: tx(
|
||||
"settings.channels.weixinScanDescription",
|
||||
"Use WeChat on your phone to scan this code. nanobot saves the account state locally after login.",
|
||||
),
|
||||
waiting: tx("settings.channels.weixinWaiting", "Waiting for WeChat scan..."),
|
||||
connected: tx("settings.channels.weixinConnected", "WeChat is connected."),
|
||||
stopped: tx("settings.channels.weixinConnectStopped", "WeChat login stopped."),
|
||||
connecting: tx("settings.channels.weixinConnecting", "Connecting..."),
|
||||
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
|
||||
connect: tx("settings.channels.connect", "Connect"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import {
|
||||
type ChannelProviderPreset,
|
||||
type ChannelSetupPresentation,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import {
|
||||
CredentialForm,
|
||||
channelValuesForSubmit,
|
||||
defaultChannelFieldValues,
|
||||
} from "@/components/settings/channels/CredentialForm";
|
||||
import {
|
||||
ChannelLogo,
|
||||
ChannelStatusBadge,
|
||||
channelDescription,
|
||||
channelDisplayName,
|
||||
channelRequirements,
|
||||
channelSetup,
|
||||
channelStatusLabel,
|
||||
} from "@/components/settings/channels/ChannelIdentity";
|
||||
import {
|
||||
FeishuConnectFlow,
|
||||
WeixinConnectFlow,
|
||||
} from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
import {
|
||||
ChannelProviderPresets,
|
||||
ChannelSetupActions,
|
||||
ChannelSetupLinks,
|
||||
ChannelSetupSteps,
|
||||
ChannelValidationBadge,
|
||||
ChannelValidationChecks,
|
||||
ChannelValidationDetails,
|
||||
} from "@/components/settings/channels/ChannelSetupParts";
|
||||
import { FeishuAssistantsPanel } from "@/components/settings/channels/FeishuAssistantsPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
configureChannel,
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import type {
|
||||
ChannelValidationPayload,
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ChannelCatalogRow({
|
||||
feature,
|
||||
selected,
|
||||
showBrandLogos,
|
||||
onSelect,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
selected: boolean;
|
||||
showBrandLogos: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("settings.channels.selectChannel", {
|
||||
name: channelDisplayName(feature),
|
||||
defaultValue: "View {{name}} settings",
|
||||
})}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
|
||||
selected
|
||||
? "border-border/55 bg-muted/35"
|
||||
: "border-transparent hover:border-border/45 hover:bg-muted/25",
|
||||
)}
|
||||
>
|
||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||
{channelDisplayName(feature)}
|
||||
</h3>
|
||||
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">
|
||||
{channelDescription(feature, t)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
selected && "translate-x-0.5 text-foreground",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupPanel({
|
||||
token,
|
||||
feature,
|
||||
actionKey,
|
||||
chatAppsDocsUrl,
|
||||
showBrandLogos,
|
||||
onAction,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
actionKey: string | null;
|
||||
chatAppsDocsUrl?: string;
|
||||
showBrandLogos: boolean;
|
||||
onAction: (action: "enable" | "disable", name: string) => void;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [connectRequestId, setConnectRequestId] = useState(0);
|
||||
if (feature.name === "feishu") {
|
||||
return (
|
||||
<FeishuAssistantsPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const enableBusy = actionKey === `enable:${feature.name}`;
|
||||
const disableBusy = actionKey === `disable:${feature.name}`;
|
||||
const missingSupport = feature.enabled && !feature.installed;
|
||||
const requiredWebui = feature.name === "websocket";
|
||||
const channelChecked = requiredWebui || feature.enabled;
|
||||
const channelBusy = enableBusy || disableBusy;
|
||||
const setup = channelSetup(feature);
|
||||
const needsSetupBeforeEnable =
|
||||
!channelChecked
|
||||
&& feature.configured === false
|
||||
&& !(feature.name === "weixin" && setup.mode === "connect");
|
||||
const channelToggleDisabled =
|
||||
requiredWebui
|
||||
|| channelBusy
|
||||
|| needsSetupBeforeEnable
|
||||
|| (!feature.install_supported && !feature.installed && !feature.enabled);
|
||||
const installSupportLabel = tx("settings.nanobotFeatures.installSupport", "Install support");
|
||||
const toggleAriaLabel = t("settings.channels.toggleChannel", {
|
||||
name: channelDisplayName(feature),
|
||||
defaultValue: "{{name}} channel",
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
|
||||
{channelDisplayName(feature)}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
||||
{channelDescription(feature, t)}
|
||||
</p>
|
||||
{missingSupport && feature.install_supported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={enableBusy}
|
||||
onClick={() => onAction("enable", feature.name)}
|
||||
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{enableBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{installSupportLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pt-1">
|
||||
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
|
||||
{channelBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : null}
|
||||
<ToggleButton
|
||||
checked={channelChecked}
|
||||
disabled={channelToggleDisabled}
|
||||
ariaLabel={toggleAriaLabel}
|
||||
label={channelChecked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
onChange={(checked) => {
|
||||
if (
|
||||
feature.name === "weixin"
|
||||
&& checked
|
||||
&& !channelChecked
|
||||
&& feature.configured === false
|
||||
) {
|
||||
setConnectRequestId((current) => current + 1);
|
||||
return;
|
||||
}
|
||||
onAction(checked ? "enable" : "disable", feature.name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChannelSetupSurface
|
||||
token={token}
|
||||
feature={feature}
|
||||
setup={setup}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelSetupSurface({
|
||||
token,
|
||||
feature,
|
||||
setup,
|
||||
chatAppsDocsUrl,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
chatAppsDocsUrl?: string;
|
||||
connectRequestId: number;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validation, setValidation] = useState<ChannelValidationPayload | null>(null);
|
||||
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
|
||||
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
|
||||
const configValuesKey = JSON.stringify(feature.config_values ?? {});
|
||||
const configuredFields = useMemo(
|
||||
() => new Set(feature.configured_fields ?? []),
|
||||
[feature.configured_fields],
|
||||
);
|
||||
const mode = setup.mode ?? "credentials";
|
||||
const fields = setup.fields ?? [];
|
||||
const requiredFields = fields.filter((field) => !field.optional);
|
||||
const primaryFields = requiredFields.length ? requiredFields : fields.slice(0, 1);
|
||||
const optionalFields = fields.filter((field) => field.optional);
|
||||
const manualFields = setup.manualFields ?? [];
|
||||
const advancedFields = mode === "connect" ? manualFields : optionalFields;
|
||||
const editableFields = mode === "credentials" ? fields : mode === "connect" ? manualFields : [];
|
||||
const hasAdvanced = advancedFields.length > 0;
|
||||
const requirements = channelRequirements(feature, t);
|
||||
const summary = t(`settings.channels.items.${feature.name}.setup.summary`, {
|
||||
defaultValue:
|
||||
setup.summary ??
|
||||
tx(
|
||||
"settings.channels.setupSummary",
|
||||
"Enable only turns on nanobot support. Add the platform credentials, then restart nanobot.",
|
||||
),
|
||||
});
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
|
||||
defaultChannelFieldValues(editableFields, feature.config_values),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNotice(null);
|
||||
setVisibleSecrets({});
|
||||
setSaving(false);
|
||||
setValidating(false);
|
||||
setValidation(null);
|
||||
setTouchedFields(new Set());
|
||||
setFieldValues(defaultChannelFieldValues(editableFields, feature.config_values));
|
||||
}, [configValuesKey, feature.name]);
|
||||
|
||||
const toggleSecret = (key: string) => {
|
||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
|
||||
};
|
||||
|
||||
const setFieldValue = (key: string, value: string) => {
|
||||
setFieldValues((current) => ({ ...current, [key]: value }));
|
||||
setTouchedFields((current) => new Set(current).add(key));
|
||||
};
|
||||
|
||||
const applyPreset = (preset: ChannelProviderPreset) => {
|
||||
setFieldValues((current) => ({ ...current, ...preset.values }));
|
||||
setTouchedFields((current) => {
|
||||
const next = new Set(current);
|
||||
for (const key of Object.keys(preset.values)) next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const copyCommand = () => {
|
||||
if (!setup.command) return;
|
||||
void copyTextToClipboard(setup.command).then((ok) => {
|
||||
setNotice(
|
||||
ok
|
||||
? tx("settings.channels.commandCopied", "Command copied.")
|
||||
: tx("settings.channels.commandCopyFailed", "Could not copy command."),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const saveCredentialSettings = async () => {
|
||||
setSaving(true);
|
||||
setValidating(true);
|
||||
setNotice(null);
|
||||
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
||||
try {
|
||||
const validationPayload = await validateChannel(token, feature.name, values);
|
||||
setValidation(validationPayload);
|
||||
if (!validationPayload.can_enable) {
|
||||
setNotice(
|
||||
validationPayload.message
|
||||
?? tx("settings.channels.validationFailed", "Check the required setup before enabling."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
feature.name,
|
||||
values,
|
||||
{ enable: true },
|
||||
);
|
||||
if (payload.nanobot_features) {
|
||||
onFeaturesUpdate(payload.nanobot_features);
|
||||
}
|
||||
setNotice(tx("settings.channels.checkedAndEnabled", "Checked and enabled."));
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkCurrentSettings = async () => {
|
||||
setValidating(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await validateChannel(
|
||||
token,
|
||||
feature.name,
|
||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||
);
|
||||
setValidation(payload);
|
||||
if (payload.message) setNotice(payload.message);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const primaryActionLabel = feature.enabled
|
||||
? tx("settings.channels.checkConnection", "Check connection")
|
||||
: tx("settings.channels.checkAndEnable", "Check and enable");
|
||||
|
||||
return (
|
||||
<div className="mt-5 overflow-hidden rounded-[16px] border border-border/70 bg-background shadow-none">
|
||||
<section className="px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{tx("settings.channels.requiredSetup", "Required setup")}
|
||||
</div>
|
||||
<div className="flex max-w-full flex-wrap justify-end gap-2">
|
||||
{mode !== "webui" ? (
|
||||
<ChannelValidationBadge
|
||||
validation={validation}
|
||||
validating={validating}
|
||||
feature={feature}
|
||||
/>
|
||||
) : null}
|
||||
{mode === "webui" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-1 text-[11.5px] font-medium text-emerald-700 dark:text-emerald-200">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.channels.managedByWebui", "Managed by WebUI")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">{requirements}</p>
|
||||
|
||||
<p className="mt-3 text-[12.5px] leading-5 text-muted-foreground">{summary}</p>
|
||||
<ChannelValidationDetails validation={validation} />
|
||||
<ChannelSetupLinks feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} />
|
||||
<ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} />
|
||||
|
||||
{mode === "connect" && feature.name === "feishu" ? (
|
||||
<FeishuConnectFlow
|
||||
token={token}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
) : mode === "connect" && feature.name === "weixin" ? (
|
||||
<WeixinConnectFlow
|
||||
token={token}
|
||||
idleLabel={t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
|
||||
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
|
||||
})}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
) : mode === "connect" ? (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() =>
|
||||
setNotice(
|
||||
tx(
|
||||
"settings.channels.connectPreview",
|
||||
"The in-browser connect flow is next. For now, run the command below.",
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
|
||||
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
|
||||
})}
|
||||
</Button>
|
||||
{setup.command ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
onClick={copyCommand}
|
||||
>
|
||||
<Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.channels.copyCommand", "Copy command")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{setup.command ? (
|
||||
<code className="mt-3 block rounded-[10px] border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
|
||||
{setup.command}
|
||||
</code>
|
||||
) : null}
|
||||
</>
|
||||
) : mode === "credentials" ? (
|
||||
<>
|
||||
{setup.presets?.length ? (
|
||||
<ChannelProviderPresets
|
||||
featureName={feature.name}
|
||||
presets={setup.presets}
|
||||
onApply={applyPreset}
|
||||
/>
|
||||
) : null}
|
||||
{primaryFields.length ? (
|
||||
<CredentialForm
|
||||
fields={primaryFields}
|
||||
values={fieldValues}
|
||||
configuredFields={configuredFields}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={setFieldValue}
|
||||
onToggleSecret={toggleSecret}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void saveCredentialSettings()}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving || validating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
{feature.configured || validation ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
onClick={() => void checkCurrentSettings()}
|
||||
disabled={saving || validating}
|
||||
>
|
||||
{tx("settings.channels.checkOnly", "Check only")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{notice ? (
|
||||
<div
|
||||
role="status"
|
||||
className="border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground"
|
||||
>
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{setup.steps.length ? (
|
||||
<ChannelSetupSteps featureName={feature.name} steps={setup.steps} tryIt={setup.tryIt} />
|
||||
) : null}
|
||||
|
||||
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
|
||||
|
||||
{hasAdvanced ? (
|
||||
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
|
||||
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{tx("settings.channels.advanced", "Advanced")}
|
||||
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open:rotate-180" aria-hidden />
|
||||
</span>
|
||||
</summary>
|
||||
{advancedFields.length ? (
|
||||
<div className="mt-3">
|
||||
<CredentialForm
|
||||
fields={advancedFields}
|
||||
values={fieldValues}
|
||||
configuredFields={configuredFields}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={setFieldValue}
|
||||
onToggleSecret={toggleSecret}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Clipboard, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CHANNEL_PRESENTATION,
|
||||
docsUrlWithBase,
|
||||
type ChannelProviderPreset,
|
||||
type ChannelSetupPresentation,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import {
|
||||
channelValidationCheckIcon,
|
||||
channelValidationCheckIconClass,
|
||||
channelValidationStatusClass,
|
||||
channelValidationStatusIcon,
|
||||
channelValidationStatusLabel,
|
||||
} from "@/components/settings/channels/CredentialForm";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChannelValidationPayload,
|
||||
NanobotFeatureInfo,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ChannelGuideLink({
|
||||
feature,
|
||||
setup,
|
||||
chatAppsDocsUrl,
|
||||
compact = false,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
chatAppsDocsUrl?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const logoUrls = useMemo(
|
||||
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
|
||||
[presentation?.logoUrl, setup.docsLogoUrl],
|
||||
);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const Icon = presentation?.icon;
|
||||
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#6B7280";
|
||||
const docsUrl = docsUrlWithBase(setup.docsUrl, chatAppsDocsUrl);
|
||||
|
||||
if (!docsUrl) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-2 border border-border/65 bg-background/90 font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45",
|
||||
compact
|
||||
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
|
||||
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background font-bold",
|
||||
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
|
||||
)}
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
|
||||
aria-hidden
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", compact ? "h-3.5 w-3.5" : "h-4 w-4")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className={compact ? "h-3 w-3" : "h-3.5 w-3.5"} strokeWidth={2.25} />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{t(`settings.channels.items.${feature.name}.setup.docsLabel`, {
|
||||
defaultValue: setup.docsLabel ?? tx("settings.channels.officialGuide", "Official guide"),
|
||||
})}
|
||||
</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupLinks({
|
||||
feature,
|
||||
setup,
|
||||
chatAppsDocsUrl,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
chatAppsDocsUrl?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<ChannelOfficialLink feature={feature} setup={setup} />
|
||||
<ChannelGuideLink feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} compact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelOfficialLink({
|
||||
feature,
|
||||
setup,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
}) {
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const logoUrls = useMemo(
|
||||
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
|
||||
[presentation?.logoUrl, setup.docsLogoUrl],
|
||||
);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const Icon = presentation?.icon;
|
||||
const color = presentation?.color ?? "#6B7280";
|
||||
const label = setup.officialLabel;
|
||||
if (!setup.officialUrl || !label) return null;
|
||||
return (
|
||||
<a
|
||||
href={setup.officialUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full border border-border/65 bg-background/90 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45"
|
||||
>
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background"
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
|
||||
aria-hidden
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className="h-3 w-3" strokeWidth={2.25} />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupActions({
|
||||
feature,
|
||||
setup,
|
||||
onNotice,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
onNotice: (message: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!setup.actions?.length) return null;
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{setup.actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => {
|
||||
if (action.copyText) {
|
||||
void copyTextToClipboard(action.copyText).then((ok) =>
|
||||
onNotice(
|
||||
ok
|
||||
? t("settings.channels.helperCopied", {
|
||||
name: action.label,
|
||||
defaultValue: "{{name}} copied.",
|
||||
})
|
||||
: t("settings.channels.helperCopyFailed", {
|
||||
name: action.label,
|
||||
defaultValue: "Could not copy {{name}}.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{action.copyText ? <Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="sr-only">
|
||||
{CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelProviderPresets({
|
||||
featureName,
|
||||
presets,
|
||||
onApply,
|
||||
}: {
|
||||
featureName: string;
|
||||
presets: ChannelProviderPreset[];
|
||||
onApply: (preset: ChannelProviderPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState("");
|
||||
if (!presets.length) return null;
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 text-[11px] font-medium text-foreground/85">
|
||||
{t(`settings.channels.items.${featureName}.providerPreset`, {
|
||||
defaultValue: "Provider",
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t(`settings.channels.items.${featureName}.providerPreset`, {
|
||||
defaultValue: "Provider",
|
||||
})}
|
||||
className="grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
|
||||
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected === preset.id}
|
||||
onClick={() => {
|
||||
setSelected(preset.id);
|
||||
onApply(preset);
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
||||
selected === preset.id
|
||||
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelValidationBadge({
|
||||
validation,
|
||||
validating,
|
||||
feature,
|
||||
}: {
|
||||
validation: ChannelValidationPayload | null;
|
||||
validating: boolean;
|
||||
feature: NanobotFeatureInfo;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const status = validation?.status ?? (feature.configured ? "configured" : "needs_setup");
|
||||
const label = validating
|
||||
? t("settings.channels.checking", { defaultValue: "Checking..." })
|
||||
: channelValidationStatusLabel(status, t);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
|
||||
channelValidationStatusClass(status),
|
||||
)}
|
||||
>
|
||||
{validating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
channelValidationStatusIcon(status)
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelValidationDetails({ validation }: { validation: ChannelValidationPayload | null }) {
|
||||
const message = validation?.message;
|
||||
if (!validation?.identity?.name && !message) return null;
|
||||
return (
|
||||
<div className="mt-2 truncate text-[11.5px] text-muted-foreground">
|
||||
{validation?.identity?.name
|
||||
? validation.identity.workspace
|
||||
? `${validation.identity.name} · ${validation.identity.workspace}`
|
||||
: validation.identity.name
|
||||
: message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelValidationChecks({ validation }: { validation: ChannelValidationPayload }) {
|
||||
if (!validation.checks.length) return null;
|
||||
return (
|
||||
<div className="border-t border-border/60 px-4 py-4">
|
||||
<div className="mb-2 text-[12px] font-semibold text-foreground">Connection checks</div>
|
||||
<div className="space-y-2">
|
||||
{validation.checks.slice(0, 6).map((check) => (
|
||||
<div key={check.id} className="flex gap-2 text-[12px] leading-5">
|
||||
<span className={cn("mt-0.5", channelValidationCheckIconClass(check.status))}>
|
||||
{channelValidationCheckIcon(check.status)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-foreground/85">{check.label}</div>
|
||||
{check.message ? (
|
||||
<div className="text-muted-foreground">{check.message}</div>
|
||||
) : null}
|
||||
{check.action_url ? (
|
||||
<a
|
||||
href={check.action_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-foreground underline decoration-border underline-offset-4"
|
||||
>
|
||||
Open
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupSteps({
|
||||
featureName,
|
||||
steps,
|
||||
action,
|
||||
tryIt,
|
||||
}: {
|
||||
featureName: string;
|
||||
steps: string[];
|
||||
action?: ReactNode;
|
||||
tryIt?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className="border-t border-border/60 px-4 py-4 text-[12.5px] leading-5 text-muted-foreground">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-[12px] font-semibold text-foreground">
|
||||
{tx("settings.channels.setupSteps", "Next steps")}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<ol className="space-y-1.5">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step} className="flex gap-2">
|
||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-background text-[10px] font-semibold text-muted-foreground shadow-sm">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>
|
||||
{t(`settings.channels.items.${featureName}.setup.steps.${index}`, {
|
||||
defaultValue: step,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{tryIt ? (
|
||||
<div className="mt-3 rounded-[12px] border border-border/55 bg-background px-3 py-2 text-[12px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{tx("settings.channels.tryIt", "Try it")}
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
{t(`settings.channels.items.${featureName}.setup.tryIt`, { defaultValue: tryIt })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Check, CircleAlert, Eye, EyeOff, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
|
||||
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
|
||||
}
|
||||
|
||||
export function defaultChannelFieldValues(
|
||||
fields: ChannelConfigField[],
|
||||
configValues: Record<string, string> | undefined = undefined,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
fields.map((field) => [
|
||||
field.key,
|
||||
configValues?.[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function channelValuesForSave(
|
||||
fields: ChannelConfigField[],
|
||||
values: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const value = channelFieldValue(field, values);
|
||||
if (field.secret && !value.trim()) continue;
|
||||
payload[field.key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function channelValuesForSubmit(
|
||||
fields: ChannelConfigField[],
|
||||
values: Record<string, string>,
|
||||
touchedFields: Set<string>,
|
||||
): Record<string, string> {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const touched = touchedFields.has(field.key);
|
||||
const value = channelFieldValue(field, values);
|
||||
if (field.secret && !value.trim()) continue;
|
||||
if (!touched && !value.trim()) continue;
|
||||
if (!touched && field.options?.length) continue;
|
||||
payload[field.key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function channelValidationStatusLabel(
|
||||
status: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
const labels: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
configured: "Configured manually",
|
||||
needs_setup: "Needs setup",
|
||||
invalid: "Invalid",
|
||||
unsupported: "Manual setup",
|
||||
};
|
||||
return t(`settings.channels.validation.${status}`, {
|
||||
defaultValue: labels[status] ?? "Checked",
|
||||
});
|
||||
}
|
||||
|
||||
export function channelValidationStatusClass(status: string): string {
|
||||
if (status === "connected") {
|
||||
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200";
|
||||
}
|
||||
if (status === "configured") {
|
||||
return "bg-blue-500/10 text-blue-700 dark:text-blue-200";
|
||||
}
|
||||
if (status === "invalid") {
|
||||
return "bg-destructive/10 text-destructive";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
export function channelValidationStatusIcon(status: string): ReactNode {
|
||||
if (status === "connected" || status === "configured") {
|
||||
return <Check className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
if (status === "invalid") {
|
||||
return <X className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
|
||||
export function channelValidationCheckIcon(status: string): ReactNode {
|
||||
if (status === "pass") return <Check className="h-3.5 w-3.5" aria-hidden />;
|
||||
if (status === "fail") return <X className="h-3.5 w-3.5" aria-hidden />;
|
||||
if (status === "warn") return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
|
||||
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
|
||||
export function channelValidationCheckIconClass(status: string): string {
|
||||
if (status === "pass") return "text-emerald-600";
|
||||
if (status === "fail") return "text-destructive";
|
||||
if (status === "warn") return "text-amber-600";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function CredentialForm({
|
||||
fields,
|
||||
values,
|
||||
configuredFields,
|
||||
visibleSecrets,
|
||||
onChange,
|
||||
onToggleSecret,
|
||||
compact = false,
|
||||
}: {
|
||||
fields: ChannelConfigField[];
|
||||
values: Record<string, string>;
|
||||
configuredFields?: Set<string>;
|
||||
visibleSecrets: Record<string, boolean>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onToggleSecret: (key: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className={cn(compact ? "space-y-2.5" : "mt-3 space-y-2.5")}>
|
||||
{fields.map((field) => {
|
||||
const visible = Boolean(visibleSecrets[field.key]);
|
||||
const value = values[field.key] ?? "";
|
||||
const savedSecret = Boolean(field.secret && configuredFields?.has(field.key) && !value.trim());
|
||||
const showSecretToggle = Boolean(field.secret && value.trim());
|
||||
const inputType = field.secret && !visible ? "password" : field.inputType ?? "text";
|
||||
const selectedOption = channelFieldValue(field, values);
|
||||
const header = (
|
||||
<span className="flex items-center justify-between gap-2 text-[11px] font-medium text-foreground/85">
|
||||
<span>{field.label}</span>
|
||||
{savedSecret ? (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{tx("settings.channels.savedSecret", "Saved")}
|
||||
</span>
|
||||
) : field.optional && !compact ? (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{tx("settings.channels.optional", "Optional")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
const help = field.help ? (
|
||||
<span className="mt-1 block text-[11px] leading-4 text-muted-foreground">
|
||||
{field.help}
|
||||
</span>
|
||||
) : null;
|
||||
if (field.options?.length) {
|
||||
return (
|
||||
<div key={field.key} className="block">
|
||||
{header}
|
||||
<span
|
||||
role="radiogroup"
|
||||
aria-label={field.label}
|
||||
className="mt-1 grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
|
||||
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedOption === option.value}
|
||||
onClick={() => onChange(field.key, option.value)}
|
||||
className={cn(
|
||||
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
||||
selectedOption === option.value
|
||||
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
{help}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label key={field.key} className="block">
|
||||
{header}
|
||||
<span className="relative mt-1 block">
|
||||
<Input
|
||||
aria-label={field.label}
|
||||
type={inputType}
|
||||
inputMode={field.inputType === "number" ? "numeric" : undefined}
|
||||
placeholder={
|
||||
savedSecret
|
||||
? tx("settings.channels.savedSecretPlaceholder", "Saved secret")
|
||||
: field.placeholder
|
||||
}
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
className={cn(
|
||||
"h-9 rounded-[10px] border-border/60 bg-muted/35 text-[13px]",
|
||||
showSecretToggle && "pr-9",
|
||||
)}
|
||||
/>
|
||||
{showSecretToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
visible
|
||||
? tx("settings.channels.hideSecret", "Hide secret")
|
||||
: tx("settings.channels.showSecret", "Show secret")
|
||||
}
|
||||
onClick={() => onToggleSecret(field.key)}
|
||||
className="absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{help}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Loader2, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import {
|
||||
CHANNEL_PRESENTATION,
|
||||
type ChannelConfigField,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import {
|
||||
CredentialForm,
|
||||
channelValidationStatusClass,
|
||||
channelValidationStatusIcon,
|
||||
channelValuesForSave,
|
||||
defaultChannelFieldValues,
|
||||
} from "@/components/settings/channels/CredentialForm";
|
||||
import {
|
||||
ChannelLogo,
|
||||
ChannelStatusBadge,
|
||||
channelDisplayName,
|
||||
channelSetup,
|
||||
channelStatusLabel,
|
||||
} from "@/components/settings/channels/ChannelIdentity";
|
||||
import { FeishuConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
import {
|
||||
ChannelGuideLink,
|
||||
ChannelSetupSteps,
|
||||
} from "@/components/settings/channels/ChannelSetupParts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import {
|
||||
configureChannel,
|
||||
disableNanobotFeature,
|
||||
enableNanobotFeature,
|
||||
} from "@/lib/api";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type {
|
||||
NanobotChannelInstanceInfo,
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FeishuAssistantsPanel({
|
||||
token,
|
||||
feature,
|
||||
showBrandLogos,
|
||||
chatAppsDocsUrl,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
showBrandLogos: boolean;
|
||||
chatAppsDocsUrl?: string;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const instances = feishuFeatureInstances(feature);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const selected = selectedId ? instances.find((instance) => instance.id === selectedId) : undefined;
|
||||
const setup = channelSetup(feature);
|
||||
const manualFields = setup.manualFields ?? [];
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
|
||||
feishuInstanceFieldValues(manualFields, selected),
|
||||
);
|
||||
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
|
||||
const [savingFields, setSavingFields] = useState(false);
|
||||
const connectedAssistantCount = instances.filter((instance) => instance.configured).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId && !instances.some((instance) => instance.id === selectedId)) {
|
||||
setSelectedId(null);
|
||||
}
|
||||
}, [instances, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValues(feishuInstanceFieldValues(manualFields, selected));
|
||||
setVisibleSecrets({});
|
||||
}, [
|
||||
manualFields,
|
||||
selected?.allow_from,
|
||||
selected?.app_id,
|
||||
selected?.domain,
|
||||
selected?.group_policy,
|
||||
selected?.id,
|
||||
]);
|
||||
|
||||
const toggleInstance = async (instance: NanobotChannelInstanceInfo, checked: boolean) => {
|
||||
setBusyInstanceId(instance.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = checked
|
||||
? await enableNanobotFeature(token, "feishu", { instanceId: instance.id })
|
||||
: await disableNanobotFeature(token, "feishu", { instanceId: instance.id });
|
||||
onFeaturesUpdate(payload);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reconnectInstance = async (instance: NanobotChannelInstanceInfo) => {
|
||||
setBusyInstanceId(instance.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await enableNanobotFeature(token, "feishu", { instanceId: instance.id });
|
||||
onFeaturesUpdate(payload);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSelectedInstanceSettings = async () => {
|
||||
if (!selected) return;
|
||||
setSavingFields(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
"feishu",
|
||||
channelValuesForSave(manualFields, fieldValues),
|
||||
{ enable: selected.enabled, instanceId: selected.id },
|
||||
);
|
||||
if (payload.nanobot_features) {
|
||||
onFeaturesUpdate(payload.nanobot_features);
|
||||
}
|
||||
setNotice(tx("settings.channels.savedSettings", "Saved settings."));
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setSavingFields(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
|
||||
{channelDisplayName(feature)}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
||||
{feishuAssistantCountLabel(connectedAssistantCount, tx)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{instances.map((instance) => {
|
||||
const expanded = selected?.id === instance.id;
|
||||
return (
|
||||
<article
|
||||
key={instance.id}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[18px] border transition-colors",
|
||||
expanded
|
||||
? "border-border/75 bg-card/95 shadow-sm"
|
||||
: "border-border/55 bg-background hover:border-border/75 hover:bg-muted/15",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() =>
|
||||
setSelectedId((current) => (current === instance.id ? null : instance.id))
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<FeishuAssistantAvatar
|
||||
feature={feature}
|
||||
instance={instance}
|
||||
showBrandLogos={showBrandLogos}
|
||||
size="lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
|
||||
{feishuInstanceDisplayName(instance)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{busyInstanceId === instance.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : null}
|
||||
<ToggleButton
|
||||
checked={instance.enabled}
|
||||
disabled={busyInstanceId === instance.id || !instance.configured}
|
||||
ariaLabel={t("settings.channels.toggleFeishuAssistant", {
|
||||
name: feishuInstanceDisplayName(instance),
|
||||
defaultValue: "{{name}} assistant",
|
||||
})}
|
||||
label={instance.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
onChange={(checked) => void toggleInstance(instance, checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="border-t border-border/60">
|
||||
<section className="px-4 py-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-[11.5px] leading-6 text-muted-foreground">
|
||||
{maskFeishuAppId(instance.app_id) || tx("settings.channels.noAppId", "No App ID")}
|
||||
</p>
|
||||
<FeishuAssistantConnectionBadge instance={instance} />
|
||||
</div>
|
||||
{instance.configured ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void reconnectInstance(instance)}
|
||||
disabled={busyInstanceId === instance.id || !instance.enabled}
|
||||
>
|
||||
{busyInstanceId === instance.id ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{tx("settings.channels.reconnectAssistant", "Reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FeishuConnectFlow
|
||||
key={`connect-${instance.id}`}
|
||||
token={token}
|
||||
instanceId={instance.id}
|
||||
mode="replace"
|
||||
idleLabel={tx("settings.channels.connect", "Connect")}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<ChannelSetupSteps
|
||||
featureName={feature.name}
|
||||
steps={setup.steps}
|
||||
action={
|
||||
<ChannelGuideLink
|
||||
feature={feature}
|
||||
setup={setup}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
compact
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{manualFields.length ? (
|
||||
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
|
||||
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{tx("settings.channels.advanced", "Advanced")}
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5 transition-transform group-open:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
<CredentialForm
|
||||
fields={manualFields}
|
||||
values={fieldValues}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={(key, value) =>
|
||||
setFieldValues((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
onToggleSecret={(key) =>
|
||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }))
|
||||
}
|
||||
compact
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void saveSelectedInstanceSettings()}
|
||||
disabled={savingFields}
|
||||
>
|
||||
{savingFields ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{tx("settings.channels.saveSettings", "Save settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{tx("settings.channels.createFeishuAssistant", "Create another assistant")}
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
|
||||
{tx(
|
||||
"settings.channels.createFeishuAssistantHint",
|
||||
"Create a separate Feishu bot for another team, space, or workflow.",
|
||||
)}
|
||||
</p>
|
||||
<FeishuConnectFlow
|
||||
key="create-feishu-assistant"
|
||||
token={token}
|
||||
instanceId="default"
|
||||
mode="create"
|
||||
idleLabel={tx("settings.channels.createAssistant", "Create assistant")}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function feishuFeatureInstances(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo[] {
|
||||
if (feature.instances?.length) return feature.instances;
|
||||
return [{
|
||||
id: "default",
|
||||
name: "nanobot",
|
||||
domain: "feishu",
|
||||
enabled: feature.enabled,
|
||||
configured: Boolean(feature.configured),
|
||||
app_id: "",
|
||||
}];
|
||||
}
|
||||
|
||||
function feishuAssistantCountLabel(
|
||||
count: number,
|
||||
tx: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
if (count === 0) {
|
||||
return tx("settings.channels.noFeishuAssistants", "No assistant connected");
|
||||
}
|
||||
if (count === 1) {
|
||||
return tx("settings.channels.oneFeishuAssistant", "1 assistant connected");
|
||||
}
|
||||
return tx("settings.channels.manyFeishuAssistants", `${count} assistants connected`);
|
||||
}
|
||||
|
||||
function feishuInstanceDisplayName(instance: NanobotChannelInstanceInfo): string {
|
||||
const displayName = instance.display_name?.trim();
|
||||
if (displayName) return displayName;
|
||||
const localName = instance.name?.trim();
|
||||
if (localName) return localName;
|
||||
return instance.id === "default" ? "nanobot" : "nanobot";
|
||||
}
|
||||
|
||||
function FeishuAssistantConnectionBadge({ instance }: { instance: NanobotChannelInstanceInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const status = instance.configured ? "connected" : "needs_setup";
|
||||
const label = instance.configured
|
||||
? t("settings.channels.feishuConfigured", { defaultValue: "Connected" })
|
||||
: t("settings.channels.feishuNotConfigured", { defaultValue: "Needs authorization" });
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
|
||||
channelValidationStatusClass(status),
|
||||
)}
|
||||
>
|
||||
{channelValidationStatusIcon(status)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FeishuAssistantAvatar({
|
||||
feature,
|
||||
instance,
|
||||
showBrandLogos,
|
||||
size,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
instance: NanobotChannelInstanceInfo;
|
||||
showBrandLogos: boolean;
|
||||
size: "sm" | "lg";
|
||||
}) {
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
const fallbackLogoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(fallbackLogoUrls);
|
||||
const remoteAvatarUrl = !avatarFailed ? instance.avatar_url?.trim() : "";
|
||||
const imageUrl = remoteAvatarUrl || (showBrandLogos ? logoUrl : "");
|
||||
const Icon = presentation?.icon;
|
||||
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#3370FF";
|
||||
const frameClass = size === "lg" ? "h-11 w-11" : "h-9 w-9";
|
||||
const fallbackImageClass = size === "lg" ? "h-6 w-6" : "h-5 w-5";
|
||||
const iconClass = size === "lg" ? "h-5 w-5" : "h-4 w-4";
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarFailed(false);
|
||||
}, [instance.avatar_url]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background text-[10px] font-bold",
|
||||
frameClass,
|
||||
)}
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
|
||||
aria-hidden
|
||||
>
|
||||
{remoteAvatarUrl ? (
|
||||
<img
|
||||
src={remoteAvatarUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarFailed(true)}
|
||||
/>
|
||||
) : imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", fallbackImageClass)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className={iconClass} strokeWidth={2.25} />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function maskFeishuAppId(appId: string | undefined): string {
|
||||
if (!appId) return "";
|
||||
if (appId.length <= 10) return appId;
|
||||
return `${appId.slice(0, 7)}...${appId.slice(-4)}`;
|
||||
}
|
||||
|
||||
function feishuInstanceFieldValues(
|
||||
fields: ChannelConfigField[],
|
||||
instance: NanobotChannelInstanceInfo | undefined,
|
||||
): Record<string, string> {
|
||||
const values = defaultChannelFieldValues(fields);
|
||||
if (!instance) return values;
|
||||
values["channels.feishu.appId"] = instance.app_id ?? "";
|
||||
values["channels.feishu.appSecret"] = "";
|
||||
values["channels.feishu.domain"] = instance.domain ?? values["channels.feishu.domain"] ?? "feishu";
|
||||
values["channels.feishu.groupPolicy"] =
|
||||
instance.group_policy ?? values["channels.feishu.groupPolicy"] ?? "mention";
|
||||
values["channels.feishu.allowFrom"] = (instance.allow_from ?? []).join(", ");
|
||||
return values;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ import {
|
||||
type ActivityEvidence,
|
||||
} from "@/lib/activity-timeline";
|
||||
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { hasRenderableFileDiff } from "@/lib/file-diff";
|
||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
|
||||
@@ -943,10 +944,12 @@ function TraceIconMark({
|
||||
fallbackIcon: LucideIcon;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [faviconIndex, setFaviconIndex] = useState(0);
|
||||
const faviconUrl = trace.host ? faviconUrls(trace.host)[faviconIndex] : undefined;
|
||||
|
||||
useEffect(() => setFaviconIndex(0), [trace.host]);
|
||||
const faviconCandidates = useMemo(() => (trace.host ? faviconUrls(trace.host) : []), [trace.host]);
|
||||
const {
|
||||
logoUrl: faviconUrl,
|
||||
onLogoError: onFaviconError,
|
||||
onLogoLoad: onFaviconLoad,
|
||||
} = useLogoFallback(faviconCandidates);
|
||||
|
||||
if (trace.url && trace.host && faviconUrl) {
|
||||
return (
|
||||
@@ -962,7 +965,10 @@ function TraceIconMark({
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onError={() => setFaviconIndex((index) => index + 1)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onFaviconLoad}
|
||||
onError={onFaviconError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
@@ -1661,19 +1667,16 @@ function CliRunGroup({
|
||||
|
||||
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const args = formatCliArgs(run);
|
||||
const failed = run.status === "error";
|
||||
const rowActive = active && run.status === "running";
|
||||
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(app?.logo_url), [app?.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const label = t(cliRunLabelKey(run, active), {
|
||||
defaultValue: cliRunLabelDefault(run, active),
|
||||
});
|
||||
|
||||
useEffect(() => setLogoIndex(0), [app?.logo_url]);
|
||||
|
||||
return (
|
||||
<ActivityStep
|
||||
as="li"
|
||||
@@ -1699,8 +1702,11 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-[78%] w-[78%] object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : app ? (
|
||||
cliAppInitials(app).slice(0, 2)
|
||||
@@ -1772,19 +1778,16 @@ function McpRunGroup({
|
||||
|
||||
function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolean; preset?: McpPresetInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const failed = run.status === "error";
|
||||
const rowActive = active && run.status === "running";
|
||||
const color = failed ? "#DC2626" : preset?.brand_color || "#6D5DF6";
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(preset?.logo_url), [preset?.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const displayName = preset?.display_name || run.displayName;
|
||||
const label = t(mcpRunLabelKey(run, active), {
|
||||
defaultValue: mcpRunLabelDefault(run, active),
|
||||
});
|
||||
|
||||
useEffect(() => setLogoIndex(0), [preset?.logo_url]);
|
||||
|
||||
return (
|
||||
<ActivityStep
|
||||
as="li"
|
||||
@@ -1810,8 +1813,11 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-[78%] w-[78%] object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : preset ? (
|
||||
mcpPresetInitials(preset).slice(0, 2)
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type RestoredReadyImage,
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||
import type {
|
||||
@@ -2260,15 +2261,12 @@ function ComposerModelBadge({
|
||||
}) {
|
||||
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const logoUrl = brand?.logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
const showLogo = !!logoUrl;
|
||||
const title = providerLabel ? `${label} · ${providerLabel}` : label;
|
||||
const interactive = Boolean(onClick);
|
||||
const Container = interactive ? "button" : "span";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [inferredProvider]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
title={title}
|
||||
@@ -2305,8 +2303,11 @@ function ComposerModelBadge({
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
@@ -2502,15 +2503,12 @@ function MentionCandidateLogo({
|
||||
candidate: MentionCandidate;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const color = (candidate.kind === "cli"
|
||||
? candidate.app.brand_color
|
||||
: candidate.preset.brand_color) || "hsl(var(--primary))";
|
||||
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
|
||||
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
|
||||
if (logoUrl) {
|
||||
return (
|
||||
@@ -2523,8 +2521,11 @@ function MentionCandidateLogo({
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-5 w-5 object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
const loadedLogoUrls = new Set<string>();
|
||||
const failedLogoUrls = new Set<string>();
|
||||
const resolvedLogoIndexByKey = new Map<string, number>();
|
||||
|
||||
function logoCacheKey(urls: readonly string[]): string {
|
||||
return urls.join("\n");
|
||||
}
|
||||
|
||||
function logoUrlsFromKey(key: string): string[] {
|
||||
return key ? key.split("\n") : [];
|
||||
}
|
||||
|
||||
function firstUsableLogoIndex(urls: readonly string[]): number {
|
||||
const key = logoCacheKey(urls);
|
||||
const cachedIndex = resolvedLogoIndexByKey.get(key);
|
||||
if (
|
||||
typeof cachedIndex === "number" &&
|
||||
cachedIndex >= 0 &&
|
||||
cachedIndex < urls.length &&
|
||||
!failedLogoUrls.has(urls[cachedIndex])
|
||||
) {
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
const loadedIndex = urls.findIndex((url) => loadedLogoUrls.has(url));
|
||||
if (loadedIndex >= 0) {
|
||||
resolvedLogoIndexByKey.set(key, loadedIndex);
|
||||
return loadedIndex;
|
||||
}
|
||||
|
||||
const firstUnfailedIndex = urls.findIndex((url) => !failedLogoUrls.has(url));
|
||||
if (firstUnfailedIndex >= 0) return firstUnfailedIndex;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function nextLogoIndex(urls: readonly string[], afterIndex: number): number {
|
||||
for (let index = afterIndex + 1; index < urls.length; index += 1) {
|
||||
if (!failedLogoUrls.has(urls[index])) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function useLogoFallback(urls: readonly string[] | undefined) {
|
||||
const cacheKey = useMemo(() => logoCacheKey(urls?.filter(Boolean) ?? []), [urls]);
|
||||
const safeUrls = useMemo(() => logoUrlsFromKey(cacheKey), [cacheKey]);
|
||||
const [logoIndex, setLogoIndex] = useState(() => firstUsableLogoIndex(safeUrls));
|
||||
const logoUrl = logoIndex >= 0 ? safeUrls[logoIndex] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setLogoIndex(firstUsableLogoIndex(safeUrls));
|
||||
}, [cacheKey, safeUrls]);
|
||||
|
||||
const onLogoLoad = useCallback(() => {
|
||||
if (!logoUrl || logoIndex < 0) return;
|
||||
loadedLogoUrls.add(logoUrl);
|
||||
failedLogoUrls.delete(logoUrl);
|
||||
resolvedLogoIndexByKey.set(cacheKey, logoIndex);
|
||||
}, [cacheKey, logoIndex, logoUrl]);
|
||||
|
||||
const onLogoError = useCallback(() => {
|
||||
if (!logoUrl || logoIndex < 0) return;
|
||||
failedLogoUrls.add(logoUrl);
|
||||
if (resolvedLogoIndexByKey.get(cacheKey) === logoIndex) {
|
||||
resolvedLogoIndexByKey.delete(cacheKey);
|
||||
}
|
||||
setLogoIndex(nextLogoIndex(safeUrls, logoIndex));
|
||||
}, [cacheKey, logoIndex, logoUrl, safeUrls]);
|
||||
|
||||
return { logoUrl, onLogoLoad, onLogoError };
|
||||
}
|
||||
|
||||
export function __clearLogoFallbackCacheForTests(): void {
|
||||
loadedLogoUrls.clear();
|
||||
failedLogoUrls.clear();
|
||||
resolvedLogoIndexByKey.clear();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string, fallback = false): boolean {
|
||||
const readMatch = () => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
};
|
||||
|
||||
const [matches, setMatches] = useState(readMatch);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
)
|
||||
return;
|
||||
const media = window.matchMedia(query);
|
||||
const update = () => setMatches(media.matches);
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Image",
|
||||
"voice": "Voice",
|
||||
"browser": "Web",
|
||||
"channels": "Channels",
|
||||
"cliApps": "CLI Apps",
|
||||
"mcp": "MCP",
|
||||
"runtime": "System",
|
||||
@@ -236,21 +237,21 @@
|
||||
"searchPlaceholder": "Search CLIs",
|
||||
"loading": "Loading CLI Apps...",
|
||||
"empty": "No CLI Apps match this filter.",
|
||||
"statusInstalled": "CLI installed",
|
||||
"statusInstalled": "App ready",
|
||||
"statusMissing": "Missing",
|
||||
"statusAvailable": "Available",
|
||||
"statusUnsupported": "Unsupported",
|
||||
"statusNotInstalled": "CLI not installed",
|
||||
"statusNotInstalled": "App not installed",
|
||||
"requires": "Requires",
|
||||
"test": "Test CLI",
|
||||
"update": "Update CLI",
|
||||
"uninstall": "Uninstall CLI",
|
||||
"install": "Install CLI",
|
||||
"test": "Test app",
|
||||
"update": "Update app",
|
||||
"uninstall": "Uninstall app",
|
||||
"install": "Install app",
|
||||
"readyTitle": "@{{name}} is ready",
|
||||
"readyStatus": "Ready",
|
||||
"readyTry": "Try @{{name}}",
|
||||
"readyCopied": "Copied",
|
||||
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
|
||||
"readyPrompt": "Ask nanobot to use @{{name}} for this task.",
|
||||
"openChat": "Open chat",
|
||||
"unsupported": "Unsupported",
|
||||
"unavailable": "Unavailable",
|
||||
@@ -270,8 +271,8 @@
|
||||
"filterInstalled": "Enabled",
|
||||
"filterNotInstalled": "Not enabled",
|
||||
"searchPlaceholder": "Search MCP presets",
|
||||
"moreOptions": "More MCP options",
|
||||
"moreOptionsSubtitle": "Add a custom server or import mcp.json.",
|
||||
"moreOptions": "Add integration",
|
||||
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
|
||||
"customTitle": "Custom MCP",
|
||||
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
|
||||
"customAction": "Custom",
|
||||
@@ -462,23 +463,77 @@
|
||||
"configureProvider": "Configure provider",
|
||||
"missingCredential": "Configure this provider before enabling image generation."
|
||||
},
|
||||
"api": {
|
||||
"title": "API server",
|
||||
"openaiCompatible": "OpenAI-compatible API",
|
||||
"description": "Connect SDKs and agents through a local /v1 endpoint.",
|
||||
"start": "Start API server",
|
||||
"starting": "Starting...",
|
||||
"stop": "Stop",
|
||||
"stopping": "Stopping...",
|
||||
"access": "Access",
|
||||
"thisDevice": "This device",
|
||||
"localNetwork": "Local network",
|
||||
"localHelp": "Only this device can connect.",
|
||||
"networkHelp": "Other devices can connect; an API key is required.",
|
||||
"port": "Port",
|
||||
"portHelp": "The API uses this local port.",
|
||||
"apiKey": "API key",
|
||||
"apiKeyHelp": "Clients send this as a Bearer token.",
|
||||
"apiKeyRequired": "Required before exposing the API to your network.",
|
||||
"apiKeyPlaceholder": "Enter an API key",
|
||||
"autoInstall": "API support will be installed automatically when you start it."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observability",
|
||||
"configured": "Tracing credentials are available to nanobot.",
|
||||
"environment": "Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
|
||||
"enable": "Enable tracing support"
|
||||
},
|
||||
"apps": {
|
||||
"description": "Enable plugins, local app adapters, and connected tool servers.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Add tools to nanobot, then @ them in chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Integration",
|
||||
"channelLabel": "Channel",
|
||||
"featureLabel": "Feature",
|
||||
"filterAll": "All",
|
||||
"filterAll": "Ready",
|
||||
"filterPlugins": "Plugins",
|
||||
"filterCli": "CLI apps",
|
||||
"filterMcp": "MCP services",
|
||||
"enabledSummary": "{{count}} enabled",
|
||||
"caption": "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Search Apps",
|
||||
"featured": "Catalog",
|
||||
"filterCli": "Apps",
|
||||
"filterMcp": "Integrations",
|
||||
"enabledSummary": "{{count}} ready",
|
||||
"caption": "{{cli}} apps · {{mcp}} integrations",
|
||||
"searchPlaceholder": "Search tools",
|
||||
"featured": "Tools",
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No apps match this filter.",
|
||||
"restartRequired": "Restart nanobot to apply updated apps and features."
|
||||
"empty": "No tools match this view.",
|
||||
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Connect chat apps, email, and WebUI to nanobot.",
|
||||
"caption": "{{enabled}} enabled · {{total}} channels",
|
||||
"searchPlaceholder": "Search channels",
|
||||
"backToChannels": "All channels",
|
||||
"catalog": "Channels",
|
||||
"loading": "Loading Channels...",
|
||||
"empty": "No channels match this filter.",
|
||||
"restartRequired": "Restart nanobot to apply updated channel support.",
|
||||
"requires": "Requires: {{requirements}}",
|
||||
"setUp": "Set up",
|
||||
"setupGuide": "Setup guide",
|
||||
"setupSummary": "Enable only turns on nanobot support. Add the platform credentials, then restart nanobot.",
|
||||
"configKeys": "Config keys",
|
||||
"enable": "Enable channel",
|
||||
"disable": "Disable channel",
|
||||
"needsConfig": "Needs setup",
|
||||
"connect": "Connect",
|
||||
"reconnect": "Reconnect",
|
||||
"feishuQrAlt": "Feishu connection QR code",
|
||||
"feishuScanTitle": "Scan with Feishu",
|
||||
"feishuScanDescription": "Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
|
||||
"feishuWaiting": "Waiting for authorization...",
|
||||
"feishuConnected": "Feishu is connected.",
|
||||
"feishuConnectStopped": "Connection stopped.",
|
||||
"feishuConnecting": "Connecting..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Enabled",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Imagen",
|
||||
"voice": "Voz",
|
||||
"browser": "Internet",
|
||||
"channels": "Canales",
|
||||
"runtime": "Sistema",
|
||||
"advanced": "Seguridad",
|
||||
"cliApps": "Apps CLI",
|
||||
@@ -459,27 +460,68 @@
|
||||
"noTools": "Ninguna",
|
||||
"testForTools": "Ejecuta Probar para inspeccionar y elegir herramientas individuales."
|
||||
},
|
||||
"api": {
|
||||
"title": "Servidor API", "openaiCompatible": "API compatible con OpenAI",
|
||||
"description": "Conecta SDK y agentes mediante un endpoint /v1 local.",
|
||||
"start": "Iniciar servidor API", "starting": "Iniciando...", "stop": "Detener", "stopping": "Deteniendo...",
|
||||
"access": "Acceso", "thisDevice": "Este dispositivo", "localNetwork": "Red local",
|
||||
"localHelp": "Solo este dispositivo puede conectarse.", "networkHelp": "Otros dispositivos pueden conectarse; se requiere una clave API.",
|
||||
"port": "Puerto", "portHelp": "La API usa este puerto local.", "apiKey": "Clave API",
|
||||
"apiKeyHelp": "Los clientes la envían como token Bearer.", "apiKeyRequired": "Obligatoria antes de exponer la API en la red.",
|
||||
"apiKeyPlaceholder": "Introduce una clave API", "autoInstall": "El soporte API se instalará automáticamente al iniciarlo."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.",
|
||||
"environment": "Configura LANGFUSE_SECRET_KEY y LANGFUSE_PUBLIC_KEY y reinicia nanobot.", "enable": "Habilitar soporte de trazas"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Activa complementos, adaptadores locales de apps y servidores de herramientas conectados.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Integración",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Función",
|
||||
"filterAll": "Todo",
|
||||
"filterAll": "Listo",
|
||||
"filterPlugins": "Complementos",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Servicios MCP",
|
||||
"enabledSummary": "{{count}} activados",
|
||||
"caption": "{{plugins}} complementos · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Apps",
|
||||
"filterMcp": "Integraciones",
|
||||
"enabledSummary": "{{count}} listos",
|
||||
"caption": "{{cli}} apps · {{mcp}} integraciones",
|
||||
"searchPlaceholder": "Buscar apps",
|
||||
"featured": "Catálogo",
|
||||
"featured": "Herramientas",
|
||||
"loading": "Cargando apps...",
|
||||
"empty": "Ninguna app coincide con este filtro.",
|
||||
"empty": "Ninguna herramienta coincide con esta vista.",
|
||||
"restartRequired": "Reinicia nanobot para aplicar apps y funciones actualizadas."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Conecta nanobot con apps de chat. Instalar soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
||||
"caption": "{{enabled}} activados · {{total}} canales",
|
||||
"searchPlaceholder": "Buscar canales",
|
||||
"backToChannels": "Todos los canales",
|
||||
"catalog": "Canales",
|
||||
"loading": "Cargando canales...",
|
||||
"empty": "Ningún canal coincide con este filtro.",
|
||||
"restartRequired": "Reinicia nanobot para aplicar el soporte de canales actualizado.",
|
||||
"requires": "Requiere: {{requirements}}",
|
||||
"setUp": "Configurar",
|
||||
"setupGuide": "Guía de configuración",
|
||||
"setupSummary": "Activar solo habilita el soporte en nanobot. Añade las credenciales de la plataforma y reinicia nanobot.",
|
||||
"configKeys": "Claves de configuración",
|
||||
"enable": "Activar canal",
|
||||
"disable": "Desactivar canal",
|
||||
"needsConfig": "Necesita configuración",
|
||||
"connect": "Conectar",
|
||||
"reconnect": "Reconectar",
|
||||
"feishuQrAlt": "Código QR de conexión de Feishu",
|
||||
"feishuScanTitle": "Escanea con Feishu",
|
||||
"feishuScanDescription": "Usa Feishu o Lark en tu teléfono para escanear este código. nanobot terminará la configuración automáticamente después de la autorización.",
|
||||
"feishuWaiting": "Esperando autorización...",
|
||||
"feishuConnected": "Feishu está conectado.",
|
||||
"feishuConnectStopped": "Conexión detenida.",
|
||||
"feishuConnecting": "Conectando..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Activado",
|
||||
"enable": "Activar",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Images",
|
||||
"voice": "Voix",
|
||||
"browser": "Internet",
|
||||
"channels": "Canaux",
|
||||
"runtime": "Système",
|
||||
"advanced": "Sécurité",
|
||||
"cliApps": "Apps CLI",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "Aucun",
|
||||
"testForTools": "Exécutez Tester pour inspecter et choisir des outils individuels."
|
||||
},
|
||||
"api": {
|
||||
"title": "Serveur API", "openaiCompatible": "API compatible OpenAI", "description": "Connectez des SDK et agents via un endpoint /v1 local.",
|
||||
"start": "Démarrer le serveur API", "starting": "Démarrage...", "stop": "Arrêter", "stopping": "Arrêt...",
|
||||
"access": "Accès", "thisDevice": "Cet appareil", "localNetwork": "Réseau local",
|
||||
"localHelp": "Seul cet appareil peut se connecter.", "networkHelp": "D’autres appareils peuvent se connecter ; une clé API est requise.",
|
||||
"port": "Port", "portHelp": "L’API utilise ce port local.", "apiKey": "Clé API", "apiKeyHelp": "Les clients l’envoient comme jeton Bearer.",
|
||||
"apiKeyRequired": "Requise avant d’exposer l’API au réseau.", "apiKeyPlaceholder": "Saisissez une clé API",
|
||||
"autoInstall": "Le support API sera installé automatiquement au démarrage."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.",
|
||||
"environment": "Définissez LANGFUSE_SECRET_KEY et LANGFUSE_PUBLIC_KEY, puis redémarrez nanobot.", "enable": "Activer le traçage"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Activez des extensions, des adaptateurs d’apps locales et des serveurs d’outils connectés.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Intégration",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Fonction",
|
||||
"filterAll": "Tout",
|
||||
"filterAll": "Prêts",
|
||||
"filterPlugins": "Extensions",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Services MCP",
|
||||
"enabledSummary": "{{count}} activés",
|
||||
"caption": "{{plugins}} extensions · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Apps",
|
||||
"filterMcp": "Intégrations",
|
||||
"enabledSummary": "{{count}} prêts",
|
||||
"caption": "{{cli}} apps · {{mcp}} intégrations",
|
||||
"searchPlaceholder": "Rechercher des apps",
|
||||
"featured": "Catalogue",
|
||||
"featured": "Outils",
|
||||
"loading": "Chargement des apps...",
|
||||
"empty": "Aucune app ne correspond.",
|
||||
"empty": "Aucun outil ne correspond à cette vue.",
|
||||
"restartRequired": "Redémarrez nanobot pour appliquer les apps et fonctions mises à jour."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Connectez nanobot aux apps de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des tokens ou des réglages d'espace de travail.",
|
||||
"caption": "{{enabled}} activés · {{total}} canaux",
|
||||
"searchPlaceholder": "Rechercher des canaux",
|
||||
"backToChannels": "Tous les canaux",
|
||||
"catalog": "Canaux",
|
||||
"loading": "Chargement des canaux...",
|
||||
"empty": "Aucun canal ne correspond à ce filtre.",
|
||||
"restartRequired": "Redémarrez nanobot pour appliquer le support de canal mis à jour.",
|
||||
"requires": "Requiert : {{requirements}}",
|
||||
"setUp": "Configurer",
|
||||
"setupGuide": "Guide de configuration",
|
||||
"setupSummary": "L'activation n'active que le support nanobot. Ajoutez les identifiants de la plateforme, puis redémarrez nanobot.",
|
||||
"configKeys": "Clés de configuration",
|
||||
"enable": "Activer le canal",
|
||||
"disable": "Désactiver le canal",
|
||||
"needsConfig": "Configuration requise",
|
||||
"connect": "Connecter",
|
||||
"reconnect": "Reconnecter",
|
||||
"feishuQrAlt": "QR code de connexion Feishu",
|
||||
"feishuScanTitle": "Scanner avec Feishu",
|
||||
"feishuScanDescription": "Utilisez Feishu ou Lark sur votre téléphone pour scanner ce code. nanobot terminera la configuration automatiquement après l'autorisation.",
|
||||
"feishuWaiting": "En attente d'autorisation...",
|
||||
"feishuConnected": "Feishu est connecté.",
|
||||
"feishuConnectStopped": "Connexion arrêtée.",
|
||||
"feishuConnecting": "Connexion..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Gambar",
|
||||
"voice": "Suara",
|
||||
"browser": "Internet",
|
||||
"channels": "Kanal",
|
||||
"runtime": "Sistem",
|
||||
"advanced": "Keamanan",
|
||||
"cliApps": "Aplikasi CLI",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "Tidak ada",
|
||||
"testForTools": "Jalankan Uji untuk memeriksa dan memilih alat individual."
|
||||
},
|
||||
"api": {
|
||||
"title": "Server API", "openaiCompatible": "API kompatibel OpenAI", "description": "Hubungkan SDK dan agen melalui endpoint /v1 lokal.",
|
||||
"start": "Mulai server API", "starting": "Memulai...", "stop": "Hentikan", "stopping": "Menghentikan...",
|
||||
"access": "Akses", "thisDevice": "Perangkat ini", "localNetwork": "Jaringan lokal",
|
||||
"localHelp": "Hanya perangkat ini yang dapat terhubung.", "networkHelp": "Perangkat lain dapat terhubung; kunci API diperlukan.",
|
||||
"port": "Port", "portHelp": "API menggunakan port lokal ini.", "apiKey": "Kunci API", "apiKeyHelp": "Klien mengirimkannya sebagai token Bearer.",
|
||||
"apiKeyRequired": "Wajib sebelum membuka API ke jaringan.", "apiKeyPlaceholder": "Masukkan kunci API",
|
||||
"autoInstall": "Dukungan API akan dipasang otomatis saat dimulai."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.",
|
||||
"environment": "Atur LANGFUSE_SECRET_KEY dan LANGFUSE_PUBLIC_KEY, lalu mulai ulang nanobot.", "enable": "Aktifkan dukungan tracing"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Aktifkan plugin, adaptor aplikasi lokal, dan server alat terhubung.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||
"cliLabel": "Aplikasi",
|
||||
"mcpLabel": "Integrasi",
|
||||
"channelLabel": "Kanal",
|
||||
"featureLabel": "Fitur",
|
||||
"filterAll": "Semua",
|
||||
"filterAll": "Siap",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Aplikasi CLI",
|
||||
"filterMcp": "Layanan MCP",
|
||||
"enabledSummary": "{{count}} aktif",
|
||||
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Aplikasi",
|
||||
"filterMcp": "Integrasi",
|
||||
"enabledSummary": "{{count}} siap",
|
||||
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Katalog",
|
||||
"featured": "Alat",
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada aplikasi yang cocok.",
|
||||
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan workspace.",
|
||||
"caption": "{{enabled}} aktif · {{total}} kanal",
|
||||
"searchPlaceholder": "Cari kanal",
|
||||
"backToChannels": "Semua kanal",
|
||||
"catalog": "Kanal",
|
||||
"loading": "Memuat kanal...",
|
||||
"empty": "Tidak ada kanal yang cocok dengan filter ini.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan dukungan kanal yang diperbarui.",
|
||||
"requires": "Memerlukan: {{requirements}}",
|
||||
"setUp": "Siapkan",
|
||||
"setupGuide": "Panduan setup",
|
||||
"setupSummary": "Mengaktifkan hanya menyalakan dukungan nanobot. Tambahkan kredensial platform, lalu mulai ulang nanobot.",
|
||||
"configKeys": "Kunci konfigurasi",
|
||||
"enable": "Aktifkan kanal",
|
||||
"disable": "Nonaktifkan kanal",
|
||||
"needsConfig": "Perlu konfigurasi",
|
||||
"connect": "Hubungkan",
|
||||
"reconnect": "Hubungkan ulang",
|
||||
"feishuQrAlt": "Kode QR koneksi Feishu",
|
||||
"feishuScanTitle": "Pindai dengan Feishu",
|
||||
"feishuScanDescription": "Gunakan Feishu atau Lark di ponsel untuk memindai kode ini. nanobot akan menyelesaikan setup secara otomatis setelah otorisasi.",
|
||||
"feishuWaiting": "Menunggu otorisasi...",
|
||||
"feishuConnected": "Feishu sudah terhubung.",
|
||||
"feishuConnectStopped": "Koneksi dihentikan.",
|
||||
"feishuConnecting": "Menghubungkan..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Aktif",
|
||||
"enable": "Aktifkan",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "画像",
|
||||
"voice": "音声",
|
||||
"browser": "ウェブ",
|
||||
"channels": "チャンネル",
|
||||
"runtime": "システム",
|
||||
"advanced": "セキュリティ",
|
||||
"cliApps": "CLI アプリ",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "なし",
|
||||
"testForTools": "テストを実行して個別のツールを確認・選択します。"
|
||||
},
|
||||
"api": {
|
||||
"title": "API サーバー", "openaiCompatible": "OpenAI 互換 API", "description": "ローカルの /v1 エンドポイントから SDK やエージェントを接続します。",
|
||||
"start": "API サーバーを起動", "starting": "起動中...", "stop": "停止", "stopping": "停止中...",
|
||||
"access": "アクセス", "thisDevice": "このデバイス", "localNetwork": "ローカルネットワーク",
|
||||
"localHelp": "このデバイスだけが接続できます。", "networkHelp": "他のデバイスも接続できるため API キーが必要です。",
|
||||
"port": "ポート", "portHelp": "API が使用するローカルポートです。", "apiKey": "API キー", "apiKeyHelp": "クライアントは Bearer トークンとして送信します。",
|
||||
"apiKeyRequired": "ネットワークに公開する前に必要です。", "apiKeyPlaceholder": "API キーを入力",
|
||||
"autoInstall": "起動時に API サポートを自動インストールします。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。",
|
||||
"environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "プラグイン、ローカルアプリアダプター、接続済みツールサーバーを有効にします。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||
"cliLabel": "アプリ",
|
||||
"mcpLabel": "連携",
|
||||
"channelLabel": "チャンネル",
|
||||
"featureLabel": "機能",
|
||||
"filterAll": "すべて",
|
||||
"filterAll": "使用可能",
|
||||
"filterPlugins": "プラグイン",
|
||||
"filterCli": "CLI アプリ",
|
||||
"filterMcp": "MCP サービス",
|
||||
"enabledSummary": "{{count}} 件有効",
|
||||
"caption": "{{plugins}} 件のプラグイン · CLI {{cli}} 件 · MCP {{mcp}} 件",
|
||||
"filterCli": "アプリ",
|
||||
"filterMcp": "連携",
|
||||
"enabledSummary": "{{count}} 件使用可能",
|
||||
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "カタログ",
|
||||
"featured": "ツール",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "一致するアプリはありません。",
|
||||
"empty": "この表示に一致するツールはありません。",
|
||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
||||
"caption": "{{enabled}} 件有効 · 全 {{total}} チャンネル",
|
||||
"searchPlaceholder": "チャンネルを検索",
|
||||
"backToChannels": "すべてのチャンネル",
|
||||
"catalog": "チャンネル",
|
||||
"loading": "チャンネルを読み込み中...",
|
||||
"empty": "一致するチャンネルはありません。",
|
||||
"restartRequired": "更新したチャンネルサポートを反映するには nanobot を再起動してください。",
|
||||
"requires": "必要: {{requirements}}",
|
||||
"setUp": "設定",
|
||||
"setupGuide": "設定ガイド",
|
||||
"setupSummary": "有効化は nanobot 側のサポートをオンにするだけです。プラットフォームの認証情報を追加してから nanobot を再起動してください。",
|
||||
"configKeys": "設定キー",
|
||||
"enable": "チャンネルを有効化",
|
||||
"disable": "チャンネルを無効化",
|
||||
"needsConfig": "設定が必要",
|
||||
"connect": "接続",
|
||||
"reconnect": "再接続",
|
||||
"feishuQrAlt": "Feishu 接続 QR コード",
|
||||
"feishuScanTitle": "Feishu でスキャン",
|
||||
"feishuScanDescription": "スマートフォンの Feishu または Lark でこのコードをスキャンしてください。認可後、nanobot が自動で設定を完了します。",
|
||||
"feishuWaiting": "認可を待っています...",
|
||||
"feishuConnected": "Feishu に接続しました。",
|
||||
"feishuConnectStopped": "接続を停止しました。",
|
||||
"feishuConnecting": "接続中..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "有効",
|
||||
"enable": "有効化",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "이미지",
|
||||
"voice": "음성",
|
||||
"browser": "웹",
|
||||
"channels": "채널",
|
||||
"runtime": "시스템",
|
||||
"advanced": "보안",
|
||||
"cliApps": "CLI 앱",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "없음",
|
||||
"testForTools": "테스트를 실행해 개별 도구를 확인하고 선택하세요."
|
||||
},
|
||||
"api": {
|
||||
"title": "API 서버", "openaiCompatible": "OpenAI 호환 API", "description": "로컬 /v1 엔드포인트로 SDK와 에이전트를 연결합니다.",
|
||||
"start": "API 서버 시작", "starting": "시작 중...", "stop": "중지", "stopping": "중지 중...",
|
||||
"access": "접근", "thisDevice": "이 기기", "localNetwork": "로컬 네트워크",
|
||||
"localHelp": "이 기기만 연결할 수 있습니다.", "networkHelp": "다른 기기도 연결할 수 있으므로 API 키가 필요합니다.",
|
||||
"port": "포트", "portHelp": "API가 사용할 로컬 포트입니다.", "apiKey": "API 키", "apiKeyHelp": "클라이언트는 Bearer 토큰으로 전송합니다.",
|
||||
"apiKeyRequired": "네트워크에 공개하기 전에 필요합니다.", "apiKeyPlaceholder": "API 키 입력",
|
||||
"autoInstall": "시작할 때 API 지원을 자동으로 설치합니다."
|
||||
},
|
||||
"observability": {
|
||||
"title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.",
|
||||
"environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
|
||||
},
|
||||
"apps": {
|
||||
"description": "플러그인, 로컬 앱 어댑터, 연결된 도구 서버를 활성화합니다.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||
"cliLabel": "앱",
|
||||
"mcpLabel": "연동",
|
||||
"channelLabel": "채널",
|
||||
"featureLabel": "기능",
|
||||
"filterAll": "전체",
|
||||
"filterAll": "사용 가능",
|
||||
"filterPlugins": "플러그인",
|
||||
"filterCli": "CLI 앱",
|
||||
"filterMcp": "MCP 서비스",
|
||||
"enabledSummary": "{{count}}개 활성화됨",
|
||||
"caption": "플러그인 {{plugins}}개 · CLI {{cli}}개 · MCP {{mcp}}개",
|
||||
"filterCli": "앱",
|
||||
"filterMcp": "연동",
|
||||
"enabledSummary": "{{count}}개 사용 가능",
|
||||
"caption": "앱 {{cli}}개 · 연동 {{mcp}}개",
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "카탈로그",
|
||||
"featured": "도구",
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "일치하는 앱이 없습니다.",
|
||||
"empty": "이 보기에 일치하는 도구가 없습니다.",
|
||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
||||
},
|
||||
"channels": {
|
||||
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
||||
"caption": "{{enabled}}개 활성화됨 · 총 {{total}}개 채널",
|
||||
"searchPlaceholder": "채널 검색",
|
||||
"backToChannels": "모든 채널",
|
||||
"catalog": "채널",
|
||||
"loading": "채널을 불러오는 중...",
|
||||
"empty": "일치하는 채널이 없습니다.",
|
||||
"restartRequired": "업데이트된 채널 지원을 적용하려면 nanobot을 다시 시작하세요.",
|
||||
"requires": "필요: {{requirements}}",
|
||||
"setUp": "설정",
|
||||
"setupGuide": "설정 가이드",
|
||||
"setupSummary": "활성화는 nanobot 지원만 켭니다. 플랫폼 자격 증명을 추가한 뒤 nanobot을 다시 시작하세요.",
|
||||
"configKeys": "설정 키",
|
||||
"enable": "채널 활성화",
|
||||
"disable": "채널 비활성화",
|
||||
"needsConfig": "설정 필요",
|
||||
"connect": "연결",
|
||||
"reconnect": "다시 연결",
|
||||
"feishuQrAlt": "Feishu 연결 QR 코드",
|
||||
"feishuScanTitle": "Feishu로 스캔",
|
||||
"feishuScanDescription": "휴대폰의 Feishu 또는 Lark로 이 코드를 스캔하세요. 승인 후 nanobot이 자동으로 설정을 완료합니다.",
|
||||
"feishuWaiting": "승인을 기다리는 중...",
|
||||
"feishuConnected": "Feishu가 연결되었습니다.",
|
||||
"feishuConnectStopped": "연결이 중지되었습니다.",
|
||||
"feishuConnecting": "연결 중..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "활성화됨",
|
||||
"enable": "활성화",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Hình ảnh",
|
||||
"voice": "Giọng nói",
|
||||
"browser": "Trang web",
|
||||
"channels": "Kênh",
|
||||
"runtime": "Hệ thống",
|
||||
"advanced": "Bảo mật",
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "Không có",
|
||||
"testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
|
||||
},
|
||||
"api": {
|
||||
"title": "Máy chủ API", "openaiCompatible": "API tương thích OpenAI", "description": "Kết nối SDK và agent qua endpoint /v1 cục bộ.",
|
||||
"start": "Khởi động API", "starting": "Đang khởi động...", "stop": "Dừng", "stopping": "Đang dừng...",
|
||||
"access": "Truy cập", "thisDevice": "Thiết bị này", "localNetwork": "Mạng nội bộ",
|
||||
"localHelp": "Chỉ thiết bị này có thể kết nối.", "networkHelp": "Thiết bị khác có thể kết nối; cần khóa API.",
|
||||
"port": "Cổng", "portHelp": "API sử dụng cổng cục bộ này.", "apiKey": "Khóa API", "apiKeyHelp": "Client gửi khóa dưới dạng Bearer token.",
|
||||
"apiKeyRequired": "Bắt buộc trước khi mở API ra mạng.", "apiKeyPlaceholder": "Nhập khóa API",
|
||||
"autoInstall": "Hỗ trợ API sẽ tự động được cài khi khởi động."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Khả năng quan sát", "configured": "Thông tin xác thực tracing đã sẵn sàng cho nanobot.",
|
||||
"environment": "Đặt LANGFUSE_SECRET_KEY và LANGFUSE_PUBLIC_KEY rồi khởi động lại nanobot.", "enable": "Bật hỗ trợ tracing"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Bật plugin, bộ chuyển đổi ứng dụng cục bộ và máy chủ công cụ đã kết nối.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||
"cliLabel": "Ứng dụng",
|
||||
"mcpLabel": "Tích hợp",
|
||||
"channelLabel": "Kênh",
|
||||
"featureLabel": "Tính năng",
|
||||
"filterAll": "Tất cả",
|
||||
"filterAll": "Sẵn sàng",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Ứng dụng CLI",
|
||||
"filterMcp": "Dịch vụ MCP",
|
||||
"enabledSummary": "{{count}} đã bật",
|
||||
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Ứng dụng",
|
||||
"filterMcp": "Tích hợp",
|
||||
"enabledSummary": "{{count}} sẵn sàng",
|
||||
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Danh mục",
|
||||
"featured": "Công cụ",
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có ứng dụng phù hợp.",
|
||||
"empty": "Không có công cụ phù hợp với chế độ xem này.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình workspace.",
|
||||
"caption": "{{enabled}} đã bật · {{total}} kênh",
|
||||
"searchPlaceholder": "Tìm kênh",
|
||||
"backToChannels": "Tất cả kênh",
|
||||
"catalog": "Kênh",
|
||||
"loading": "Đang tải kênh...",
|
||||
"empty": "Không có kênh nào phù hợp.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng hỗ trợ kênh đã cập nhật.",
|
||||
"requires": "Yêu cầu: {{requirements}}",
|
||||
"setUp": "Thiết lập",
|
||||
"setupGuide": "Hướng dẫn thiết lập",
|
||||
"setupSummary": "Bật chỉ kích hoạt hỗ trợ của nanobot. Thêm thông tin xác thực nền tảng rồi khởi động lại nanobot.",
|
||||
"configKeys": "Khóa cấu hình",
|
||||
"enable": "Bật kênh",
|
||||
"disable": "Tắt kênh",
|
||||
"needsConfig": "Cần cấu hình",
|
||||
"connect": "Kết nối",
|
||||
"reconnect": "Kết nối lại",
|
||||
"feishuQrAlt": "Mã QR kết nối Feishu",
|
||||
"feishuScanTitle": "Quét bằng Feishu",
|
||||
"feishuScanDescription": "Dùng Feishu hoặc Lark trên điện thoại để quét mã này. nanobot sẽ tự hoàn tất cấu hình sau khi cấp quyền.",
|
||||
"feishuWaiting": "Đang chờ cấp quyền...",
|
||||
"feishuConnected": "Feishu đã kết nối.",
|
||||
"feishuConnectStopped": "Kết nối đã dừng.",
|
||||
"feishuConnecting": "Đang kết nối..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Đã bật",
|
||||
"enable": "Bật",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "图片",
|
||||
"voice": "语音",
|
||||
"browser": "网页",
|
||||
"channels": "渠道",
|
||||
"cliApps": "CLI 应用",
|
||||
"mcp": "MCP",
|
||||
"runtime": "系统",
|
||||
@@ -236,21 +237,21 @@
|
||||
"searchPlaceholder": "搜索 CLI",
|
||||
"loading": "正在加载 CLI 应用...",
|
||||
"empty": "没有匹配的 CLI 应用。",
|
||||
"statusInstalled": "CLI 已安装",
|
||||
"statusInstalled": "应用已就绪",
|
||||
"statusMissing": "缺失",
|
||||
"statusAvailable": "可用",
|
||||
"statusUnsupported": "暂不支持",
|
||||
"statusNotInstalled": "CLI 未安装",
|
||||
"statusNotInstalled": "应用未安装",
|
||||
"requires": "依赖",
|
||||
"test": "测试 CLI",
|
||||
"update": "更新 CLI",
|
||||
"uninstall": "卸载 CLI",
|
||||
"install": "安装 CLI",
|
||||
"test": "测试应用",
|
||||
"update": "更新应用",
|
||||
"uninstall": "卸载应用",
|
||||
"install": "安装应用",
|
||||
"readyTitle": "@{{name}} 已就绪",
|
||||
"readyStatus": "就绪",
|
||||
"readyTry": "试试 @{{name}}",
|
||||
"readyCopied": "已复制",
|
||||
"readyPrompt": "用 @{{name}} 看看这个 CLI 能做什么。",
|
||||
"readyPrompt": "让 nanobot 在这个任务中使用 @{{name}}。",
|
||||
"openChat": "回到对话",
|
||||
"unsupported": "暂不支持",
|
||||
"unavailable": "不可用",
|
||||
@@ -270,8 +271,8 @@
|
||||
"filterInstalled": "已启用",
|
||||
"filterNotInstalled": "未启用",
|
||||
"searchPlaceholder": "搜索 MCP 预设",
|
||||
"moreOptions": "更多 MCP 选项",
|
||||
"moreOptionsSubtitle": "添加自定义服务,或导入 mcp.json。",
|
||||
"moreOptions": "添加集成",
|
||||
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
|
||||
"customTitle": "自定义 MCP",
|
||||
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
|
||||
"customAction": "自定义",
|
||||
@@ -462,23 +463,77 @@
|
||||
"configureProvider": "配置提供商",
|
||||
"missingCredential": "启用图片生成前请先配置此提供商。"
|
||||
},
|
||||
"api": {
|
||||
"title": "API 服务",
|
||||
"openaiCompatible": "OpenAI 兼容 API",
|
||||
"description": "让 SDK 和其他 Agent 通过本地 /v1 接口连接 nanobot。",
|
||||
"start": "启动 API 服务",
|
||||
"starting": "正在启动...",
|
||||
"stop": "停止",
|
||||
"stopping": "正在停止...",
|
||||
"access": "访问范围",
|
||||
"thisDevice": "仅此设备",
|
||||
"localNetwork": "局域网",
|
||||
"localHelp": "只有当前设备可以连接。",
|
||||
"networkHelp": "局域网内其他设备可以连接,因此必须设置 API Key。",
|
||||
"port": "端口",
|
||||
"portHelp": "API 服务使用的本地端口。",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyHelp": "客户端使用 Bearer Token 发送此密钥。",
|
||||
"apiKeyRequired": "向局域网开放 API 前必须设置密钥。",
|
||||
"apiKeyPlaceholder": "输入 API Key",
|
||||
"autoInstall": "启动时会自动安装 API 支持。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可观测性",
|
||||
"configured": "nanobot 已检测到追踪凭证。",
|
||||
"environment": "设置 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 后重启 nanobot。",
|
||||
"enable": "启用追踪支持"
|
||||
},
|
||||
"apps": {
|
||||
"description": "启用插件、本地应用适配器和已连接的工具服务。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "把工具连接到 nanobot,然后在对话中 @ 使用。",
|
||||
"cliLabel": "应用",
|
||||
"mcpLabel": "集成",
|
||||
"channelLabel": "渠道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "全部",
|
||||
"filterAll": "可用",
|
||||
"filterPlugins": "插件",
|
||||
"filterCli": "CLI 应用",
|
||||
"filterMcp": "MCP 服务",
|
||||
"enabledSummary": "已启用 {{count}} 个",
|
||||
"caption": "{{plugins}} 个插件 · {{cli}} 个 CLI · {{mcp}} 个 MCP",
|
||||
"searchPlaceholder": "搜索应用",
|
||||
"featured": "应用目录",
|
||||
"filterCli": "应用",
|
||||
"filterMcp": "集成",
|
||||
"enabledSummary": "{{count}} 个可用",
|
||||
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
|
||||
"searchPlaceholder": "搜索工具",
|
||||
"featured": "工具",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有匹配的应用。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和能力。"
|
||||
"empty": "当前视图没有匹配的工具。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
|
||||
"caption": "{{enabled}} 个已启用 · 共 {{total}} 个渠道",
|
||||
"searchPlaceholder": "搜索渠道",
|
||||
"backToChannels": "所有渠道",
|
||||
"catalog": "渠道",
|
||||
"loading": "正在加载渠道...",
|
||||
"empty": "没有匹配的渠道。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的渠道支持。",
|
||||
"requires": "需要:{{requirements}}",
|
||||
"setUp": "设置",
|
||||
"setupGuide": "配置指南",
|
||||
"setupSummary": "启用只会打开 nanobot 的渠道支持。请补充平台凭据,然后重启 nanobot。",
|
||||
"configKeys": "配置字段",
|
||||
"enable": "启用渠道",
|
||||
"disable": "禁用渠道",
|
||||
"needsConfig": "需要配置",
|
||||
"connect": "连接",
|
||||
"reconnect": "重新连接",
|
||||
"feishuQrAlt": "飞书连接二维码",
|
||||
"feishuScanTitle": "使用飞书扫码",
|
||||
"feishuScanDescription": "用手机上的飞书或 Lark 扫描二维码。授权完成后,nanobot 会自动完成配置。",
|
||||
"feishuWaiting": "正在等待授权...",
|
||||
"feishuConnected": "飞书已连接。",
|
||||
"feishuConnectStopped": "连接已停止。",
|
||||
"feishuConnecting": "正在连接..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "已启用",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "圖片",
|
||||
"voice": "語音",
|
||||
"browser": "網頁",
|
||||
"channels": "渠道",
|
||||
"runtime": "系統",
|
||||
"advanced": "安全",
|
||||
"cliApps": "CLI 應用",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "無",
|
||||
"testForTools": "執行測試以檢查並選擇個別工具。"
|
||||
},
|
||||
"api": {
|
||||
"title": "API 服務", "openaiCompatible": "OpenAI 相容 API", "description": "讓 SDK 和其他 Agent 透過本機 /v1 介面連接 nanobot。",
|
||||
"start": "啟動 API 服務", "starting": "正在啟動...", "stop": "停止", "stopping": "正在停止...",
|
||||
"access": "存取範圍", "thisDevice": "僅此裝置", "localNetwork": "區域網路",
|
||||
"localHelp": "只有目前裝置可以連接。", "networkHelp": "區域網路內其他裝置可以連接,因此必須設定 API Key。",
|
||||
"port": "連接埠", "portHelp": "API 服務使用的本機連接埠。", "apiKey": "API Key", "apiKeyHelp": "用戶端使用 Bearer Token 傳送此金鑰。",
|
||||
"apiKeyRequired": "向區域網路開放 API 前必須設定金鑰。", "apiKeyPlaceholder": "輸入 API Key",
|
||||
"autoInstall": "啟動時會自動安裝 API 支援。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。",
|
||||
"environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "啟用插件、本機應用適配器和已連接的工具服務。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "把工具連接到 nanobot,然後在對話中使用 @。",
|
||||
"cliLabel": "應用",
|
||||
"mcpLabel": "整合",
|
||||
"channelLabel": "通道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "全部",
|
||||
"filterAll": "可用",
|
||||
"filterPlugins": "插件",
|
||||
"filterCli": "CLI 應用",
|
||||
"filterMcp": "MCP 服務",
|
||||
"enabledSummary": "已啟用 {{count}} 個",
|
||||
"caption": "{{plugins}} 個插件 · {{cli}} 個 CLI · {{mcp}} 個 MCP",
|
||||
"filterCli": "應用",
|
||||
"filterMcp": "整合",
|
||||
"enabledSummary": "{{count}} 個可用",
|
||||
"caption": "{{cli}} 個應用 · {{mcp}} 個整合",
|
||||
"searchPlaceholder": "搜尋應用",
|
||||
"featured": "應用目錄",
|
||||
"featured": "工具",
|
||||
"loading": "正在載入應用...",
|
||||
"empty": "沒有符合的應用。",
|
||||
"empty": "目前檢視沒有符合的工具。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用和能力。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "把聊天應用、郵箱和 WebUI 連接到 nanobot。",
|
||||
"caption": "{{enabled}} 個已啟用 · 共 {{total}} 個渠道",
|
||||
"searchPlaceholder": "搜尋渠道",
|
||||
"backToChannels": "所有渠道",
|
||||
"catalog": "渠道",
|
||||
"loading": "正在載入渠道...",
|
||||
"empty": "沒有符合的渠道。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的渠道支援。",
|
||||
"requires": "需要:{{requirements}}",
|
||||
"setUp": "設定",
|
||||
"setupGuide": "設定指南",
|
||||
"setupSummary": "啟用只會打開 nanobot 的渠道支援。請補充平台憑證,然後重新啟動 nanobot。",
|
||||
"configKeys": "設定欄位",
|
||||
"enable": "啟用渠道",
|
||||
"disable": "停用渠道",
|
||||
"needsConfig": "需要設定",
|
||||
"connect": "連接",
|
||||
"reconnect": "重新連接",
|
||||
"feishuQrAlt": "飛書連接二維碼",
|
||||
"feishuScanTitle": "使用飛書掃碼",
|
||||
"feishuScanDescription": "用手機上的飛書或 Lark 掃描二維碼。授權完成後,nanobot 會自動完成設定。",
|
||||
"feishuWaiting": "正在等待授權...",
|
||||
"feishuConnected": "飛書已連接。",
|
||||
"feishuConnectStopped": "連接已停止。",
|
||||
"feishuConnecting": "正在連接..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "已啟用",
|
||||
"enable": "啟用",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type {
|
||||
ApiServicePayload,
|
||||
AutomationsPayload,
|
||||
AutomationUpdatePayload,
|
||||
ChannelConfigurePayload,
|
||||
ChannelConnectPayload,
|
||||
ChannelValidationPayload,
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
FilePreviewPayload,
|
||||
@@ -10,6 +14,7 @@ import type {
|
||||
ModelConfigurationCreate,
|
||||
ModelConfigurationUpdate,
|
||||
NetworkSafetySettingsUpdate,
|
||||
PairingPayload,
|
||||
ProviderModelsPayload,
|
||||
ProviderSettingsUpdate,
|
||||
SessionDeleteResult,
|
||||
@@ -44,6 +49,8 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
||||
&& SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle)
|
||||
);
|
||||
}
|
||||
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
||||
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -386,13 +393,43 @@ export async function fetchNanobotFeatures(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
||||
return request<ApiServicePayload>(`${base}/api/settings/api-service`, token);
|
||||
}
|
||||
|
||||
export async function startApiService(
|
||||
token: string,
|
||||
values: { host: string; port: number; timeout: number; apiKey?: string },
|
||||
base: string = "",
|
||||
): Promise<ApiServicePayload> {
|
||||
const query = new URLSearchParams({
|
||||
host: values.host,
|
||||
port: String(values.port),
|
||||
timeout: String(values.timeout),
|
||||
});
|
||||
const headers = values.apiKey === undefined
|
||||
? undefined
|
||||
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
|
||||
return request<ApiServicePayload>(
|
||||
`${base}/api/settings/api-service/start?${query}`,
|
||||
token,
|
||||
{ headers },
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
||||
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
|
||||
}
|
||||
|
||||
export async function enableNanobotFeature(
|
||||
token: string,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/enable?${query}`,
|
||||
token,
|
||||
@@ -402,16 +439,138 @@ export async function enableNanobotFeature(
|
||||
export async function disableNanobotFeature(
|
||||
token: string,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/disable?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPairingRequests(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<PairingPayload> {
|
||||
return request<PairingPayload>(
|
||||
`${base}/api/settings/pairing`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPairingAction(
|
||||
token: string,
|
||||
action: "approve" | "deny",
|
||||
code: string,
|
||||
base: string = "",
|
||||
): Promise<PairingPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("code", code);
|
||||
return request<PairingPayload>(
|
||||
`${base}/api/settings/pairing/${action}?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startChannelConnect(
|
||||
token: string,
|
||||
channel: "feishu" | "weixin",
|
||||
options: {
|
||||
domain?: "feishu" | "lark";
|
||||
instanceId?: string;
|
||||
mode?: "replace" | "create";
|
||||
force?: boolean;
|
||||
} = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (options.domain) query.set("domain", options.domain);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
if (options.mode) query.set("mode", options.mode);
|
||||
if (options.force) query.set("force", "true");
|
||||
const suffix = query.toString();
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pollChannelConnect(
|
||||
token: string,
|
||||
channel: "feishu" | "weixin",
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelChannelConnect(
|
||||
token: string,
|
||||
channel: "feishu" | "weixin",
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureChannel(
|
||||
token: string,
|
||||
name: string,
|
||||
values: Record<string, string>,
|
||||
options: { enable?: boolean; instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConfigurePayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.enable !== undefined) query.set("enable", String(options.enable));
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelConfigurePayload>(
|
||||
`${base}/api/settings/channels/configure?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateChannel(
|
||||
token: string,
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelValidationPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelValidationPayload>(
|
||||
`${base}/api/settings/channels/validate?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCliAppAction(
|
||||
token: string,
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
let normalized = host.trim().toLowerCase();
|
||||
if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
|
||||
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||
normalized = normalized.slice(1, -1);
|
||||
}
|
||||
if (normalized === "localhost" || normalized === "::1") return true;
|
||||
|
||||
const octets = normalized.split(".");
|
||||
return (
|
||||
octets.length === 4 &&
|
||||
octets[0] === "127" &&
|
||||
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
|
||||
);
|
||||
}
|
||||
+132
-1
@@ -332,6 +332,7 @@ export interface RuntimeCapabilities {
|
||||
export interface ProviderModelInfo {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
owned_by?: string | null;
|
||||
context_window?: number | null;
|
||||
}
|
||||
@@ -345,7 +346,7 @@ export interface ProviderModelsPayload {
|
||||
| "not_configured"
|
||||
| "missing_api_base"
|
||||
| "error";
|
||||
catalog_kind: "official" | "catalog" | "local" | "custom" | "unsupported";
|
||||
catalog_kind: "builtin" | "official" | "catalog" | "local" | "custom" | "unsupported";
|
||||
models: ProviderModelInfo[];
|
||||
model_count: number;
|
||||
message?: string | null;
|
||||
@@ -399,6 +400,7 @@ export interface SettingsPayload {
|
||||
api_base?: string | null;
|
||||
default_api_base?: string | null;
|
||||
model_selectable?: boolean;
|
||||
model_catalog?: ProviderModelsPayload["catalog_kind"];
|
||||
api_type?: "auto" | "chat_completions" | "responses";
|
||||
oauth_account?: string | null;
|
||||
oauth_expires_at?: number | null;
|
||||
@@ -428,6 +430,17 @@ export interface SettingsPayload {
|
||||
use_jina_reader: boolean;
|
||||
};
|
||||
};
|
||||
api?: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeout: number;
|
||||
api_key_hint?: string | null;
|
||||
};
|
||||
observability?: {
|
||||
provider: "langfuse" | string;
|
||||
configured: boolean;
|
||||
base_url: string;
|
||||
};
|
||||
image_generation: {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
@@ -543,6 +556,26 @@ export interface SettingsPayload {
|
||||
version?: {
|
||||
current: string;
|
||||
};
|
||||
docs?: {
|
||||
version: string;
|
||||
base_url: string;
|
||||
chat_apps_url: string;
|
||||
latest_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiServicePayload {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
managed: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
timeout: number;
|
||||
api_key_hint?: string | null;
|
||||
endpoint: string;
|
||||
command: string;
|
||||
log_path?: string | null;
|
||||
last_action?: "started" | "stopped" | string;
|
||||
}
|
||||
|
||||
export interface AppPackageRef {
|
||||
@@ -638,6 +671,10 @@ export interface NanobotFeatureInfo {
|
||||
display_name: string;
|
||||
type: "channel" | "feature" | string;
|
||||
enabled: boolean;
|
||||
configured?: boolean;
|
||||
config_values?: Record<string, string>;
|
||||
configured_fields?: string[];
|
||||
instances?: NanobotChannelInstanceInfo[];
|
||||
installed: boolean;
|
||||
ready: boolean;
|
||||
status: "enabled" | "missing_dependency" | "not_enabled" | string;
|
||||
@@ -645,6 +682,19 @@ export interface NanobotFeatureInfo {
|
||||
requires_restart: boolean;
|
||||
}
|
||||
|
||||
export interface NanobotChannelInstanceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
domain?: "feishu" | "lark" | string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
app_id?: string;
|
||||
group_policy?: string;
|
||||
allow_from?: string[];
|
||||
}
|
||||
|
||||
export interface NanobotFeaturesPayload {
|
||||
features: NanobotFeatureInfo[];
|
||||
enabled_count: number;
|
||||
@@ -656,6 +706,64 @@ export interface NanobotFeaturesPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export type ChannelSetupStatus =
|
||||
| "connected"
|
||||
| "configured"
|
||||
| "needs_setup"
|
||||
| "invalid"
|
||||
| "unsupported"
|
||||
| string;
|
||||
|
||||
export type ChannelValidationCheckStatus = "pass" | "warn" | "fail" | "skipped" | string;
|
||||
|
||||
export interface ChannelValidationCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
status: ChannelValidationCheckStatus;
|
||||
message?: string;
|
||||
action_url?: string;
|
||||
}
|
||||
|
||||
export interface ChannelIdentity {
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
account?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface ChannelValidationPayload {
|
||||
name: string;
|
||||
status: ChannelSetupStatus;
|
||||
checks: ChannelValidationCheck[];
|
||||
identity?: ChannelIdentity;
|
||||
missing_fields: string[];
|
||||
can_enable: boolean;
|
||||
requires_restart: boolean;
|
||||
checked_at?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PairingRequestInfo {
|
||||
code: string;
|
||||
channel: string;
|
||||
sender_id: string;
|
||||
created_at_ms?: number | null;
|
||||
expires_at_ms?: number | null;
|
||||
expires_in_seconds?: number | null;
|
||||
}
|
||||
|
||||
export interface PairingPayload {
|
||||
requests: PairingRequestInfo[];
|
||||
last_action?: {
|
||||
ok: boolean;
|
||||
action: "approve" | "deny" | string;
|
||||
message: string;
|
||||
code?: string;
|
||||
channel?: string;
|
||||
sender_id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpPresetField {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -725,6 +833,29 @@ export interface McpPresetsPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export type ChannelConnectStatus = "pending" | "succeeded" | "expired" | "cancelled" | "failed";
|
||||
|
||||
export interface ChannelConnectPayload {
|
||||
session_id: string;
|
||||
instance_id?: string;
|
||||
status: ChannelConnectStatus;
|
||||
message?: string;
|
||||
qr_url?: string;
|
||||
domain?: string;
|
||||
interval_ms?: number;
|
||||
expires_at_ms?: number;
|
||||
app_id?: string;
|
||||
account?: string;
|
||||
nanobot_features?: NanobotFeaturesPayload;
|
||||
}
|
||||
|
||||
export interface ChannelConfigurePayload {
|
||||
name: string;
|
||||
saved: boolean;
|
||||
saved_keys?: string[];
|
||||
nanobot_features?: NanobotFeaturesPayload;
|
||||
}
|
||||
|
||||
export interface SettingsUpdate {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
configureChannel,
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchAutomations,
|
||||
fetchApiService,
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
@@ -28,6 +30,11 @@ import {
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
startApiService,
|
||||
stopApiService,
|
||||
cancelChannelConnect,
|
||||
pollChannelConnect,
|
||||
startChannelConnect,
|
||||
updateAutomation,
|
||||
updateSidebarState,
|
||||
updateImageGenerationSettings,
|
||||
@@ -37,6 +44,7 @@ import {
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateWebSearchSettings,
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
|
||||
describe("webui API helpers", () => {
|
||||
@@ -116,6 +124,82 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("validates channel settings with form values", async () => {
|
||||
await validateChannel(
|
||||
"tok",
|
||||
"slack",
|
||||
{ "channels.slack.botToken": "xoxb-test" },
|
||||
{ instanceId: "default" },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/validate?name=slack&instance_id=default",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.slack.botToken": "xoxb-test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("configures channels through the WebSocket HTTP shim", async () => {
|
||||
await configureChannel(
|
||||
"tok",
|
||||
"discord",
|
||||
{ "channels.discord.token": "saved-secret" },
|
||||
{ enable: true },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/configure?name=discord&enable=true",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.discord.token": "saved-secret",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes channel QR connect helpers", async () => {
|
||||
await startChannelConnect("tok", "weixin", { force: true });
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/start?force=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await pollChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await cancelChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes workspace automation actions", async () => {
|
||||
await runAutomationAction("tok", "disable", "job 1/2");
|
||||
|
||||
@@ -478,6 +562,44 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("manages the API service capability", async () => {
|
||||
await fetchApiService("tok");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
await startApiService(
|
||||
"tok",
|
||||
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-token"),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await stopApiService("tok");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/stop",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads MCP presets and serializes actions", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -249,6 +249,8 @@ describe("App layout", () => {
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
||||
localStorage.removeItem("nanobot-webui.restartRoute");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
api_token: "api-tok",
|
||||
@@ -343,6 +345,35 @@ describe("App layout", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
window.history.replaceState(null, "", "/#/new");
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/nanobot-features": {
|
||||
features: [{
|
||||
name: "websocket",
|
||||
display_name: "Websocket",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
}],
|
||||
enabled_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect((await screen.findAllByRole("heading", { name: "Channels" })).length).toBeGreaterThan(0);
|
||||
expect(window.location.hash).toBe("#/settings?section=channels");
|
||||
});
|
||||
|
||||
it("opens Skills from the main sidebar", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
@@ -1586,6 +1617,7 @@ describe("App layout", () => {
|
||||
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Files" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
|
||||
@@ -1708,6 +1740,16 @@ describe("App layout", () => {
|
||||
expect(window.location.hash).toBe("#/settings?section=voice");
|
||||
});
|
||||
|
||||
it("falls back to Overview for the retired Files settings URL", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
window.history.replaceState(null, "", "/#/settings?section=files");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the URL hash when switching settings sections", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog";
|
||||
|
||||
describe("Slack setup manifest", () => {
|
||||
it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])(
|
||||
"subscribes to %s",
|
||||
(event) => expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${event}`),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"app_mentions:read",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
"im:write",
|
||||
"mpim:history",
|
||||
"reactions:write",
|
||||
"users:read",
|
||||
])("requests the %s scope", (scope) => {
|
||||
expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${scope}`);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.sections.capabilities",
|
||||
"settings.sections.apps",
|
||||
"settings.apps.description",
|
||||
"settings.apps.filterPlugins",
|
||||
"settings.apps.caption",
|
||||
"settings.apps.restartRequired",
|
||||
"settings.nanobotFeatures.disable",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isLoopbackHost } from "@/lib/network";
|
||||
|
||||
describe("isLoopbackHost", () => {
|
||||
it.each(["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"])(
|
||||
"accepts explicit loopback host %s",
|
||||
(host) => expect(isLoopbackHost(host)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"])(
|
||||
"rejects network host %s",
|
||||
(host) => expect(isLoopbackHost(host)).toBe(false),
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
__clearLogoFallbackCacheForTests,
|
||||
useLogoFallback,
|
||||
} from "@/hooks/useLogoFallback";
|
||||
|
||||
function TestLogo({ urls }: { urls: string[] }) {
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(urls);
|
||||
if (!logoUrl) return <span>No logo</span>;
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Logo"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useLogoFallback", () => {
|
||||
afterEach(() => {
|
||||
__clearLogoFallbackCacheForTests();
|
||||
});
|
||||
|
||||
it("remembers failed and loaded logo candidates across remounts", () => {
|
||||
const urls = [
|
||||
"https://bad.example/favicon.ico",
|
||||
"https://good.example/favicon.ico",
|
||||
];
|
||||
const first = render(<TestLogo urls={urls} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[0]);
|
||||
|
||||
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
|
||||
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
|
||||
|
||||
fireEvent.load(screen.getByRole("img", { name: "Logo" }));
|
||||
first.unmount();
|
||||
render(<TestLogo urls={urls} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
|
||||
});
|
||||
|
||||
it("returns no logo once every candidate failed", () => {
|
||||
const urls = ["https://bad.example/favicon.ico"];
|
||||
render(<TestLogo urls={urls} />);
|
||||
|
||||
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
|
||||
|
||||
expect(screen.getByText("No logo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user