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