mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-03 01:31:47 +03:00
283 lines
9.3 KiB
TypeScript
283 lines
9.3 KiB
TypeScript
import { useMemo, type ReactNode } from "react";
|
|
import type { useTranslation } from "react-i18next";
|
|
|
|
import {
|
|
channelFieldMessageKey,
|
|
channelTranslator,
|
|
} from "@/channel-plugins/i18n";
|
|
import { channelLocaleMessages } from "@/channel-plugins/locale-registry";
|
|
import {
|
|
channelUiOwner,
|
|
channelUiPresentation,
|
|
} from "@/channel-plugins/registry";
|
|
import type {
|
|
ChannelConfigField,
|
|
ChannelSetupPresentation,
|
|
} from "@/components/settings/channels/catalog";
|
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
|
import { normalizeLocale } from "@/i18n/config";
|
|
import { logoFallbackUrls } from "@/lib/provider-brand";
|
|
import type { ChannelRuntimeStatus, NanobotFeatureInfo } from "@/lib/types";
|
|
|
|
export type ChannelFilter = "all" | "on" | "off";
|
|
|
|
export function channelSetup(
|
|
feature: NanobotFeatureInfo,
|
|
locale = "en",
|
|
): ChannelSetupPresentation {
|
|
const definition = channelUiPresentation(feature.name, feature.webui)?.setup;
|
|
const owner = channelUiOwner(feature.name);
|
|
const messages = channelLocaleMessages(owner, normalizeLocale(locale));
|
|
const setupMessages = messages?.setup;
|
|
const localizeField = (key: string): ChannelConfigField => {
|
|
const copy = setupMessages?.fields?.[channelFieldMessageKey(feature.name, key)];
|
|
return {
|
|
key,
|
|
label: copy?.label ?? fieldLabel(key.split(".").at(-1) ?? key),
|
|
placeholder: copy?.placeholder,
|
|
help: copy?.help,
|
|
};
|
|
};
|
|
const presentation: ChannelSetupPresentation = {
|
|
...definition,
|
|
primaryActionLabel: setupMessages?.primaryAction,
|
|
docsLabel: setupMessages?.docsLabel,
|
|
officialLabel: setupMessages?.officialLabel,
|
|
summary:
|
|
setupMessages?.summary
|
|
?? "Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.",
|
|
tryIt: setupMessages?.tryIt,
|
|
steps: setupMessages?.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.",
|
|
],
|
|
fields: definition?.fields?.map((field) => localizeField(field.key)),
|
|
manualFields: definition?.manualFields?.map((field) => localizeField(field.key)),
|
|
actions: definition?.actions?.map((action) => ({
|
|
...action,
|
|
label: setupMessages?.actions?.[action.id] ?? fieldLabel(action.id),
|
|
})),
|
|
presets: definition?.presets?.map((preset) => ({
|
|
...preset,
|
|
label: setupMessages?.presets?.[preset.id] ?? fieldLabel(preset.id),
|
|
})),
|
|
};
|
|
const contract = feature.setup;
|
|
if (!contract) return presentation;
|
|
|
|
const primaryFields = new Map(
|
|
(presentation.fields ?? []).map((field) => [field.key, field]),
|
|
);
|
|
const manualFields = new Map(
|
|
(presentation.manualFields ?? []).map((field) => [field.key, field]),
|
|
);
|
|
const authoritativeFields = contract.fields.map((field): ChannelConfigField => {
|
|
const local = primaryFields.get(field.key) ?? manualFields.get(field.key);
|
|
const copy = local ?? localizeField(field.key);
|
|
const choiceLabels = setupMessages?.fields?.[
|
|
channelFieldMessageKey(feature.name, field.key)
|
|
]?.choices ?? {};
|
|
const choices = field.kind === "bool" ? ["true", "false"] : field.choices;
|
|
return {
|
|
...copy,
|
|
key: field.key,
|
|
label: copy.label,
|
|
secret: field.kind === "secret",
|
|
optional: !field.required,
|
|
inputType: field.kind === "int" ? "number" : undefined,
|
|
defaultValue: field.default_value,
|
|
options:
|
|
field.kind === "enum" || field.kind === "bool"
|
|
? choices.map((choice) => ({
|
|
value: choice,
|
|
label: choiceLabels[choice] ?? fieldLabel(choice),
|
|
}))
|
|
: undefined,
|
|
};
|
|
});
|
|
const manualKeys = new Set(manualFields.keys());
|
|
const fields = authoritativeFields.filter((field) => !manualKeys.has(field.key));
|
|
const manual = authoritativeFields.filter((field) => manualKeys.has(field.key));
|
|
|
|
return {
|
|
...presentation,
|
|
officialUrl: contract.official_url,
|
|
officialLabel:
|
|
presentation.officialLabel
|
|
?? (contract.official_url ? "Open official setup" : undefined),
|
|
fields: fields.length ? fields : undefined,
|
|
manualFields: manual.length ? manual : undefined,
|
|
};
|
|
}
|
|
|
|
function fieldLabel(value: string): string {
|
|
const spaced = value
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
.replace(/[_-]+/g, " ")
|
|
.trim();
|
|
return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : value;
|
|
}
|
|
|
|
export function ChannelLogo({
|
|
feature,
|
|
showBrandLogos,
|
|
}: {
|
|
feature: NanobotFeatureInfo;
|
|
showBrandLogos: boolean;
|
|
}) {
|
|
const presentation = channelUiPresentation(feature.name, feature.webui);
|
|
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-control bg-background"
|
|
>
|
|
<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-control bg-background"
|
|
style={{ color }}
|
|
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-control bg-background text-[11px] font-bold"
|
|
style={{ color }}
|
|
aria-hidden
|
|
>
|
|
{initials}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function channelDisplayName(feature: NanobotFeatureInfo): string {
|
|
return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name;
|
|
}
|
|
|
|
export function localizedChannelDisplayName(
|
|
feature: NanobotFeatureInfo,
|
|
t: ReturnType<typeof useTranslation>["t"],
|
|
): string {
|
|
const fallback = channelDisplayName(feature);
|
|
return channelTranslator(t, channelUiOwner(feature.name))("displayName", fallback);
|
|
}
|
|
|
|
export function channelDescription(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
|
|
const fallback =
|
|
`Use nanobot from ${channelDisplayName(feature)}.`;
|
|
return channelTranslator(t, channelUiOwner(feature.name))("description", fallback);
|
|
}
|
|
|
|
export function channelRequirements(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
|
|
const fallback =
|
|
"Channel credentials and gateway settings";
|
|
return channelTranslator(t, channelUiOwner(feature.name))("requirements", fallback);
|
|
}
|
|
|
|
export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean {
|
|
if (filter === "on") return channelIsRunning(feature);
|
|
if (filter === "off") return !channelIsRunning(feature);
|
|
return true;
|
|
}
|
|
|
|
export function channelIsRunning(feature: NanobotFeatureInfo): boolean {
|
|
return feature.runtime_status === "running";
|
|
}
|
|
|
|
export function channelToggleChecked(feature: NanobotFeatureInfo): boolean {
|
|
return feature.runtime_status === "running" || feature.runtime_status === "starting";
|
|
}
|
|
|
|
export function channelStatusLabel(
|
|
feature: NanobotFeatureInfo,
|
|
tx: (key: string, fallback: string) => string,
|
|
): string {
|
|
if (feature.runtime_status === "failed") {
|
|
return tx("settings.channels.runtimeFailed", "Failed");
|
|
}
|
|
if (feature.runtime_status === "starting") {
|
|
return tx("settings.channels.runtimeStarting", "Starting");
|
|
}
|
|
if (channelIsRunning(feature)) return tx("settings.values.on", "On");
|
|
if (feature.enabled) return tx("settings.channels.runtimeStopped", "Not running");
|
|
return tx("settings.values.off", "Off");
|
|
}
|
|
|
|
export function channelSearchText(
|
|
feature: NanobotFeatureInfo,
|
|
t?: ReturnType<typeof useTranslation>["t"],
|
|
): string {
|
|
return [
|
|
t ? localizedChannelDisplayName(feature, t) : undefined,
|
|
channelDisplayName(feature),
|
|
feature.display_name,
|
|
feature.name,
|
|
feature.status,
|
|
t ? channelDescription(feature, t) : undefined,
|
|
t ? channelRequirements(feature, t) : undefined,
|
|
]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
|
|
export function ChannelStatusBadge({
|
|
children,
|
|
status,
|
|
}: {
|
|
children: ReactNode;
|
|
status?: ChannelRuntimeStatus;
|
|
}) {
|
|
return (
|
|
<span className={[
|
|
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-4",
|
|
status === "failed"
|
|
? "bg-destructive/10 text-destructive"
|
|
: status === "running"
|
|
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200"
|
|
: "bg-muted/75 text-muted-foreground",
|
|
].join(" ")}>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ChannelRuntimeError({
|
|
message,
|
|
className = "mt-3",
|
|
}: {
|
|
message?: string;
|
|
className?: string;
|
|
}) {
|
|
if (!message) return null;
|
|
return (
|
|
<div className={`${className} rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}>
|
|
{message}
|
|
</div>
|
|
);
|
|
}
|