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