mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
568 lines
20 KiB
TypeScript
568 lines
20 KiB
TypeScript
import { useEffect, useMemo, useState, type ComponentType } from "react";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Clipboard,
|
|
Loader2,
|
|
Plus,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { channelUiContribution } from "@/channel-plugins/registry";
|
|
import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types";
|
|
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,
|
|
ChannelRuntimeError,
|
|
ChannelStatusBadge,
|
|
channelDescription,
|
|
channelRequirements,
|
|
channelSetup,
|
|
channelStatusLabel,
|
|
channelToggleChecked,
|
|
localizedChannelDisplayName,
|
|
} from "@/components/settings/channels/ChannelIdentity";
|
|
import {
|
|
ChannelProviderPresets,
|
|
ChannelSetupActions,
|
|
ChannelSetupLinks,
|
|
ChannelSetupSteps,
|
|
ChannelValidationBadge,
|
|
ChannelValidationChecks,
|
|
ChannelValidationDetails,
|
|
} from "@/components/settings/channels/ChannelSetupParts";
|
|
import { ChannelInstancesPanel } from "@/components/settings/channels/ChannelInstancesPanel";
|
|
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 });
|
|
const displayName = localizedChannelDisplayName(feature, t);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={t("settings.channels.selectChannel", {
|
|
name: displayName,
|
|
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 border-transparent px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
|
|
selected ? "bg-background" : "hover:bg-muted",
|
|
)}
|
|
>
|
|
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
|
{displayName}
|
|
</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 status={feature.runtime_status}>
|
|
{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, i18n } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const displayName = localizedChannelDisplayName(feature, t);
|
|
const [connectRequestId, setConnectRequestId] = useState(0);
|
|
const uiContribution = channelUiContribution(feature.name, feature.webui);
|
|
const PluginPanel = uiContribution?.Panel;
|
|
if (PluginPanel) {
|
|
return (
|
|
<PluginPanel
|
|
token={token}
|
|
feature={feature}
|
|
actionKey={actionKey}
|
|
showBrandLogos={showBrandLogos}
|
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
|
onAction={onAction}
|
|
onFeaturesUpdate={onFeaturesUpdate}
|
|
/>
|
|
);
|
|
}
|
|
if (feature.instances !== undefined) {
|
|
return (
|
|
<ChannelInstancesPanel
|
|
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 alwaysEnabled = feature.capabilities?.includes("always_enabled") ?? false;
|
|
const channelChecked = alwaysEnabled || channelToggleChecked(feature);
|
|
const channelBusy = enableBusy || disableBusy;
|
|
const setup = channelSetup(feature, i18n.resolvedLanguage ?? i18n.language);
|
|
const needsSetupBeforeEnable =
|
|
!channelChecked
|
|
&& feature.configured === false
|
|
&& !(uiContribution?.canConnectBeforeConfigured && setup.mode === "connect");
|
|
const channelToggleDisabled =
|
|
alwaysEnabled
|
|
|| channelBusy
|
|
|| needsSetupBeforeEnable
|
|
|| (!feature.install_supported && !feature.installed && !feature.enabled);
|
|
const installSupportLabel = tx("settings.nanobotFeatures.installSupport", "Install support");
|
|
const toggleAriaLabel = t("settings.channels.toggleChannel", {
|
|
name: displayName,
|
|
defaultValue: "{{name}} channel",
|
|
});
|
|
|
|
return (
|
|
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
|
|
<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">
|
|
{displayName}
|
|
</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 status={feature.runtime_status}>
|
|
{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 (
|
|
uiContribution?.canConnectBeforeConfigured
|
|
&& checked
|
|
&& !channelChecked
|
|
&& feature.configured === false
|
|
) {
|
|
setConnectRequestId((current) => current + 1);
|
|
return;
|
|
}
|
|
onAction(checked ? "enable" : "disable", feature.name);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ChannelRuntimeError message={feature.runtime_error} className="mt-4" />
|
|
|
|
<ChannelSetupSurface
|
|
token={token}
|
|
feature={feature}
|
|
setup={setup}
|
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
|
connectRequestId={connectRequestId}
|
|
ConnectFlow={uiContribution?.ConnectFlow}
|
|
onFeaturesUpdate={onFeaturesUpdate}
|
|
/>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function ChannelSetupSurface({
|
|
token,
|
|
feature,
|
|
setup,
|
|
chatAppsDocsUrl,
|
|
connectRequestId,
|
|
ConnectFlow,
|
|
onFeaturesUpdate,
|
|
}: {
|
|
token: string;
|
|
feature: NanobotFeatureInfo;
|
|
setup: ChannelSetupPresentation;
|
|
chatAppsDocsUrl?: string;
|
|
connectRequestId: number;
|
|
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
|
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 = 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 = channelToggleChecked(feature)
|
|
? tx("settings.channels.checkConnection", "Check connection")
|
|
: tx("settings.channels.checkAndEnable", "Check and enable");
|
|
|
|
return (
|
|
<form
|
|
className="mt-5 overflow-hidden rounded-[16px] bg-background/55"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (mode === "credentials") void saveCredentialSettings();
|
|
}}
|
|
>
|
|
<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" && ConnectFlow ? (
|
|
<ConnectFlow
|
|
token={token}
|
|
feature={feature}
|
|
idleLabel={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.",
|
|
),
|
|
)
|
|
}
|
|
>
|
|
{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
|
|
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="submit"
|
|
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"
|
|
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 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}
|
|
</form>
|
|
);
|
|
}
|