mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +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}
|
||||
|
||||
Reference in New Issue
Block a user