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
+15 -106
View File
@@ -8,6 +8,7 @@ import {
} from "react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
@@ -95,106 +96,6 @@ 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",
@@ -624,11 +525,9 @@ function PairingCodePopup({
}
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 presentation = pairingChannelPresentation(channel);
const initials = presentation.initials;
const color = presentation.color;
const logoUrls = useMemo(
() => logoFallbackUrls(presentation?.logoUrl),
[presentation?.logoUrl],
@@ -765,8 +664,18 @@ function pairingChannelKey(channel: string): string {
}
function channelLabel(channel: string): string {
return pairingChannelPresentation(channel).label;
}
function pairingChannelPresentation(channel: string) {
const key = pairingChannelKey(channel);
return PAIRING_CHANNEL_PRESENTATION[key]?.label ?? channel;
const plugin = channelUiPresentation(key);
return {
label: plugin?.displayName ?? channel,
initials: plugin?.initials ?? channel.slice(0, 2).toUpperCase(),
color: plugin?.color ?? "#10B981",
logoUrl: plugin?.logoUrl,
};
}
function formatPairingExpiry(seconds: number | null | undefined): string {
+51
View File
@@ -0,0 +1,51 @@
import type { TFunction } from "i18next";
export type ChannelFieldMessages = {
label: string;
placeholder?: string;
help?: string;
choices?: Record<string, string>;
};
export type ChannelMessages = {
displayName?: string;
description: string;
requirements: string;
setup: {
primaryAction?: string;
docsLabel?: string;
officialLabel?: string;
summary?: string;
tryIt?: string;
steps: string[];
fields?: Record<string, ChannelFieldMessages>;
actions?: Record<string, string>;
presets?: Record<string, string>;
};
custom?: Record<string, string>;
};
export type ChannelTranslator = (
key: string,
fallback: string,
values?: Record<string, unknown>,
) => string;
export function channelNamespace(channel: string): string {
return `channel-${channel}`;
}
export function channelTranslator(t: TFunction, channel: string): ChannelTranslator {
const namespace = channelNamespace(channel);
return (key, fallback, values = {}) => t(key, {
ns: namespace,
defaultValue: fallback,
...values,
});
}
export function channelFieldMessageKey(channel: string, configKey: string): string {
const prefix = `channels.${channel}.`;
const field = configKey.startsWith(prefix) ? configKey.slice(prefix.length) : configKey;
return field.replace(/[^A-Za-z0-9_-]+/g, "_");
}
@@ -0,0 +1,65 @@
import type { ChannelMessages } from "@/channel-plugins/i18n";
import { channelNamespace } from "@/channel-plugins/i18n";
import {
supportedLocales,
type SupportedLocale,
} from "@/i18n/config";
type ChannelMessagesModule = {
default?: ChannelMessages;
};
const modules = import.meta.glob<ChannelMessagesModule>(
"../../../nanobot/channels/*/webui/locales/*.json",
{ eager: true },
);
const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>();
const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code));
for (const [modulePath, module] of Object.entries(modules)) {
const messages = module.default;
if (!messages) continue;
const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/);
if (!match) {
throw new Error(`Cannot derive channel locale identity from '${modulePath}'`);
}
const [, channel, locale] = match;
if (!supportedLocaleCodes.has(locale)) {
throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`);
}
const translations = translationsByChannel.get(channel) ?? new Map();
if (translations.has(locale as SupportedLocale)) {
throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`);
}
translations.set(locale as SupportedLocale, messages);
translationsByChannel.set(channel, translations);
}
export function channelLocaleNamespaces(): string[] {
return [...translationsByChannel.keys()].map(channelNamespace);
}
export function channelLocaleResources(locale: SupportedLocale): Record<string, unknown> {
return Object.fromEntries(
[...translationsByChannel.keys()].map((channel) => [
channelNamespace(channel),
channelLocaleMessages(channel, locale) ?? {},
]),
);
}
export function channelLocaleMessages(
channel: string,
locale: SupportedLocale,
): ChannelMessages | undefined {
const translations = translationsByChannel.get(channel);
return translations?.get(locale) ?? translations?.get("en");
}
export function registeredChannelLocales(): ReadonlyMap<
string,
ReadonlyMap<SupportedLocale, ChannelMessages>
> {
return translationsByChannel;
}
+83
View File
@@ -0,0 +1,83 @@
import type {
ChannelUiContribution,
RegisteredChannelUiContribution,
} from "@/channel-plugins/types";
type ChannelUiContributionModule = {
default?: ChannelUiContribution;
};
const modules = import.meta.glob<ChannelUiContributionModule>(
"../../../nanobot/channels/*/webui/**/*.{ts,tsx}",
{
eager: true,
},
);
const registrations = new Map<string, RegisteredChannelUiContribution>();
const registrationsByChannel = new Map<string, RegisteredChannelUiContribution>();
const presentationsByChannel = new Map<string, ChannelUiContribution["presentation"]>();
const translationOwners = new Map<string, string>();
for (const [modulePath, module] of Object.entries(modules)) {
const contribution = module.default;
if (!contribution) continue;
const match = modulePath.match(/nanobot\/channels\/([^/]+)\/(.+)$/);
if (!match) {
throw new Error(`Cannot derive channel UI identity from '${modulePath}'`);
}
const [, channel, webui] = match;
const registration = { channel, webui, contribution };
if (registrationsByChannel.has(channel)) {
throw new Error(`Channel '${channel}' has more than one UI contribution`);
}
registrations.set(registrationKey(channel, webui), registration);
registrationsByChannel.set(channel, registration);
presentationsByChannel.set(channel, contribution.presentation);
translationOwners.set(channel, channel);
for (const [alias, aliasPresentation] of Object.entries(contribution.aliases ?? {})) {
if (presentationsByChannel.has(alias)) {
throw new Error(`Channel UI alias '${alias}' is registered more than once`);
}
presentationsByChannel.set(alias, {
...contribution.presentation,
...aliasPresentation,
});
translationOwners.set(alias, channel);
}
}
export function channelUiContribution(
channel: string,
webui: string | undefined,
): ChannelUiContribution | undefined {
if (!webui) return undefined;
return registrations.get(registrationKey(channel, webui))?.contribution;
}
export function registeredChannelUiContributions(): readonly RegisteredChannelUiContribution[] {
return [...registrations.values()];
}
export function channelUiOwner(channel: string): string {
return translationOwners.get(channel) ?? channel;
}
export function channelUiPresentation(
channel: string,
): ChannelUiContribution["presentation"] | undefined;
export function channelUiPresentation(
channel: string,
webui: string | undefined,
): ChannelUiContribution["presentation"] | undefined;
export function channelUiPresentation(
channel: string,
webui?: string,
): ChannelUiContribution["presentation"] | undefined {
if (arguments.length > 1) return channelUiContribution(channel, webui)?.presentation;
return presentationsByChannel.get(channel);
}
function registrationKey(channel: string, webui: string): string {
return `${channel}:${webui.replaceAll("\\", "/")}`;
}
+39
View File
@@ -0,0 +1,39 @@
import type { ComponentType } from "react";
import type { ChannelPresentation } from "@/components/settings/channels/catalog";
import type {
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
export type ChannelPluginPanelProps = {
token: string;
feature: NanobotFeatureInfo;
actionKey: string | null;
chatAppsDocsUrl?: string;
showBrandLogos: boolean;
onAction: (action: "enable" | "disable", name: string) => void;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
};
export type ChannelPluginConnectFlowProps = {
token: string;
feature: NanobotFeatureInfo;
idleLabel?: string;
connectRequestId?: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
};
export type ChannelUiContribution = {
presentation: ChannelPresentation;
aliases?: Record<string, Partial<ChannelPresentation>>;
Panel?: ComponentType<ChannelPluginPanelProps>;
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
canConnectBeforeConfigured?: boolean;
};
export type RegisteredChannelUiContribution = {
channel: string;
webui: string;
contribution: ChannelUiContribution;
};
+42 -33
View File
@@ -61,14 +61,16 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton";
import {
channelDisplayName,
channelIsRunning,
channelMatchesFilter,
channelSearchText,
localizedChannelDisplayName,
type ChannelFilter,
} from "@/components/settings/channels/ChannelIdentity";
import {
@@ -746,23 +748,37 @@ export function SettingsView({
useEffect(() => {
if (!["channels", "models", "browser", "runtime"].includes(activeSection)) return;
let cancelled = false;
setNanobotFeaturesLoading(true);
fetchNanobotFeatures(token)
.then((payload) => {
const refresh = async (showLoading = false) => {
if (showLoading) setNanobotFeaturesLoading(true);
try {
const payload = await fetchNanobotFeatures(token);
if (!cancelled) {
setNanobotFeatures(payload);
setNanobotFeaturesError(null);
}
})
.catch((err) => {
} catch (err) {
const message = (err as Error).message;
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
})
.finally(() => {
if (!cancelled) setNanobotFeaturesLoading(false);
});
} finally {
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
}
};
void refresh(true);
const interval = activeSection === "channels"
? window.setInterval(() => void refresh(false), 5000)
: null;
const refreshOnFocus = () => {
if (activeSection === "channels" && document.visibilityState !== "hidden") {
void refresh(false);
}
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
if (interval !== null) window.clearInterval(interval);
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [activeSection, token]);
@@ -4890,22 +4906,9 @@ const AUTOMATION_SEARCH_FIELDS = new Set<AutomationSearchField>([
"status",
]);
const AUTOMATION_CHANNEL_LABELS: Record<string, string> = {
const HOST_AUTOMATION_CHANNEL_LABELS: Record<string, string> = {
api: "API",
cli: "CLI",
dingtalk: "DingTalk",
discord: "Discord",
email: "Email",
feishu: "Feishu",
matrix: "Matrix",
msteams: "Microsoft Teams",
qq: "QQ",
slack: "Slack",
telegram: "Telegram",
wechat: "WeChat",
wecom: "WeCom",
weixin: "WeChat",
whatsapp: "WhatsApp",
};
function parseAutomationSearchQuery(query: string): AutomationSearchToken[] {
@@ -4974,7 +4977,7 @@ function automationOriginSearchParts(job: SessionAutomationJob): Array<string |
origin.title,
origin.preview,
origin.channel,
AUTOMATION_CHANNEL_LABELS[channel],
automationChannelDisplayName(channel),
];
}
@@ -5111,11 +5114,17 @@ function automationChannelLabel(
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
const key = channel.trim().toLowerCase();
return AUTOMATION_CHANNEL_LABELS[key]
? tx(`settings.automations.channels.${key}`, AUTOMATION_CHANNEL_LABELS[key])
const displayName = automationChannelDisplayName(key);
return displayName
? tx(`settings.automations.channels.${key}`, displayName)
: channel;
}
function automationChannelDisplayName(channel: string): string | undefined {
const key = channel.trim().toLowerCase();
return channelUiPresentation(key)?.displayName ?? HOST_AUTOMATION_CHANNEL_LABELS[key];
}
function formatAutomationSchedule(
job: SessionAutomationJob,
locale: string,
@@ -5331,8 +5340,6 @@ function RestartRequiredNotice({
);
}
const HIDDEN_WEBUI_CHANNELS = new Set(["mochat"]);
function ChannelsSettings({
token,
nanobotFeatures,
@@ -5376,17 +5383,19 @@ function ChannelsSettings({
const [compactDetailOpen, setCompactDetailOpen] = useState(false);
const allChannels = (nanobotFeatures?.features ?? [])
.filter((feature) => feature.type === "channel")
.filter((feature) => !HIDDEN_WEBUI_CHANNELS.has(feature.name))
.filter((feature) => !normalizedQuery || channelSearchText(feature).includes(normalizedQuery))
.filter((feature) => feature.settings_visible !== false)
.filter((feature) => !normalizedQuery || channelSearchText(feature, t).includes(normalizedQuery))
.sort((left, right) => {
const rank = Number(!left.ready) - Number(!right.ready);
return rank || channelDisplayName(left).localeCompare(channelDisplayName(right));
return rank || localizedChannelDisplayName(left, t).localeCompare(
localizedChannelDisplayName(right, t),
);
});
const channels = allChannels.filter((feature) => channelMatchesFilter(feature, filter));
const [selectedChannelName, setSelectedChannelName] = useState<string | null>(null);
const selectedChannel =
channels.find((feature) => feature.name === selectedChannelName) ?? channels[0] ?? null;
const enabledCount = allChannels.filter((feature) => feature.enabled).length;
const enabledCount = allChannels.filter(channelIsRunning).length;
const offCount = Math.max(0, allChannels.length - enabledCount);
const filterOptions: Array<{ value: ChannelFilter; label: string; count: number }> = [
{ value: "all", label: tx("settings.channels.filterAll", "All"), count: allChannels.length },
@@ -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
+16 -11
View File
@@ -1,6 +1,11 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import {
channelLocaleNamespaces,
channelLocaleResources,
} from "@/channel-plugins/locale-registry";
import {
applyDocumentLocale,
defaultLocale,
@@ -24,16 +29,16 @@ import viCommon from "./locales/vi/common.json";
import idCommon from "./locales/id/common.json";
export const resources = {
en: { common: enCommon },
"zh-CN": { common: zhCNCommon },
"zh-TW": { common: zhTWCommon },
fr: { common: frCommon },
ja: { common: jaCommon },
ko: { common: koCommon },
es: { common: esCommon },
"pt-BR": { common: ptBRCommon },
vi: { common: viCommon },
id: { common: idCommon },
en: { common: enCommon, ...channelLocaleResources("en") },
"zh-CN": { common: zhCNCommon, ...channelLocaleResources("zh-CN") },
"zh-TW": { common: zhTWCommon, ...channelLocaleResources("zh-TW") },
fr: { common: frCommon, ...channelLocaleResources("fr") },
ja: { common: jaCommon, ...channelLocaleResources("ja") },
ko: { common: koCommon, ...channelLocaleResources("ko") },
es: { common: esCommon, ...channelLocaleResources("es") },
"pt-BR": { common: ptBRCommon, ...channelLocaleResources("pt-BR") },
vi: { common: viCommon, ...channelLocaleResources("vi") },
id: { common: idCommon, ...channelLocaleResources("id") },
} as const;
export function currentLocale(): SupportedLocale {
@@ -52,7 +57,7 @@ if (!i18n.isInitialized) {
lng: resolveInitialLocale(),
fallbackLng: fallbackLocale,
defaultNS: "common",
ns: ["common"],
ns: ["common", ...channelLocaleNamespaces()],
interpolation: {
escapeValue: false,
},
+46 -8
View File
@@ -510,7 +510,7 @@
},
"channels": {
"description": "Connect chat apps, email, and WebUI to nanobot.",
"caption": "{{enabled}} enabled · {{total}} channels",
"caption": "{{enabled}} running · {{total}} channels",
"searchPlaceholder": "Search channels",
"backToChannels": "All channels",
"catalog": "Channels",
@@ -527,13 +527,51 @@
"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..."
"advanced": "Advanced",
"checkAndEnable": "Check and enable",
"checkConnection": "Check connection",
"checkedAndEnabled": "Checked and enabled.",
"checking": "Checking...",
"checkOnly": "Check only",
"commandCopied": "Command copied.",
"commandCopyFailed": "Could not copy command.",
"configuredInstances": "{{count}} instances configured",
"connectPreview": "The in-browser connect flow is next. For now, run the command below.",
"copyCommand": "Copy command",
"filterAll": "All",
"filterOff": "Not running",
"filterOn": "Running",
"helperCopied": "{{name}} copied.",
"helperCopyFailed": "Could not copy {{name}}.",
"hideSecret": "Hide secret",
"instanceConfigured": "Configured",
"instanceNeedsSetup": "Needs setup",
"runtimeFailed": "Failed",
"runtimeStarting": "Starting",
"runtimeStopped": "Not running",
"managedByWebui": "Managed by WebUI",
"officialGuide": "Official guide",
"optional": "Optional",
"providerPreset": "Provider",
"requiredSetup": "Required setup",
"savedSecret": "Saved",
"savedSecretPlaceholder": "Saved secret",
"savedSettings": "Saved settings.",
"saveSettings": "Save settings",
"selectChannel": "View {{name}} settings",
"setupSteps": "Next steps",
"showSecret": "Show secret",
"toggleChannel": "{{name}} channel",
"toggleInstance": "{{name}} instance",
"tryIt": "Try it",
"validationFailed": "Check the required setup before enabling.",
"validation": {
"connected": "Connected",
"configured": "Configured manually",
"needs_setup": "Needs setup",
"invalid": "Invalid",
"unsupported": "Manual setup"
}
},
"nanobotFeatures": {
"enabled": "Enabled",
+45 -7
View File
@@ -514,13 +514,51 @@
"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..."
"advanced": "Avanzado",
"checkAndEnable": "Comprobar y activar",
"checkConnection": "Comprobar conexión",
"checkedAndEnabled": "Comprobado y activado.",
"checking": "Comprobando...",
"checkOnly": "Solo comprobar",
"commandCopied": "Comando copiado.",
"commandCopyFailed": "No se pudo copiar el comando.",
"configuredInstances": "{{count}} instancias configuradas",
"connectPreview": "El siguiente paso es la conexión en el navegador. Por ahora, ejecuta el comando siguiente.",
"copyCommand": "Copiar comando",
"filterAll": "Todos",
"filterOff": "Desactivados",
"filterOn": "Activados",
"helperCopied": "{{name}} copiado.",
"helperCopyFailed": "No se pudo copiar {{name}}.",
"hideSecret": "Ocultar secreto",
"instanceConfigured": "Configurada",
"instanceNeedsSetup": "Requiere configuración",
"runtimeFailed": "Error al iniciar",
"runtimeStarting": "Iniciando",
"runtimeStopped": "No está en ejecución",
"managedByWebui": "Gestionado por WebUI",
"officialGuide": "Guía oficial",
"optional": "Opcional",
"providerPreset": "Proveedor",
"requiredSetup": "Configuración requerida",
"savedSecret": "Guardado",
"savedSecretPlaceholder": "Secreto guardado",
"savedSettings": "Configuración guardada.",
"saveSettings": "Guardar configuración",
"selectChannel": "Ver la configuración de {{name}}",
"setupSteps": "Siguientes pasos",
"showSecret": "Mostrar secreto",
"toggleChannel": "Canal {{name}}",
"toggleInstance": "Instancia {{name}}",
"tryIt": "Pruébalo",
"validationFailed": "Comprueba la configuración requerida antes de activar.",
"validation": {
"connected": "Conectado",
"configured": "Configurado manualmente",
"needs_setup": "Requiere configuración",
"invalid": "No válido",
"unsupported": "Configuración manual"
}
},
"nanobotFeatures": {
"enabled": "Activado",
+45 -7
View File
@@ -513,13 +513,51 @@
"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..."
"advanced": "Avancé",
"checkAndEnable": "Vérifier et activer",
"checkConnection": "Vérifier la connexion",
"checkedAndEnabled": "Vérifié et activé.",
"checking": "Vérification...",
"checkOnly": "Vérifier uniquement",
"commandCopied": "Commande copiée.",
"commandCopyFailed": "Impossible de copier la commande.",
"configuredInstances": "{{count}} instances configurées",
"connectPreview": "La connexion dans le navigateur est la prochaine étape. Pour l'instant, exécutez la commande ci-dessous.",
"copyCommand": "Copier la commande",
"filterAll": "Tous",
"filterOff": "Désactivés",
"filterOn": "Activés",
"helperCopied": "{{name}} copié.",
"helperCopyFailed": "Impossible de copier {{name}}.",
"hideSecret": "Masquer le secret",
"instanceConfigured": "Configurée",
"instanceNeedsSetup": "Configuration requise",
"runtimeFailed": "Échec du démarrage",
"runtimeStarting": "Démarrage",
"runtimeStopped": "À l'arrêt",
"managedByWebui": "Géré par la WebUI",
"officialGuide": "Guide officiel",
"optional": "Facultatif",
"providerPreset": "Fournisseur",
"requiredSetup": "Configuration requise",
"savedSecret": "Enregistré",
"savedSecretPlaceholder": "Secret enregistré",
"savedSettings": "Paramètres enregistrés.",
"saveSettings": "Enregistrer les paramètres",
"selectChannel": "Afficher les paramètres de {{name}}",
"setupSteps": "Étapes suivantes",
"showSecret": "Afficher le secret",
"toggleChannel": "Canal {{name}}",
"toggleInstance": "Instance {{name}}",
"tryIt": "Essayer",
"validationFailed": "Vérifiez la configuration requise avant l'activation.",
"validation": {
"connected": "Connecté",
"configured": "Configuré manuellement",
"needs_setup": "Configuration requise",
"invalid": "Non valide",
"unsupported": "Configuration manuelle"
}
},
"nanobotFeatures": {
"enabled": "Activé",
+45 -7
View File
@@ -513,13 +513,51 @@
"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..."
"advanced": "Lanjutan",
"checkAndEnable": "Periksa dan aktifkan",
"checkConnection": "Periksa koneksi",
"checkedAndEnabled": "Sudah diperiksa dan diaktifkan.",
"checking": "Memeriksa...",
"checkOnly": "Periksa saja",
"commandCopied": "Perintah disalin.",
"commandCopyFailed": "Tidak dapat menyalin perintah.",
"configuredInstances": "{{count}} instans dikonfigurasi",
"connectPreview": "Langkah berikutnya adalah koneksi di browser. Untuk saat ini, jalankan perintah di bawah.",
"copyCommand": "Salin perintah",
"filterAll": "Semua",
"filterOff": "Nonaktif",
"filterOn": "Aktif",
"helperCopied": "{{name}} disalin.",
"helperCopyFailed": "Tidak dapat menyalin {{name}}.",
"hideSecret": "Sembunyikan rahasia",
"instanceConfigured": "Dikonfigurasi",
"instanceNeedsSetup": "Perlu penyiapan",
"runtimeFailed": "Gagal dimulai",
"runtimeStarting": "Memulai",
"runtimeStopped": "Tidak berjalan",
"managedByWebui": "Dikelola oleh WebUI",
"officialGuide": "Panduan resmi",
"optional": "Opsional",
"providerPreset": "Penyedia",
"requiredSetup": "Penyiapan wajib",
"savedSecret": "Tersimpan",
"savedSecretPlaceholder": "Rahasia tersimpan",
"savedSettings": "Pengaturan disimpan.",
"saveSettings": "Simpan pengaturan",
"selectChannel": "Lihat pengaturan {{name}}",
"setupSteps": "Langkah berikutnya",
"showSecret": "Tampilkan rahasia",
"toggleChannel": "Kanal {{name}}",
"toggleInstance": "Instans {{name}}",
"tryIt": "Coba",
"validationFailed": "Periksa penyiapan wajib sebelum mengaktifkan.",
"validation": {
"connected": "Terhubung",
"configured": "Dikonfigurasi manual",
"needs_setup": "Perlu penyiapan",
"invalid": "Tidak valid",
"unsupported": "Penyiapan manual"
}
},
"nanobotFeatures": {
"enabled": "Aktif",
+45 -7
View File
@@ -513,13 +513,51 @@
"needsConfig": "設定が必要",
"connect": "接続",
"reconnect": "再接続",
"feishuQrAlt": "Feishu 接続 QR コード",
"feishuScanTitle": "Feishu でスキャン",
"feishuScanDescription": "スマートフォンの Feishu または Lark でこのコードをスキャンしてください。認可後、nanobot が自動で設定を完了します。",
"feishuWaiting": "認可を待っています...",
"feishuConnected": "Feishu に接続しました。",
"feishuConnectStopped": "接続を停止しました。",
"feishuConnecting": "接続中..."
"advanced": "詳細設定",
"checkAndEnable": "確認して有効化",
"checkConnection": "接続を確認",
"checkedAndEnabled": "確認して有効化しました。",
"checking": "確認中...",
"checkOnly": "確認のみ",
"commandCopied": "コマンドをコピーしました。",
"commandCopyFailed": "コマンドをコピーできませんでした。",
"configuredInstances": "{{count}} 個のインスタンスを設定済み",
"connectPreview": "次の手順でブラウザー内接続を行います。今は下のコマンドを実行してください。",
"copyCommand": "コマンドをコピー",
"filterAll": "すべて",
"filterOff": "オフ",
"filterOn": "オン",
"helperCopied": "{{name}} をコピーしました。",
"helperCopyFailed": "{{name}} をコピーできませんでした。",
"hideSecret": "シークレットを隠す",
"instanceConfigured": "設定済み",
"instanceNeedsSetup": "設定が必要",
"runtimeFailed": "起動失敗",
"runtimeStarting": "起動中",
"runtimeStopped": "未実行",
"managedByWebui": "WebUI で管理",
"officialGuide": "公式ガイド",
"optional": "任意",
"providerPreset": "プロバイダー",
"requiredSetup": "必要な設定",
"savedSecret": "保存済み",
"savedSecretPlaceholder": "保存済みのシークレット",
"savedSettings": "設定を保存しました。",
"saveSettings": "設定を保存",
"selectChannel": "{{name}} の設定を表示",
"setupSteps": "次の手順",
"showSecret": "シークレットを表示",
"toggleChannel": "{{name}} チャンネル",
"toggleInstance": "{{name}} インスタンス",
"tryIt": "試してみる",
"validationFailed": "有効化する前に必要な設定を確認してください。",
"validation": {
"connected": "接続済み",
"configured": "手動設定済み",
"needs_setup": "設定が必要",
"invalid": "無効",
"unsupported": "手動設定"
}
},
"nanobotFeatures": {
"enabled": "有効",
+45 -7
View File
@@ -513,13 +513,51 @@
"needsConfig": "설정 필요",
"connect": "연결",
"reconnect": "다시 연결",
"feishuQrAlt": "Feishu 연결 QR 코드",
"feishuScanTitle": "Feishu로 스캔",
"feishuScanDescription": "휴대폰의 Feishu 또는 Lark로 이 코드를 스캔하세요. 승인 후 nanobot이 자동으로 설정을 완료합니다.",
"feishuWaiting": "승인을 기다리는 중...",
"feishuConnected": "Feishu가 연결되었습니다.",
"feishuConnectStopped": "연결이 중지되었습니다.",
"feishuConnecting": "연결 중..."
"advanced": "고급",
"checkAndEnable": "확인 후 활성화",
"checkConnection": "연결 확인",
"checkedAndEnabled": "확인 후 활성화했습니다.",
"checking": "확인 중...",
"checkOnly": "확인만",
"commandCopied": "명령을 복사했습니다.",
"commandCopyFailed": "명령을 복사하지 못했습니다.",
"configuredInstances": "인스턴스 {{count}}개 구성됨",
"connectPreview": "다음 단계에서 브라우저 내 연결을 진행합니다. 지금은 아래 명령을 실행하세요.",
"copyCommand": "명령 복사",
"filterAll": "전체",
"filterOff": "꺼짐",
"filterOn": "켜짐",
"helperCopied": "{{name}}을(를) 복사했습니다.",
"helperCopyFailed": "{{name}}을(를) 복사하지 못했습니다.",
"hideSecret": "비밀 값 숨기기",
"instanceConfigured": "구성됨",
"instanceNeedsSetup": "설정 필요",
"runtimeFailed": "시작 실패",
"runtimeStarting": "시작 중",
"runtimeStopped": "실행 중 아님",
"managedByWebui": "WebUI에서 관리",
"officialGuide": "공식 가이드",
"optional": "선택 사항",
"providerPreset": "제공자",
"requiredSetup": "필수 설정",
"savedSecret": "저장됨",
"savedSecretPlaceholder": "저장된 비밀 값",
"savedSettings": "설정을 저장했습니다.",
"saveSettings": "설정 저장",
"selectChannel": "{{name}} 설정 보기",
"setupSteps": "다음 단계",
"showSecret": "비밀 값 표시",
"toggleChannel": "{{name}} 채널",
"toggleInstance": "{{name}} 인스턴스",
"tryIt": "사용해 보기",
"validationFailed": "활성화하기 전에 필수 설정을 확인하세요.",
"validation": {
"connected": "연결됨",
"configured": "수동 구성됨",
"needs_setup": "설정 필요",
"invalid": "잘못됨",
"unsupported": "수동 설정"
}
},
"nanobotFeatures": {
"enabled": "활성화됨",
+45 -7
View File
@@ -527,13 +527,51 @@
"needsConfig": "Precisa de configuração",
"connect": "Conectar",
"reconnect": "Reconectar",
"feishuQrAlt": "QR code de conexão do Feishu",
"feishuScanTitle": "Escaneie com o Feishu",
"feishuScanDescription": "Use o Feishu ou o Lark no seu celular para escanear este código. O nanobot concluirá a configuração automaticamente após a autorização.",
"feishuWaiting": "Aguardando autorização...",
"feishuConnected": "Feishu está conectado.",
"feishuConnectStopped": "Conexão interrompida.",
"feishuConnecting": "Conectando..."
"advanced": "Avançado",
"checkAndEnable": "Verificar e ativar",
"checkConnection": "Verificar conexão",
"checkedAndEnabled": "Verificado e ativado.",
"checking": "Verificando...",
"checkOnly": "Apenas verificar",
"commandCopied": "Comando copiado.",
"commandCopyFailed": "Não foi possível copiar o comando.",
"configuredInstances": "{{count}} instâncias configuradas",
"connectPreview": "A próxima etapa é a conexão no navegador. Por enquanto, execute o comando abaixo.",
"copyCommand": "Copiar comando",
"filterAll": "Todos",
"filterOff": "Desativados",
"filterOn": "Ativados",
"helperCopied": "{{name}} copiado.",
"helperCopyFailed": "Não foi possível copiar {{name}}.",
"hideSecret": "Ocultar segredo",
"instanceConfigured": "Configurada",
"instanceNeedsSetup": "Requer configuração",
"runtimeFailed": "Falha ao iniciar",
"runtimeStarting": "Iniciando",
"runtimeStopped": "Não está em execução",
"managedByWebui": "Gerenciado pela WebUI",
"officialGuide": "Guia oficial",
"optional": "Opcional",
"providerPreset": "Provedor",
"requiredSetup": "Configuração obrigatória",
"savedSecret": "Salvo",
"savedSecretPlaceholder": "Segredo salvo",
"savedSettings": "Configurações salvas.",
"saveSettings": "Salvar configurações",
"selectChannel": "Ver configurações de {{name}}",
"setupSteps": "Próximas etapas",
"showSecret": "Mostrar segredo",
"toggleChannel": "Canal {{name}}",
"toggleInstance": "Instância {{name}}",
"tryIt": "Experimente",
"validationFailed": "Verifique a configuração obrigatória antes de ativar.",
"validation": {
"connected": "Conectado",
"configured": "Configurado manualmente",
"needs_setup": "Requer configuração",
"invalid": "Inválido",
"unsupported": "Configuração manual"
}
},
"nanobotFeatures": {
"enabled": "Habilitado",
+45 -7
View File
@@ -513,13 +513,51 @@
"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..."
"advanced": "Nâng cao",
"checkAndEnable": "Kiểm tra và bật",
"checkConnection": "Kiểm tra kết nối",
"checkedAndEnabled": "Đã kiểm tra và bật.",
"checking": "Đang kiểm tra...",
"checkOnly": "Chỉ kiểm tra",
"commandCopied": "Đã sao chép lệnh.",
"commandCopyFailed": "Không thể sao chép lệnh.",
"configuredInstances": "Đã cấu hình {{count}} phiên bản",
"connectPreview": "Bước tiếp theo là kết nối trong trình duyệt. Hiện tại, hãy chạy lệnh bên dưới.",
"copyCommand": "Sao chép lệnh",
"filterAll": "Tất cả",
"filterOff": "Tắt",
"filterOn": "Bật",
"helperCopied": "Đã sao chép {{name}}.",
"helperCopyFailed": "Không thể sao chép {{name}}.",
"hideSecret": "Ẩn khóa bí mật",
"instanceConfigured": "Đã cấu hình",
"instanceNeedsSetup": "Cần thiết lập",
"runtimeFailed": "Khởi động thất bại",
"runtimeStarting": "Đang khởi động",
"runtimeStopped": "Không chạy",
"managedByWebui": "Do WebUI quản lý",
"officialGuide": "Hướng dẫn chính thức",
"optional": "Tùy chọn",
"providerPreset": "Nhà cung cấp",
"requiredSetup": "Thiết lập bắt buộc",
"savedSecret": "Đã lưu",
"savedSecretPlaceholder": "Khóa bí mật đã lưu",
"savedSettings": "Đã lưu cài đặt.",
"saveSettings": "Lưu cài đặt",
"selectChannel": "Xem cài đặt {{name}}",
"setupSteps": "Các bước tiếp theo",
"showSecret": "Hiện khóa bí mật",
"toggleChannel": "Kênh {{name}}",
"toggleInstance": "Phiên bản {{name}}",
"tryIt": "Dùng thử",
"validationFailed": "Hãy kiểm tra thiết lập bắt buộc trước khi bật.",
"validation": {
"connected": "Đã kết nối",
"configured": "Đã cấu hình thủ công",
"needs_setup": "Cần thiết lập",
"invalid": "Không hợp lệ",
"unsupported": "Thiết lập thủ công"
}
},
"nanobotFeatures": {
"enabled": "Đã bật",
+46 -8
View File
@@ -510,7 +510,7 @@
},
"channels": {
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
"caption": "{{enabled}} 个已启用 · 共 {{total}} 个渠道",
"caption": "{{enabled}} 个运行中 · 共 {{total}} 个渠道",
"searchPlaceholder": "搜索渠道",
"backToChannels": "所有渠道",
"catalog": "渠道",
@@ -527,13 +527,51 @@
"needsConfig": "需要配置",
"connect": "连接",
"reconnect": "重新连接",
"feishuQrAlt": "飞书连接二维码",
"feishuScanTitle": "使用飞书扫码",
"feishuScanDescription": "用手机上的飞书或 Lark 扫描二维码。授权完成后,nanobot 会自动完成配置。",
"feishuWaiting": "正在等待授权...",
"feishuConnected": "飞书已连接。",
"feishuConnectStopped": "连接已停止。",
"feishuConnecting": "正在连接..."
"advanced": "高级",
"checkAndEnable": "检查并启用",
"checkConnection": "检查连接",
"checkedAndEnabled": "已检查并启用。",
"checking": "正在检查...",
"checkOnly": "仅检查",
"commandCopied": "命令已复制。",
"commandCopyFailed": "无法复制命令。",
"configuredInstances": "已配置 {{count}} 个实例",
"connectPreview": "下一步将在浏览器内连接。目前请先运行下方命令。",
"copyCommand": "复制命令",
"filterAll": "全部",
"filterOff": "未运行",
"filterOn": "运行中",
"helperCopied": "已复制 {{name}}。",
"helperCopyFailed": "无法复制 {{name}}。",
"hideSecret": "隐藏密钥",
"instanceConfigured": "已配置",
"instanceNeedsSetup": "需要配置",
"runtimeFailed": "启动失败",
"runtimeStarting": "正在启动",
"runtimeStopped": "未运行",
"managedByWebui": "由 WebUI 管理",
"officialGuide": "官方指南",
"optional": "可选",
"providerPreset": "服务商",
"requiredSetup": "必需配置",
"savedSecret": "已保存",
"savedSecretPlaceholder": "已保存的密钥",
"savedSettings": "设置已保存。",
"saveSettings": "保存设置",
"selectChannel": "查看 {{name}} 设置",
"setupSteps": "后续步骤",
"showSecret": "显示密钥",
"toggleChannel": "{{name}} 渠道",
"toggleInstance": "{{name}} 实例",
"tryIt": "试一试",
"validationFailed": "启用前请检查必需配置。",
"validation": {
"connected": "已连接",
"configured": "手动配置",
"needs_setup": "需要配置",
"invalid": "无效",
"unsupported": "手动配置"
}
},
"nanobotFeatures": {
"enabled": "已启用",
+45 -7
View File
@@ -513,13 +513,51 @@
"needsConfig": "需要設定",
"connect": "連線",
"reconnect": "重新連線",
"feishuQrAlt": "飛書連線 QR Code",
"feishuScanTitle": "使用飛書掃描",
"feishuScanDescription": "請使用手機上的飛書或 Lark 掃描此 QR Code。完成授權後,nanobot 會自動完成設定。",
"feishuWaiting": "正在等待授權…",
"feishuConnected": "飛書已連線。",
"feishuConnectStopped": "連線已停止。",
"feishuConnecting": "正在連線…"
"advanced": "進階",
"checkAndEnable": "檢查並啟用",
"checkConnection": "檢查連線",
"checkedAndEnabled": "已檢查並啟用。",
"checking": "正在檢查...",
"checkOnly": "僅檢查",
"commandCopied": "指令已複製。",
"commandCopyFailed": "無法複製指令。",
"configuredInstances": "已設定 {{count}} 個執行個體",
"connectPreview": "下一步將在瀏覽器內連線。目前請先執行下方指令。",
"copyCommand": "複製指令",
"filterAll": "全部",
"filterOff": "關閉",
"filterOn": "開啟",
"helperCopied": "已複製 {{name}}。",
"helperCopyFailed": "無法複製 {{name}}。",
"hideSecret": "隱藏密鑰",
"instanceConfigured": "已設定",
"instanceNeedsSetup": "需要設定",
"runtimeFailed": "啟動失敗",
"runtimeStarting": "正在啟動",
"runtimeStopped": "未執行",
"managedByWebui": "由 WebUI 管理",
"officialGuide": "官方指南",
"optional": "選填",
"providerPreset": "服務供應商",
"requiredSetup": "必要設定",
"savedSecret": "已儲存",
"savedSecretPlaceholder": "已儲存的密鑰",
"savedSettings": "設定已儲存。",
"saveSettings": "儲存設定",
"selectChannel": "檢視 {{name}} 設定",
"setupSteps": "後續步驟",
"showSecret": "顯示密鑰",
"toggleChannel": "{{name}} 渠道",
"toggleInstance": "{{name}} 執行個體",
"tryIt": "試試看",
"validationFailed": "啟用前請檢查必要設定。",
"validation": {
"connected": "已連線",
"configured": "手動設定",
"needs_setup": "需要設定",
"invalid": "無效",
"unsupported": "手動設定"
}
},
"nanobotFeatures": {
"enabled": "已啟用",
+4 -4
View File
@@ -497,9 +497,9 @@ export async function runPairingAction(
export async function startChannelConnect(
token: string,
channel: "feishu" | "weixin",
channel: string,
options: {
domain?: "feishu" | "lark";
domain?: string;
instanceId?: string;
mode?: "replace" | "create";
force?: boolean;
@@ -520,7 +520,7 @@ export async function startChannelConnect(
export async function pollChannelConnect(
token: string,
channel: "feishu" | "weixin",
channel: string,
sessionId: string,
base: string = "",
): Promise<ChannelConnectPayload> {
@@ -534,7 +534,7 @@ export async function pollChannelConnect(
export async function cancelChannelConnect(
token: string,
channel: "feishu" | "weixin",
channel: string,
sessionId: string,
base: string = "",
): Promise<ChannelConnectPayload> {
+28 -4
View File
@@ -691,11 +691,18 @@ export interface CliAppsPayload {
export interface NanobotFeatureInfo {
name: string;
display_name: string;
capabilities?: string[];
settings_visible?: boolean;
webui?: string;
type: "channel" | "feature" | string;
enabled: boolean;
running?: boolean;
runtime_status?: ChannelRuntimeStatus;
runtime_error?: string;
configured?: boolean;
config_values?: Record<string, string>;
configured_fields?: string[];
setup?: ChannelSetupContract;
instances?: NanobotChannelInstanceInfo[];
installed: boolean;
ready: boolean;
@@ -704,19 +711,36 @@ export interface NanobotFeatureInfo {
requires_restart: boolean;
}
export interface ChannelSetupContractField {
key: string;
field: string;
kind: "string" | "secret" | "int" | "bool" | "list" | "enum" | string;
choices: string[];
required: boolean;
default_value?: string;
}
export interface ChannelSetupContract {
fields: ChannelSetupContractField[];
official_url?: string;
}
export interface NanobotChannelInstanceInfo {
id: string;
name: string;
display_name?: string;
avatar_url?: string;
domain?: "feishu" | "lark" | string;
enabled: boolean;
running?: boolean;
runtime_status?: ChannelRuntimeStatus;
runtime_error?: string;
configured: boolean;
app_id?: string;
group_policy?: string;
allow_from?: string[];
config_values: Record<string, string>;
configured_fields: string[];
}
export type ChannelRuntimeStatus = "running" | "starting" | "failed" | "stopped" | string;
export interface NanobotFeaturesPayload {
features: NanobotFeatureInfo[];
enabled_count: number;
+5 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog";
import { channelUiPresentation } from "@/channel-plugins/registry";
const SLACK_SOCKET_MODE_MANIFEST = channelUiPresentation("slack")?.setup?.actions?.find(
(action) => action.id === "slack-manifest",
)?.copyText;
describe("Slack setup manifest", () => {
it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])(
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
channelIsRunning,
channelSetup,
channelStatusLabel,
channelToggleChecked,
} from "@/components/settings/channels/ChannelIdentity";
import type { NanobotFeatureInfo } from "@/lib/types";
function feature(overrides: Partial<NanobotFeatureInfo>): NanobotFeatureInfo {
return {
name: "plugin-chat",
display_name: "Plugin Chat",
type: "channel",
enabled: false,
installed: true,
ready: false,
status: "not_enabled",
install_supported: true,
requires_restart: true,
...overrides,
};
}
describe("channelSetup", () => {
it("builds editable fields for a plugin-owned backend contract", () => {
const setup = channelSetup(feature({
setup: {
fields: [
{
key: "channels.plugin-chat.apiToken",
field: "apiToken",
kind: "secret",
choices: [],
required: true,
},
{
key: "channels.plugin-chat.region",
field: "region",
kind: "enum",
choices: ["us", "eu"],
required: false,
},
],
official_url: "https://plugin.example/setup",
},
}));
expect(setup.officialUrl).toBe("https://plugin.example/setup");
expect(setup.officialLabel).toBe("Open official setup");
expect(setup.fields).toEqual([
expect.objectContaining({
key: "channels.plugin-chat.apiToken",
label: "Api Token",
secret: true,
optional: false,
}),
expect.objectContaining({
key: "channels.plugin-chat.region",
options: [
{ value: "us", label: "Us" },
{ value: "eu", label: "Eu" },
],
}),
]);
});
it("filters catalog-only fields that the backend does not accept", () => {
const setup = channelSetup(feature({
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
setup: {
fields: [{
key: "channels.discord.token",
field: "token",
kind: "secret",
choices: [],
required: true,
}],
},
}));
expect(setup.fields?.map((field) => field.key)).toEqual(["channels.discord.token"]);
expect(setup.manualFields).toBeUndefined();
});
it("uses backend defaults and choices with catalog presentation labels", () => {
const setup = channelSetup(feature({
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
setup: {
fields: [{
key: "channels.discord.groupPolicy",
field: "groupPolicy",
kind: "enum",
choices: ["open"],
required: false,
default_value: "open",
}],
},
}));
expect(setup.fields).toEqual([
expect.objectContaining({
key: "channels.discord.groupPolicy",
label: "Group behavior",
defaultValue: "open",
options: [{ value: "open", label: "All messages" }],
}),
]);
});
it("loads setup copy from the channel-owned locale", () => {
const setup = channelSetup(feature({
name: "dingtalk",
display_name: "DingTalk",
webui: "webui/index.ts",
}), "zh-CN");
expect(setup.summary).toBe("钉钉需要 Stream 模式的应用凭据。");
expect(setup.steps[0]).toBe("创建或选择一个已启用 Stream 模式的钉钉应用。");
expect(setup.fields).toContainEqual(expect.objectContaining({
key: "channels.dingtalk.allowFrom",
label: "允许的用户",
}));
});
});
describe("channel runtime state", () => {
const tx = (_key: string, fallback: string) => fallback;
it("only reports a channel on when the runtime is explicitly running", () => {
const running = feature({ enabled: true, runtime_status: "running" });
const unknown = feature({ enabled: true });
expect(channelIsRunning(running)).toBe(true);
expect(channelToggleChecked(running)).toBe(true);
expect(channelStatusLabel(running, tx)).toBe("On");
expect(channelIsRunning(unknown)).toBe(false);
expect(channelToggleChecked(unknown)).toBe(false);
expect(channelStatusLabel(unknown, tx)).toBe("Not running");
});
});
@@ -0,0 +1,112 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { channelFieldMessageKey } from "@/channel-plugins/i18n";
import { registeredChannelLocales } from "@/channel-plugins/locale-registry";
import { registeredChannelUiContributions } from "@/channel-plugins/registry";
import { supportedLocales } from "@/i18n/config";
const expectedChannels = [
"dingtalk",
"discord",
"email",
"feishu",
"matrix",
"mattermost",
"msteams",
"napcat",
"qq",
"signal",
"slack",
"telegram",
"websocket",
"wecom",
"weixin",
"whatsapp",
];
function flatten(value: unknown, prefix = ""): Map<string, string> {
const entries = new Map<string, string>();
if (typeof value === "string") {
entries.set(prefix, value);
return entries;
}
if (!value || typeof value !== "object") return entries;
for (const [key, child] of Object.entries(value)) {
if (!prefix && key === "displayName") continue;
const childPrefix = prefix ? `${prefix}.${key}` : key;
for (const [childKey, text] of flatten(child, childPrefix)) {
entries.set(childKey, text);
}
}
return entries;
}
function interpolationKeys(value: string): string[] {
return [...value.matchAll(/{{\s*([\w.-]+)\s*}}/g)]
.map((match) => match[1])
.sort();
}
describe("channel locale registry", () => {
it("loads every supported locale from every built-in channel package", () => {
const registrations = registeredChannelLocales();
expect([...registrations.keys()].sort()).toEqual(expectedChannels);
for (const [channel, locales] of registrations) {
expect([...locales.keys()].sort()).toEqual(
supportedLocales.map(({ code }) => code).sort(),
);
const english = flatten(locales.get("en"));
for (const [locale, messages] of locales) {
const translated = flatten(messages);
expect([...translated.keys()].sort(), `${channel}/${locale} message keys`).toEqual(
[...english.keys()].sort(),
);
for (const [key, source] of english) {
expect(
interpolationKeys(translated.get(key) ?? ""),
`${channel}/${locale}:${key} interpolation keys`,
).toEqual(interpolationKeys(source));
}
}
}
});
it("keeps structural UI definitions aligned with English locale keys", () => {
const locales = registeredChannelLocales();
for (const { channel, contribution } of registeredChannelUiContributions()) {
const messages = locales.get(channel)?.get("en");
expect(messages, `${channel} English messages`).toBeDefined();
const setup = contribution.presentation.setup;
for (const field of [...(setup?.fields ?? []), ...(setup?.manualFields ?? [])]) {
const messageKey = channelFieldMessageKey(channel, field.key);
expect(messages?.setup.fields?.[messageKey], `${channel} field ${messageKey}`).toBeDefined();
}
for (const action of setup?.actions ?? []) {
expect(messages?.setup.actions?.[action.id], `${channel} action ${action.id}`).toBeTypeOf("string");
}
for (const preset of setup?.presets ?? []) {
expect(messages?.setup.presets?.[preset.id], `${channel} preset ${preset.id}`).toBeTypeOf("string");
}
}
});
it("keeps i18n initialization independent from channel React modules", () => {
const localeRegistry = readFileSync(
resolve(process.cwd(), "src/channel-plugins/locale-registry.ts"),
"utf8",
);
const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8");
expect(localeRegistry).toContain("webui/locales/*.json");
expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i);
expect(i18nEntry).toContain("channel-plugins/locale-registry");
expect(i18nEntry).not.toContain("channel-plugins/registry");
});
});
@@ -0,0 +1,78 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
channelUiContribution,
channelUiOwner,
channelUiPresentation,
registeredChannelUiContributions,
} from "@/channel-plugins/registry";
describe("channel UI contributions", () => {
it("selects channel-owned UI only through the backend manifest entry", () => {
expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeTypeOf("function");
expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeTypeOf("function");
expect(channelUiContribution("feishu", undefined)).toBeUndefined();
expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined();
expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined();
const registrations = registeredChannelUiContributions();
const channels = registrations.map((entry) => entry.channel);
expect(channels).toEqual(expect.arrayContaining(["feishu", "weixin"]));
expect(new Set(channels).size).toBe(channels.length);
expect(registrations.every((entry) => /^webui\/index\.tsx?$/.test(entry.webui))).toBe(true);
expect(channelUiContribution("slack", "webui/index.ts")?.presentation.displayName).toBe("Slack");
});
it("keeps aliases inside the owning channel contribution", () => {
expect(channelUiPresentation("lark")?.displayName).toBe("Lark");
expect(channelUiPresentation("wechat")?.displayName).toBe("WeChat");
expect(channelUiOwner("lark")).toBe("feishu");
expect(channelUiOwner("wechat")).toBe("weixin");
});
it("uses the DingTalk Open Platform brand mark", () => {
expect(channelUiPresentation("dingtalk")?.logoUrl).toBe(
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
);
});
it("keeps the core setup panel independent of concrete channel plugins", () => {
const source = readFileSync(
resolve(process.cwd(), "src/components/settings/channels/ChannelSetupPanel.tsx"),
"utf8",
);
expect(source).not.toMatch(/feature\.name\s*===\s*["'](?:feishu|weixin)["']/);
expect(source).not.toMatch(/channel-plugins\/(?:feishu|weixin)/);
expect(source).not.toMatch(/(?:Feishu|Weixin)(?:AssistantsPanel|ConnectFlow)/);
});
it("discovers UI contributions only from channel-owned packages", () => {
const source = readFileSync(
resolve(process.cwd(), "src/channel-plugins/registry.ts"),
"utf8",
);
expect(source).toContain("../../../nanobot/channels/*/webui/**/*.{ts,tsx}");
expect(source).not.toContain('"./*/index.tsx"');
});
it("derives channel identity from the package directory", () => {
for (const channel of ["feishu", "weixin"]) {
const source = readFileSync(
resolve(process.cwd(), `../nanobot/channels/${channel}/webui/index.tsx`),
"utf8",
);
expect(source).not.toMatch(/\bchannel\s*:/);
}
});
it("includes channel-owned UI in Tailwind's production scan", () => {
const source = readFileSync(resolve(process.cwd(), "tailwind.config.js"), "utf8");
expect(source).toContain("../nanobot/channels/*/webui/**/*.{ts,tsx}");
});
});
+47 -1
View File
@@ -136,6 +136,48 @@ const LOCALIZED_WORKSPACE_COPY_KEYS = [
"workspace.dialog.usePath",
"workspace.dialog.absolutePathRequired",
];
const LOCALIZED_CHANNEL_SHELL_KEYS = [
"settings.channels.advanced",
"settings.channels.checkAndEnable",
"settings.channels.checkConnection",
"settings.channels.checkedAndEnabled",
"settings.channels.checking",
"settings.channels.checkOnly",
"settings.channels.commandCopied",
"settings.channels.commandCopyFailed",
"settings.channels.configuredInstances",
"settings.channels.connectPreview",
"settings.channels.copyCommand",
"settings.channels.filterAll",
"settings.channels.filterOff",
"settings.channels.filterOn",
"settings.channels.helperCopied",
"settings.channels.helperCopyFailed",
"settings.channels.hideSecret",
"settings.channels.instanceConfigured",
"settings.channels.instanceNeedsSetup",
"settings.channels.managedByWebui",
"settings.channels.officialGuide",
"settings.channels.optional",
"settings.channels.providerPreset",
"settings.channels.requiredSetup",
"settings.channels.savedSecret",
"settings.channels.savedSecretPlaceholder",
"settings.channels.savedSettings",
"settings.channels.saveSettings",
"settings.channels.selectChannel",
"settings.channels.setupSteps",
"settings.channels.showSecret",
"settings.channels.toggleChannel",
"settings.channels.toggleInstance",
"settings.channels.tryIt",
"settings.channels.validation.connected",
"settings.channels.validation.configured",
"settings.channels.validation.invalid",
"settings.channels.validation.needs_setup",
"settings.channels.validation.unsupported",
"settings.channels.validationFailed",
];
const INDEX_HTML = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
const PREBOOT_SCRIPT = INDEX_HTML.match(
/<script>\s*(\(function \(\) \{\s*var localeKey = "nanobot\.locale";[\s\S]*?\}\)\(\);)\s*<\/script>/,
@@ -375,7 +417,11 @@ describe("webui i18n", () => {
for (const [locale, resource] of Object.entries(resources)) {
if (locale === "en") continue;
const current = flattenResource(resource.common);
const leaked = [...LOCALIZED_SETTINGS_COPY_KEYS, ...LOCALIZED_WORKSPACE_COPY_KEYS].filter(
const leaked = [
...LOCALIZED_SETTINGS_COPY_KEYS,
...LOCALIZED_WORKSPACE_COPY_KEYS,
...LOCALIZED_CHANNEL_SHELL_KEYS,
].filter(
(key) => current.get(key) === english.get(key),
);
+381 -14
View File
@@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
import type {
ChannelSetupContract,
ChannelSetupContractField,
SettingsPayload,
} from "@/lib/types";
function jsonResponse(body: unknown): Response {
return {
@@ -120,6 +124,118 @@ function settingsPayload(): SettingsPayload {
};
}
function channelSetupField(
channel: string,
field: string,
kind: ChannelSetupContractField["kind"] = "string",
options: {
required?: boolean;
choices?: string[];
defaultValue?: string;
} = {},
): ChannelSetupContractField {
return {
key: `channels.${channel}.${field}`,
field,
kind,
choices: options.choices ?? [],
required: options.required ?? false,
...(options.defaultValue === undefined ? {} : { default_value: options.defaultValue }),
};
}
function channelSetupContract(
channel: "discord" | "email" | "feishu" | "matrix" | "qq",
): ChannelSetupContract {
const field = (
name: string,
kind: ChannelSetupContractField["kind"] = "string",
options: Parameters<typeof channelSetupField>[3] = {},
) => channelSetupField(channel, name, kind, options);
switch (channel) {
case "discord":
return {
official_url: "https://discord.com/developers/applications",
fields: [
field("token", "secret", { required: true }),
field("allowFrom", "list"),
field("allowChannels", "list"),
field("groupPolicy", "enum", {
choices: ["mention", "open"],
defaultValue: "mention",
}),
],
};
case "email":
return {
official_url: "https://support.google.com/accounts/answer/185833",
fields: [
field("consentGranted", "bool", { required: true, defaultValue: "false" }),
field("imapHost", "string", { required: true }),
field("imapPort", "int"),
field("imapUsername", "string", { required: true }),
field("imapPassword", "secret", { required: true }),
field("smtpHost", "string", { required: true }),
field("smtpPort", "int"),
field("smtpUsername", "string", { required: true }),
field("smtpPassword", "secret", { required: true }),
field("fromAddress"),
field("pollIntervalSeconds", "int"),
field("allowFrom", "list"),
field("verifyDkim", "bool", { defaultValue: "true" }),
field("verifySpf", "bool", { defaultValue: "true" }),
],
};
case "feishu":
return {
official_url: "https://open.feishu.cn/app",
fields: [
field("appId", "string", { required: true }),
field("appSecret", "secret", { required: true }),
field("domain", "enum", {
choices: ["feishu", "lark"],
defaultValue: "feishu",
}),
field("groupPolicy", "enum", {
choices: ["mention", "open"],
defaultValue: "mention",
}),
field("allowFrom", "list"),
field("topicIsolation", "bool"),
],
};
case "matrix":
return {
official_url: "https://matrix.org/ecosystem/clients/",
fields: [
field("homeserver", "string", { required: true }),
field("userId", "string", { required: true }),
field("password", "secret"),
field("accessToken", "secret"),
field("deviceId"),
field("groupPolicy", "enum", {
choices: ["allowlist", "mention", "open"],
defaultValue: "open",
}),
],
};
case "qq":
return {
official_url: "https://q.qq.com/",
fields: [
field("appId", "string", { required: true }),
field("secret", "secret", { required: true }),
field("allowFrom", "list"),
field("msgFormat", "enum", {
choices: ["markdown", "plain"],
defaultValue: "plain",
}),
],
};
}
}
function autoDynamicProviderPayload(
options: {
configured: boolean;
@@ -417,6 +533,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: false,
@@ -433,8 +550,11 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
installed: true,
ready: true,
status: "enabled",
@@ -450,6 +570,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: true,
@@ -519,7 +640,7 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Disabled channel 'matrix'")).not.toBeInTheDocument();
});
it("shows enabled nanobot channels with missing support as enabled", async () => {
it("shows an enabled channel with missing support as failed", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
@@ -532,6 +653,9 @@ describe("SettingsView Apps catalog", () => {
display_name: "Matrix",
type: "channel",
enabled: true,
running: false,
runtime_status: "failed",
runtime_error: "Channel dependencies could not be installed. Check gateway logs.",
installed: false,
ready: false,
status: "missing_dependency",
@@ -565,11 +689,11 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
expect(screen.getByText("1 enabled · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("On").length).toBeGreaterThan(0);
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
expect(screen.queryByText("Enabled, support needs install")).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false");
fireEvent.click(screen.getByRole("button", { name: "Install support" }));
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
@@ -583,6 +707,51 @@ describe("SettingsView Apps catalog", () => {
);
});
it("shows a configured channel as failed when its runtime did not start", async () => {
const runtimeError = "Channel failed to start. Check gateway logs.";
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
configured: true,
installed: true,
ready: false,
running: false,
runtime_status: "failed",
runtime_error: runtimeError,
status: "failed",
install_supported: true,
requires_restart: false,
}],
enabled_count: 0,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
expect(screen.getByText(runtimeError)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute(
"aria-checked",
"false",
);
});
it("starts Feishu connect in WebUI instead of showing a CLI command", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
@@ -594,6 +763,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: false,
configured: false,
@@ -651,6 +821,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: false,
configured: false,
@@ -706,6 +877,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: false,
configured: true,
@@ -723,16 +895,24 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
instances: [{
id: "default",
name: "nanobot",
domain: "feishu",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
app_id: "cli_test",
config_values: { "channels.feishu.appId": "cli_test" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
}],
installed: true,
ready: true,
@@ -787,6 +967,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
configured: true,
@@ -795,26 +976,42 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("feishu"),
instances: [
{
id: "default",
name: "nanobot",
display_name: "Support Bot",
avatar_url: "https://example.com/support.png",
domain: "feishu",
enabled: true,
configured: true,
app_id: "cli_default",
config_values: {
"channels.feishu.appId": "cli_default",
"channels.feishu.domain": "feishu",
"channels.feishu.groupPolicy": "mention",
"channels.feishu.allowFrom": "",
"channels.feishu.topicIsolation": "true",
},
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
"channels.feishu.domain",
"channels.feishu.groupPolicy",
"channels.feishu.topicIsolation",
],
},
{
id: "product",
name: "Product bot",
display_name: "Product Helper",
avatar_url: "https://example.com/product.png",
domain: "feishu",
enabled: false,
configured: true,
app_id: "cli_product",
config_values: { "channels.feishu.appId": "cli_product" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
},
],
}],
@@ -847,6 +1044,8 @@ describe("SettingsView Apps catalog", () => {
"true",
);
expect(screen.getAllByText("cli_def...ault").length).toBeGreaterThan(0);
expect(screen.getByText("Advanced")).toBeInTheDocument();
expect(screen.getByText("Topic isolation")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Product Helper/ }));
expect(screen.getByRole("button", { name: /Support Bot/ })).toHaveAttribute(
@@ -859,18 +1058,98 @@ describe("SettingsView Apps catalog", () => {
);
});
it("renders external multi-instance channels from the shared contract", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "multiplugin",
display_name: "Multi Plugin",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: false,
setup: {
fields: [
channelSetupField("multiplugin", "token", "secret", { required: true }),
channelSetupField("multiplugin", "region", "enum", {
choices: ["eu", "us"],
defaultValue: "us",
}),
],
},
instances: [
{
id: "default",
name: "Default worker",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
config_values: { "channels.multiplugin.region": "us" },
configured_fields: ["channels.multiplugin.token"],
},
{
id: "product",
name: "Product worker",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
config_values: { "channels.multiplugin.region": "eu" },
configured_fields: ["channels.multiplugin.token"],
},
],
}],
enabled_count: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByText("Default worker")).toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "Multi Plugin channel" })).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Default worker instance" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByRole("switch", { name: "Product worker instance" })).toHaveAttribute(
"aria-checked",
"true",
);
fireEvent.click(screen.getByRole("button", { name: "Product worker" }));
expect(screen.getByRole("radio", { name: "Eu" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("Saved")).toBeInTheDocument();
});
it("shows a single Feishu assistant without a duplicate assistant list", async () => {
const reconnectUrls: string[] = [];
const feishuPayload = {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
configured: true,
installed: true,
ready: true,
status: "enabled",
running: true,
runtime_status: "running",
install_supported: true,
requires_restart: true,
instances: [{
@@ -878,10 +1157,15 @@ describe("SettingsView Apps catalog", () => {
name: "nanobot",
display_name: "Support Bot",
avatar_url: "https://example.com/support.png",
domain: "feishu",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
app_id: "cli_support",
config_values: { "channels.feishu.appId": "cli_support" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
}],
}],
enabled_count: 1,
@@ -920,6 +1204,67 @@ describe("SettingsView Apps catalog", () => {
expect(document.querySelector('img[src="https://example.com/support.png"]')).toBeTruthy();
});
it("does not call a configured Feishu assistant connected after runtime failure", async () => {
const runtimeError = "Channel failed to start. Check gateway logs.";
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
configured: true,
installed: true,
ready: false,
running: false,
runtime_status: "failed",
runtime_error: runtimeError,
status: "failed",
install_supported: true,
requires_restart: false,
instances: [{
id: "default",
name: "test",
enabled: true,
configured: true,
running: false,
runtime_status: "failed",
runtime_error: runtimeError,
config_values: { "channels.feishu.appId": "cli_test" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
}],
}],
enabled_count: 0,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "channels" });
await screen.findByText("No assistant connected");
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
expect(screen.getByText(runtimeError)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "test assistant" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.queryByText("Connected")).not.toBeInTheDocument();
});
it("shows group behavior fields as options", async () => {
vi.stubGlobal(
"fetch",
@@ -933,6 +1278,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: true,
installed: true,
@@ -940,6 +1286,7 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 1,
});
@@ -1039,6 +1386,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: false,
configured: false,
@@ -1047,6 +1395,7 @@ describe("SettingsView Apps catalog", () => {
status: "not_enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 0,
});
@@ -1064,14 +1413,18 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 1,
requires_restart: false,
@@ -1150,6 +1503,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: false,
configured: true,
@@ -1167,6 +1521,7 @@ describe("SettingsView Apps catalog", () => {
"channels.discord.allowChannels",
"channels.discord.groupPolicy",
],
setup: channelSetupContract("discord"),
}],
enabled_count: 0,
});
@@ -1176,6 +1531,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: true,
configured: true,
@@ -1184,6 +1540,7 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 1,
requires_restart: false,
@@ -1203,7 +1560,10 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("switch", { name: "Discord channel" })).toBeEnabled();
expect(screen.getByText("Configured manually")).toBeInTheDocument();
expect(screen.getByText("Saved")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Saved secret")).toHaveValue("");
const savedSecret = screen.getByPlaceholderText("Saved secret");
expect(savedSecret).toHaveValue("");
expect(savedSecret).toHaveAttribute("autocomplete", "off");
expect(savedSecret.closest("form")).not.toBeNull();
expect(screen.queryByDisplayValue("discord-secret-token")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Advanced"));
@@ -1235,6 +1595,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "telegram",
display_name: "Telegram",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: true,
@@ -1291,6 +1652,7 @@ describe("SettingsView Apps catalog", () => {
features: channels.map(([name, displayName]) => ({
name,
display_name: displayName,
webui: ["feishu", "weixin"].includes(name) ? "webui/index.tsx" : "webui/index.ts",
type: "channel",
enabled: name === "websocket",
installed: true,
@@ -1301,6 +1663,7 @@ describe("SettingsView Apps catalog", () => {
})).concat(hiddenChannels.map(([name, displayName]) => ({
name,
display_name: displayName,
settings_visible: false,
type: "channel",
enabled: false,
installed: true,
@@ -1343,6 +1706,7 @@ describe("SettingsView Apps catalog", () => {
features: ["email", "feishu", "matrix", "qq"].map((name) => ({
name,
display_name: name === "qq" ? "QQ" : name[0].toUpperCase() + name.slice(1),
webui: name === "feishu" ? "webui/index.tsx" : "webui/index.ts",
type: "channel",
enabled: true,
installed: true,
@@ -1350,6 +1714,7 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract(name as "email" | "feishu" | "matrix" | "qq"),
})),
enabled_count: 4,
});
@@ -1409,6 +1774,8 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "websocket",
display_name: "Websocket",
capabilities: ["always_enabled"],
webui: "webui/index.ts",
type: "channel",
enabled: true,
installed: true,