refactor(channels): make built-in channels self-contained (#4908)

* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
This commit is contained in:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
@@ -2,25 +2,121 @@ import { useMemo, type ReactNode } from "react";
import type { useTranslation } from "react-i18next";
import {
CHANNEL_PRESENTATION,
type ChannelSetupPresentation,
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 { NanobotFeatureInfo } from "@/lib/types";
import type { ChannelRuntimeStatus, NanobotFeatureInfo } from "@/lib/types";
export type ChannelFilter = "all" | "on" | "off";
export function channelSetup(feature: NanobotFeatureInfo): ChannelSetupPresentation {
return CHANNEL_PRESENTATION[feature.name]?.setup ?? {
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:
"Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.",
steps: [
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({
@@ -30,7 +126,7 @@ export function ChannelLogo({
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
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;
@@ -80,55 +176,108 @@ export function ChannelLogo({
}
export function channelDisplayName(feature: NanobotFeatureInfo): string {
return CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name;
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 =
CHANNEL_PRESENTATION[feature.name]?.description ??
`Use nanobot from ${channelDisplayName(feature)}.`;
return t(`settings.channels.items.${feature.name}.description`, { defaultValue: fallback });
return channelTranslator(t, channelUiOwner(feature.name))("description", 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 });
return channelTranslator(t, channelUiOwner(feature.name))("requirements", fallback);
}
export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean {
if (filter === "on") return feature.enabled;
if (filter === "off") return !feature.enabled;
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.enabled) return tx("settings.values.on", "On");
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): string {
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,
CHANNEL_PRESENTATION[feature.name]?.description,
CHANNEL_PRESENTATION[feature.name]?.requirements,
t ? channelDescription(feature, t) : undefined,
t ? channelRequirements(feature, t) : undefined,
]
.join(" ")
.toLowerCase();
}
export function ChannelStatusBadge({ children }: { children: ReactNode }) {
export function ChannelStatusBadge({
children,
status,
}: {
children: ReactNode;
status?: ChannelRuntimeStatus;
}) {
return (
<span className="shrink-0 rounded-full bg-muted/75 px-2 py-0.5 text-[11px] font-medium leading-4 text-muted-foreground">
<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-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}>
{message}
</div>
);
}
@@ -1,12 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ChevronDown, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { ToggleButton } from "@/components/settings/ToggleButton";
import {
CHANNEL_PRESENTATION,
type ChannelConfigField,
} from "@/components/settings/channels/catalog";
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
import {
CredentialForm,
channelValidationStatusClass,
@@ -16,12 +14,12 @@ import {
} from "@/components/settings/channels/CredentialForm";
import {
ChannelLogo,
ChannelRuntimeError,
ChannelStatusBadge,
channelDisplayName,
channelSetup,
channelStatusLabel,
localizedChannelDisplayName,
} from "@/components/settings/channels/ChannelIdentity";
import { FeishuConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
import {
ChannelGuideLink,
ChannelSetupSteps,
@@ -41,34 +39,61 @@ import type {
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function FeishuAssistantsPanel({
export type ChannelInstancesPanelCustomization = {
countLabel?: (runningCount: number) => string;
toggleAriaLabel?: (instance: NanobotChannelInstanceInfo) => string;
configuredLabel?: string;
needsSetupLabel?: string;
renderInstanceSummary?: (instance: NanobotChannelInstanceInfo) => ReactNode;
renderInstanceAction?: (instance: NanobotChannelInstanceInfo) => ReactNode;
footer?: ReactNode;
};
export function ChannelInstancesPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
instances: providedInstances,
onFeaturesUpdate,
customization = {},
}: {
token: string;
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
chatAppsDocsUrl?: string;
instances?: NanobotChannelInstanceInfo[];
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
customization?: ChannelInstancesPanelCustomization;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const instances = feishuFeatureInstances(feature);
const displayName = localizedChannelDisplayName(feature, t);
const instances = providedInstances ?? feature.instances ?? [];
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 setup = useMemo(
() => channelSetup(feature, i18n.resolvedLanguage ?? i18n.language),
[feature.name, feature.setup, i18n.language, i18n.resolvedLanguage],
);
const instanceFields = useMemo(
() => channelInstanceFields(feature, setup.fields, setup.manualFields),
[feature, setup.fields, setup.manualFields],
);
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
feishuInstanceFieldValues(manualFields, selected),
defaultChannelFieldValues(instanceFields, selected?.config_values),
);
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
const [savingFields, setSavingFields] = useState(false);
const connectedAssistantCount = instances.filter((instance) => instance.configured).length;
const configuredCount = instances.filter((instance) => instance.configured).length;
const runningCount = instances.filter((instance) => instance.runtime_status === "running").length;
const selectedValuesKey = JSON.stringify(selected?.config_values ?? {});
const selectedConfiguredFields = useMemo(
() => new Set(selected?.configured_fields ?? []),
[selected?.configured_fields],
);
useEffect(() => {
if (selectedId && !instances.some((instance) => instance.id === selectedId)) {
@@ -77,37 +102,17 @@ export function FeishuAssistantsPanel({
}, [instances, selectedId]);
useEffect(() => {
setFieldValues(feishuInstanceFieldValues(manualFields, selected));
setFieldValues(defaultChannelFieldValues(instanceFields, selected?.config_values));
setVisibleSecrets({});
}, [
manualFields,
selected?.allow_from,
selected?.app_id,
selected?.domain,
selected?.group_policy,
selected?.id,
]);
}, [instanceFields, selected?.id, selectedValuesKey]);
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 });
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
@@ -123,8 +128,8 @@ export function FeishuAssistantsPanel({
try {
const payload = await configureChannel(
token,
"feishu",
channelValuesForSave(manualFields, fieldValues),
feature.name,
channelValuesForSave(instanceFields, fieldValues),
{ enable: selected.enabled, instanceId: selected.id },
);
if (payload.nanobot_features) {
@@ -145,16 +150,26 @@ export function FeishuAssistantsPanel({
<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)}
{displayName}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{feishuAssistantCountLabel(connectedAssistantCount, tx)}
{customization.countLabel?.(runningCount)
?? t("settings.channels.configuredInstances", {
count: configuredCount,
defaultValue: `${configuredCount} instances configured`,
})}
</p>
</div>
</div>
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
<div className="flex shrink-0 items-center gap-2">
<ChannelStatusBadge status={feature.runtime_status}>
{channelStatusLabel(feature, tx)}
</ChannelStatusBadge>
</div>
</div>
<ChannelRuntimeError message={feature.runtime_error} />
<div className="mt-5 space-y-3">
{instances.map((instance) => {
const expanded = selected?.id === instance.id;
@@ -177,14 +192,13 @@ export function FeishuAssistantsPanel({
}
aria-expanded={expanded}
>
<FeishuAssistantAvatar
<ChannelInstanceAvatar
feature={feature}
instance={instance}
showBrandLogos={showBrandLogos}
size="lg"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
{feishuInstanceDisplayName(instance)}
{channelInstanceDisplayName(instance)}
</span>
<ChevronDown
className={cn(
@@ -199,13 +213,17 @@ export function FeishuAssistantsPanel({
<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")}
checked={instanceToggleChecked(instance)}
disabled={
busyInstanceId === instance.id
|| !instance.configured
}
ariaLabel={customization.toggleAriaLabel?.(instance)
?? t("settings.channels.toggleInstance", {
name: channelInstanceDisplayName(instance),
defaultValue: "{{name}} instance",
})}
label={instanceToggleChecked(instance) ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
onChange={(checked) => void toggleInstance(instance, checked)}
/>
</div>
@@ -216,41 +234,17 @@ export function FeishuAssistantsPanel({
<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")}
{customization.renderInstanceSummary?.(instance) ?? instance.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}
<ChannelInstanceStatusBadge
instance={instance}
configuredLabel={customization.configuredLabel}
needsSetupLabel={customization.needsSetupLabel}
/>
)}
</div>
{customization.renderInstanceAction?.(instance)}
</section>
<ChannelSetupSteps
featureName={feature.name}
steps={setup.steps}
action={
<ChannelGuideLink
@@ -261,7 +255,7 @@ export function FeishuAssistantsPanel({
/>
}
/>
{manualFields.length ? (
{instanceFields.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">
@@ -272,10 +266,17 @@ export function FeishuAssistantsPanel({
/>
</span>
</summary>
<div className="mt-3">
<form
className="mt-3"
onSubmit={(event) => {
event.preventDefault();
void saveSelectedInstanceSettings();
}}
>
<CredentialForm
fields={manualFields}
fields={instanceFields}
values={fieldValues}
configuredFields={selectedConfiguredFields}
visibleSecrets={visibleSecrets}
onChange={(key, value) =>
setFieldValues((current) => ({ ...current, [key]: value }))
@@ -287,11 +288,10 @@ export function FeishuAssistantsPanel({
/>
<div className="mt-3 flex justify-end">
<Button
type="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"
onClick={() => void saveSelectedInstanceSettings()}
disabled={savingFields}
>
{savingFields ? (
@@ -300,7 +300,7 @@ export function FeishuAssistantsPanel({
{tx("settings.channels.saveSettings", "Save settings")}
</Button>
</div>
</div>
</form>
</details>
) : null}
</div>
@@ -310,25 +310,7 @@ export function FeishuAssistantsPanel({
})}
</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>
{customization.footer}
{notice ? (
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
@@ -339,45 +321,44 @@ export function FeishuAssistantsPanel({
);
}
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 {
function channelInstanceDisplayName(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";
return instance.id;
}
function FeishuAssistantConnectionBadge({ instance }: { instance: NanobotChannelInstanceInfo }) {
function instanceToggleChecked(instance: NanobotChannelInstanceInfo): boolean {
return instance.runtime_status === "running" || instance.runtime_status === "starting";
}
function ChannelInstanceStatusBadge({
instance,
configuredLabel,
needsSetupLabel,
}: {
instance: NanobotChannelInstanceInfo;
configuredLabel?: string;
needsSetupLabel?: string;
}) {
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" });
let status = instance.configured ? "configured" : "needs_setup";
let label = instance.configured
? t("settings.channels.instanceConfigured", { defaultValue: "Configured" })
: needsSetupLabel ?? t("settings.channels.instanceNeedsSetup", { defaultValue: "Needs setup" });
if (instance.runtime_status === "failed") {
status = "invalid";
label = t("settings.channels.runtimeFailed", { defaultValue: "Failed" });
} else if (instance.runtime_status === "starting") {
label = t("settings.channels.runtimeStarting", { defaultValue: "Starting" });
} else if (instance.enabled && instance.runtime_status !== "running") {
label = t("settings.channels.runtimeStopped", { defaultValue: "Not running" });
} else if (instance.runtime_status === "running") {
status = "connected";
label = configuredLabel
?? t("settings.channels.validation.connected", { defaultValue: "Connected" });
}
return (
<span
className={cn(
@@ -391,18 +372,16 @@ function FeishuAssistantConnectionBadge({ instance }: { instance: NanobotChannel
);
}
function FeishuAssistantAvatar({
function ChannelInstanceAvatar({
feature,
instance,
showBrandLogos,
size,
}: {
feature: NanobotFeatureInfo;
instance: NanobotChannelInstanceInfo;
showBrandLogos: boolean;
size: "sm" | "lg";
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const presentation = channelUiPresentation(feature.name, feature.webui);
const [avatarFailed, setAvatarFailed] = useState(false);
const fallbackLogoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(fallbackLogoUrls);
@@ -411,9 +390,6 @@ function FeishuAssistantAvatar({
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);
@@ -421,10 +397,7 @@ function FeishuAssistantAvatar({
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,
)}
className="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background text-[10px] font-bold"
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
aria-hidden
>
@@ -443,12 +416,12 @@ function FeishuAssistantAvatar({
alt=""
decoding="async"
loading="lazy"
className={cn("object-contain", fallbackImageClass)}
className="h-6 w-6 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : Icon ? (
<Icon className={iconClass} strokeWidth={2.25} />
<Icon className="h-5 w-5" strokeWidth={2.25} />
) : (
initials
)}
@@ -456,23 +429,17 @@ function FeishuAssistantAvatar({
);
}
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;
function channelInstanceFields(
feature: NanobotFeatureInfo,
fields: ChannelConfigField[] | undefined,
manualFields: ChannelConfigField[] | undefined,
): ChannelConfigField[] {
const available = new Map(
[...(fields ?? []), ...(manualFields ?? [])].map((field) => [field.key, field]),
);
if (!feature.setup) return [...available.values()];
return feature.setup.fields.flatMap((field) => {
const resolved = available.get(field.key);
return resolved ? [resolved] : [];
});
}
@@ -26,25 +26,29 @@ export type ChannelQrConnectLabels = {
connect: string;
};
export type ChannelConnectStartOptions = {
domain?: string;
instanceId?: string;
mode?: "replace" | "create";
force?: boolean;
};
export function ChannelQrConnectFlow({
token,
channelName,
startOptions = {},
idleLabel,
connectRequestId,
forceOnRepeat = false,
labels,
onFeaturesUpdate,
}: {
token: string;
channelName: "feishu" | "weixin";
startOptions?: {
domain?: "feishu" | "lark";
instanceId?: string;
mode?: "replace" | "create";
force?: boolean;
};
channelName: string;
startOptions?: ChannelConnectStartOptions;
idleLabel?: string;
connectRequestId?: number;
forceOnRepeat?: boolean;
labels: ChannelQrConnectLabels;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
@@ -232,7 +236,7 @@ export function ChannelQrConnectFlow({
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)}
onClick={() => void start(forceOnRepeat && succeeded)}
disabled={!canStart}
>
{busy ? (
@@ -252,83 +256,3 @@ export function ChannelQrConnectFlow({
</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"),
}}
/>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ComponentType } from "react";
import {
Check,
ChevronDown,
@@ -9,6 +9,8 @@ import {
} 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,
@@ -21,17 +23,15 @@ import {
} from "@/components/settings/channels/CredentialForm";
import {
ChannelLogo,
ChannelRuntimeError,
ChannelStatusBadge,
channelDescription,
channelDisplayName,
channelRequirements,
channelSetup,
channelStatusLabel,
channelToggleChecked,
localizedChannelDisplayName,
} from "@/components/settings/channels/ChannelIdentity";
import {
FeishuConnectFlow,
WeixinConnectFlow,
} from "@/components/settings/channels/ChannelQrConnectFlow";
import {
ChannelProviderPresets,
ChannelSetupActions,
@@ -41,7 +41,7 @@ import {
ChannelValidationChecks,
ChannelValidationDetails,
} from "@/components/settings/channels/ChannelSetupParts";
import { FeishuAssistantsPanel } from "@/components/settings/channels/FeishuAssistantsPanel";
import { ChannelInstancesPanel } from "@/components/settings/channels/ChannelInstancesPanel";
import { Button } from "@/components/ui/button";
import {
configureChannel,
@@ -68,12 +68,13 @@ export function ChannelCatalogRow({
}) {
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: channelDisplayName(feature),
name: displayName,
defaultValue: "View {{name}} settings",
})}
aria-pressed={selected}
@@ -88,14 +89,16 @@ export function ChannelCatalogRow({
<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)}
{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>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
<ChannelStatusBadge status={feature.runtime_status}>
{channelStatusLabel(feature, tx)}
</ChannelStatusBadge>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
@@ -125,12 +128,28 @@ export function ChannelSetupPanel({
onAction: (action: "enable" | "disable", name: string) => void;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const displayName = localizedChannelDisplayName(feature, t);
const [connectRequestId, setConnectRequestId] = useState(0);
if (feature.name === "feishu") {
const uiContribution = channelUiContribution(feature.name, feature.webui);
const PluginPanel = uiContribution?.Panel;
if (PluginPanel) {
return (
<FeishuAssistantsPanel
<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}
@@ -142,22 +161,22 @@ export function ChannelSetupPanel({
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 alwaysEnabled = feature.capabilities?.includes("always_enabled") ?? false;
const channelChecked = alwaysEnabled || channelToggleChecked(feature);
const channelBusy = enableBusy || disableBusy;
const setup = channelSetup(feature);
const setup = channelSetup(feature, i18n.resolvedLanguage ?? i18n.language);
const needsSetupBeforeEnable =
!channelChecked
&& feature.configured === false
&& !(feature.name === "weixin" && setup.mode === "connect");
&& !(uiContribution?.canConnectBeforeConfigured && setup.mode === "connect");
const channelToggleDisabled =
requiredWebui
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: channelDisplayName(feature),
name: displayName,
defaultValue: "{{name}} channel",
});
@@ -168,7 +187,7 @@ export function ChannelSetupPanel({
<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)}
{displayName}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{channelDescription(feature, t)}
@@ -193,7 +212,9 @@ export function ChannelSetupPanel({
</div>
</div>
<div className="flex shrink-0 items-center gap-2 pt-1">
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
<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}
@@ -204,7 +225,7 @@ export function ChannelSetupPanel({
label={channelChecked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
onChange={(checked) => {
if (
feature.name === "weixin"
uiContribution?.canConnectBeforeConfigured
&& checked
&& !channelChecked
&& feature.configured === false
@@ -218,12 +239,15 @@ export function ChannelSetupPanel({
</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>
@@ -236,6 +260,7 @@ function ChannelSetupSurface({
setup,
chatAppsDocsUrl,
connectRequestId,
ConnectFlow,
onFeaturesUpdate,
}: {
token: string;
@@ -243,6 +268,7 @@ function ChannelSetupSurface({
setup: ChannelSetupPresentation;
chatAppsDocsUrl?: string;
connectRequestId: number;
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
@@ -268,14 +294,10 @@ function ChannelSetupSurface({
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 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),
);
@@ -370,12 +392,18 @@ function ChannelSetupSurface({
}
};
const primaryActionLabel = feature.enabled
const primaryActionLabel = channelToggleChecked(feature)
? 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">
<form
className="mt-5 overflow-hidden rounded-[16px] border border-border/70 bg-background shadow-none"
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">
@@ -404,18 +432,11 @@ function ChannelSetupSurface({
<ChannelSetupLinks feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} />
<ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} />
{mode === "connect" && feature.name === "feishu" ? (
<FeishuConnectFlow
{mode === "connect" && ConnectFlow ? (
<ConnectFlow
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"),
})}
feature={feature}
idleLabel={setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect")}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
@@ -436,9 +457,7 @@ function ChannelSetupSurface({
)
}
>
{t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
})}
{setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect")}
</Button>
{setup.command ? (
<Button
@@ -463,7 +482,6 @@ function ChannelSetupSurface({
<>
{setup.presets?.length ? (
<ChannelProviderPresets
featureName={feature.name}
presets={setup.presets}
onApply={applyPreset}
/>
@@ -480,11 +498,10 @@ function ChannelSetupSurface({
) : null}
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button
type="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"
onClick={() => void saveCredentialSettings()}
disabled={saving}
>
{saving || validating ? (
@@ -519,7 +536,7 @@ function ChannelSetupSurface({
) : null}
{setup.steps.length ? (
<ChannelSetupSteps featureName={feature.name} steps={setup.steps} tryIt={setup.tryIt} />
<ChannelSetupSteps steps={setup.steps} tryIt={setup.tryIt} />
) : null}
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
@@ -547,6 +564,6 @@ function ChannelSetupSurface({
) : null}
</details>
) : null}
</div>
</form>
);
}
@@ -2,9 +2,9 @@ import { useMemo, useState, type ReactNode } from "react";
import { Clipboard, ExternalLink, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Button } from "@/components/ui/button";
import {
CHANNEL_PRESENTATION,
docsUrlWithBase,
type ChannelProviderPreset,
type ChannelSetupPresentation,
@@ -38,7 +38,7 @@ export function ChannelGuideLink({
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const presentation = CHANNEL_PRESENTATION[feature.name];
const presentation = channelUiPresentation(feature.name, feature.webui);
const logoUrls = useMemo(
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
[presentation?.logoUrl, setup.docsLogoUrl],
@@ -88,9 +88,7 @@ export function ChannelGuideLink({
)}
</span>
<span className="truncate">
{t(`settings.channels.items.${feature.name}.setup.docsLabel`, {
defaultValue: setup.docsLabel ?? tx("settings.channels.officialGuide", "Official guide"),
})}
{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>
@@ -121,13 +119,14 @@ export function ChannelOfficialLink({
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const presentation = channelUiPresentation(feature.name, feature.webui);
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 label = setup.officialLabel;
if (!setup.officialUrl || !label) return null;
@@ -155,7 +154,9 @@ export function ChannelOfficialLink({
/>
) : Icon ? (
<Icon className="h-3 w-3" strokeWidth={2.25} />
) : null}
) : (
<span className="text-[8px] font-bold">{initials}</span>
)}
</span>
<span className="truncate">{label}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
@@ -206,18 +207,16 @@ export function ChannelSetupActions({
</Button>
))}
<span className="sr-only">
{CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name}
{channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name}
</span>
</div>
);
}
export function ChannelProviderPresets({
featureName,
presets,
onApply,
}: {
featureName: string;
presets: ChannelProviderPreset[];
onApply: (preset: ChannelProviderPreset) => void;
}) {
@@ -227,15 +226,11 @@ export function ChannelProviderPresets({
return (
<div className="mt-3">
<div className="mb-1 text-[11px] font-medium text-foreground/85">
{t(`settings.channels.items.${featureName}.providerPreset`, {
defaultValue: "Provider",
})}
{t("settings.channels.providerPreset", { defaultValue: "Provider" })}
</div>
<div
role="radiogroup"
aria-label={t(`settings.channels.items.${featureName}.providerPreset`, {
defaultValue: "Provider",
})}
aria-label={t("settings.channels.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))` }}
>
@@ -344,12 +339,10 @@ export function ChannelValidationChecks({ validation }: { validation: ChannelVal
}
export function ChannelSetupSteps({
featureName,
steps,
action,
tryIt,
}: {
featureName: string;
steps: string[];
action?: ReactNode;
tryIt?: string;
@@ -370,11 +363,7 @@ export function ChannelSetupSteps({
<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>
<span>{step}</span>
</li>
))}
</ol>
@@ -384,7 +373,7 @@ export function ChannelSetupSteps({
{tx("settings.channels.tryIt", "Try it")}
</span>
<span className="ml-2">
{t(`settings.channels.items.${featureName}.setup.tryIt`, { defaultValue: tryIt })}
{tryIt}
</span>
</div>
) : null}
@@ -190,6 +190,7 @@ export function CredentialForm({
<Input
aria-label={field.label}
type={inputType}
autoComplete={field.secret ? "off" : undefined}
inputMode={field.inputType === "number" ? "numeric" : undefined}
placeholder={
savedSecret
File diff suppressed because it is too large Load Diff