Add user-owned IM channel connections

This commit is contained in:
taohe
2026-06-10 21:07:44 +08:00
parent 0fb18e368c
commit dbe3a3bb0d
47 changed files with 4009 additions and 47 deletions
@@ -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>