mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-13 08:28:22 +08:00
### What problem does this PR solve? #15844 Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can be exposed as a bot on external messaging platforms (Feishu/Lark, Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot in the UI, connects it to an assistant, and inbound messages are answered from that assistant's knowledge base — replies are delivered back on the channel. **Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram, LINE, and WeCom are scaffolded against the same interface; the remaining listed channels are tracked as follow-ups. ### Design **Backend** - New `chat_channel` table (`tenant_id`, `name`, `channel`, `config` JSON holding `{credential: {...}}`, `dialog_id`, `status`) + `ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`. - Channel framework under `api/channels/`: a `core` registry + per-channel packages that self-register a builder and implement a common `Channel` interface (`start`/`stop`/`send` + inbound normalization) over `IncomingMessage`/`OutgoingMessage`. - Embedded **reconcile loop** in `ragflow_server` (`api/channels/bootstrap.py`): loads enabled bots, and starts/stops/restarts them as rows change (no server restart needed). Inbound messages run the connected dialog via the non-streaming completion path, keeping per-end-user conversation history. - Missing optional channel SDKs degrade gracefully (channel skipped with a warning; others unaffected). Channel-level errors are logged, not crashed. - Feishu's WebSocket client runs in a dedicated thread with its own event loop to avoid cross-loop/contextvars conflicts with the channel runtime. **Frontend** - **Settings → Chat channels** panel: available-channels grid + configured-bots list with add/edit/delete and a **Connect assistant** popup that binds a bot to a dialog. - Brand icons via simple-icons / reused shared data-source assets, with colored fallbacks for brands not available. - Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix so the settings page no longer highlights the Chat tab. ### Type of change - [x] New Feature (non-breaking change which adds functionality) ### Notes - DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id` is also covered by a `migrate_db` `alter_db_add_column` for existing installs. - Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`, `line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies. - Screenshots / per-channel credential docs to follow. <img width="1338" height="1290" alt="Image" src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704" /> <img width="1344" height="738" alt="Image" src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617" /> <img width="672" height="887" alt="Image" src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84" /> ---------
435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
import { lazy, memo, Suspense } from 'react';
|
|
import {
|
|
createBrowserRouter,
|
|
Navigate,
|
|
redirect,
|
|
type RouteObject,
|
|
} from 'react-router';
|
|
import FallbackComponent from './components/fallback-component';
|
|
import { IS_ENTERPRISE } from './pages/admin/utils';
|
|
import authorizationUtil from './utils/authorization-util';
|
|
|
|
export enum Routes {
|
|
Root = '/',
|
|
Login = '/login-next',
|
|
Logout = '/logout',
|
|
Home = '/home',
|
|
Datasets = '/datasets',
|
|
DatasetBase = '/dataset',
|
|
Files = '/files',
|
|
Dataset = `${Routes.DatasetBase}/${Routes.Files}`,
|
|
Agent = '/agent',
|
|
AgentTemplates = '/agent-templates',
|
|
Agents = '/agents',
|
|
Explore = '/explore',
|
|
AgentExplore = `${Routes.Agent}/:id/explore`,
|
|
Memories = '/memories',
|
|
Memory = '/memory',
|
|
MemoryMessage = '/memory-message',
|
|
MemorySetting = '/memory-setting',
|
|
AgentList = '/agent-list',
|
|
Searches = '/searches',
|
|
Search = '/search',
|
|
SearchShare = '/search/share',
|
|
Chats = '/chats',
|
|
Chat = '/chat',
|
|
|
|
Skills = '/files/skills',
|
|
ProfileSetting = '/profile-setting',
|
|
Profile = '/profile',
|
|
Api = '/api',
|
|
Mcp = '/mcp',
|
|
Team = '/team',
|
|
Plan = '/plan',
|
|
Model = '/model',
|
|
Prompt = '/prompt',
|
|
DataSource = '/data-source',
|
|
DataSourceDetailPage = '/data-source-detail-page',
|
|
ChatChannel = '/chat-channel',
|
|
ProfileMcp = `${ProfileSetting}${Mcp}`,
|
|
ProfileTeam = `${ProfileSetting}${Team}`,
|
|
ProfilePlan = `${ProfileSetting}${Plan}`,
|
|
ProfileModel = `${ProfileSetting}${Model}`,
|
|
ProfilePrompt = `${ProfileSetting}${Prompt}`,
|
|
ProfileProfile = `${ProfileSetting}${Profile}`,
|
|
DatasetTesting = '/retrieval',
|
|
Chunk = '/chunk',
|
|
ChunkResult = `${Chunk}${Chunk}`,
|
|
Parsed = '/parsed',
|
|
ParsedResult = `${Chunk}${Parsed}`,
|
|
Result = '/result',
|
|
ResultView = `${Chunk}${Result}`,
|
|
KnowledgeGraph = '/knowledge-graph',
|
|
AgentLogPage = '/agent-log-page',
|
|
AgentShare = '/agent/share',
|
|
ChatShare = `${Chats}/share`,
|
|
ChatWidget = `${Chats}/widget`,
|
|
UserSetting = '/user-setting',
|
|
DataSetOverview = '/logs',
|
|
DataSetSetting = '/configuration',
|
|
DataflowResult = '/dataflow-result',
|
|
Admin = '/admin',
|
|
AdminServices = `${Admin}/services`,
|
|
AdminUserManagement = `${Admin}/users`,
|
|
AdminSandboxSettings = `${Admin}/sandbox-settings`,
|
|
AdminWhitelist = `${Admin}/whitelist`,
|
|
AdminRoles = `${Admin}/roles`,
|
|
AdminMonitoring = `${Admin}/monitoring`,
|
|
}
|
|
|
|
const defaultRouteFallback = (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-[1px]">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/70 border-t-transparent" />
|
|
</div>
|
|
);
|
|
|
|
type LazyRouteConfig = Omit<RouteObject, 'Component' | 'children'> & {
|
|
Component?: () => Promise<{ default: React.ComponentType<any> }>;
|
|
children?: LazyRouteConfig[];
|
|
};
|
|
|
|
const withLazyRoute = (
|
|
importer: () => Promise<{ default: React.ComponentType<any> }>,
|
|
fallback: React.ReactNode = defaultRouteFallback,
|
|
) => {
|
|
const LazyComponent = lazy(importer);
|
|
const Wrapped: React.FC<any> = (props) => (
|
|
<Suspense fallback={fallback}>
|
|
<LazyComponent {...props} />
|
|
</Suspense>
|
|
);
|
|
Wrapped.displayName = `LazyRoute(${
|
|
(LazyComponent as unknown as React.ComponentType<any>).displayName ||
|
|
LazyComponent.name ||
|
|
'Component'
|
|
})`;
|
|
return process.env.NODE_ENV === 'development' ? LazyComponent : memo(Wrapped);
|
|
};
|
|
|
|
const routeConfigOptions = [
|
|
{
|
|
path: '/login',
|
|
Component: () => import('@/pages/login-next'),
|
|
layout: false,
|
|
},
|
|
{
|
|
path: '/login-next',
|
|
Component: () => import('@/pages/login-next'),
|
|
layout: false,
|
|
},
|
|
{
|
|
path: Routes.ChatShare,
|
|
Component: () => import('@/pages/next-chats/share'),
|
|
layout: false,
|
|
},
|
|
{
|
|
path: Routes.AgentShare,
|
|
Component: () => import('@/pages/agent/share'),
|
|
layout: false,
|
|
},
|
|
{
|
|
path: Routes.ChatWidget,
|
|
Component: () => import('@/pages/next-chats/widget'),
|
|
layout: false,
|
|
},
|
|
{
|
|
path: Routes.AgentList,
|
|
Component: () => import('@/pages/agents'),
|
|
},
|
|
{
|
|
path: '/document/:id',
|
|
Component: () => import('@/pages/document-viewer'),
|
|
layout: false,
|
|
},
|
|
{
|
|
path: '/*',
|
|
Component: () => import('@/pages/404'),
|
|
layout: false,
|
|
},
|
|
{
|
|
path: Routes.Root,
|
|
layout: false,
|
|
Component: () => import('@/layouts/root-layout'),
|
|
loader: ({ request }: { request: Request }) => {
|
|
const url = new URL(request.url);
|
|
const auth = url.searchParams.get('auth');
|
|
if (auth) {
|
|
authorizationUtil.setAuthorization(auth);
|
|
url.searchParams.delete('auth');
|
|
return redirect(`${url.pathname}${url.search}`);
|
|
}
|
|
return null;
|
|
},
|
|
children: [
|
|
{
|
|
path: Routes.Root,
|
|
Component: () => import('@/pages/home'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: Routes.Chat + '/:id',
|
|
Component: () => import('@/pages/next-chats/chat'),
|
|
},
|
|
{
|
|
path: Routes.Root,
|
|
Component: () => import('@/layouts/root-layout'),
|
|
children: [
|
|
{
|
|
path: Routes.Datasets,
|
|
Component: () => import('@/pages/datasets'),
|
|
},
|
|
{
|
|
path: Routes.DatasetBase,
|
|
Component: () => import('@/pages/dataset'),
|
|
children: [
|
|
{
|
|
path: `${Routes.Dataset}/:id`,
|
|
Component: () => import('@/pages/dataset/dataset'),
|
|
},
|
|
{
|
|
path: `${Routes.DatasetBase}${Routes.DatasetTesting}/:id`,
|
|
Component: () => import('@/pages/dataset/testing'),
|
|
},
|
|
{
|
|
path: `${Routes.DatasetBase}${Routes.KnowledgeGraph}/:id`,
|
|
Component: () => import('@/pages/dataset/knowledge-graph'),
|
|
},
|
|
{
|
|
path: `${Routes.DatasetBase}${Routes.DataSetOverview}/:id`,
|
|
Component: () => import('@/pages/dataset/dataset-overview'),
|
|
},
|
|
{
|
|
path: `${Routes.DatasetBase}${Routes.DataSetSetting}/:id`,
|
|
Component: () => import('@/pages/dataset/dataset-setting'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: Routes.Chats,
|
|
Component: () => import('@/pages/next-chats'),
|
|
},
|
|
{
|
|
path: Routes.Searches,
|
|
Component: () => import('@/pages/next-searches'),
|
|
},
|
|
{
|
|
path: `${Routes.Search}/:id`,
|
|
layout: false,
|
|
Component: () => import('@/pages/next-search'),
|
|
},
|
|
{
|
|
path: Routes.Agents,
|
|
Component: () => import('@/pages/agents'),
|
|
},
|
|
{
|
|
path: Routes.AgentTemplates,
|
|
layout: false,
|
|
Component: () => import('@/pages/agents/agent-templates'),
|
|
},
|
|
{
|
|
path: Routes.Memories,
|
|
Component: () => import('@/pages/memories'),
|
|
},
|
|
{
|
|
path: `${Routes.Memory}`,
|
|
Component: () => import('@/pages/memory'),
|
|
children: [
|
|
{
|
|
path: `${Routes.Memory}/${Routes.MemoryMessage}/:id`,
|
|
Component: () => import('@/pages/memory/memory-message'),
|
|
},
|
|
{
|
|
path: `${Routes.Memory}/${Routes.MemorySetting}/:id`,
|
|
Component: () => import('@/pages/memory/memory-setting'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: Routes.Files,
|
|
Component: () => import('@/pages/files'),
|
|
},
|
|
{
|
|
path: Routes.Skills,
|
|
Component: () => import('@/pages/skills'),
|
|
},
|
|
{
|
|
path: Routes.UserSetting,
|
|
Component: () => import('@/pages/user-setting'),
|
|
layout: false,
|
|
children: [
|
|
{
|
|
path: Routes.UserSetting,
|
|
element: (
|
|
<Navigate to={`/user-setting${Routes.DataSource}`} replace />
|
|
),
|
|
},
|
|
{
|
|
path: `${Routes.UserSetting}/profile`,
|
|
Component: () => import('@/pages/user-setting/profile'),
|
|
},
|
|
/*
|
|
{
|
|
path: `${Routes.UserSetting}/locale`,
|
|
Component: () => import('@/pages/user-setting/setting-locale'),
|
|
},
|
|
*/
|
|
{
|
|
path: `${Routes.UserSetting}/model`,
|
|
Component: () => import('@/pages/user-setting/setting-model'),
|
|
},
|
|
{
|
|
path: `${Routes.UserSetting}/team`,
|
|
Component: () => import('@/pages/user-setting/setting-team'),
|
|
},
|
|
{
|
|
path: `${Routes.UserSetting}${Routes.Api}`,
|
|
Component: () => import('@/pages/user-setting/setting-api'),
|
|
},
|
|
{
|
|
path: `${Routes.UserSetting}${Routes.Mcp}`,
|
|
Component: () => import('@/pages/user-setting/mcp'),
|
|
},
|
|
|
|
{
|
|
path: `${Routes.UserSetting}${Routes.DataSource}`,
|
|
Component: () => import('@/pages/user-setting/data-source'),
|
|
},
|
|
{
|
|
path: `${Routes.UserSetting}${Routes.ChatChannel}`,
|
|
Component: () => import('@/pages/user-setting/chat-channel'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: `${Routes.UserSetting}${Routes.DataSource}${Routes.DataSourceDetailPage}`,
|
|
layout: false,
|
|
Component: () =>
|
|
import('@/pages/user-setting/data-source/data-source-detail-page'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: `${Routes.SearchShare}`,
|
|
Component: () => import('@/pages/next-search/share'),
|
|
},
|
|
{
|
|
path: Routes.Agent,
|
|
children: [
|
|
{
|
|
path: `${Routes.Agent}/:id`,
|
|
Component: () => import('@/pages/agent'),
|
|
},
|
|
{
|
|
path: Routes.AgentExplore,
|
|
Component: () => import('@/pages/agent/explore'),
|
|
errorElement: <FallbackComponent />,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: `${Routes.AgentLogPage}/:id`,
|
|
Component: () => import('@/pages/agents/agent-log-page'),
|
|
},
|
|
{
|
|
path: `${Routes.DataflowResult}`,
|
|
Component: () => import('@/pages/dataflow-result'),
|
|
},
|
|
{
|
|
path: Routes.Chunk,
|
|
children: [
|
|
{
|
|
path: `${Routes.Chunk}`,
|
|
Component: () => import('@/pages/chunk'),
|
|
},
|
|
{
|
|
path: `${Routes.ParsedResult}/chunks`,
|
|
Component: () =>
|
|
import('@/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk'),
|
|
},
|
|
{
|
|
path: `${Routes.ChunkResult}/:id`,
|
|
Component: () => import('@/pages/chunk/chunk-result'),
|
|
},
|
|
{
|
|
path: `${Routes.ResultView}/:id`,
|
|
Component: () => import('@/pages/chunk/result-view'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: Routes.Admin,
|
|
Component: () => import('@/pages/admin/layouts/root-layout'),
|
|
children: [
|
|
{
|
|
path: Routes.Admin,
|
|
Component: () => import('@/pages/admin/login'),
|
|
},
|
|
{
|
|
path: Routes.Admin,
|
|
Component: () => import('@/pages/admin/layouts/authorized-layout'),
|
|
children: [
|
|
{
|
|
path: `${Routes.AdminUserManagement}/:id`,
|
|
Component: () => import('@/pages/admin/user-detail'),
|
|
},
|
|
{
|
|
Component: () => import('@/pages/admin/layouts/navigation-layout'),
|
|
children: [
|
|
{
|
|
path: Routes.AdminServices,
|
|
Component: () => import('@/pages/admin/service-status'),
|
|
},
|
|
{
|
|
path: Routes.AdminUserManagement,
|
|
Component: () => import('@/pages/admin/users'),
|
|
},
|
|
{
|
|
path: Routes.AdminSandboxSettings,
|
|
Component: () => import('@/pages/admin/sandbox-settings'),
|
|
},
|
|
...(IS_ENTERPRISE
|
|
? [
|
|
{
|
|
path: Routes.AdminWhitelist,
|
|
Component: () => import('@/pages/admin/whitelist'),
|
|
},
|
|
{
|
|
path: Routes.AdminRoles,
|
|
Component: () => import('@/pages/admin/roles'),
|
|
},
|
|
{
|
|
path: Routes.AdminMonitoring,
|
|
Component: () => import('@/pages/admin/monitoring'),
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} satisfies LazyRouteConfig,
|
|
];
|
|
|
|
const wrapRoutes = (routes: LazyRouteConfig[]): RouteObject[] =>
|
|
routes.map((item) => {
|
|
const { Component, children, ...rest } = item;
|
|
const next: RouteObject = { ...rest, errorElement: <FallbackComponent /> };
|
|
if (Component) {
|
|
next.Component = withLazyRoute(Component);
|
|
}
|
|
if (children) {
|
|
next.children = wrapRoutes(children);
|
|
}
|
|
return next;
|
|
});
|
|
|
|
const routeConfig = wrapRoutes(routeConfigOptions);
|
|
|
|
const routers = createBrowserRouter(routeConfig, {
|
|
basename: import.meta.env.VITE_BASE_URL || '/',
|
|
});
|
|
|
|
export { routers };
|