mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 09:55:59 +00:00
Add runtime setup for enabled IM channels
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type {
|
||||
ChannelProvider,
|
||||
ChannelRuntimeConfigValues,
|
||||
} from "@/core/channels/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
type ChannelRuntimeConfigDialogProps = {
|
||||
provider: ChannelProvider | null;
|
||||
open: boolean;
|
||||
submitting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (
|
||||
provider: ChannelProvider,
|
||||
values: ChannelRuntimeConfigValues,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function ChannelRuntimeConfigDialog({
|
||||
provider,
|
||||
open,
|
||||
submitting,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: ChannelRuntimeConfigDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [values, setValues] = useState<ChannelRuntimeConfigValues>({});
|
||||
const fields = useMemo(
|
||||
() => provider?.credential_fields ?? [],
|
||||
[provider?.credential_fields],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !provider) {
|
||||
setValues({});
|
||||
return;
|
||||
}
|
||||
setValues(
|
||||
Object.fromEntries(fields.map((field) => [field.name, ""])) as
|
||||
| ChannelRuntimeConfigValues
|
||||
| {},
|
||||
);
|
||||
}, [fields, open, provider]);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit(provider, values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t.channels.setupTitle(provider.display_name)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t.channels.setupDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field) => {
|
||||
const inputId = `channel-${provider.provider}-${field.name}`;
|
||||
return (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-sm leading-none font-medium"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<Input
|
||||
id={inputId}
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
value={values[field.name] ?? ""}
|
||||
required={field.required}
|
||||
autoComplete="off"
|
||||
onChange={(event) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : null}
|
||||
{t.channels.saveAndConnect}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useConfigureChannelProvider,
|
||||
useChannelProviders,
|
||||
useConnectChannelProvider,
|
||||
} from "@/core/channels/hooks";
|
||||
@@ -26,6 +28,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ChannelProviderIcon } from "./channel-provider-icon";
|
||||
import { ChannelRuntimeConfigDialog } from "./channel-runtime-config-dialog";
|
||||
|
||||
function providerCanConnect(provider: ChannelProvider): boolean {
|
||||
return (
|
||||
@@ -50,11 +53,52 @@ function getProviderUnavailableReason(
|
||||
return provider.unavailable_reason ?? undefined;
|
||||
}
|
||||
|
||||
function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
|
||||
return (
|
||||
provider.enabled &&
|
||||
!provider.configured &&
|
||||
(provider.credential_fields?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceChannelsList() {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { enabled, providers, isLoading, error } = useChannelProviders();
|
||||
const connectMutation = useConnectChannelProvider();
|
||||
const configureMutation = useConfigureChannelProvider();
|
||||
const [setupProvider, setSetupProvider] = useState<ChannelProvider | null>(
|
||||
null,
|
||||
);
|
||||
const visibleProviders = providers.filter((provider) => provider.enabled);
|
||||
|
||||
const startConnect = (
|
||||
provider: ChannelProvider,
|
||||
preparedWindow?: Window | null,
|
||||
) => {
|
||||
const connectWindow =
|
||||
preparedWindow !== undefined
|
||||
? preparedWindow
|
||||
: provider.auth_mode === "deep_link"
|
||||
? prepareConnectWindow()
|
||||
: null;
|
||||
void connectMutation
|
||||
.mutateAsync(provider.provider)
|
||||
.then((result) => {
|
||||
if (result.url) {
|
||||
openConnectUrl(result.url, connectWindow);
|
||||
return;
|
||||
}
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.success(result.instruction);
|
||||
})
|
||||
.catch((error) => {
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.channels.unavailable,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (!isSidebarOpen) {
|
||||
return null;
|
||||
@@ -73,7 +117,7 @@ export function WorkspaceChannelsList() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !enabled || providers.length === 0) {
|
||||
if (error || !enabled || visibleProviders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -81,11 +125,13 @@ export function WorkspaceChannelsList() {
|
||||
<SidebarGroup className="pt-0">
|
||||
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{providers.map((provider) => {
|
||||
{visibleProviders.map((provider) => {
|
||||
const isConnected = provider.connection_status === "connected";
|
||||
const isPending =
|
||||
connectMutation.isPending &&
|
||||
connectMutation.variables === provider.provider;
|
||||
(connectMutation.isPending &&
|
||||
connectMutation.variables === provider.provider) ||
|
||||
(configureMutation.isPending &&
|
||||
configureMutation.variables?.provider === provider.provider);
|
||||
const canConnect = providerCanConnect(provider);
|
||||
const unavailableReason = getProviderUnavailableReason(provider, t);
|
||||
|
||||
@@ -110,33 +156,17 @@ export function WorkspaceChannelsList() {
|
||||
disabled={isConnected || isPending}
|
||||
title={unavailableReason}
|
||||
onClick={() => {
|
||||
if (providerNeedsRuntimeConfig(provider)) {
|
||||
setSetupProvider(provider);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canConnect) {
|
||||
toast.error(unavailableReason ?? t.channels.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
const connectWindow =
|
||||
provider.auth_mode === "deep_link"
|
||||
? prepareConnectWindow()
|
||||
: null;
|
||||
void connectMutation
|
||||
.mutateAsync(provider.provider)
|
||||
.then((result) => {
|
||||
if (result.url) {
|
||||
openConnectUrl(result.url, connectWindow);
|
||||
return;
|
||||
}
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.success(result.instruction);
|
||||
})
|
||||
.catch((error) => {
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t.channels.unavailable,
|
||||
);
|
||||
});
|
||||
startConnect(provider);
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
@@ -153,6 +183,32 @@ export function WorkspaceChannelsList() {
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
<ChannelRuntimeConfigDialog
|
||||
provider={setupProvider}
|
||||
open={setupProvider !== null}
|
||||
submitting={configureMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSetupProvider(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={(provider, values) => {
|
||||
const connectWindow =
|
||||
provider.auth_mode === "deep_link" ? prepareConnectWindow() : null;
|
||||
void configureMutation
|
||||
.mutateAsync({ provider: provider.provider, values })
|
||||
.then((configuredProvider) => {
|
||||
setSetupProvider(null);
|
||||
startConnect(configuredProvider, connectWindow);
|
||||
})
|
||||
.catch((error) => {
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.channels.unavailable,
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PlugIcon,
|
||||
UnplugIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
useConfigureChannelProvider,
|
||||
useChannelConnections,
|
||||
useChannelProviders,
|
||||
useConnectChannelProvider,
|
||||
@@ -35,6 +37,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ChannelProviderIcon } from "../channels/channel-provider-icon";
|
||||
import { ChannelRuntimeConfigDialog } from "../channels/channel-runtime-config-dialog";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -97,6 +100,14 @@ function getProviderUnavailableReason(
|
||||
return provider.unavailable_reason ?? undefined;
|
||||
}
|
||||
|
||||
function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
|
||||
return (
|
||||
provider.enabled &&
|
||||
!provider.configured &&
|
||||
(provider.credential_fields?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelProviderItem({
|
||||
provider,
|
||||
connection,
|
||||
@@ -106,14 +117,18 @@ function ChannelProviderItem({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const connectMutation = useConnectChannelProvider();
|
||||
const configureMutation = useConfigureChannelProvider();
|
||||
const disconnectMutation = useDisconnectChannelConnection();
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const isConnected = connection?.status === "connected";
|
||||
const canConnect =
|
||||
(provider.connectable ?? (provider.enabled && provider.configured)) &&
|
||||
!isConnected;
|
||||
const isConnecting =
|
||||
connectMutation.isPending &&
|
||||
connectMutation.variables === provider.provider;
|
||||
(connectMutation.isPending &&
|
||||
connectMutation.variables === provider.provider) ||
|
||||
(configureMutation.isPending &&
|
||||
configureMutation.variables?.provider === provider.provider);
|
||||
const isDisconnecting =
|
||||
disconnectMutation.isPending &&
|
||||
disconnectMutation.variables === connection?.id;
|
||||
@@ -121,94 +136,137 @@ function ChannelProviderItem({
|
||||
const statusLabel = getStatusLabel(provider, connection, t);
|
||||
const unavailableReason = getProviderUnavailableReason(provider, t);
|
||||
|
||||
return (
|
||||
<Item variant="outline" className="w-full items-start">
|
||||
<ItemMedia variant="icon" className="bg-background">
|
||||
<ChannelProviderIcon provider={provider.provider} className="size-5" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full">
|
||||
<span className="truncate">{provider.display_name}</span>
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className={cn(!isConnected && "text-muted-foreground")}
|
||||
>
|
||||
{isConnected ? <CheckCircle2Icon /> : <AlertCircleIcon />}
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-none">
|
||||
{getProviderDescription(provider, t.channels.descriptions)}
|
||||
{connectionLabel ? ` ${t.channels.connectedAs(connectionLabel)}` : ""}
|
||||
{!isConnected && provider.unavailable_reason
|
||||
? ` ${provider.unavailable_reason}`
|
||||
: ""}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="ml-auto">
|
||||
{isConnected && 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>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isConnecting}
|
||||
title={unavailableReason}
|
||||
onClick={() => {
|
||||
if (!canConnect) {
|
||||
toast.error(unavailableReason ?? t.channels.unavailable);
|
||||
return;
|
||||
}
|
||||
const startConnect = (
|
||||
connectProvider: ChannelProvider,
|
||||
preparedWindow?: Window | null,
|
||||
) => {
|
||||
const connectWindow =
|
||||
preparedWindow !== undefined
|
||||
? preparedWindow
|
||||
: connectProvider.auth_mode === "deep_link"
|
||||
? prepareConnectWindow()
|
||||
: null;
|
||||
void connectMutation
|
||||
.mutateAsync(connectProvider.provider)
|
||||
.then((result) => {
|
||||
if (result.url) {
|
||||
openConnectUrl(result.url, connectWindow);
|
||||
return;
|
||||
}
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.success(result.instruction);
|
||||
})
|
||||
.catch((error) => {
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.channels.unavailable,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const connectWindow =
|
||||
provider.auth_mode === "deep_link"
|
||||
? prepareConnectWindow()
|
||||
: null;
|
||||
void connectMutation
|
||||
.mutateAsync(provider.provider)
|
||||
.then((result) => {
|
||||
if (result.url) {
|
||||
openConnectUrl(result.url, connectWindow);
|
||||
return;
|
||||
}
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.success(result.instruction);
|
||||
})
|
||||
.catch((error) => {
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t.channels.unavailable,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<PlugIcon />
|
||||
)}
|
||||
{connection?.status === "revoked"
|
||||
? t.channels.reconnect
|
||||
: t.channels.connect}
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
return (
|
||||
<>
|
||||
<Item variant="outline" className="w-full items-start">
|
||||
<ItemMedia variant="icon" className="bg-background">
|
||||
<ChannelProviderIcon
|
||||
provider={provider.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full">
|
||||
<span className="truncate">{provider.display_name}</span>
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className={cn(!isConnected && "text-muted-foreground")}
|
||||
>
|
||||
{isConnected ? <CheckCircle2Icon /> : <AlertCircleIcon />}
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-none">
|
||||
{getProviderDescription(provider, t.channels.descriptions)}
|
||||
{connectionLabel
|
||||
? ` ${t.channels.connectedAs(connectionLabel)}`
|
||||
: ""}
|
||||
{!isConnected && provider.unavailable_reason
|
||||
? ` ${provider.unavailable_reason}`
|
||||
: ""}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="ml-auto">
|
||||
{isConnected && 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>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isConnecting}
|
||||
title={unavailableReason}
|
||||
onClick={() => {
|
||||
if (providerNeedsRuntimeConfig(provider)) {
|
||||
setSetupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canConnect) {
|
||||
toast.error(unavailableReason ?? t.channels.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
startConnect(provider);
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<PlugIcon />
|
||||
)}
|
||||
{connection?.status === "revoked"
|
||||
? t.channels.reconnect
|
||||
: t.channels.connect}
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<ChannelRuntimeConfigDialog
|
||||
provider={provider}
|
||||
open={setupOpen}
|
||||
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) => {
|
||||
setSetupOpen(false);
|
||||
startConnect(configuredProvider, connectWindow);
|
||||
})
|
||||
.catch((error) => {
|
||||
closeConnectWindow(connectWindow);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.channels.unavailable,
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +285,7 @@ export function ChannelsSettingsPage() {
|
||||
} = useChannelConnections();
|
||||
const isLoading = providersLoading || connectionsLoading;
|
||||
const error = providersError ?? connectionsError;
|
||||
const visibleProviders = providers.filter((provider) => provider.enabled);
|
||||
|
||||
const connectionByProvider = new Map<string, ChannelConnection>();
|
||||
for (const connection of connections) {
|
||||
@@ -249,9 +308,13 @@ export function ChannelsSettingsPage() {
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.channels.disabled}
|
||||
</div>
|
||||
) : visibleProviders.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.channels.disabled}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{providers.map((provider) => (
|
||||
{visibleProviders.map((provider) => (
|
||||
<ChannelProviderItem
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
ChannelConnection,
|
||||
ChannelConnectionsResponse,
|
||||
ChannelProviderId,
|
||||
ChannelProvider,
|
||||
ChannelProvidersResponse,
|
||||
ChannelRuntimeConfigValues,
|
||||
} from "./types";
|
||||
|
||||
function channelsUrl(path: string): string {
|
||||
@@ -62,6 +64,27 @@ export async function connectChannelProvider(
|
||||
return response.json() as Promise<ChannelConnectResponse>;
|
||||
}
|
||||
|
||||
export async function configureChannelProvider(
|
||||
provider: ChannelProviderId,
|
||||
values: ChannelRuntimeConfigValues,
|
||||
): Promise<ChannelProvider> {
|
||||
const response = await fetch(
|
||||
channelsUrl(`/${encodeURIComponent(provider)}/runtime-config`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await throwChannelApiError(
|
||||
response,
|
||||
`Failed to configure ${provider}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<ChannelProvider>;
|
||||
}
|
||||
|
||||
export async function disconnectChannelConnection(
|
||||
connectionId: string,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
configureChannelProvider,
|
||||
connectChannelProvider,
|
||||
disconnectChannelConnection,
|
||||
listChannelConnections,
|
||||
listChannelProviders,
|
||||
} from "./api";
|
||||
import type { ChannelProviderId } from "./types";
|
||||
import type { ChannelProviderId, ChannelRuntimeConfigValues } from "./types";
|
||||
|
||||
export const channelProviderQueryKey = ["channelProviders"] as const;
|
||||
export const channelConnectionsQueryKey = ["channelConnections"] as const;
|
||||
@@ -46,6 +47,25 @@ export function useConnectChannelProvider() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfigureChannelProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
provider,
|
||||
values,
|
||||
}: {
|
||||
provider: ChannelProviderId;
|
||||
values: ChannelRuntimeConfigValues;
|
||||
}) => configureChannelProvider(provider, values),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: channelConnectionsQueryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisconnectChannelConnection() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export type ChannelProviderId = "telegram" | "slack" | "discord" | string;
|
||||
|
||||
export interface ChannelCredentialField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelProvider {
|
||||
provider: ChannelProviderId;
|
||||
display_name: string;
|
||||
@@ -9,6 +16,7 @@ export interface ChannelProvider {
|
||||
unavailable_reason?: string | null;
|
||||
auth_mode: string;
|
||||
connection_status: string;
|
||||
credential_fields: ChannelCredentialField[];
|
||||
}
|
||||
|
||||
export interface ChannelProvidersResponse {
|
||||
@@ -40,3 +48,5 @@ export interface ChannelConnectResponse {
|
||||
instruction: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export type ChannelRuntimeConfigValues = Record<string, string>;
|
||||
|
||||
@@ -269,6 +269,10 @@ export const enUS: Translations = {
|
||||
unconfigured: "Not configured",
|
||||
unavailable: "Channel connections are unavailable right now.",
|
||||
unavailableShort: "Unavailable",
|
||||
setupTitle: (name: string) => `Connect ${name}`,
|
||||
setupDescription:
|
||||
"Enter the values needed by this server process. They are not written to config.yaml.",
|
||||
saveAndConnect: "Save and connect",
|
||||
descriptions: {
|
||||
telegram: "Telegram direct messages through your DeerFlow bot.",
|
||||
slack: "Slack workspace messages and mentions.",
|
||||
|
||||
@@ -200,6 +200,9 @@ export interface Translations {
|
||||
unconfigured: string;
|
||||
unavailable: string;
|
||||
unavailableShort: string;
|
||||
setupTitle: (name: string) => string;
|
||||
setupDescription: string;
|
||||
saveAndConnect: string;
|
||||
descriptions: Record<string, string>;
|
||||
connectedAs: (name: string) => string;
|
||||
};
|
||||
|
||||
@@ -257,6 +257,10 @@ export const zhCN: Translations = {
|
||||
unconfigured: "未配置",
|
||||
unavailable: "当前无法使用渠道连接。",
|
||||
unavailableShort: "不可用",
|
||||
setupTitle: (name: string) => `连接 ${name}`,
|
||||
setupDescription:
|
||||
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
|
||||
saveAndConnect: "保存并连接",
|
||||
descriptions: {
|
||||
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
|
||||
slack: "接收 Slack 工作区消息和提及。",
|
||||
|
||||
@@ -21,6 +21,12 @@ type MockChannelProvider = {
|
||||
auth_mode: string;
|
||||
connection_status: string;
|
||||
unavailable_reason?: string | null;
|
||||
credential_fields?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function defaultProviders(): MockChannelProvider[] {
|
||||
@@ -32,6 +38,7 @@ function defaultProviders(): MockChannelProvider[] {
|
||||
connectable: true,
|
||||
auth_mode: authMode,
|
||||
connection_status: "not_connected",
|
||||
credential_fields: [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -121,41 +128,129 @@ test.describe("IM channels", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("unavailable providers stay clickable and explain what is missing", async ({
|
||||
test("only enabled providers are shown and setup runs before connect", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
const unavailableReason =
|
||||
"Enable and configure channels.slack with channels.slack.bot_token and channels.slack.app_token.";
|
||||
let slackConfigured = false;
|
||||
let connectRequests = 0;
|
||||
mockChannelsAPI(
|
||||
page,
|
||||
[
|
||||
{
|
||||
let submittedValues: Record<string, string> | undefined;
|
||||
|
||||
void page.route("**/api/channels/providers", (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
providers: [
|
||||
{
|
||||
provider: "slack",
|
||||
display_name: "Slack",
|
||||
enabled: true,
|
||||
configured: slackConfigured,
|
||||
connectable: slackConfigured,
|
||||
auth_mode: "binding_code",
|
||||
connection_status: "not_connected",
|
||||
credential_fields: [
|
||||
{
|
||||
name: "bot_token",
|
||||
label: "Bot token",
|
||||
type: "password",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "app_token",
|
||||
label: "App token",
|
||||
type: "password",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "discord",
|
||||
display_name: "Discord",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
connectable: false,
|
||||
auth_mode: "binding_code",
|
||||
connection_status: "not_connected",
|
||||
credential_fields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
void page.route("**/api/channels/connections", (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ connections: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
void page.route("**/api/channels/slack/runtime-config", async (route) => {
|
||||
const body = route.request().postDataJSON() as {
|
||||
values: Record<string, string>;
|
||||
};
|
||||
submittedValues = body.values;
|
||||
slackConfigured = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
provider: "slack",
|
||||
display_name: "Slack",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
connectable: false,
|
||||
unavailable_reason: unavailableReason,
|
||||
configured: true,
|
||||
connectable: true,
|
||||
auth_mode: "binding_code",
|
||||
connection_status: "not_connected",
|
||||
},
|
||||
],
|
||||
() => {
|
||||
connectRequests += 1;
|
||||
},
|
||||
);
|
||||
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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||
await expect(sidebar.getByText("Slack")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar.getByText("Discord")).toBeHidden();
|
||||
const connectButton = sidebar.getByRole("button", { name: "Connect" });
|
||||
await expect(connectButton).toBeEnabled({ timeout: 15_000 });
|
||||
await expect(connectButton).toBeEnabled();
|
||||
|
||||
await connectButton.click();
|
||||
|
||||
await expect(page.getByText(unavailableReason)).toBeVisible();
|
||||
expect(connectRequests).toBe(0);
|
||||
const setupDialog = page.getByRole("dialog", { name: "Connect Slack" });
|
||||
await expect(setupDialog).toBeVisible();
|
||||
await setupDialog.getByLabel("Bot token").fill("xoxb-ui");
|
||||
await setupDialog.getByLabel("App token").fill("xapp-ui");
|
||||
await setupDialog.getByRole("button", { name: "Save and connect" }).click();
|
||||
|
||||
await expect(setupDialog).toBeHidden();
|
||||
await expect(
|
||||
page.getByText("Send /connect abc123 to the DeerFlow Slack bot."),
|
||||
).toBeVisible();
|
||||
expect(submittedValues).toEqual({
|
||||
bot_token: "xoxb-ui",
|
||||
app_token: "xapp-ui",
|
||||
});
|
||||
expect(connectRequests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("@/core/config", () => ({
|
||||
|
||||
import { fetch as fetcher } from "@/core/api/fetcher";
|
||||
import {
|
||||
configureChannelProvider,
|
||||
connectChannelProvider,
|
||||
disconnectChannelConnection,
|
||||
listChannelConnections,
|
||||
@@ -122,6 +123,41 @@ describe("channels api", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("submits runtime provider configuration", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
provider: "slack",
|
||||
display_name: "Slack",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
connectable: true,
|
||||
auth_mode: "binding_code",
|
||||
connection_status: "not_connected",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
configureChannelProvider("slack", {
|
||||
bot_token: "xoxb-ui",
|
||||
app_token: "xapp-ui",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: "slack",
|
||||
configured: true,
|
||||
connectable: true,
|
||||
});
|
||||
expect(mockedFetch).toHaveBeenCalledWith(
|
||||
"/backend/api/channels/slack/runtime-config",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
values: { bot_token: "xoxb-ui", app_token: "xapp-ui" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnects a channel connection", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user