mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 01:45:58 +00:00
Add user-owned IM channel connections
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon } from "lucide-react";
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChannelProviderIconProps = SVGProps<SVGSVGElement> & {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export function ChannelProviderIcon({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ChannelProviderIconProps) {
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
|
||||
if (normalizedProvider === "telegram") {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={cn("size-5", className)}
|
||||
{...props}
|
||||
>
|
||||
<circle cx="12" cy="12" r="11" fill="#2AABEE" />
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M17.4 7.2 15.7 16c-.1.7-.5.9-1 .6l-2.8-2.1-1.4 1.3c-.1.2-.3.3-.6.3l.2-2.9 5.3-4.8c.2-.2 0-.3-.3-.1l-6.6 4.1-2.8-.9c-.6-.2-.6-.6.1-.8l10.9-4.2c.5-.2.9.1.7.7Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedProvider === "slack") {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={cn("size-5", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect x="10.1" y="2" width="3.8" height="8.5" rx="1.9" fill="#36C5F0" />
|
||||
<rect
|
||||
x="10.1"
|
||||
y="13.5"
|
||||
width="3.8"
|
||||
height="8.5"
|
||||
rx="1.9"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<rect x="2" y="10.1" width="8.5" height="3.8" rx="1.9" fill="#ECB22E" />
|
||||
<rect
|
||||
x="13.5"
|
||||
y="10.1"
|
||||
width="8.5"
|
||||
height="3.8"
|
||||
rx="1.9"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M8.2 2a1.9 1.9 0 0 1 1.9 1.9v1.9H8.2a1.9 1.9 0 1 1 0-3.8Z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M15.8 22a1.9 1.9 0 0 1-1.9-1.9v-1.9h1.9a1.9 1.9 0 1 1 0 3.8Z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M2 15.8a1.9 1.9 0 0 1 1.9-1.9h1.9v1.9a1.9 1.9 0 1 1-3.8 0Z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M22 8.2a1.9 1.9 0 0 1-1.9 1.9h-1.9V8.2a1.9 1.9 0 1 1 3.8 0Z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedProvider === "discord") {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={cn("size-5", className)}
|
||||
{...props}
|
||||
>
|
||||
<circle cx="12" cy="12" r="11" fill="#5865F2" />
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M8.1 8.4c1.4-.6 2.7-.7 3.9-.7s2.5.1 3.9.7c1 1.5 1.5 3.1 1.4 4.8-.9.7-1.8 1.1-2.8 1.3l-.7-1.1c.4-.1.7-.3 1.1-.5-.3.1-.6.3-.9.4-.7.3-1.4.4-2 .4s-1.3-.1-2-.4c-.3-.1-.6-.2-.9-.4.3.2.7.4 1.1.5l-.7 1.1c-1-.2-1.9-.6-2.8-1.3-.1-1.7.4-3.3 1.4-4.8Zm2.1 3.9c.5 0 .9-.5.9-1.1s-.4-1.1-.9-1.1-.9.5-.9 1.1.4 1.1.9 1.1Zm3.6 0c.5 0 .9-.5.9-1.1s-.4-1.1-.9-1.1-.9.5-.9 1.1.4 1.1.9 1.1Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageCircleIcon aria-hidden="true" className={cn("size-5", className)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useChannelProviders,
|
||||
useConnectChannelProvider,
|
||||
} from "@/core/channels/hooks";
|
||||
import type { ChannelProvider } from "@/core/channels/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ChannelProviderIcon } from "./channel-provider-icon";
|
||||
|
||||
function openConnectUrl(url: string) {
|
||||
const opened = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (!opened) {
|
||||
window.location.assign(url);
|
||||
}
|
||||
}
|
||||
|
||||
function providerCanConnect(provider: ChannelProvider): boolean {
|
||||
return (
|
||||
provider.enabled &&
|
||||
provider.configured &&
|
||||
provider.connection_status !== "connected"
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceChannelsList() {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { enabled, providers, isLoading, error } = useChannelProviders();
|
||||
const connectMutation = useConnectChannelProvider();
|
||||
|
||||
if (!isSidebarOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SidebarGroup className="pt-0">
|
||||
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
|
||||
<div className="space-y-2 px-2 py-1">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !enabled || providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup className="pt-0">
|
||||
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{providers.map((provider) => {
|
||||
const isConnected = provider.connection_status === "connected";
|
||||
const isPending =
|
||||
connectMutation.isPending &&
|
||||
connectMutation.variables === provider.provider;
|
||||
const canConnect = providerCanConnect(provider);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={provider.provider}>
|
||||
<div className="hover:bg-sidebar-accent flex h-10 items-center gap-2 rounded-md px-2 transition-colors">
|
||||
<ChannelProviderIcon
|
||||
provider={provider.provider}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{provider.display_name}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isConnected ? "outline" : "secondary"}
|
||||
className={cn(
|
||||
"h-8 w-24 px-2 text-xs",
|
||||
isConnected && "gap-1",
|
||||
)}
|
||||
disabled={!canConnect || isPending}
|
||||
title={
|
||||
!provider.configured ? t.channels.unconfigured : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
connectMutation.mutate(provider.provider, {
|
||||
onSuccess: (result) => openConnectUrl(result.url),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||
) : isConnected ? (
|
||||
<CheckIcon className="size-3.5" />
|
||||
) : null}
|
||||
<span>
|
||||
{isConnected ? t.channels.connected : t.channels.connect}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckCircle2Icon,
|
||||
LoaderCircleIcon,
|
||||
PlugIcon,
|
||||
UnplugIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
useChannelConnections,
|
||||
useChannelProviders,
|
||||
useConnectChannelProvider,
|
||||
useDisconnectChannelConnection,
|
||||
} from "@/core/channels/hooks";
|
||||
import type { ChannelConnection, ChannelProvider } from "@/core/channels/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ChannelProviderIcon } from "../channels/channel-provider-icon";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
function openConnectUrl(url: string) {
|
||||
const opened = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (!opened) {
|
||||
window.location.assign(url);
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderDescription(
|
||||
provider: ChannelProvider,
|
||||
descriptions: Record<string, string>,
|
||||
): string {
|
||||
return descriptions[provider.provider] ?? provider.display_name;
|
||||
}
|
||||
|
||||
function getConnectionLabel(connection: ChannelConnection): string | null {
|
||||
const account = connection.external_account_name;
|
||||
const workspace = connection.workspace_name;
|
||||
if (account && workspace) {
|
||||
return `${account} · ${workspace}`;
|
||||
}
|
||||
return account ?? workspace ?? connection.external_account_id ?? null;
|
||||
}
|
||||
|
||||
function getStatusLabel(
|
||||
provider: ChannelProvider,
|
||||
connection: ChannelConnection | undefined,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
): string {
|
||||
if (!provider.enabled) {
|
||||
return t.channels.disabled;
|
||||
}
|
||||
if (!provider.configured) {
|
||||
return t.channels.unconfigured;
|
||||
}
|
||||
const status = connection?.status ?? provider.connection_status;
|
||||
if (status === "connected") {
|
||||
return t.channels.connected;
|
||||
}
|
||||
if (status === "pending") {
|
||||
return t.channels.pending;
|
||||
}
|
||||
if (status === "revoked") {
|
||||
return t.channels.revoked;
|
||||
}
|
||||
return t.channels.notConnected;
|
||||
}
|
||||
|
||||
function ChannelProviderItem({
|
||||
provider,
|
||||
connection,
|
||||
}: {
|
||||
provider: ChannelProvider;
|
||||
connection?: ChannelConnection;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const connectMutation = useConnectChannelProvider();
|
||||
const disconnectMutation = useDisconnectChannelConnection();
|
||||
const isConnected = connection?.status === "connected";
|
||||
const canConnect = provider.enabled && provider.configured && !isConnected;
|
||||
const isConnecting =
|
||||
connectMutation.isPending &&
|
||||
connectMutation.variables === provider.provider;
|
||||
const isDisconnecting =
|
||||
disconnectMutation.isPending &&
|
||||
disconnectMutation.variables === connection?.id;
|
||||
const connectionLabel = connection ? getConnectionLabel(connection) : null;
|
||||
const statusLabel = getStatusLabel(provider, connection, 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)}` : ""}
|
||||
</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={!canConnect || isConnecting}
|
||||
title={!provider.configured ? t.channels.unconfigured : undefined}
|
||||
onClick={() => {
|
||||
connectMutation.mutate(provider.provider, {
|
||||
onSuccess: (result) => openConnectUrl(result.url),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<PlugIcon />
|
||||
)}
|
||||
{connection?.status === "revoked"
|
||||
? t.channels.reconnect
|
||||
: t.channels.connect}
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelsSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
enabled,
|
||||
providers,
|
||||
isLoading: providersLoading,
|
||||
error: providersError,
|
||||
} = useChannelProviders();
|
||||
const {
|
||||
connections,
|
||||
isLoading: connectionsLoading,
|
||||
error: connectionsError,
|
||||
} = useChannelConnections();
|
||||
const isLoading = providersLoading || connectionsLoading;
|
||||
const error = providersError ?? connectionsError;
|
||||
|
||||
const connectionByProvider = new Map<string, ChannelConnection>();
|
||||
for (const connection of connections) {
|
||||
const existing = connectionByProvider.get(connection.provider);
|
||||
if (!existing || connection.status === "connected") {
|
||||
connectionByProvider.set(connection.provider, connection);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.channels.title}
|
||||
description={t.settings.channels.description}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive text-sm">{t.channels.unavailable}</div>
|
||||
) : !enabled ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.channels.disabled}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{providers.map((provider) => (
|
||||
<ChannelProviderItem
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
connection={connectionByProvider.get(provider.provider)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
CableIcon,
|
||||
InfoIcon,
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
@@ -21,6 +22,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { ChannelsSettingsPage } from "@/components/workspace/settings/channels-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
@@ -31,6 +33,7 @@ import { cn } from "@/lib/utils";
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "channels"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "skills"
|
||||
@@ -72,6 +75,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
label: t.settings.sections.notification,
|
||||
icon: BellIcon,
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
label: t.settings.sections.channels,
|
||||
icon: CableIcon,
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: t.settings.sections.memory,
|
||||
@@ -84,6 +92,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
[
|
||||
t.settings.sections.account,
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.channels,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.skills,
|
||||
@@ -143,6 +152,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
/>
|
||||
)}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "channels" && <ChannelsSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
import { WorkspaceChannelsList } from "./channels/workspace-channels-list";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
@@ -26,6 +27,7 @@ export function WorkspaceSidebar({
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
<WorkspaceChannelsList />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
ChannelConnectResponse,
|
||||
ChannelConnection,
|
||||
ChannelConnectionsResponse,
|
||||
ChannelProviderId,
|
||||
ChannelProvidersResponse,
|
||||
} from "./types";
|
||||
|
||||
function channelsUrl(path: string): string {
|
||||
return `${getBackendBaseURL()}/api/channels${path}`;
|
||||
}
|
||||
|
||||
async function throwChannelApiError(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<never> {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
detail?: unknown;
|
||||
};
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : fallback);
|
||||
}
|
||||
|
||||
export async function listChannelProviders(): Promise<ChannelProvidersResponse> {
|
||||
const response = await fetch(channelsUrl("/providers"));
|
||||
if (!response.ok) {
|
||||
await throwChannelApiError(
|
||||
response,
|
||||
`Failed to load channel providers: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<ChannelProvidersResponse>;
|
||||
}
|
||||
|
||||
export async function listChannelConnections(): Promise<ChannelConnection[]> {
|
||||
const response = await fetch(channelsUrl("/connections"));
|
||||
if (!response.ok) {
|
||||
await throwChannelApiError(
|
||||
response,
|
||||
`Failed to load channel connections: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as ChannelConnectionsResponse;
|
||||
return data.connections;
|
||||
}
|
||||
|
||||
export async function connectChannelProvider(
|
||||
provider: ChannelProviderId,
|
||||
): Promise<ChannelConnectResponse> {
|
||||
const response = await fetch(
|
||||
channelsUrl(`/${encodeURIComponent(provider)}/connect`),
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
await throwChannelApiError(
|
||||
response,
|
||||
`Failed to connect ${provider}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<ChannelConnectResponse>;
|
||||
}
|
||||
|
||||
export async function disconnectChannelConnection(
|
||||
connectionId: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
channelsUrl(`/connections/${encodeURIComponent(connectionId)}`),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
await throwChannelApiError(
|
||||
response,
|
||||
`Failed to disconnect channel: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
connectChannelProvider,
|
||||
disconnectChannelConnection,
|
||||
listChannelConnections,
|
||||
listChannelProviders,
|
||||
} from "./api";
|
||||
import type { ChannelProviderId } from "./types";
|
||||
|
||||
export const channelProviderQueryKey = ["channelProviders"] as const;
|
||||
export const channelConnectionsQueryKey = ["channelConnections"] as const;
|
||||
|
||||
export function useChannelProviders() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: channelProviderQueryKey,
|
||||
queryFn: () => listChannelProviders(),
|
||||
});
|
||||
return {
|
||||
enabled: data?.enabled ?? false,
|
||||
providers: data?.providers ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useChannelConnections() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: channelConnectionsQueryKey,
|
||||
queryFn: () => listChannelConnections(),
|
||||
});
|
||||
return { connections: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useConnectChannelProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (provider: ChannelProviderId) =>
|
||||
connectChannelProvider(provider),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: channelConnectionsQueryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisconnectChannelConnection() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (connectionId: string) =>
|
||||
disconnectChannelConnection(connectionId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: channelConnectionsQueryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ChannelProviderId = "telegram" | "slack" | "discord" | string;
|
||||
|
||||
export interface ChannelProvider {
|
||||
provider: ChannelProviderId;
|
||||
display_name: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
auth_mode: string;
|
||||
connection_status: string;
|
||||
}
|
||||
|
||||
export interface ChannelProvidersResponse {
|
||||
enabled: boolean;
|
||||
providers: ChannelProvider[];
|
||||
}
|
||||
|
||||
export interface ChannelConnection {
|
||||
id: string;
|
||||
provider: ChannelProviderId;
|
||||
status: string;
|
||||
external_account_id?: string | null;
|
||||
external_account_name?: string | null;
|
||||
workspace_id?: string | null;
|
||||
workspace_name?: string | null;
|
||||
scopes: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChannelConnectionsResponse {
|
||||
connections: ChannelConnection[];
|
||||
}
|
||||
|
||||
export interface ChannelConnectResponse {
|
||||
provider: ChannelProviderId;
|
||||
mode: string;
|
||||
url: string;
|
||||
expires_in: number;
|
||||
}
|
||||
@@ -170,6 +170,7 @@ export const enUS: Translations = {
|
||||
sidebar: {
|
||||
newChat: "New chat",
|
||||
chats: "Chats",
|
||||
channels: "Channels",
|
||||
recentChats: "Recent chats",
|
||||
demoChats: "Demo chats",
|
||||
agents: "Agents",
|
||||
@@ -254,6 +255,27 @@ export const enUS: Translations = {
|
||||
searchChats: "Search chats",
|
||||
},
|
||||
|
||||
// Channels
|
||||
channels: {
|
||||
title: "Channels",
|
||||
connect: "Connect",
|
||||
reconnect: "Reconnect",
|
||||
disconnect: "Disconnect",
|
||||
connected: "Connected",
|
||||
notConnected: "Not connected",
|
||||
pending: "Pending",
|
||||
revoked: "Disconnected",
|
||||
disabled: "Disabled",
|
||||
unconfigured: "Not configured",
|
||||
unavailable: "Channel connections are unavailable right now.",
|
||||
descriptions: {
|
||||
telegram: "Telegram direct messages through your DeerFlow bot.",
|
||||
slack: "Slack workspace messages and mentions.",
|
||||
discord: "Discord server messages through your DeerFlow bot.",
|
||||
},
|
||||
connectedAs: (name: string) => `Connected as ${name}.`,
|
||||
},
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: "DeerFlow",
|
||||
@@ -354,6 +376,7 @@ export const enUS: Translations = {
|
||||
sections: {
|
||||
account: "Account",
|
||||
appearance: "Appearance",
|
||||
channels: "Channels",
|
||||
memory: "Memory",
|
||||
tools: "Tools",
|
||||
skills: "Skills",
|
||||
@@ -456,6 +479,13 @@ export const enUS: Translations = {
|
||||
title: "Tools",
|
||||
description: "Manage the configuration and enabled status of MCP tools.",
|
||||
},
|
||||
channels: {
|
||||
title: "Channels",
|
||||
description:
|
||||
"Connect IM accounts that can send messages to DeerFlow from outside the browser.",
|
||||
disabled:
|
||||
"Channel connections are not enabled on this server. Ask an administrator to enable channel_connections.",
|
||||
},
|
||||
skills: {
|
||||
title: "Agent Skills",
|
||||
description:
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface Translations {
|
||||
chats: string;
|
||||
demoChats: string;
|
||||
agents: string;
|
||||
channels: string;
|
||||
};
|
||||
|
||||
// Agents
|
||||
@@ -185,6 +186,23 @@ export interface Translations {
|
||||
searchChats: string;
|
||||
};
|
||||
|
||||
// Channels
|
||||
channels: {
|
||||
title: string;
|
||||
connect: string;
|
||||
reconnect: string;
|
||||
disconnect: string;
|
||||
connected: string;
|
||||
notConnected: string;
|
||||
pending: string;
|
||||
revoked: string;
|
||||
disabled: string;
|
||||
unconfigured: string;
|
||||
unavailable: string;
|
||||
descriptions: Record<string, string>;
|
||||
connectedAs: (name: string) => string;
|
||||
};
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: string;
|
||||
@@ -281,6 +299,7 @@ export interface Translations {
|
||||
sections: {
|
||||
account: string;
|
||||
appearance: string;
|
||||
channels: string;
|
||||
memory: string;
|
||||
tools: string;
|
||||
skills: string;
|
||||
@@ -376,6 +395,11 @@ export interface Translations {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
channels: {
|
||||
title: string;
|
||||
description: string;
|
||||
disabled: string;
|
||||
};
|
||||
skills: {
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
@@ -164,6 +164,7 @@ export const zhCN: Translations = {
|
||||
sidebar: {
|
||||
newChat: "新对话",
|
||||
chats: "对话",
|
||||
channels: "渠道",
|
||||
recentChats: "最近的对话",
|
||||
demoChats: "演示对话",
|
||||
agents: "智能体",
|
||||
@@ -242,6 +243,27 @@ export const zhCN: Translations = {
|
||||
searchChats: "搜索对话",
|
||||
},
|
||||
|
||||
// Channels
|
||||
channels: {
|
||||
title: "渠道",
|
||||
connect: "连接",
|
||||
reconnect: "重新连接",
|
||||
disconnect: "断开连接",
|
||||
connected: "已连接",
|
||||
notConnected: "未连接",
|
||||
pending: "待完成",
|
||||
revoked: "已断开",
|
||||
disabled: "已停用",
|
||||
unconfigured: "未配置",
|
||||
unavailable: "当前无法使用渠道连接。",
|
||||
descriptions: {
|
||||
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
|
||||
slack: "接收 Slack 工作区消息和提及。",
|
||||
discord: "通过 DeerFlow Bot 接收 Discord 服务器消息。",
|
||||
},
|
||||
connectedAs: (name: string) => `已连接为 ${name}。`,
|
||||
},
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: "DeerFlow",
|
||||
@@ -338,6 +360,7 @@ export const zhCN: Translations = {
|
||||
sections: {
|
||||
account: "账号",
|
||||
appearance: "外观",
|
||||
channels: "渠道",
|
||||
memory: "记忆",
|
||||
tools: "工具",
|
||||
skills: "技能",
|
||||
@@ -437,6 +460,12 @@ export const zhCN: Translations = {
|
||||
title: "工具",
|
||||
description: "管理 MCP 工具的配置和启用状态。",
|
||||
},
|
||||
channels: {
|
||||
title: "渠道",
|
||||
description: "连接可在浏览器外向 DeerFlow 发送消息的即时通讯账号。",
|
||||
disabled:
|
||||
"当前服务器未启用渠道连接。请联系管理员开启 channel_connections。",
|
||||
},
|
||||
skills: {
|
||||
title: "技能",
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
function mockChannelsAPI(page: Page) {
|
||||
void page.route("**/api/channels/providers", (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
providers: [
|
||||
{
|
||||
provider: "telegram",
|
||||
display_name: "Telegram",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
auth_mode: "deep_link",
|
||||
connection_status: "not_connected",
|
||||
},
|
||||
{
|
||||
provider: "slack",
|
||||
display_name: "Slack",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
auth_mode: "oauth",
|
||||
connection_status: "not_connected",
|
||||
},
|
||||
{
|
||||
provider: "discord",
|
||||
display_name: "Discord",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
auth_mode: "oauth_and_bot_install",
|
||||
connection_status: "not_connected",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
void page.route("**/api/channels/connections", (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ connections: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("IM channels", () => {
|
||||
test("sidebar and settings expose channel connections", async ({ page }) => {
|
||||
mockLangGraphAPI(page);
|
||||
mockChannelsAPI(page);
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||
await expect(sidebar.getByText("Channels")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(sidebar.getByText("Telegram")).toBeVisible();
|
||||
await expect(sidebar.getByText("Slack")).toBeVisible();
|
||||
await expect(sidebar.getByText("Discord")).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Connect" })).toHaveCount(
|
||||
3,
|
||||
);
|
||||
|
||||
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
|
||||
await page.getByRole("menuitem", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: "Channels" }).click();
|
||||
|
||||
await expect(page.getByText("Telegram direct messages")).toBeVisible();
|
||||
await expect(page.getByText("Slack workspace messages")).toBeVisible();
|
||||
await expect(page.getByText("Discord server messages")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/core/api/fetcher", () => ({
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/core/config", () => ({
|
||||
getBackendBaseURL: () => "/backend",
|
||||
}));
|
||||
|
||||
import { fetch as fetcher } from "@/core/api/fetcher";
|
||||
import {
|
||||
connectChannelProvider,
|
||||
disconnectChannelConnection,
|
||||
listChannelConnections,
|
||||
listChannelProviders,
|
||||
} from "@/core/channels/api";
|
||||
|
||||
const mockedFetch = vi.mocked(fetcher);
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
statusText: status >= 400 ? "Bad Request" : "OK",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("channels api", () => {
|
||||
test("loads provider catalog", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
enabled: true,
|
||||
providers: [
|
||||
{
|
||||
provider: "telegram",
|
||||
display_name: "Telegram",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
auth_mode: "deep_link",
|
||||
connection_status: "not_connected",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(listChannelProviders()).resolves.toMatchObject({
|
||||
enabled: true,
|
||||
providers: [{ provider: "telegram", display_name: "Telegram" }],
|
||||
});
|
||||
expect(mockedFetch).toHaveBeenCalledWith("/backend/api/channels/providers");
|
||||
});
|
||||
|
||||
test("loads current user's connections", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
connections: [
|
||||
{
|
||||
id: "connection-1",
|
||||
provider: "telegram",
|
||||
status: "connected",
|
||||
external_account_name: "Alice",
|
||||
scopes: [],
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(listChannelConnections()).resolves.toMatchObject([
|
||||
{ id: "connection-1", provider: "telegram", status: "connected" },
|
||||
]);
|
||||
expect(mockedFetch).toHaveBeenCalledWith(
|
||||
"/backend/api/channels/connections",
|
||||
);
|
||||
});
|
||||
|
||||
test("starts a provider connection flow", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
provider: "telegram",
|
||||
mode: "deep_link",
|
||||
url: "https://t.me/deerflow_bot?start=state",
|
||||
expires_in: 600,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(connectChannelProvider("telegram")).resolves.toMatchObject({
|
||||
provider: "telegram",
|
||||
url: "https://t.me/deerflow_bot?start=state",
|
||||
});
|
||||
expect(mockedFetch).toHaveBeenCalledWith(
|
||||
"/backend/api/channels/telegram/connect",
|
||||
{ method: "POST" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnects a channel connection", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
await expect(
|
||||
disconnectChannelConnection("connection-1"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockedFetch).toHaveBeenCalledWith(
|
||||
"/backend/api/channels/connections/connection-1",
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
});
|
||||
|
||||
test("uses backend detail for failed requests", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(400, { detail: "Channel provider is not configured" }),
|
||||
);
|
||||
|
||||
await expect(connectChannelProvider("slack")).rejects.toThrow(
|
||||
"Channel provider is not configured",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user