Keep configured IM channels editable

This commit is contained in:
taohe
2026-06-11 14:37:58 +08:00
parent c966eb71a7
commit 9d51e38641
9 changed files with 110 additions and 68 deletions
@@ -284,6 +284,12 @@ def _provider_response(
connection: dict[str, Any] | None = None,
) -> ChannelProviderResponse:
status, unavailable_reason = _provider_status(config, channels_config, provider)
if connection:
connection_status = connection["status"]
elif status["configured"] and unavailable_reason is None:
connection_status = "connected"
else:
connection_status = "not_connected"
return ChannelProviderResponse(
provider=provider,
display_name=meta["display_name"],
@@ -292,7 +298,7 @@ def _provider_response(
connectable=status["enabled"] and status["configured"] and unavailable_reason is None,
unavailable_reason=unavailable_reason,
auth_mode=meta["auth_mode"],
connection_status=connection["status"] if connection else "not_connected",
connection_status=connection_status,
credential_fields=_credential_fields(provider),
)
@@ -123,10 +123,12 @@ def test_get_providers_uses_existing_channels_config(tmp_path):
assert by_provider["telegram"]["auth_mode"] == "deep_link"
assert by_provider["slack"]["configured"] is True
assert by_provider["slack"]["auth_mode"] == "binding_code"
assert by_provider["slack"]["connection_status"] == "connected"
assert by_provider["discord"]["configured"] is True
assert by_provider["discord"]["auth_mode"] == "binding_code"
assert by_provider["feishu"]["configured"] is True
assert by_provider["feishu"]["auth_mode"] == "binding_code"
assert by_provider["feishu"]["connection_status"] == "connected"
assert by_provider["dingtalk"]["configured"] is True
assert by_provider["dingtalk"]["auth_mode"] == "binding_code"
assert by_provider["wechat"]["configured"] is True
@@ -384,6 +386,7 @@ def test_configure_provider_runtime_credentials_enables_connect_without_file_edi
assert configured["provider"] == "slack"
assert configured["configured"] is True
assert configured["connectable"] is True
assert configured["connection_status"] == "connected"
assert app.state.channels_config["slack"] == {
"enabled": True,
"bot_token": "xoxb-ui",
@@ -60,6 +60,8 @@ export function ChannelRuntimeConfigDialog({
return null;
}
const isEditing = provider.configured;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit(provider, values);
@@ -71,7 +73,9 @@ export function ChannelRuntimeConfigDialog({
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>
{t.channels.setupTitle(provider.display_name)}
{isEditing
? t.channels.setupEditTitle(provider.display_name)
: t.channels.setupTitle(provider.display_name)}
</DialogTitle>
<DialogDescription>{t.channels.setupDescription}</DialogDescription>
</DialogHeader>
@@ -118,7 +122,7 @@ export function ChannelRuntimeConfigDialog({
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
) : null}
{t.channels.saveAndConnect}
{isEditing ? t.channels.saveChanges : t.channels.saveAndConnect}
</Button>
</DialogFooter>
</form>
@@ -37,6 +37,10 @@ function providerCanConnect(provider: ChannelProvider): boolean {
);
}
function providerCanEditRuntimeConfig(provider: ChannelProvider): boolean {
return provider.enabled && (provider.credential_fields?.length ?? 0) > 0;
}
function getProviderUnavailableReason(
provider: ChannelProvider,
t: ReturnType<typeof useI18n>["t"],
@@ -126,6 +130,7 @@ export function WorkspaceChannelsList() {
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
<SidebarMenu>
{visibleProviders.map((provider) => {
const canEditRuntimeConfig = providerCanEditRuntimeConfig(provider);
const isConnected = provider.connection_status === "connected";
const isPending =
(connectMutation.isPending &&
@@ -153,10 +158,13 @@ export function WorkspaceChannelsList() {
"h-8 w-24 px-2 text-xs",
isConnected && "gap-1",
)}
disabled={isConnected || isPending}
disabled={isPending}
title={unavailableReason}
onClick={() => {
if (providerNeedsRuntimeConfig(provider)) {
if (
providerNeedsRuntimeConfig(provider) ||
canEditRuntimeConfig
) {
setSetupProvider(provider);
return;
}
@@ -193,16 +201,13 @@ export function WorkspaceChannelsList() {
}
}}
onSubmit={(provider, values) => {
const connectWindow =
provider.auth_mode === "deep_link" ? prepareConnectWindow() : null;
void configureMutation
.mutateAsync({ provider: provider.provider, values })
.then((configuredProvider) => {
.then(() => {
setSetupProvider(null);
startConnect(configuredProvider, connectWindow);
toast.success(t.channels.connected);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
@@ -108,6 +108,10 @@ function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
);
}
function providerCanEditRuntimeConfig(provider: ChannelProvider): boolean {
return provider.enabled && (provider.credential_fields?.length ?? 0) > 0;
}
function ChannelProviderItem({
provider,
connection,
@@ -120,7 +124,10 @@ function ChannelProviderItem({
const configureMutation = useConfigureChannelProvider();
const disconnectMutation = useDisconnectChannelConnection();
const [setupOpen, setSetupOpen] = useState(false);
const isConnected = connection?.status === "connected";
const isConnected =
connection?.status === "connected" ||
provider.connection_status === "connected";
const canEditRuntimeConfig = providerCanEditRuntimeConfig(provider);
const canConnect =
(provider.connectable ?? (provider.enabled && provider.configured)) &&
!isConnected;
@@ -195,7 +202,25 @@ function ChannelProviderItem({
</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto">
{isConnected && connection ? (
{isConnected ? (
<>
{canEditRuntimeConfig ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isConnecting}
onClick={() => setSetupOpen(true)}
>
{isConnecting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<PlugIcon />
)}
{t.channels.modify}
</Button>
) : null}
{connection ? (
<Button
type="button"
variant="outline"
@@ -210,6 +235,8 @@ function ChannelProviderItem({
)}
{t.channels.disconnect}
</Button>
) : null}
</>
) : (
<Button
type="button"
@@ -217,7 +244,10 @@ function ChannelProviderItem({
disabled={isConnecting}
title={unavailableReason}
onClick={() => {
if (providerNeedsRuntimeConfig(provider)) {
if (
providerNeedsRuntimeConfig(provider) ||
canEditRuntimeConfig
) {
setSetupOpen(true);
return;
}
@@ -248,18 +278,13 @@ function ChannelProviderItem({
submitting={configureMutation.isPending}
onOpenChange={setSetupOpen}
onSubmit={(submitProvider, values) => {
const connectWindow =
submitProvider.auth_mode === "deep_link"
? prepareConnectWindow()
: null;
void configureMutation
.mutateAsync({ provider: submitProvider.provider, values })
.then((configuredProvider) => {
.then(() => {
setSetupOpen(false);
startConnect(configuredProvider, connectWindow);
toast.success(t.channels.connected);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
+3
View File
@@ -259,6 +259,7 @@ export const enUS: Translations = {
channels: {
title: "Channels",
connect: "Connect",
modify: "Modify",
reconnect: "Reconnect",
disconnect: "Disconnect",
connected: "Connected",
@@ -270,9 +271,11 @@ export const enUS: Translations = {
unavailable: "Channel connections are unavailable right now.",
unavailableShort: "Unavailable",
setupTitle: (name: string) => `Connect ${name}`,
setupEditTitle: (name: string) => `Modify ${name}`,
setupDescription:
"Enter the values needed by this server process. They are not written to config.yaml.",
saveAndConnect: "Save and connect",
saveChanges: "Save changes",
descriptions: {
telegram: "Telegram direct messages through your DeerFlow bot.",
slack: "Slack workspace messages and mentions.",
+3
View File
@@ -190,6 +190,7 @@ export interface Translations {
channels: {
title: string;
connect: string;
modify: string;
reconnect: string;
disconnect: string;
connected: string;
@@ -201,8 +202,10 @@ export interface Translations {
unavailable: string;
unavailableShort: string;
setupTitle: (name: string) => string;
setupEditTitle: (name: string) => string;
setupDescription: string;
saveAndConnect: string;
saveChanges: string;
descriptions: Record<string, string>;
connectedAs: (name: string) => string;
};
+3
View File
@@ -247,6 +247,7 @@ export const zhCN: Translations = {
channels: {
title: "渠道",
connect: "连接",
modify: "修改",
reconnect: "重新连接",
disconnect: "断开连接",
connected: "已连接",
@@ -258,9 +259,11 @@ export const zhCN: Translations = {
unavailable: "当前无法使用渠道连接。",
unavailableShort: "不可用",
setupTitle: (name: string) => `连接 ${name}`,
setupEditTitle: (name: string) => `修改 ${name}`,
setupDescription:
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
saveAndConnect: "保存并连接",
saveChanges: "保存修改",
descriptions: {
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
slack: "接收 Slack 工作区消息和提及。",
+24 -34
View File
@@ -37,8 +37,15 @@ function defaultProviders(): MockChannelProvider[] {
configured: true,
connectable: true,
auth_mode: authMode,
connection_status: "not_connected",
credential_fields: [],
connection_status: "connected",
credential_fields: [
{
name: "token",
label: "Token",
type: "password",
required: true,
},
],
}));
}
@@ -101,9 +108,9 @@ test.describe("IM channels", () => {
await expect(sidebar.getByText("DingTalk")).toBeVisible();
await expect(sidebar.getByText("WeChat")).toBeVisible();
await expect(sidebar.getByText("WeCom")).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Connect" })).toHaveCount(
7,
);
await expect(
sidebar.getByRole("button", { name: "Connected" }),
).toHaveCount(7);
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
await page.getByRole("menuitem", { name: "Settings" }).click();
@@ -118,22 +125,14 @@ test.describe("IM channels", () => {
await expect(page.getByText("WeCom messages")).toBeVisible();
const dialog = page.getByRole("dialog", { name: "Settings" });
const connectButtons = dialog.getByRole("button", { name: "Connect" });
await expect(connectButtons).toHaveCount(7);
await connectButtons.nth(1).click();
await expect(page).toHaveURL(/\/workspace\/chats\/new/);
await expect(
page.getByText("Send /connect abc123 to the DeerFlow Slack bot."),
).toBeVisible();
await expect(dialog.getByRole("button", { name: "Modify" })).toHaveCount(7);
});
test("only enabled providers are shown and setup runs before connect", async ({
test("only enabled providers are shown and runtime setup stays editable", async ({
page,
}) => {
mockLangGraphAPI(page);
let slackConfigured = false;
let connectRequests = 0;
let submittedValues: Record<string, string> | undefined;
void page.route("**/api/channels/providers", (route) => {
@@ -150,7 +149,9 @@ test.describe("IM channels", () => {
configured: slackConfigured,
connectable: slackConfigured,
auth_mode: "binding_code",
connection_status: "not_connected",
connection_status: slackConfigured
? "connected"
: "not_connected",
credential_fields: [
{
name: "bot_token",
@@ -205,27 +206,13 @@ test.describe("IM channels", () => {
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "not_connected",
connection_status: "connected",
credential_fields: [],
}),
});
});
void page.route("**/api/channels/slack/connect", (route) => {
connectRequests += 1;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
provider: "slack",
mode: "binding_code",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
expires_in: 600,
}),
});
});
void page.route("**/api/channels/slack/connect", (route) => route.abort());
await page.goto("/workspace/chats/new");
@@ -245,12 +232,15 @@ test.describe("IM channels", () => {
await expect(setupDialog).toBeHidden();
await expect(
page.getByText("Send /connect abc123 to the DeerFlow Slack bot."),
sidebar.getByRole("button", { name: "Connected" }),
).toBeVisible();
await sidebar.getByRole("button", { name: "Connected" }).click();
await expect(
page.getByRole("dialog", { name: "Modify Slack" }),
).toBeVisible();
expect(submittedValues).toEqual({
bot_token: "xoxb-ui",
app_token: "xapp-ui",
});
expect(connectRequests).toBe(1);
});
});