feat(im): Add user-owned IM channel connections (#3487)

* Add user-owned IM channel connections

* Fix dev startup and channel connect popup

* Use async channel connect flow

* Harden dev service daemon startup

* Support local IM channel connections

* Align IM connections with local channels

* Fix safe user id digest algorithm

* Address Copilot IM channel feedback

* Address IM channel review comments

* Support all integrated IM channel connections

* Format additional channel connection tests

* Keep unavailable channel connect buttons clickable

* Fix IM channel provider icons

* Add runtime setup for enabled IM channels

* Guard global shortcut key handling

* Keep configured IM channels editable

* Avoid password autofill for channel secrets

* Make channel threads visible to connection owners

* Persist IM runtime config locally

* Allow disconnecting runtime IM channels

* Route no-auth channel sessions to local user

* Use default user for auth-disabled local mode

* Show IM channel source on threads

* Prefill IM channel runtime config

* Reflect IM channel runtime health

* Ignore Feishu message read events

* Ignore Feishu non-content message events

* Let setup wizard enable IM channels

* Fix frontend formatting after merge

* Stabilize backend tests without local config

* Isolate channel runtime config tests

* Address channel connection review comments

* Use sha256 user buckets with legacy migration

* Ensure runtime IM channels are ready after restart

* Persist disconnected IM channel state

* Address channel connection review comments

* Address channel connection review findings

Frontend connect flow:
- Open the runtime-config dialog only when a provider still needs
  credentials; configured providers go straight to the connect flow, so
  the binding-code/deep-link path is reachable from the UI again.
- After saving credentials, continue into the connect flow when a user
  binding is still required (multi-user mode) instead of stopping at a
  "Connected" toast.
- Extract shared provider-state helpers to core/channels/provider-state
  and add unit + e2e coverage for the direct-connect and
  configure-then-connect paths.

Provider status semantics:
- Report connection_status from the user's newest connection row;
  with no binding it is not_connected, except in auth-disabled local
  mode where a configured running channel is effectively connected.

Concurrency and event-loop correctness:
- Offload ChannelRuntimeConfigStore construction and writes, channel
  service construction, and Slack connection replies to threads; add a
  tests/blocking_io/ anchor for the runtime-config handlers.
- Consume binding codes with a conditional UPDATE so a code can only be
  used once under concurrent workers; retry upsert_connection as an
  update when a concurrent insert wins the unique constraint.
- Serialize ensure_channel_ready per channel so concurrent provider
  polls cannot double-start a channel worker.

Config and migration hardening:
- Stop mutating the get_app_config()-cached Telegram provider config;
  the runtime store now owns the UI-entered bot username.
- Register channel_connections in STARTUP_ONLY_FIELDS with the
  standardized startup-only Field description.
- Match the legacy unsafe-id bucket by recomputing its exact SHA-1 name
  so another user's same-prefix bucket can never be migrated.
- Remove the unused Telegram process_webhook_update path and document
  src/core/channels in the frontend docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address PR review comments on authz scoping and channel runtime

Security (review feedback from ShenAC-SAC):
- Scope internal-token callers to the connection owner carried in
  X-DeerFlow-Owner-User-Id instead of bypassing owner checks outright,
  in both require_permission(owner_check=True) and the stateless run
  endpoints. Internal callers keep access to their own and
  shared/legacy threads, and may claim a default-owned channel thread
  for its real owner, but a leaked internal token no longer grants
  cross-user thread access.
- Require admin privileges for POST/DELETE /api/channels/{provider}/
  runtime-config: runtime credentials and channel workers are
  instance-wide shared state (same model as the MCP config API).
  Read-only provider listing stays available to all users.

Performance (review feedback from willem-bd):
- Skip the redundant thread channel-metadata PATCH after the first
  successful backfill per thread.
- Reuse the per-connection Slack WebClient until its token changes
  instead of constructing one per outbound message.
- Reconcile channel readiness for all providers concurrently in
  GET /api/channels/providers.

Also resolve the code-quality unused-import flag in the blocking-io
anchor by pre-importing the channel service via importlib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix prettier formatting in provider-state test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reconcile UI runtime channel config with config reload on restart

Main now reloads a channel's config.yaml entry on restart_channel()
(#3514, issue #3497). Adapt the user-owned connection flow to coexist:

- configure_channel() restarts with reload_config=False — the caller
  just supplied the authoritative config (browser-entered credentials
  that are never written to config.yaml), so a file reload must not
  clobber it with the stale on-disk entry.
- _load_channel_config() re-applies the UI runtime-store overlay used
  at startup, so an operator-triggered restart keeps browser-entered
  credentials for channels without a config.yaml entry and does not
  resurrect a channel disconnected from the UI.
- Offload the reload's disk IO (config.yaml + runtime store) with
  asyncio.to_thread, matching the blocking-IO policy on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
DanielWalnut
2026-06-12 15:24:58 +08:00
committed by GitHub
parent b8f5ed360f
commit aa015462a7
96 changed files with 8585 additions and 277 deletions
+1
View File
@@ -52,6 +52,7 @@ src/
├── core/ # Core business logic
│ ├── api/ # API client & data fetching
│ ├── artifacts/ # Artifact management
│ ├── channels/ # IM channel connections (providers, connect flow)
│ ├── config/ # App configuration
│ ├── i18n/ # Internationalization
│ ├── mcp/ # MCP integration
+1
View File
@@ -48,6 +48,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
- `threads/` — Thread creation, streaming, state management (hooks + types)
- `api/` — LangGraph client singleton
- `artifacts/` — Artifact loading and caching
- `channels/` — IM channel connections (provider catalog, connect/runtime-config API + hooks)
- `i18n/` — Internationalization (en-US, zh-CN)
- `settings/` — User preferences in localStorage
- `memory/` — Persistent user memory system
+32 -14
View File
@@ -6,6 +6,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ThreadChannelBadge,
ThreadChannelIcon,
} from "@/components/workspace/thread-channel-source";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -13,7 +17,11 @@ import {
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useInfiniteThreads } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import {
channelSourceOfThread,
pathOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
export default function ChatsPage() {
@@ -82,20 +90,30 @@ export default function ChatsPage() {
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads?.map((thread) => (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{titleOfThread(thread)}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
{filteredThreads.map((thread) => {
const channelSource = channelSourceOfThread(thread);
return (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div className="flex min-w-0 items-center gap-2">
<ThreadChannelIcon source={channelSource} />
<div className="min-w-0 flex-1 truncate">
{titleOfThread(thread)}
</div>
<ThreadChannelBadge
source={channelSource}
className="hidden sm:inline-flex"
/>
</div>
)}
</div>
</Link>
))}
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
);
})}
{hasNextPage && !isSearching && (
<div
ref={sentinelRef}
@@ -0,0 +1,184 @@
"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 256 256"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<path
fill="#E01E5A"
d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"
/>
<path
fill="#36C5F0"
d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"
/>
<path
fill="#2EB67D"
d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"
/>
<path
fill="#ECB22E"
d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"
/>
</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>
);
}
if (normalizedProvider === "feishu") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<rect
x="1.25"
y="1.25"
width="21.5"
height="21.5"
rx="5.25"
fill="#FFFFFF"
stroke="#E5E7EB"
strokeWidth=".5"
/>
<path
d="M6.1 4.5h8.3c.9 0 1.7.4 2.2 1.1a16 16 0 0 1 2.9 6.2c-1.8-.8-3.8-.9-5.9-.3L5.6 5.8c-.6-.5-.3-1.3.5-1.3Z"
fill="#14D6C5"
/>
<path
d="M3.2 8.9c3.6 3.4 7.5 5.7 11.7 6.8 2.7.7 5.1.4 7-.6-1.6 3.1-5.2 5.4-9.4 5.4-3.4 0-6.7-.9-9.2-2.6a2 2 0 0 1-.9-1.7V9.6c0-.7.4-1 .8-.7Z"
fill="#3370FF"
/>
<path
d="M11 14.1c2.3-3.1 6.1-4.6 10.5-3.3l.8.2-2.6 4.1a6.3 6.3 0 0 1-6 2.9c-1.9-.2-3.9-.8-5.9-1.7 1.1-.7 2.2-1.4 3.2-2.2Z"
fill="#1E3A9F"
/>
</svg>
);
}
if (normalizedProvider === "dingtalk") {
return (
<svg
viewBox="0 0 1024 1024"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<g transform="translate(512 512) scale(1.35) translate(-512 -512)">
<path
fill="#0B86FF"
d="M739 449.3c-1 4.2-3.5 10.4-7 17.8h.1l-.4.7c-20.3 43.1-73.1 127.7-73.1 127.7l-.3-.5-15.5 26.8h74.5L575.1 810l32.3-128h-58.6l20.4-84.7c-16.5 3.9-35.9 9.4-59 16.8 0 0-31.2 18.2-89.9-35 0 0-39.6-34.7-16.6-43.4 9.8-3.7 47.4-8.4 77-12.3 40-5.4 64.6-8.2 64.6-8.2S422 517 392.7 512.5c-29.3-4.6-66.4-53.1-74.3-95.8 0 0-12.2-23.4 26.3-12.3s197.9 43.2 197.9 43.2-207.4-63.3-221.2-78.7-40.6-84.2-37.1-126.5c0 0 1.5-10.5 12.4-7.7 0 0 153.3 69.7 258.1 107.9 104.8 37.9 195.9 57.3 184.2 106.7Z"
/>
</g>
</svg>
);
}
if (normalizedProvider === "wechat") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<circle cx="12" cy="12" r="11" fill="#07C160" />
<path
fill="#FFFFFF"
d="M10.4 6.5c-3 0-5.4 2-5.4 4.5 0 1.4.8 2.7 2.1 3.5l-.5 1.8 2-.9c.6.1 1.2.2 1.8.2 3 0 5.4-2 5.4-4.5s-2.4-4.6-5.4-4.6Zm-1.9 3.7a.7.7 0 1 1 0-1.4.7.7 0 0 1 0 1.4Zm3.7 0a.7.7 0 1 1 0-1.4.7.7 0 0 1 0 1.4Z"
/>
<path
fill="#FFFFFF"
fillOpacity=".86"
d="M14.4 12.3c2.5 0 4.6 1.7 4.6 3.8 0 1.1-.6 2.1-1.6 2.8l.4 1.5-1.7-.8c-.5.1-1.1.2-1.7.2-2.5 0-4.6-1.7-4.6-3.8s2.1-3.7 4.6-3.7Zm-1.6 3.1a.6.6 0 1 0 0-1.2.6.6 0 0 0 0 1.2Zm3.1 0a.6.6 0 1 0 0-1.2.6.6 0 0 0 0 1.2Z"
/>
</svg>
);
}
if (normalizedProvider === "wecom") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<rect
x="1.25"
y="1.25"
width="21.5"
height="21.5"
rx="5.25"
fill="#FFFFFF"
stroke="#E5E7EB"
strokeWidth=".5"
/>
<path
fill="#168DEB"
d="m17.326 8.158-.003-.007a6.6 6.6 0 0 0-1.178-1.674c-1.266-1.307-3.067-2.19-5.102-2.417a9.3 9.3 0 0 0-2.124 0h-.001c-2.061.228-3.882 1.107-5.14 2.405a6.7 6.7 0 0 0-1.194 1.682A5.7 5.7 0 0 0 2 10.657c0 1.106.332 2.218.988 3.201l.006.01c.391.594 1.092 1.39 1.637 1.83l.983.793-.208.875.527-.267.708-.358.761.225c.467.137.955.227 1.517.29h.005q.515.06 1.026.059c.355 0 .724-.02 1.095-.06a9 9 0 0 0 1.346-.258c.095.7.43 1.337.932 1.81-.658.208-1.352.358-2.061.436-.442.048-.883.072-1.312.072q-.627 0-1.253-.072a10.7 10.7 0 0 1-1.861-.36l-2.84 1.438s-.29.131-.44.131c-.418 0-.702-.285-.702-.704 0-.252.067-.598.128-.84l.394-1.653c-.728-.586-1.563-1.544-2.052-2.287A7.76 7.76 0 0 1 0 10.658a7.7 7.7 0 0 1 .787-3.39 8.7 8.7 0 0 1 1.551-2.19c1.61-1.665 3.878-2.73 6.359-3.006a11.3 11.3 0 0 1 2.565 0c2.47.275 4.712 1.353 6.323 3.017a8.6 8.6 0 0 1 1.539 2.192c.466.945.769 1.937.769 2.978a3.06 3.06 0 0 0-2-.005c-.001-.644-.189-1.329-.564-2.09zm4.125 6.977-.024-.024-.024-.018-.024-.018-.096-.095a4.24 4.24 0 0 1-1.169-2.192q0-.038-.006-.075l-.006-.056-.035-.144a1.3 1.3 0 0 0-.358-.61 1.386 1.386 0 0 0-1.957 0 1.4 1.4 0 0 0 0 1.963c.191.191.418.311.668.371.024.012.06.012.084.012q.019 0 .041.006.023.005.042.006a4.24 4.24 0 0 1 2.231 1.186c.048.048.096.095.131.143a.323.323 0 0 0 .466 0 .35.35 0 0 0 .036-.455m-1.05 4.37-.025.025c-.119.096-.31.096-.453-.036a.326.326 0 0 1 0-.467c.047-.036.094-.083.141-.13l.002-.002a4.27 4.27 0 0 0 1.187-2.28q.005-.024.006-.043c0-.024 0-.06.012-.084a1.386 1.386 0 0 1 2.326-.67 1.4 1.4 0 0 1 0 1.964c-.167.18-.382.299-.608.359l-.143.036-.057.005q-.035.006-.075.007a4.2 4.2 0 0 0-2.183 1.173l-.095.096q-.009.01-.018.024t-.018.024m-4.392-1.053.024.024.024.018q.015.009.024.018l.096.096a4.25 4.25 0 0 1 1.169 2.19q0 .04.006.076.005.03.006.057l.035.143c.06.228.18.443.358.611.537.539 1.42.539 1.957 0a1.4 1.4 0 0 0 0-1.964 1.4 1.4 0 0 0-.668-.371c-.024-.012-.06-.012-.084-.012q-.018 0-.041-.006l-.042-.006a4.25 4.25 0 0 1-2.231-1.185 1.4 1.4 0 0 1-.131-.144.323.323 0 0 0-.466 0 .325.325 0 0 0-.036.455m1.039-4.358.024-.024a.32.32 0 0 1 .453.035.326.326 0 0 1 0 .467c-.047.036-.094.083-.141.13l-.002.002a4.27 4.27 0 0 0-1.187 2.281l-.006.042c0 .024 0 .06-.012.084a1.386 1.386 0 0 1-2.326.67 1.4 1.4 0 0 1 0-1.963c.166-.18.381-.3.608-.36l.143-.035q.026 0 .056-.006.037-.005.075-.006a4.2 4.2 0 0 0 2.183-1.174l.096-.095.018-.025z"
/>
</svg>
);
}
return (
<MessageCircleIcon aria-hidden="true" className={cn("size-5", className)} />
);
}
@@ -0,0 +1,159 @@
"use client";
import { LoaderCircleIcon } from "lucide-react";
import {
type CSSProperties,
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;
};
type SecretInputStyle = CSSProperties & {
WebkitTextSecurity?: "disc";
};
const SECRET_INPUT_STYLE: SecretInputStyle = {
WebkitTextSecurity: "disc",
};
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],
);
const credentialValues = useMemo<ChannelRuntimeConfigValues>(
() => provider?.credential_values ?? {},
[provider?.credential_values],
);
useEffect(() => {
if (!open || !provider) {
setValues({});
return;
}
setValues(
Object.fromEntries(
fields.map((field) => [field.name, credentialValues[field.name] ?? ""]),
) as ChannelRuntimeConfigValues,
);
}, [credentialValues, fields, open, provider]);
if (!provider) {
return null;
}
const isEditing = provider.configured;
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>
{isEditing
? t.channels.setupEditTitle(provider.display_name)
: 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}`;
const isSecretField = field.type === "password";
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="text"
value={values[field.name] ?? ""}
required={field.required}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
data-1p-ignore={isSecretField ? "true" : undefined}
data-bwignore={isSecretField ? "true" : undefined}
data-form-type={isSecretField ? "other" : undefined}
data-lpignore={isSecretField ? "true" : undefined}
style={isSecretField ? SECRET_INPUT_STYLE : undefined}
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}
{isEditing ? t.channels.saveChanges : t.channels.saveAndConnect}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,213 @@
"use client";
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
import {
useConfigureChannelProvider,
useChannelProviders,
useConnectChannelProvider,
} from "@/core/channels/hooks";
import {
closeConnectWindow,
openConnectUrl,
prepareConnectWindow,
} from "@/core/channels/open-connect-url";
import {
providerCanConnect,
providerCanEditRuntimeConfig,
providerNeedsRuntimeConfig,
} from "@/core/channels/provider-state";
import type { ChannelProvider } from "@/core/channels/types";
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 getProviderUnavailableReason(
provider: ChannelProvider,
t: ReturnType<typeof useI18n>["t"],
): string | undefined {
if (provider.unavailable_reason) {
return provider.unavailable_reason;
}
if (!provider.enabled) {
return t.channels.disabled;
}
if (!provider.configured) {
return t.channels.unconfigured;
}
return provider.unavailable_reason ?? undefined;
}
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;
}
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 || visibleProviders.length === 0) {
return null;
}
return (
<SidebarGroup className="pt-0">
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
<SidebarMenu>
{visibleProviders.map((provider) => {
const canEditRuntimeConfig = providerCanEditRuntimeConfig(provider);
const isConnected = provider.connection_status === "connected";
const isPending =
(connectMutation.isPending &&
connectMutation.variables === provider.provider) ||
(configureMutation.isPending &&
configureMutation.variables?.provider === provider.provider);
const canConnect = providerCanConnect(provider);
const unavailableReason = getProviderUnavailableReason(provider, t);
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={isPending}
title={unavailableReason}
onClick={() => {
if (
providerNeedsRuntimeConfig(provider) ||
(isConnected && canEditRuntimeConfig)
) {
setSetupProvider(provider);
return;
}
if (!canConnect) {
toast.error(unavailableReason ?? t.channels.unavailable);
return;
}
startConnect(provider);
}}
>
{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>
<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((updated) => {
setSetupProvider(null);
if (providerCanConnect(updated)) {
startConnect(updated, connectWindow);
return;
}
closeConnectWindow(connectWindow);
toast.success(t.channels.connected);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
});
}}
/>
</SidebarGroup>
);
}
@@ -55,10 +55,16 @@ import {
useRenameThread,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import {
channelSourceOfThread,
pathOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { isIMEComposing } from "@/lib/ime";
import { ThreadChannelIcon } from "./thread-channel-source";
export function RecentChatList() {
const { t } = useI18n();
const router = useRouter();
@@ -210,6 +216,7 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{threads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const channelSource = channelSourceOfThread(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
@@ -218,10 +225,23 @@ export function RecentChatList() {
<SidebarMenuButton isActive={isActive} asChild>
<div>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
className="text-muted-foreground flex min-w-0 items-center gap-1.5 pr-7 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
<ThreadChannelIcon source={channelSource} />
<span className="min-w-0 truncate">
{titleOfThread(thread)}
</span>
{channelSource && (
<span
className="bg-muted text-muted-foreground ml-auto inline-flex h-5 max-w-14 shrink-0 items-center rounded-md px-1.5 text-[10px] font-medium"
title={`${channelSource.label} channel`}
>
<span className="truncate">
{channelSource.label}
</span>
</span>
)}
</Link>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
@@ -0,0 +1,377 @@
"use client";
import {
AlertCircleIcon,
CheckCircle2Icon,
LoaderCircleIcon,
PlugIcon,
UnplugIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
useConfigureChannelProvider,
useChannelConnections,
useChannelProviders,
useConnectChannelProvider,
useDisconnectChannelProvider,
} from "@/core/channels/hooks";
import {
closeConnectWindow,
openConnectUrl,
prepareConnectWindow,
} from "@/core/channels/open-connect-url";
import {
providerCanConnect,
providerCanEditRuntimeConfig,
providerNeedsRuntimeConfig,
} from "@/core/channels/provider-state";
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 { ChannelRuntimeConfigDialog } from "../channels/channel-runtime-config-dialog";
import { SettingsSection } from "./settings-section";
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;
}
if (provider.unavailable_reason) {
return t.channels.unavailableShort;
}
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 getProviderUnavailableReason(
provider: ChannelProvider,
t: ReturnType<typeof useI18n>["t"],
): string | undefined {
if (provider.unavailable_reason) {
return provider.unavailable_reason;
}
if (!provider.enabled) {
return t.channels.disabled;
}
if (!provider.configured) {
return t.channels.unconfigured;
}
return provider.unavailable_reason ?? undefined;
}
function ChannelProviderItem({
provider,
connection,
}: {
provider: ChannelProvider;
connection?: ChannelConnection;
}) {
const { t } = useI18n();
const connectMutation = useConnectChannelProvider();
const configureMutation = useConfigureChannelProvider();
const disconnectProviderMutation = useDisconnectChannelProvider();
const [setupOpen, setSetupOpen] = useState(false);
const isConnected =
connection?.status === "connected" ||
provider.connection_status === "connected";
const canEditRuntimeConfig = providerCanEditRuntimeConfig(provider);
const canConnect =
(provider.connectable ?? (provider.enabled && provider.configured)) &&
!isConnected;
const isConnecting =
(connectMutation.isPending &&
connectMutation.variables === provider.provider) ||
(configureMutation.isPending &&
configureMutation.variables?.provider === provider.provider);
const isDisconnecting =
disconnectProviderMutation.isPending &&
disconnectProviderMutation.variables === provider.provider;
const connectionLabel = connection ? getConnectionLabel(connection) : null;
const statusLabel = getStatusLabel(provider, connection, t);
const unavailableReason = getProviderUnavailableReason(provider, t);
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,
);
});
};
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 ? (
<>
{canEditRuntimeConfig ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isConnecting || isDisconnecting}
onClick={() => setSetupOpen(true)}
>
{isConnecting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<PlugIcon />
)}
{t.channels.modify}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={isDisconnecting}
onClick={() => {
void disconnectProviderMutation
.mutateAsync(provider.provider)
.then(() => {
toast.success(t.channels.revoked);
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: t.channels.unavailable,
);
});
}}
>
{isDisconnecting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<UnplugIcon />
)}
{t.channels.disconnect}
</Button>
</>
) : (
<>
{provider.configured && canEditRuntimeConfig ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isConnecting || isDisconnecting}
onClick={() => setSetupOpen(true)}
>
{t.channels.modify}
</Button>
) : null}
<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((updated) => {
setSetupOpen(false);
if (providerCanConnect(updated)) {
startConnect(updated, connectWindow);
return;
}
closeConnectWindow(connectWindow);
toast.success(t.channels.connected);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
});
}}
/>
</>
);
}
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 visibleProviders = providers.filter((provider) => provider.enabled);
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>
) : visibleProviders.length === 0 ? (
<div className="text-muted-foreground text-sm">
{t.settings.channels.disabled}
</div>
) : (
<div className="flex w-full flex-col gap-4">
{visibleProviders.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>
@@ -0,0 +1,56 @@
"use client";
import { ChannelProviderIcon } from "@/components/workspace/channels/channel-provider-icon";
import type { ChannelThreadSource } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
type ThreadChannelIconProps = {
source: ChannelThreadSource | null;
className?: string;
};
export function ThreadChannelIcon({
source,
className,
}: ThreadChannelIconProps) {
if (!source) {
return null;
}
return (
<span
aria-label={`${source.label} channel`}
title={`${source.label} channel`}
className={cn("inline-flex shrink-0 items-center", className)}
>
<ChannelProviderIcon provider={source.provider} className="size-4" />
</span>
);
}
type ThreadChannelBadgeProps = {
source: ChannelThreadSource | null;
className?: string;
};
export function ThreadChannelBadge({
source,
className,
}: ThreadChannelBadgeProps) {
if (!source) {
return null;
}
return (
<span
className={cn(
"bg-muted text-muted-foreground inline-flex h-6 max-w-32 items-center gap-1 rounded-md px-2 text-xs font-medium",
className,
)}
title={`${source.label} channel`}
>
<ChannelProviderIcon provider={source.provider} className="size-3.5" />
<span className="truncate">{source.label}</span>
</span>
);
}
@@ -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>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { User } from "./types";
export const AUTH_DISABLED_USER: User = {
id: "e2e-user",
email: "e2e@test.local",
id: "default",
email: "default@test.local",
system_role: "admin",
needs_setup: false,
};
+117
View File
@@ -0,0 +1,117 @@
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type {
ChannelConnectResponse,
ChannelConnection,
ChannelConnectionsResponse,
ChannelProviderId,
ChannelProvider,
ChannelProvidersResponse,
ChannelRuntimeConfigValues,
} 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 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> {
const response = await fetch(
channelsUrl(`/connections/${encodeURIComponent(connectionId)}`),
{ method: "DELETE" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to disconnect channel: ${response.statusText}`,
);
}
}
export async function disconnectChannelProvider(
provider: ChannelProviderId,
): Promise<ChannelProvider> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/runtime-config`),
{ method: "DELETE" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to disconnect ${provider}: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelProvider>;
}
+96
View File
@@ -0,0 +1,96 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
configureChannelProvider,
connectChannelProvider,
disconnectChannelConnection,
disconnectChannelProvider,
listChannelConnections,
listChannelProviders,
} from "./api";
import type { ChannelProviderId, ChannelRuntimeConfigValues } 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 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({
mutationFn: (connectionId: string) =>
disconnectChannelConnection(connectionId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
export function useDisconnectChannelProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (provider: ChannelProviderId) =>
disconnectChannelProvider(provider),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
@@ -0,0 +1,27 @@
export type ChannelConnectWindow = Window | null;
export function prepareConnectWindow(): ChannelConnectWindow {
const opened = window.open("about:blank", "_blank");
if (opened) {
opened.opener = null;
}
return opened;
}
export function openConnectUrl(
url: string,
connectWindow: ChannelConnectWindow = prepareConnectWindow(),
) {
if (connectWindow && !connectWindow.closed) {
connectWindow.location.replace(url);
return;
}
window.location.assign(url);
}
export function closeConnectWindow(connectWindow: ChannelConnectWindow) {
if (connectWindow && !connectWindow.closed) {
connectWindow.close();
}
}
@@ -0,0 +1,22 @@
import type { ChannelProvider } from "./types";
export function providerCanConnect(provider: ChannelProvider): boolean {
return (
(provider.connectable ?? (provider.enabled && provider.configured)) &&
provider.connection_status !== "connected"
);
}
export function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
return (
provider.enabled &&
!provider.configured &&
(provider.credential_fields?.length ?? 0) > 0
);
}
export function providerCanEditRuntimeConfig(
provider: ChannelProvider,
): boolean {
return provider.enabled && (provider.credential_fields?.length ?? 0) > 0;
}
+53
View File
@@ -0,0 +1,53 @@
export type ChannelProviderId = "telegram" | "slack" | "discord" | string;
export interface ChannelCredentialField {
name: string;
label: string;
type: string;
required: boolean;
}
export type ChannelRuntimeConfigValues = Record<string, string>;
export interface ChannelProvider {
provider: ChannelProviderId;
display_name: string;
enabled: boolean;
configured: boolean;
connectable?: boolean;
unavailable_reason?: string | null;
auth_mode: string;
connection_status: string;
credential_fields: ChannelCredentialField[];
credential_values?: ChannelRuntimeConfigValues;
}
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 | null;
code: string;
instruction: string;
expires_in: number;
}
+42
View File
@@ -170,6 +170,7 @@ export const enUS: Translations = {
sidebar: {
newChat: "New chat",
chats: "Chats",
channels: "Channels",
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
@@ -259,6 +260,39 @@ export const enUS: Translations = {
loadOlderChats: "Load older chats",
},
// Channels
channels: {
title: "Channels",
connect: "Connect",
modify: "Modify",
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.",
unavailableShort: "Unavailable",
setupTitle: (name: string) => `Connect ${name}`,
setupEditTitle: (name: string) => `Modify ${name}`,
setupDescription:
"Enter the values needed by this server process. They are not written to config.yaml.",
saveAndConnect: "Save and connect",
saveChanges: "Save changes",
descriptions: {
telegram: "Telegram direct messages through your DeerFlow bot.",
slack: "Slack workspace messages and mentions.",
discord: "Discord server messages through your DeerFlow bot.",
feishu: "Feishu and Lark messages through your DeerFlow app.",
dingtalk: "DingTalk Stream Push messages through your DeerFlow bot.",
wechat: "WeChat iLink messages through your DeerFlow bot.",
wecom: "WeCom messages through your DeerFlow AI bot.",
},
connectedAs: (name: string) => `Connected as ${name}.`,
},
// Page titles (document title)
pages: {
appName: "DeerFlow",
@@ -359,6 +393,7 @@ export const enUS: Translations = {
sections: {
account: "Account",
appearance: "Appearance",
channels: "Channels",
memory: "Memory",
tools: "Tools",
skills: "Skills",
@@ -461,6 +496,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:
+31
View File
@@ -117,6 +117,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
channels: string;
};
// Agents
@@ -190,6 +191,30 @@ export interface Translations {
loadOlderChats: string;
};
// Channels
channels: {
title: string;
connect: string;
modify: string;
reconnect: string;
disconnect: string;
connected: string;
notConnected: string;
pending: string;
revoked: string;
disabled: string;
unconfigured: string;
unavailable: string;
unavailableShort: string;
setupTitle: (name: string) => string;
setupEditTitle: (name: string) => string;
setupDescription: string;
saveAndConnect: string;
saveChanges: string;
descriptions: Record<string, string>;
connectedAs: (name: string) => string;
};
// Page titles (document title)
pages: {
appName: string;
@@ -286,6 +311,7 @@ export interface Translations {
sections: {
account: string;
appearance: string;
channels: string;
memory: string;
tools: string;
skills: string;
@@ -381,6 +407,11 @@ export interface Translations {
title: string;
description: string;
};
channels: {
title: string;
description: string;
disabled: string;
};
skills: {
title: string;
description: string;
+41
View File
@@ -164,6 +164,7 @@ export const zhCN: Translations = {
sidebar: {
newChat: "新对话",
chats: "对话",
channels: "渠道",
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
@@ -247,6 +248,39 @@ export const zhCN: Translations = {
loadOlderChats: "加载更早的对话",
},
// Channels
channels: {
title: "渠道",
connect: "连接",
modify: "修改",
reconnect: "重新连接",
disconnect: "断开连接",
connected: "已连接",
notConnected: "未连接",
pending: "待完成",
revoked: "已断开",
disabled: "已停用",
unconfigured: "未配置",
unavailable: "当前无法使用渠道连接。",
unavailableShort: "不可用",
setupTitle: (name: string) => `连接 ${name}`,
setupEditTitle: (name: string) => `修改 ${name}`,
setupDescription:
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
saveAndConnect: "保存并连接",
saveChanges: "保存修改",
descriptions: {
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
slack: "接收 Slack 工作区消息和提及。",
discord: "通过 DeerFlow Bot 接收 Discord 服务器消息。",
feishu: "通过 DeerFlow 应用接收飞书和 Lark 消息。",
dingtalk: "通过 DeerFlow Bot 接收钉钉 Stream Push 消息。",
wechat: "通过 DeerFlow Bot 接收微信 iLink 消息。",
wecom: "通过 DeerFlow AI Bot 接收企业微信消息。",
},
connectedAs: (name: string) => `已连接为 ${name}`,
},
// Page titles (document title)
pages: {
appName: "DeerFlow",
@@ -343,6 +377,7 @@ export const zhCN: Translations = {
sections: {
account: "账号",
appearance: "外观",
channels: "渠道",
memory: "记忆",
tools: "工具",
skills: "技能",
@@ -442,6 +477,12 @@ export const zhCN: Translations = {
title: "工具",
description: "管理 MCP 工具的配置和启用状态。",
},
channels: {
title: "渠道",
description: "连接可在浏览器外向 DeerFlow 发送消息的即时通讯账号。",
disabled:
"当前服务器未启用渠道连接。请联系管理员开启 channel_connections。",
},
skills: {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
+7 -60
View File
@@ -26,6 +26,11 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import { fetchThreadTokenUsage } from "./api";
import {
buildThreadsSearchQueryOptions,
DEFAULT_THREAD_SEARCH_PARAMS,
type ThreadSearchParams,
} from "./thread-search-query";
import { threadTokenUsageQueryKey } from "./token-usage";
import type {
AgentThread,
@@ -1201,69 +1206,11 @@ export function useThreadHistory(
}
export function useThreads(
params: Parameters<ThreadsClient["search"]>[0] = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
},
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
) {
const apiClient = getAPIClient();
return useQuery<AgentThread[]>({
queryKey: ["threads", "search", params],
queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response =
await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
},
refetchOnWindowFocus: false,
...buildThreadsSearchQueryOptions(apiClient, params),
});
}
@@ -0,0 +1,86 @@
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import type { AgentThread, AgentThreadState } from "./types";
type ThreadsSearchClient = {
threads: {
search: ThreadsClient["search"];
};
};
export type ThreadSearchParams = NonNullable<
Parameters<ThreadsClient["search"]>[0]
>;
export const DEFAULT_THREAD_SEARCH_PARAMS: ThreadSearchParams = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
};
export const THREAD_SEARCH_REFETCH_INTERVAL_MS = 5000;
export function buildThreadsSearchQueryOptions(
apiClient: ThreadsSearchClient,
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
) {
return {
queryKey: ["threads", "search", params],
queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response =
await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
},
refetchInterval: THREAD_SEARCH_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
};
}
+45
View File
@@ -2,6 +2,12 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
export type ChannelThreadSource = {
type: "im_channel";
provider: string;
label: string;
};
type ThreadRouteTarget =
| string
| {
@@ -49,3 +55,42 @@ export function textOfMessage(message: Message) {
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
dingtalk: "DingTalk",
discord: "Discord",
feishu: "Feishu",
slack: "Slack",
telegram: "Telegram",
wechat: "WeChat",
wecom: "WeCom",
};
function labelOfChannelProvider(provider: string) {
return CHANNEL_PROVIDER_LABELS[provider] ?? provider;
}
export function channelSourceOfThread(
thread: Pick<AgentThread, "metadata">,
): ChannelThreadSource | null {
const source = thread.metadata?.channel_source;
if (!source || typeof source !== "object" || Array.isArray(source)) {
return null;
}
if (Reflect.get(source, "type") !== "im_channel") {
return null;
}
const provider = Reflect.get(source, "provider");
if (typeof provider !== "string" || provider.trim().length === 0) {
return null;
}
const normalizedProvider = provider.trim().toLowerCase();
return {
type: "im_channel",
provider: normalizedProvider,
label: labelOfChannelProvider(normalizedProvider),
};
}
+8 -2
View File
@@ -19,16 +19,22 @@ interface Shortcut {
export function useGlobalShortcuts(shortcuts: Shortcut[]) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (typeof event.key !== "string" || event.key.length === 0) {
return;
}
const meta = event.metaKey || event.ctrlKey;
const eventKey = event.key.toLowerCase();
for (const shortcut of shortcuts) {
const shortcutKey = shortcut.key.toLowerCase();
if (
event.key.toLowerCase() === shortcut.key.toLowerCase() &&
eventKey === shortcutKey &&
meta === shortcut.meta &&
(shortcut.shift ?? false) === event.shiftKey
) {
// Allow Cmd+K even in inputs (standard command palette behavior)
if (shortcut.key !== "k") {
if (shortcutKey !== "k") {
const target = event.target as HTMLElement;
const tag = target.tagName;
if (
+452
View File
@@ -0,0 +1,452 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const channelProviders = [
["telegram", "Telegram", "deep_link"],
["slack", "Slack", "binding_code"],
["discord", "Discord", "binding_code"],
["feishu", "Feishu", "binding_code"],
["dingtalk", "DingTalk", "binding_code"],
["wechat", "WeChat", "binding_code"],
["wecom", "WeCom", "binding_code"],
] as const;
type MockChannelProvider = {
provider: string;
display_name: string;
enabled: boolean;
configured: boolean;
connectable: boolean;
auth_mode: string;
connection_status: string;
unavailable_reason?: string | null;
credential_fields?: Array<{
name: string;
label: string;
type: string;
required: boolean;
}>;
credential_values?: Record<string, string>;
};
function defaultProviders(): MockChannelProvider[] {
return channelProviders.map(([provider, displayName, authMode]) => ({
provider,
display_name: displayName,
enabled: true,
configured: true,
connectable: true,
auth_mode: authMode,
connection_status: "connected",
credential_fields: [
{
name: "token",
label: "Token",
type: "password",
required: true,
},
],
}));
}
function mockChannelsAPI(
page: Page,
providers: MockChannelProvider[] = defaultProviders(),
onSlackConnect?: () => void,
) {
void page.route("**/api/channels/providers", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
enabled: true,
providers,
}),
});
});
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/connect", (route) => {
onSlackConnect?.();
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,
}),
});
});
}
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.getByText("Feishu")).toBeVisible();
await expect(sidebar.getByText("DingTalk")).toBeVisible();
await expect(sidebar.getByText("WeChat")).toBeVisible();
await expect(sidebar.getByText("WeCom")).toBeVisible();
await expect(
sidebar.getByRole("button", { name: "Connected" }),
).toHaveCount(7);
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();
await expect(page.getByText("Feishu and Lark messages")).toBeVisible();
await expect(page.getByText("DingTalk Stream Push messages")).toBeVisible();
await expect(page.getByText("WeChat iLink messages")).toBeVisible();
await expect(page.getByText("WeCom messages")).toBeVisible();
const dialog = page.getByRole("dialog", { name: "Settings" });
await expect(dialog.getByRole("button", { name: "Modify" })).toHaveCount(7);
});
test("only enabled providers are shown and runtime setup stays editable", async ({
page,
}) => {
mockLangGraphAPI(page);
let slackConfigured = false;
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: slackConfigured
? "connected"
: "not_connected",
credential_fields: [
{
name: "bot_token",
label: "Bot token",
type: "password",
required: true,
},
{
name: "app_token",
label: "App token",
type: "password",
required: true,
},
],
credential_values: slackConfigured
? {
bot_token: "********",
app_token: "********",
}
: {},
},
{
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: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "connected",
credential_fields: [],
credential_values: {},
}),
});
});
void page.route("**/api/channels/slack/connect", (route) => route.abort());
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();
await connectButton.click();
const setupDialog = page.getByRole("dialog", { name: "Connect Slack" });
await expect(setupDialog).toBeVisible();
const botTokenInput = setupDialog.getByLabel("Bot token");
await expect(botTokenInput).toHaveAttribute("type", "text");
await expect(botTokenInput).toHaveAttribute("autocomplete", "off");
await expect(botTokenInput).toHaveAttribute("data-lpignore", "true");
await expect(botTokenInput).toHaveAttribute("data-1p-ignore", "true");
await expect(botTokenInput).toHaveCSS("-webkit-text-security", "disc");
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(
sidebar.getByRole("button", { name: "Connected" }),
).toBeVisible();
await sidebar.getByRole("button", { name: "Connected" }).click();
await expect(
page.getByRole("dialog", { name: "Modify Slack" }),
).toBeVisible();
await expect(page.getByLabel("Bot token")).toHaveValue("********");
await expect(page.getByLabel("App token")).toHaveValue("********");
expect(submittedValues).toEqual({
bot_token: "xoxb-ui",
app_token: "xapp-ui",
});
});
test("configured provider connects directly with a binding-code instruction", async ({
page,
}) => {
mockLangGraphAPI(page);
let slackConnectCalls = 0;
mockChannelsAPI(
page,
[
{
provider: "slack",
display_name: "Slack",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "not_connected",
credential_fields: [
{
name: "bot_token",
label: "Bot token",
type: "password",
required: true,
},
],
credential_values: { bot_token: "********" },
},
],
() => {
slackConnectCalls += 1;
},
);
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await expect(sidebar.getByText("Slack")).toBeVisible({ timeout: 15_000 });
await sidebar.getByRole("button", { name: "Connect" }).click();
await expect(
page.getByText("Send /connect abc123 to the DeerFlow Slack bot."),
).toBeVisible();
expect(slackConnectCalls).toBe(1);
});
test("runtime setup continues into the connect flow when a binding is still required", async ({
page,
}) => {
mockLangGraphAPI(page);
let slackConfigured = false;
let slackConnectCalls = 0;
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,
},
],
credential_values: {},
},
],
}),
});
});
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", (route) => {
slackConfigured = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
provider: "slack",
display_name: "Slack",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "not_connected",
credential_fields: [],
credential_values: {},
}),
});
});
void page.route("**/api/channels/slack/connect", (route) => {
slackConnectCalls += 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 sidebar.getByRole("button", { name: "Connect" }).click();
const setupDialog = page.getByRole("dialog", { name: "Connect Slack" });
await expect(setupDialog).toBeVisible();
await setupDialog.getByLabel("Bot token").fill("xoxb-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(slackConnectCalls).toBe(1);
});
test("runtime setup dialog prefills editable credential values", async ({
page,
}) => {
mockLangGraphAPI(page);
mockChannelsAPI(page, [
{
provider: "feishu",
display_name: "Feishu",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "connected",
credential_fields: [
{
name: "app_id",
label: "App ID",
type: "text",
required: true,
},
{
name: "app_secret",
label: "App secret",
type: "password",
required: true,
},
],
credential_values: {
app_id: "cli_feishu_app",
app_secret: "********",
},
},
]);
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await expect(sidebar.getByText("Feishu")).toBeVisible({ timeout: 15_000 });
await sidebar.getByRole("button", { name: "Connected" }).click();
const setupDialog = page.getByRole("dialog", { name: "Modify Feishu" });
await expect(setupDialog).toBeVisible();
await expect(setupDialog.getByLabel("App ID")).toHaveValue(
"cli_feishu_app",
);
await expect(setupDialog.getByLabel("App secret")).toHaveValue("********");
});
});
+39
View File
@@ -258,4 +258,43 @@ test.describe("Thread history", () => {
});
await expect(main.getByText("Second conversation")).toBeVisible();
});
test("IM channel threads show their source in thread lists", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Feishu conversation",
updated_at: "2025-06-03T12:00:00Z",
metadata: {
channel_source: {
type: "im_channel",
provider: "feishu",
chat_id: "oc_mock",
},
},
},
],
});
await page.goto("/workspace/chats/new");
const sidebarThread = page.locator(
`a[href='/workspace/chats/${MOCK_THREAD_ID}']`,
);
await expect(sidebarThread).toBeVisible({ timeout: 15_000 });
await expect(sidebarThread.getByLabel("Feishu channel")).toBeVisible();
await page.goto("/workspace/chats");
const mainThread = page
.locator("main")
.locator(`a[href='/workspace/chats/${MOCK_THREAD_ID}']`);
await expect(mainThread.getByText("Feishu conversation")).toBeVisible({
timeout: 15_000,
});
await expect(mainThread.getByText("Feishu", { exact: true })).toBeVisible();
});
});
+5 -1
View File
@@ -25,6 +25,7 @@ export type MockThread = {
title?: string;
updated_at?: string;
agent_name?: string;
metadata?: Record<string, unknown>;
messages?: unknown[];
artifacts?: string[];
};
@@ -90,7 +91,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
thread_id: t.thread_id,
created_at: "2025-01-01T00:00:00Z",
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
metadata: t.agent_name ? { agent_name: t.agent_name } : {},
metadata: {
...(t.metadata ?? {}),
...(t.agent_name ? { agent_name: t.agent_name } : {}),
},
status: "idle",
values: { title: t.title ?? "Untitled" },
}));
@@ -0,0 +1,220 @@
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 {
configureChannelProvider,
connectChannelProvider,
disconnectChannelConnection,
disconnectChannelProvider,
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",
credential_values: {
bot_token: "********",
bot_username: "deerflow_bot",
},
},
],
}),
);
await expect(listChannelProviders()).resolves.toMatchObject({
enabled: true,
providers: [
{
provider: "telegram",
display_name: "Telegram",
credential_values: {
bot_token: "********",
bot_username: "deerflow_bot",
},
},
],
});
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",
code: "state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
expires_in: 600,
}),
);
await expect(connectChannelProvider("telegram")).resolves.toMatchObject({
provider: "telegram",
url: "https://t.me/deerflow_bot?start=state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/telegram/connect",
{ method: "POST" },
);
});
test("starts a binding-code connection flow", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
mode: "binding_code",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
expires_in: 600,
}),
);
await expect(connectChannelProvider("slack")).resolves.toMatchObject({
provider: "slack",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
});
});
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 }));
await expect(
disconnectChannelConnection("connection-1"),
).resolves.toBeUndefined();
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/connections/connection-1",
{ method: "DELETE" },
);
});
test("disconnects provider runtime configuration", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
display_name: "Slack",
enabled: true,
configured: false,
connectable: false,
auth_mode: "binding_code",
connection_status: "not_connected",
}),
);
await expect(disconnectChannelProvider("slack")).resolves.toMatchObject({
provider: "slack",
configured: false,
connection_status: "not_connected",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/slack/runtime-config",
{ 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",
);
});
});
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
closeConnectWindow,
openConnectUrl,
prepareConnectWindow,
} from "@/core/channels/open-connect-url";
type PopupStub = {
closed: boolean;
close: ReturnType<typeof vi.fn>;
location: {
replace: ReturnType<typeof vi.fn>;
};
opener: unknown;
};
function stubWindow(openResult: PopupStub | null) {
const assign = vi.fn();
const open = vi.fn(() => openResult);
vi.stubGlobal("window", {
open,
location: { assign },
});
return { assign, open };
}
function makePopup(): PopupStub {
return {
closed: false,
close: vi.fn(),
location: { replace: vi.fn() },
opener: {},
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("channel connect window helpers", () => {
test("opens a blank tab synchronously and detaches opener", () => {
const popup = makePopup();
const { open } = stubWindow(popup);
const prepared = prepareConnectWindow();
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
expect(prepared).toBe(popup);
expect(popup.opener).toBeNull();
});
test("navigates a prepared popup without opening another window", () => {
const popup = makePopup();
const { assign, open } = stubWindow(null);
openConnectUrl(
"https://t.me/deerflow_bot?start=state",
popup as unknown as Window,
);
expect(open).not.toHaveBeenCalled();
expect(assign).not.toHaveBeenCalled();
expect(popup.location.replace).toHaveBeenCalledWith(
"https://t.me/deerflow_bot?start=state",
);
});
test("falls back to current-window navigation when no popup is available", () => {
const { assign } = stubWindow(null);
openConnectUrl("https://t.me/deerflow_bot?start=state");
expect(assign).toHaveBeenCalledWith(
"https://t.me/deerflow_bot?start=state",
);
});
test("closes a prepared popup on connect failure", () => {
const popup = makePopup();
closeConnectWindow(popup as unknown as Window);
expect(popup.close).toHaveBeenCalled();
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
providerCanConnect,
providerCanEditRuntimeConfig,
providerNeedsRuntimeConfig,
} from "@/core/channels/provider-state";
import type { ChannelProvider } from "@/core/channels/types";
function makeProvider(overrides: Partial<ChannelProvider>): ChannelProvider {
return {
provider: "slack",
display_name: "Slack",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "not_connected",
credential_fields: [
{
name: "bot_token",
label: "Bot token",
type: "password",
required: true,
},
],
...overrides,
};
}
describe("providerCanConnect", () => {
it("allows connecting a configured, not yet connected provider", () => {
expect(providerCanConnect(makeProvider({}))).toBe(true);
});
it("rejects an already connected provider", () => {
expect(
providerCanConnect(makeProvider({ connection_status: "connected" })),
).toBe(false);
});
it("rejects a non-connectable provider", () => {
expect(providerCanConnect(makeProvider({ connectable: false }))).toBe(
false,
);
});
it("falls back to enabled+configured when connectable is missing", () => {
expect(providerCanConnect(makeProvider({ connectable: undefined }))).toBe(
true,
);
expect(
providerCanConnect(
makeProvider({ connectable: undefined, configured: false }),
),
).toBe(false);
});
});
describe("providerNeedsRuntimeConfig", () => {
it("requires setup only when enabled and unconfigured with fields", () => {
expect(
providerNeedsRuntimeConfig(makeProvider({ configured: false })),
).toBe(true);
expect(providerNeedsRuntimeConfig(makeProvider({}))).toBe(false);
expect(
providerNeedsRuntimeConfig(
makeProvider({ configured: false, enabled: false }),
),
).toBe(false);
expect(
providerNeedsRuntimeConfig(
makeProvider({ configured: false, credential_fields: [] }),
),
).toBe(false);
});
});
describe("providerCanEditRuntimeConfig", () => {
it("is editable whenever enabled with credential fields", () => {
expect(providerCanEditRuntimeConfig(makeProvider({}))).toBe(true);
expect(providerCanEditRuntimeConfig(makeProvider({ enabled: false }))).toBe(
false,
);
expect(
providerCanEditRuntimeConfig(makeProvider({ credential_fields: [] })),
).toBe(false);
});
});
@@ -0,0 +1,19 @@
import { expect, test, vi } from "vitest";
import {
buildThreadsSearchQueryOptions,
DEFAULT_THREAD_SEARCH_PARAMS,
THREAD_SEARCH_REFETCH_INTERVAL_MS,
} from "@/core/threads/thread-search-query";
test("thread search query refreshes so IM-created sessions appear in the sidebar", () => {
const search = vi.fn();
const options = buildThreadsSearchQueryOptions(
{ threads: { search } },
DEFAULT_THREAD_SEARCH_PARAMS,
);
expect(options.refetchInterval).toBe(THREAD_SEARCH_REFETCH_INTERVAL_MS);
expect(options.refetchIntervalInBackground).toBe(false);
expect(options.refetchOnWindowFocus).toBe(false);
});
+38 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "vitest";
import { pathOfThread } from "@/core/threads/utils";
import { channelSourceOfThread, pathOfThread } from "@/core/threads/utils";
test("uses standard chat route when thread has no agent context", () => {
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
@@ -44,3 +44,40 @@ test("prefers context.agent_name over metadata.agent_name", () => {
}),
).toBe("/workspace/agents/from-context/chats/thread-789");
});
test("reads IM channel source metadata", () => {
expect(
channelSourceOfThread({
metadata: {
channel_source: {
type: "im_channel",
provider: "feishu",
chat_id: "oc_123",
},
},
}),
).toEqual({
type: "im_channel",
provider: "feishu",
label: "Feishu",
});
});
test("ignores threads without valid IM channel source metadata", () => {
expect(channelSourceOfThread({ metadata: {} })).toBeNull();
expect(
channelSourceOfThread({
metadata: { channel_source: { provider: "" } },
}),
).toBeNull();
expect(
channelSourceOfThread({
metadata: {
channel_source: {
type: "other",
provider: "feishu",
},
},
}),
).toBeNull();
});
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test, vi } from "vitest";
type KeydownHandler = (event: KeyboardEvent) => void;
async function loadHookWithCapturedHandler() {
let cleanup: (() => void) | undefined;
let keydownHandler: KeydownHandler | undefined;
const addEventListener = vi.fn(
(type: string, listener: EventListenerOrEventListenerObject) => {
if (type === "keydown" && typeof listener === "function") {
keydownHandler = listener as KeydownHandler;
}
},
);
const removeEventListener = vi.fn();
vi.resetModules();
vi.doMock("react", () => ({
useEffect: (effect: () => void | (() => void)) => {
const result = effect();
cleanup = typeof result === "function" ? result : undefined;
},
}));
vi.stubGlobal("window", { addEventListener, removeEventListener });
const { useGlobalShortcuts } = await import("@/hooks/use-global-shortcuts");
return {
cleanup: () => cleanup?.(),
getKeydownHandler: () => keydownHandler,
useGlobalShortcuts,
};
}
afterEach(() => {
vi.doUnmock("react");
vi.unstubAllGlobals();
vi.resetModules();
});
describe("useGlobalShortcuts", () => {
test("ignores keydown events without a key", async () => {
const action = vi.fn();
const { getKeydownHandler, useGlobalShortcuts } =
await loadHookWithCapturedHandler();
useGlobalShortcuts([{ key: "k", meta: true, action }]);
const keydownHandler = getKeydownHandler();
expect(keydownHandler).toBeDefined();
expect(() =>
keydownHandler?.({
ctrlKey: false,
metaKey: true,
shiftKey: false,
} as KeyboardEvent),
).not.toThrow();
expect(action).not.toHaveBeenCalled();
});
});