Feat: chat channels — connect assistants to external messaging bots (#15850)
### 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" /> ---------
1
web/src/assets/svg/chat-channel/clickclack.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="11" fill="#00B8D9"/><text x="24" y="25" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central">Cc</text></svg>
|
||||
|
After Width: | Height: | Size: 311 B |
1
web/src/assets/svg/chat-channel/feishu.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780992154990" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5067" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M0 0m512 0l0 0q512 0 512 512l0 0q0 512-512 512l0 0q-512 0-512-512l0 0q0-512 512-512Z" fill="#FFFFFF" p-id="5068"></path><path d="M832.032 367.2048c4.1232 0.024 8.2432 0.2656 12.3392 0.7232a345.9104 345.9104 0 0 1 91.8656 25.368c8.5232 3.8176 10.6288 6.9104 3.2912 14.608a296.456 296.456 0 0 0-51.4608 75.9424c-14.1488 29.776-29.6128 58.928-44.0896 88.576a190.048 190.048 0 0 1-43.992 58.5664c-45.2752 40.9632-98.0512 58.2384-158.264 49.88-69.096-9.5744-134.5408-32.9024-196.3648-63.6672-3.8496-1.9072-6.5808-3.2896-8.8512-4.672a4.4832 4.4832 0 0 1-2.096-3.9424 4.4832 4.4832 0 0 1 2.3584-3.7904l4.2784-2.304c50.0784-26.7488 91.8656-64.0272 132.1376-103.216 17.0112-16.4512 33.3312-33.7248 50.5072-50.0448a291.1264 291.1264 0 0 1 135.2-72.3872c11.12-2.6656 22.3392-4.8368 33.5264-7.2384h0.528l23.8208-2.2048" fill="#133C9A" p-id="5069"></path><path d="M348.0288 850.6816c-7.6-0.4272-26.3216-2.9936-28.56-3.2896a452.6144 452.6144 0 0 1-139.312-40.7344c-25.4656-11.9104-49.9792-25.96-74.5584-39.648-16.1552-9.016-23.4928-23.032-23.328-42.0496 0.528-70.3472 0.528-140.704 0-211.0736-0.2624-45.2752-1.5792-90.5488-2.2704-135.792a36.6016 36.6016 0 0 1 1.8752-11.744c2.7312-8.16 8.3584-8.656 13.92-3.2912 6.416 6.1856 11.5152 13.6864 17.8656 19.7408 56.856 56 117.1008 107.296 184.6512 149.5456a1017.56 1017.56 0 0 0 118.4512 65.2464c65.6416 29.8096 132.928 56.0992 203.44 72.7808 62.2848 14.7408 122.8608 5.4624 173.3328-34.0864 15.4-13.1616 23.0336-22.8032 41.2944-47.1184a303.6624 303.6624 0 0 1-31.5552 61.464c-11.7456 18.4912-38.2 43.168-58.368 62.5152-30.6336 29.6128-70.6768 53.632-108.2528 73.9008-40.9632 22.0784-83.5408 39.7136-129.0448 49.3536-23.328 5.824-57.0224 12.504-68.6368 13.1616-2.04-0.1648-8.9824 1.4144-12.536 1.12-29.9744 2.2688-48.4656 3.1248-78.408 0z" fill="#3370FF" p-id="5070"></path><path d="M219.28 172.912a44.256 44.256 0 0 1 6.2832 0c128.848 0 256.6448 2.072 385.328 2.072 0.224 0 0.4432 0.0688 0.6256 0.1984a303.4976 303.4976 0 0 1 33.1328 33.856c29.0544 28.8896 50.704 78.968 65.5104 109.5024 7.3712 21.0912 18.4912 41.2608 23.7568 64.752v0.4288a281.552 281.552 0 0 0-38.2992 15.5968c-37.2144 18.8864-54.1264 32.672-85.0224 63.1072-16.8128 16.4512-31.192 31.2912-53.5328 52.3488-7.008 6.5808-24.8416 23.264-25.1376 22.736-5.9232-10.464-106.08-206.4-307.2832-360.552" fill="#00D6B9" p-id="5071"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
1
web/src/assets/svg/chat-channel/googlechat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#34A853" d="M1.637 0C.733 0 0 .733 0 1.637v16.5c0 .904.733 1.636 1.637 1.636h3.955v3.323c0 .804.97 1.207 1.539.638l3.963-3.96h11.27c.903 0 1.636-.733 1.636-1.637V5.592L18.408 0Zm3.955 5.592h12.816v8.59H8.455l-2.863 2.863Z"/></svg>
|
||||
|
After Width: | Height: | Size: 325 B |
1
web/src/assets/svg/chat-channel/irc.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="11" fill="#16A085"/><text x="24" y="25" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central">#</text></svg>
|
||||
|
After Width: | Height: | Size: 310 B |
1
web/src/assets/svg/chat-channel/line.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#00C300" d="M19.365 9.863c.349 0 .63.285.63.631 0 .345-.281.63-.63.63H17.61v1.125h1.755c.349 0 .63.283.63.63 0 .344-.281.629-.63.629h-2.386c-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63h2.386c.346 0 .627.285.627.63 0 .349-.281.63-.63.63H17.61v1.125h1.755zm-3.855 3.016c0 .27-.174.51-.432.596-.064.021-.133.031-.199.031-.211 0-.391-.09-.51-.25l-2.443-3.317v2.94c0 .344-.279.629-.631.629-.346 0-.626-.285-.626-.629V8.108c0-.27.173-.51.43-.595.06-.023.136-.033.194-.033.195 0 .375.104.495.254l2.462 3.33V8.108c0-.345.282-.63.63-.63.345 0 .63.285.63.63v4.771zm-5.741 0c0 .344-.282.629-.631.629-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63.346 0 .628.285.628.63v4.771zm-2.466.629H4.917c-.345 0-.63-.285-.63-.629V8.108c0-.345.285-.63.63-.63.348 0 .63.285.63.63v4.141h1.756c.348 0 .629.283.629.63 0 .344-.282.629-.629.629M24 10.314C24 4.943 18.615.572 12 .572S0 4.943 0 10.314c0 4.811 4.27 8.842 10.035 9.608.391.082.923.258 1.058.59.12.301.079.766.038 1.08l-.164 1.02c-.045.301-.24 1.186 1.049.645 1.291-.539 6.916-4.078 9.436-6.975C23.176 14.393 24 12.458 24 10.314"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
web/src/assets/svg/chat-channel/matrix.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><rect width="24" height="24" rx="5" fill="#F2F2F2"/><path fill="#1A1A1A" d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>
|
||||
|
After Width: | Height: | Size: 997 B |
1
web/src/assets/svg/chat-channel/mattermost.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#0058CC" d="M12.081 0C7.048-.034 2.339 3.125.637 8.153c-2.125 6.276 1.24 13.086 7.516 15.21 6.276 2.125 13.086-1.24 15.21-7.516 1.727-5.1-.172-10.552-4.311-13.557l.126 2.547c2.065 2.282 2.88 5.512 1.852 8.549-1.534 4.532-6.594 6.915-11.3 5.321-4.708-1.593-7.28-6.559-5.745-11.092 1.031-3.046 3.655-5.121 6.694-5.67l1.642-1.94A4.87 4.87 0 0 0 12.08 0zm3.528 1.094a.284.284 0 0 0-.123.024l-.004.001a.33.33 0 0 0-.109.071c-.145.142-.657.828-.657.828L13.6 3.4l-1.3 1.585-2.232 2.776s-1.024 1.278-.798 2.851c.226 1.574 1.396 2.34 2.304 2.648.907.307 2.302.408 3.438-.704 1.135-1.112 1.098-2.75 1.098-2.75l-.087-3.56-.07-2.05-.047-1.775s.01-.856-.02-1.057a.33.33 0 0 0-.035-.107l-.006-.012-.007-.011a.277.277 0 0 0-.229-.14z"/></svg>
|
||||
|
After Width: | Height: | Size: 822 B |
1
web/src/assets/svg/chat-channel/nextcloud_talk.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#0082C9" d="M12.018 6.537c-2.5 0-4.6 1.712-5.241 4.015-.56-1.232-1.793-2.105-3.225-2.105A3.569 3.569 0 0 0 0 12a3.569 3.569 0 0 0 3.552 3.553c1.432 0 2.664-.874 3.224-2.106.641 2.304 2.742 4.016 5.242 4.016 2.487 0 4.576-1.693 5.231-3.977.569 1.21 1.783 2.067 3.198 2.067A3.568 3.568 0 0 0 24 12a3.569 3.569 0 0 0-3.553-3.553c-1.416 0-2.63.858-3.199 2.067-.654-2.284-2.743-3.978-5.23-3.977zm0 2.085c1.878 0 3.378 1.5 3.378 3.378 0 1.878-1.5 3.378-3.378 3.378A3.362 3.362 0 0 1 8.641 12c0-1.878 1.5-3.378 3.377-3.378zm-8.466 1.91c.822 0 1.467.645 1.467 1.468s-.644 1.467-1.467 1.468A1.452 1.452 0 0 1 2.085 12c0-.823.644-1.467 1.467-1.467zm16.895 0c.823 0 1.468.645 1.468 1.468s-.645 1.468-1.468 1.468A1.452 1.452 0 0 1 18.98 12c0-.823.644-1.467 1.467-1.467z"/></svg>
|
||||
|
After Width: | Height: | Size: 861 B |
1
web/src/assets/svg/chat-channel/nostr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="11" fill="#8E30EB"/><text x="24" y="25" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central">Ns</text></svg>
|
||||
|
After Width: | Height: | Size: 311 B |
1
web/src/assets/svg/chat-channel/qqbot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#1EBAFC" d="M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673"/></svg>
|
||||
|
After Width: | Height: | Size: 660 B |
1
web/src/assets/svg/chat-channel/synology_chat.svg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
1
web/src/assets/svg/chat-channel/telegram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#26A5E4" d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
|
After Width: | Height: | Size: 746 B |
1
web/src/assets/svg/chat-channel/tlon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="11" fill="#5B4FE0"/><text x="24" y="25" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central">Tl</text></svg>
|
||||
|
After Width: | Height: | Size: 311 B |
1
web/src/assets/svg/chat-channel/twitch.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#9146FF" d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
1
web/src/assets/svg/chat-channel/wecom.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#07C160" d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
web/src/assets/svg/chat-channel/whatsapp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#25D366" d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
web/src/assets/svg/chat-channel/yuanbao.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="11" fill="#1E6FFF"/><text x="24" y="25" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central">Y</text></svg>
|
||||
|
After Width: | Height: | Size: 310 B |
1
web/src/assets/svg/chat-channel/zalo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#0068FF" d="M12.49 10.2722v-.4496h1.3467v6.3218h-.7704a.576.576 0 01-.5763-.5729l-.0006.0005a3.273 3.273 0 01-1.9372.6321c-1.8138 0-3.2844-1.4697-3.2844-3.2823 0-1.8125 1.4706-3.2822 3.2844-3.2822a3.273 3.273 0 011.9372.6321l.0006.0005zM6.9188 7.7896v.205c0 .3823-.051.6944-.2995 1.0605l-.03.0343c-.0542.0615-.1815.206-.2421.2843L2.024 14.8h4.8948v.7682a.5764.5764 0 01-.5767.5761H0v-.3622c0-.4436.1102-.6414.2495-.8476L4.8582 9.23H.1922V7.7896h6.7266zm8.5513 8.3548a.4805.4805 0 01-.4803-.4798v-7.875h1.4416v8.3548H15.47zM20.6934 9.6C22.52 9.6 24 11.0807 24 12.9044c0 1.8252-1.4801 3.306-3.3066 3.306-1.8264 0-3.3066-1.4808-3.3066-3.306 0-1.8237 1.4802-3.3044 3.3066-3.3044zm-10.1412 5.253c1.0675 0 1.9324-.8645 1.9324-1.9312 0-1.065-.865-1.9295-1.9324-1.9295s-1.9324.8644-1.9324 1.9295c0 1.0667.865 1.9312 1.9324 1.9312zm10.1412-.0033c1.0737 0 1.945-.8707 1.945-1.9453 0-1.073-.8713-1.9436-1.945-1.9436-1.0753 0-1.945.8706-1.945 1.9436 0 1.0746.8697 1.9453 1.945 1.9453z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
web/src/assets/svg/chat-channel/zalouser.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#0068FF" d="M12.49 10.2722v-.4496h1.3467v6.3218h-.7704a.576.576 0 01-.5763-.5729l-.0006.0005a3.273 3.273 0 01-1.9372.6321c-1.8138 0-3.2844-1.4697-3.2844-3.2823 0-1.8125 1.4706-3.2822 3.2844-3.2822a3.273 3.273 0 011.9372.6321l.0006.0005zM6.9188 7.7896v.205c0 .3823-.051.6944-.2995 1.0605l-.03.0343c-.0542.0615-.1815.206-.2421.2843L2.024 14.8h4.8948v.7682a.5764.5764 0 01-.5767.5761H0v-.3622c0-.4436.1102-.6414.2495-.8476L4.8582 9.23H.1922V7.7896h6.7266zm8.5513 8.3548a.4805.4805 0 01-.4803-.4798v-7.875h1.4416v8.3548H15.47zM20.6934 9.6C22.52 9.6 24 11.0807 24 12.9044c0 1.8252-1.4801 3.306-3.3066 3.306-1.8264 0-3.3066-1.4808-3.3066-3.306 0-1.8237 1.4802-3.3044 3.3066-3.3044zm-10.1412 5.253c1.0675 0 1.9324-.8645 1.9324-1.9312 0-1.065-.865-1.9295-1.9324-1.9295s-1.9324.8644-1.9324 1.9295c0 1.0667.865 1.9312 1.9324 1.9312zm10.1412-.0033c1.0737 0 1.945-.8707 1.945-1.9453 0-1.073-.8713-1.9436-1.945-1.9436-1.0753 0-1.945.8706-1.945 1.9436 0 1.0746.8697 1.9453 1.945 1.9453z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -17,6 +17,11 @@ const PathMap = {
|
||||
[Routes.Files]: [Routes.Files],
|
||||
} as const;
|
||||
|
||||
// Match on path-segment boundaries, not a loose substring, so e.g.
|
||||
// "/user-setting/chat-channel" does not match the "/chat" tab.
|
||||
const matchesPath = (pathname: string, candidate: string) =>
|
||||
pathname === candidate || pathname.startsWith(`${candidate}/`);
|
||||
|
||||
const menuItems = [
|
||||
{ path: Routes.Root, name: 'header.Root', icon: LucideHouse },
|
||||
{ path: Routes.Datasets, name: 'header.dataset' /* icon: Library, */ },
|
||||
@@ -49,7 +54,7 @@ const GlobalNavbar = supportsCssAnchor
|
||||
return (
|
||||
Object.keys(PathMap).find((x: string) =>
|
||||
PathMap[x as keyof typeof PathMap].some((y: string) =>
|
||||
pathname.includes(y),
|
||||
matchesPath(pathname, y),
|
||||
),
|
||||
) || pathname
|
||||
);
|
||||
@@ -115,7 +120,7 @@ const GlobalNavbar = supportsCssAnchor
|
||||
return (
|
||||
Object.keys(PathMap).find((x: string) =>
|
||||
PathMap[x as keyof typeof PathMap].some((y: string) =>
|
||||
pathname.includes(y),
|
||||
matchesPath(pathname, y),
|
||||
),
|
||||
) || pathname
|
||||
);
|
||||
|
||||
@@ -1476,6 +1476,47 @@ Example: Virtual Hosted Style`,
|
||||
availableSourcesDescription: 'Select a data source to add',
|
||||
availableSources: 'Available sources',
|
||||
datasourceDescription: 'Manage your data source and connections',
|
||||
chatChannels: 'Chat channels',
|
||||
chatChannelsDescription: 'Manage your chat channel bots and credentials',
|
||||
channelEmptyTip:
|
||||
'No chat channels added yet. Select one below to connect.',
|
||||
availableChannels: 'Available channels',
|
||||
availableChannelsDescription: 'Select a chat channel to add',
|
||||
addChannelModalTitle: 'Add {{name}} bot',
|
||||
editChannelModalTitle: 'Edit {{name}} bot',
|
||||
deleteChannelModalTitle: 'Delete chat channel',
|
||||
deleteChannelModalContent:
|
||||
'Are you sure you want to delete this chat channel bot? This action cannot be undone.',
|
||||
connectDialog: 'Connect assistant',
|
||||
connectDialogTitle: 'Connect {{name}} to an assistant',
|
||||
selectDialog: 'Select an assistant',
|
||||
connectDialogTip:
|
||||
'Messages received by this channel will be answered by the connected assistant. Clear the selection to disconnect.',
|
||||
notConnected: 'No assistant connected',
|
||||
chatChannelDesc: {
|
||||
clickclack: 'Connect a ClickClack bot',
|
||||
discord: 'Connect a Discord bot',
|
||||
feishu: 'Connect a Feishu / Lark bot',
|
||||
googlechat: 'Connect a Google Chat bot',
|
||||
irc: 'Connect to an IRC server',
|
||||
line: 'Connect a LINE messaging bot',
|
||||
matrix: 'Connect a Matrix bot',
|
||||
mattermost: 'Connect a Mattermost bot',
|
||||
msteams: 'Connect a Microsoft Teams bot',
|
||||
nextcloud_talk: 'Connect a Nextcloud Talk bot',
|
||||
nostr: 'Connect a Nostr bot',
|
||||
qqbot: 'Connect a QQ bot',
|
||||
slack: 'Connect a Slack bot',
|
||||
synology_chat: 'Connect a Synology Chat bot',
|
||||
telegram: 'Connect a Telegram bot',
|
||||
tlon: 'Connect a Tlon (Urbit) bot',
|
||||
twitch: 'Connect a Twitch chat bot',
|
||||
wecom: 'Connect a WeCom bot',
|
||||
whatsapp: 'Connect a WhatsApp bot (QR pairing)',
|
||||
yuanbao: 'Connect a Tencent Yuanbao bot',
|
||||
zalo: 'Connect a Zalo bot',
|
||||
zalouser: 'Connect a personal Zalo account',
|
||||
},
|
||||
save: 'Save',
|
||||
search: 'Search',
|
||||
availableModels: 'Available models',
|
||||
|
||||
@@ -1206,6 +1206,46 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
availableSourcesDescription: '选择要添加的数据源',
|
||||
availableSources: '可用数据源',
|
||||
datasourceDescription: '管理您的数据源和连接',
|
||||
chatChannels: '聊天渠道',
|
||||
chatChannelsDescription: '管理您的聊天渠道机器人及凭证',
|
||||
channelEmptyTip: '暂未添加任何聊天渠道,请从下方选择一个进行连接。',
|
||||
availableChannels: '可用渠道',
|
||||
availableChannelsDescription: '选择要添加的聊天渠道',
|
||||
addChannelModalTitle: '添加 {{name}} 机器人',
|
||||
editChannelModalTitle: '编辑 {{name}} 机器人',
|
||||
deleteChannelModalTitle: '删除聊天渠道',
|
||||
deleteChannelModalContent:
|
||||
'确定要删除此聊天渠道机器人吗?此操作无法撤销。',
|
||||
connectDialog: '关联助手',
|
||||
connectDialogTitle: '将 {{name}} 关联到助手',
|
||||
selectDialog: '选择助手',
|
||||
connectDialogTip:
|
||||
'该渠道收到的消息将由关联的助手回复。清空选择即可解除关联。',
|
||||
notConnected: '未关联助手',
|
||||
chatChannelDesc: {
|
||||
clickclack: '连接 ClickClack 机器人',
|
||||
discord: '连接 Discord 机器人',
|
||||
feishu: '连接飞书 / Lark 机器人',
|
||||
googlechat: '连接 Google Chat 机器人',
|
||||
irc: '连接 IRC 服务器',
|
||||
line: '连接 LINE 消息机器人',
|
||||
matrix: '连接 Matrix 机器人',
|
||||
mattermost: '连接 Mattermost 机器人',
|
||||
msteams: '连接 Microsoft Teams 机器人',
|
||||
nextcloud_talk: '连接 Nextcloud Talk 机器人',
|
||||
nostr: '连接 Nostr 机器人',
|
||||
qqbot: '连接 QQ 机器人',
|
||||
slack: '连接 Slack 机器人',
|
||||
synology_chat: '连接群晖 Chat 机器人',
|
||||
telegram: '连接 Telegram 机器人',
|
||||
tlon: '连接 Tlon (Urbit) 机器人',
|
||||
twitch: '连接 Twitch 聊天机器人',
|
||||
wecom: '连接企业微信机器人',
|
||||
whatsapp: '连接 WhatsApp 机器人(扫码配对)',
|
||||
yuanbao: '连接腾讯元宝机器人',
|
||||
zalo: '连接 Zalo 机器人',
|
||||
zalouser: '连接个人 Zalo 账号',
|
||||
},
|
||||
save: '保存',
|
||||
search: '搜索',
|
||||
availableModels: '可选模型',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DynamicForm, FormFieldConfig } from '@/components/dynamic-form';
|
||||
import { Modal } from '@/components/ui/modal/modal';
|
||||
import { IModalProps } from '@/interfaces/common';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FieldValues } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChatChannelFormDefaultValues,
|
||||
getChatChannelFields,
|
||||
mergeChatChannelFormValues,
|
||||
} from './constant';
|
||||
import { IChatChannel, IChatChannelInfo } from './interface';
|
||||
|
||||
const AddChatChannelModal = ({
|
||||
visible,
|
||||
hideModal,
|
||||
loading,
|
||||
channel,
|
||||
record,
|
||||
onOk,
|
||||
}: IModalProps<FieldValues> & {
|
||||
channel?: IChatChannelInfo;
|
||||
record?: IChatChannel;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fields, setFields] = useState<FormFieldConfig[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
setFields(getChatChannelFields(channel.id));
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const base = channel ? ChatChannelFormDefaultValues[channel.id] : undefined;
|
||||
return mergeChatChannelFormValues(base, record) as FieldValues;
|
||||
}, [channel, record]);
|
||||
|
||||
const handleOk = async (values?: FieldValues) => {
|
||||
await onOk?.(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="size-6">{channel?.icon}</div>
|
||||
{record
|
||||
? t('setting.editChannelModalTitle', { name: channel?.name })
|
||||
: t('setting.addChannelModalTitle', { name: channel?.name })}
|
||||
</div>
|
||||
}
|
||||
open={visible || false}
|
||||
onOpenChange={(open) => !open && hideModal?.()}
|
||||
maskClosable={false}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
footer={<div className="p-4"></div>}
|
||||
>
|
||||
<DynamicForm.Root
|
||||
fields={fields}
|
||||
onSubmit={() => {}}
|
||||
defaultValues={defaultValues}
|
||||
labelClassName="font-normal"
|
||||
>
|
||||
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
|
||||
<DynamicForm.CancelButton
|
||||
handleCancel={() => {
|
||||
hideModal?.();
|
||||
}}
|
||||
/>
|
||||
<DynamicForm.SavingButton
|
||||
submitLoading={loading || false}
|
||||
buttonText={t('common.confirm')}
|
||||
submitFunc={(values: FieldValues) => {
|
||||
handleOk(values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DynamicForm.Root>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddChatChannelModal;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useSetModalState } from '@/hooks/common-hooks';
|
||||
import { Link2, Settings, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useChatChannelInfo } from '../constant';
|
||||
import { useDeleteChatChannel, useFetchChatChannelDetail } from '../hooks';
|
||||
import { IChatChannel, IChatChannelBase, IChatChannelInfo } from '../interface';
|
||||
import ConnectDialogModal from './connect-dialog-modal';
|
||||
import { delChannelModal } from './delete-channel-modal';
|
||||
|
||||
export type IAddedChannelCardProps = IChatChannelInfo & {
|
||||
list: IChatChannelBase[];
|
||||
onEdit: (channel: IChatChannelInfo, record: IChatChannel) => void;
|
||||
};
|
||||
|
||||
export const AddedChannelCard = (props: IAddedChannelCardProps) => {
|
||||
const { list, name, icon, onEdit, ...channel } = props;
|
||||
const { t } = useTranslation();
|
||||
const { handleDelete } = useDeleteChatChannel();
|
||||
const { fetchDetail } = useFetchChatChannelDetail();
|
||||
const { chatChannelInfo } = useChatChannelInfo();
|
||||
const {
|
||||
visible: connectVisible,
|
||||
showModal: showConnectModal,
|
||||
hideModal: hideConnectModal,
|
||||
} = useSetModalState();
|
||||
const [connectTarget, setConnectTarget] = useState<
|
||||
IChatChannelBase | undefined
|
||||
>(undefined);
|
||||
|
||||
const handleEdit = async (id: string) => {
|
||||
const record = await fetchDetail(id);
|
||||
if (record) {
|
||||
onEdit({ name, icon, ...channel }, record);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (item: IChatChannelBase) => {
|
||||
setConnectTarget(item);
|
||||
showConnectModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent border border-border-button px-5 pt-[10px] pb-5 rounded-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-0 pb-3">
|
||||
<CardTitle className="text-base items-center flex gap-1 font-normal">
|
||||
{icon}
|
||||
{name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 flex flex-col gap-2">
|
||||
{list.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-row items-center justify-between rounded-md bg-bg-card px-[10px] py-4"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-sm text-text-primary">{item.name}</div>
|
||||
{item.dialog_id ? (
|
||||
<div className="text-xs text-text-secondary flex items-center gap-1">
|
||||
<Link2 size={12} />
|
||||
{item.dialog_name || item.dialog_id}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-secondary/60">
|
||||
{t('setting.notConnected')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary flex gap-2">
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="rounded-lg px-2 py-1 bg-transparent hover:bg-bg-card"
|
||||
onClick={() => handleConnect(item)}
|
||||
title={t('setting.connectDialog')}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="rounded-lg px-2 py-1 bg-transparent hover:bg-bg-card"
|
||||
onClick={() => handleEdit(item.id)}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="rounded-lg px-2 py-1 bg-transparent hover:bg-state-error-5 hover:text-state-error"
|
||||
onClick={() =>
|
||||
delChannelModal({
|
||||
data: item,
|
||||
chatChannelInfo,
|
||||
onOk: () => {
|
||||
handleDelete(item.id);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="cursor-pointer" size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
{connectVisible && (
|
||||
<ConnectDialogModal
|
||||
visible={connectVisible}
|
||||
hideModal={hideConnectModal}
|
||||
channel={connectTarget}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modal } from '@/components/ui/modal/modal';
|
||||
import { RAGFlowSelect } from '@/components/ui/select';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useChatChannelDialogList,
|
||||
useConnectChatChannelDialog,
|
||||
} from '../hooks';
|
||||
import { IChatChannelBase } from '../interface';
|
||||
|
||||
const ConnectDialogModal = ({
|
||||
visible,
|
||||
hideModal,
|
||||
channel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
hideModal: () => void;
|
||||
channel?: IChatChannelBase;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useChatChannelDialogList();
|
||||
const { connect, connecting } = useConnectChatChannelDialog();
|
||||
const [dialogId, setDialogId] = useState<string | undefined>(
|
||||
channel?.dialog_id ?? undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDialogId(channel?.dialog_id ?? undefined);
|
||||
}, [channel?.id, channel?.dialog_id]);
|
||||
|
||||
const options = useMemo(
|
||||
() => (dialogs || []).map((d) => ({ label: d.name, value: d.id })),
|
||||
[dialogs],
|
||||
);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
await connect({ channelId: channel.id, dialogId: dialogId || null });
|
||||
hideModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('setting.connectDialogTitle', { name: channel?.name })}
|
||||
open={visible}
|
||||
maskClosable={false}
|
||||
onOpenChange={(open) => !open && hideModal()}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant={'outline'} onClick={hideModal}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={connecting}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="px-2 py-4 flex flex-col gap-1.5">
|
||||
<label className="text-sm text-text-secondary">
|
||||
{t('setting.selectDialog')}
|
||||
</label>
|
||||
<RAGFlowSelect
|
||||
value={dialogId}
|
||||
onChange={(val: string) => setDialogId(val || undefined)}
|
||||
options={options}
|
||||
allowClear
|
||||
placeholder={t('setting.selectDialog')}
|
||||
/>
|
||||
<p className="text-xs text-text-secondary/70 mt-1">
|
||||
{t('setting.connectDialogTip')}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectDialogModal;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modal, ModalType } from '@/components/ui/modal/modal';
|
||||
import { t } from 'i18next';
|
||||
import { IChatChannelBase, IChatChannelInfoMap } from '../interface';
|
||||
|
||||
export type IDelChannelModalProps = Partial<ModalType> & {
|
||||
data?: IChatChannelBase;
|
||||
onOk?: (data?: IChatChannelBase) => void;
|
||||
chatChannelInfo: IChatChannelInfoMap;
|
||||
};
|
||||
|
||||
export const delChannelModal = (props: IDelChannelModalProps) => {
|
||||
const { data, onOk, chatChannelInfo, ...otherProps } = props;
|
||||
Modal.show({
|
||||
visible: true,
|
||||
className: '!w-[560px]',
|
||||
...otherProps,
|
||||
title: t('setting.deleteChannelModalTitle'),
|
||||
titleClassName: 'border-b border-border-button',
|
||||
children: (
|
||||
<div className="px-2 space-y-6 pt-5 pb-3">
|
||||
<div
|
||||
className="text-base text-text-primary"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('setting.deleteChannelModalContent'),
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1 p-2 border border-border-button rounded-md mb-3">
|
||||
<div className="w-6 h-6 flex-shrink-0">
|
||||
{data?.channel ? chatChannelInfo[data.channel].icon : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-text-secondary text-xs">
|
||||
{data?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onVisibleChange: () => {
|
||||
Modal.destroy();
|
||||
},
|
||||
footer: (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant={'outline'} onClick={() => Modal.destroy()}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
className="!bg-state-error text-text-base"
|
||||
onClick={() => {
|
||||
onOk?.(data);
|
||||
Modal.destroy();
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
};
|
||||
657
web/src/pages/user-setting/chat-channel/constant/index.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
import { FormFieldConfig, FormFieldType } from '@/components/dynamic-form';
|
||||
import SvgIcon from '@/components/svg-icon';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IChatChannelInfoMap } from '../interface';
|
||||
|
||||
export enum ChatChannelKey {
|
||||
CLICKCLACK = 'clickclack',
|
||||
DISCORD = 'discord',
|
||||
FEISHU = 'feishu',
|
||||
GOOGLECHAT = 'googlechat',
|
||||
IRC = 'irc',
|
||||
LINE = 'line',
|
||||
MATRIX = 'matrix',
|
||||
MATTERMOST = 'mattermost',
|
||||
MSTEAMS = 'msteams',
|
||||
NEXTCLOUD_TALK = 'nextcloud_talk',
|
||||
NOSTR = 'nostr',
|
||||
QQBOT = 'qqbot',
|
||||
SLACK = 'slack',
|
||||
SYNOLOGY_CHAT = 'synology_chat',
|
||||
TELEGRAM = 'telegram',
|
||||
TLON = 'tlon',
|
||||
TWITCH = 'twitch',
|
||||
WECOM = 'wecom',
|
||||
WHATSAPP = 'whatsapp',
|
||||
YUANBAO = 'yuanbao',
|
||||
ZALO = 'zalo',
|
||||
ZALOUSER = 'zalouser',
|
||||
}
|
||||
|
||||
// Channels whose logo already exists in the shared data-source asset set —
|
||||
// reuse it instead of duplicating an icon under chat-channel/.
|
||||
const SHARED_DATA_SOURCE_ICON: Partial<Record<ChatChannelKey, string>> = {
|
||||
[ChatChannelKey.DISCORD]: 'data-source/discord',
|
||||
[ChatChannelKey.SLACK]: 'data-source/slack',
|
||||
[ChatChannelKey.MSTEAMS]: 'data-source/teams',
|
||||
};
|
||||
|
||||
const channelIcon = (key: ChatChannelKey) => (
|
||||
<SvgIcon
|
||||
name={SHARED_DATA_SOURCE_ICON[key] ?? `chat-channel/${key}`}
|
||||
width={38}
|
||||
/>
|
||||
);
|
||||
|
||||
const CHANNEL_NAMES: Record<ChatChannelKey, string> = {
|
||||
[ChatChannelKey.CLICKCLACK]: 'ClickClack',
|
||||
[ChatChannelKey.DISCORD]: 'Discord',
|
||||
[ChatChannelKey.FEISHU]: 'Feishu / Lark',
|
||||
[ChatChannelKey.GOOGLECHAT]: 'Google Chat',
|
||||
[ChatChannelKey.IRC]: 'IRC',
|
||||
[ChatChannelKey.LINE]: 'LINE',
|
||||
[ChatChannelKey.MATRIX]: 'Matrix',
|
||||
[ChatChannelKey.MATTERMOST]: 'Mattermost',
|
||||
[ChatChannelKey.MSTEAMS]: 'Microsoft Teams',
|
||||
[ChatChannelKey.NEXTCLOUD_TALK]: 'Nextcloud Talk',
|
||||
[ChatChannelKey.NOSTR]: 'Nostr',
|
||||
[ChatChannelKey.QQBOT]: 'QQ Bot',
|
||||
[ChatChannelKey.SLACK]: 'Slack',
|
||||
[ChatChannelKey.SYNOLOGY_CHAT]: 'Synology Chat',
|
||||
[ChatChannelKey.TELEGRAM]: 'Telegram',
|
||||
[ChatChannelKey.TLON]: 'Tlon (Urbit)',
|
||||
[ChatChannelKey.TWITCH]: 'Twitch',
|
||||
[ChatChannelKey.WECOM]: 'WeCom',
|
||||
[ChatChannelKey.WHATSAPP]: 'WhatsApp',
|
||||
[ChatChannelKey.YUANBAO]: 'Yuanbao',
|
||||
[ChatChannelKey.ZALO]: 'Zalo',
|
||||
[ChatChannelKey.ZALOUSER]: 'Zalo (Personal)',
|
||||
};
|
||||
|
||||
export const generateChatChannelInfo = (t: TFunction): IChatChannelInfoMap =>
|
||||
Object.values(ChatChannelKey).reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
name: CHANNEL_NAMES[key],
|
||||
description: t(`setting.chatChannelDesc.${key}`),
|
||||
icon: channelIcon(key),
|
||||
};
|
||||
return acc;
|
||||
}, {} as IChatChannelInfoMap);
|
||||
|
||||
export const useChatChannelInfo = () => {
|
||||
const { t } = useTranslation();
|
||||
const [chatChannelInfo, setChatChannelInfo] = useState<IChatChannelInfoMap>(
|
||||
generateChatChannelInfo(t) as IChatChannelInfoMap,
|
||||
);
|
||||
useEffect(() => {
|
||||
setChatChannelInfo(generateChatChannelInfo(t) as IChatChannelInfoMap);
|
||||
}, [t]);
|
||||
return { chatChannelInfo };
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
export const mergeChatChannelFormValues = (
|
||||
...values: Array<Record<string, any> | undefined>
|
||||
): Record<string, any> =>
|
||||
values.reduce<Record<string, any>>((result, current) => {
|
||||
if (!current) {
|
||||
return result;
|
||||
}
|
||||
const next = { ...result };
|
||||
Object.entries(current).forEach(([key, value]) => {
|
||||
if (isPlainObject(value) && isPlainObject(next[key])) {
|
||||
next[key] = mergeChatChannelFormValues(next[key], value);
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}, {});
|
||||
|
||||
export const ChatChannelFormBaseFields: FormFieldConfig[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
label: 'Name',
|
||||
name: 'name',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
hidden: true,
|
||||
},
|
||||
] as FormFieldConfig[];
|
||||
|
||||
// Per-channel credential fields. Credentials are stored under
|
||||
// `config.credential.*` to match the backend JSON shape.
|
||||
export const ChatChannelFormFields: Record<ChatChannelKey, FormFieldConfig[]> =
|
||||
{
|
||||
[ChatChannelKey.CLICKCLACK]: [
|
||||
{
|
||||
label: 'Bot Token',
|
||||
name: 'config.credential.token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.DISCORD]: [
|
||||
{
|
||||
label: 'Bot Token',
|
||||
name: 'config.credential.token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Application ID',
|
||||
name: 'config.credential.application_id',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: '1234567890',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.FEISHU]: [
|
||||
{
|
||||
label: 'App ID',
|
||||
name: 'config.credential.app_id',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'cli_xxxxxxxxxxxxxxxx',
|
||||
},
|
||||
{
|
||||
label: 'App Secret',
|
||||
name: 'config.credential.app_secret',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Domain',
|
||||
name: 'config.credential.domain',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
defaultValue: 'feishu',
|
||||
options: [
|
||||
{ label: 'Feishu (mainland)', value: 'feishu' },
|
||||
{ label: 'Lark (international)', value: 'lark' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.GOOGLECHAT]: [
|
||||
{
|
||||
label: 'Auth Mode',
|
||||
name: 'config.auth_mode',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
defaultValue: 'webhook_url',
|
||||
options: [
|
||||
{ label: 'Webhook URL', value: 'webhook_url' },
|
||||
{ label: 'Service Account JSON', value: 'service_account' },
|
||||
{ label: 'Service Account File', value: 'service_account_file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Webhook URL',
|
||||
name: 'config.credential.webhook_url',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder:
|
||||
'https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=...',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.auth_mode === 'webhook_url',
|
||||
customValidate: (val: string, values: any) =>
|
||||
values?.config?.auth_mode === 'webhook_url' && !(val ?? '').trim()
|
||||
? 'Webhook URL is required for this auth mode'
|
||||
: true,
|
||||
},
|
||||
{
|
||||
label: 'Service Account JSON',
|
||||
name: 'config.credential.service_account',
|
||||
type: FormFieldType.Textarea,
|
||||
required: false,
|
||||
placeholder: '{ "type": "service_account", ... }',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.auth_mode === 'service_account',
|
||||
customValidate: (val: string, values: any) =>
|
||||
values?.config?.auth_mode === 'service_account' && !(val ?? '').trim()
|
||||
? 'Service account JSON is required for this auth mode'
|
||||
: true,
|
||||
},
|
||||
{
|
||||
label: 'Service Account File Path',
|
||||
name: 'config.credential.service_account_file',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: '/path/to/sa.json',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.auth_mode === 'service_account_file',
|
||||
customValidate: (val: string, values: any) =>
|
||||
values?.config?.auth_mode === 'service_account_file' &&
|
||||
!(val ?? '').trim()
|
||||
? 'Service account file path is required for this auth mode'
|
||||
: true,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.IRC]: [
|
||||
{
|
||||
label: 'Host',
|
||||
name: 'config.credential.host',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'irc.libera.chat',
|
||||
},
|
||||
{
|
||||
label: 'Port',
|
||||
name: 'config.credential.port',
|
||||
type: FormFieldType.Number,
|
||||
required: true,
|
||||
placeholder: '6697',
|
||||
},
|
||||
{
|
||||
label: 'TLS',
|
||||
name: 'config.credential.tls',
|
||||
type: FormFieldType.Switch,
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
label: 'Nick',
|
||||
name: 'config.credential.nick',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'my-bot',
|
||||
},
|
||||
{
|
||||
label: 'Username',
|
||||
name: 'config.credential.username',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'Defaults to nick',
|
||||
},
|
||||
{
|
||||
label: 'Server Password',
|
||||
name: 'config.credential.password',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
label: 'NickServ Account',
|
||||
name: 'config.credential.nickserv.account',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
label: 'NickServ Password',
|
||||
name: 'config.credential.nickserv.password',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.LINE]: [
|
||||
{
|
||||
label: 'Channel Secret',
|
||||
name: 'config.credential.channel_secret',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Channel Access Token',
|
||||
name: 'config.credential.channel_access_token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.MATRIX]: [
|
||||
{
|
||||
label: 'Homeserver',
|
||||
name: 'config.credential.homeserver',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'https://matrix.example.org',
|
||||
},
|
||||
{
|
||||
label: 'User ID',
|
||||
name: 'config.credential.user_id',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: '@bot:example.org',
|
||||
},
|
||||
{
|
||||
label: 'Access Token',
|
||||
name: 'config.credential.access_token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
label: 'Password',
|
||||
name: 'config.credential.password',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
label: 'Device ID',
|
||||
name: 'config.credential.device_id',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'OPENCLAW_BOT',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.MATTERMOST]: [
|
||||
{
|
||||
label: 'Base URL',
|
||||
name: 'config.credential.base_url',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'https://mattermost.example.com',
|
||||
},
|
||||
{
|
||||
label: 'Bot Token',
|
||||
name: 'config.credential.bot_token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.MSTEAMS]: [
|
||||
{
|
||||
label: 'App ID',
|
||||
name: 'config.credential.app_id',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
},
|
||||
{
|
||||
label: 'App Password',
|
||||
name: 'config.credential.app_password',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Tenant ID',
|
||||
name: 'config.credential.tenant_id',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.NEXTCLOUD_TALK]: [
|
||||
{
|
||||
label: 'Base URL',
|
||||
name: 'config.credential.base_url',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'https://nextcloud.example.com',
|
||||
},
|
||||
{
|
||||
label: 'Bot Secret',
|
||||
name: 'config.credential.bot_secret',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.NOSTR]: [
|
||||
{
|
||||
label: 'Private Key',
|
||||
name: 'config.credential.private_key',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
placeholder: 'nsec1xxxx or hex',
|
||||
},
|
||||
{
|
||||
label: 'Relays',
|
||||
name: 'config.credential.relays',
|
||||
type: FormFieldType.Tag,
|
||||
required: false,
|
||||
placeholder: 'wss://relay.damus.io',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.QQBOT]: [
|
||||
{
|
||||
label: 'App ID',
|
||||
name: 'config.credential.app_id',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: '102000000',
|
||||
},
|
||||
{
|
||||
label: 'Client Secret',
|
||||
name: 'config.credential.client_secret',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Base URL',
|
||||
name: 'config.credential.base_url',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'https://api.sgroup.qq.com',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.SLACK]: [
|
||||
{
|
||||
label: 'Bot Token',
|
||||
name: 'config.credential.bot_token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
placeholder: 'xoxb-xxxx',
|
||||
},
|
||||
{
|
||||
label: 'App Token',
|
||||
name: 'config.credential.app_token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
placeholder: 'xapp-xxxx (socket mode)',
|
||||
},
|
||||
{
|
||||
label: 'Signing Secret',
|
||||
name: 'config.credential.signing_secret',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
label: 'User Token',
|
||||
name: 'config.credential.user_token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
placeholder: 'xoxp-xxxx',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.SYNOLOGY_CHAT]: [
|
||||
{
|
||||
label: 'Token',
|
||||
name: 'config.credential.token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Incoming URL',
|
||||
name: 'config.credential.incoming_url',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'https://nas.example.com/webapi/entry.cgi?...',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.TELEGRAM]: [
|
||||
{
|
||||
label: 'Bot Token',
|
||||
name: 'config.credential.token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
placeholder: 'From @BotFather',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.TLON]: [
|
||||
{
|
||||
label: 'Ship',
|
||||
name: 'config.credential.ship',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: '~sampel-palnet',
|
||||
},
|
||||
{
|
||||
label: 'URL',
|
||||
name: 'config.credential.url',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'https://sampel-palnet.tlon.network',
|
||||
},
|
||||
{
|
||||
label: 'Code',
|
||||
name: 'config.credential.code',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.TWITCH]: [
|
||||
{
|
||||
label: 'Username',
|
||||
name: 'config.credential.username',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'my-bot',
|
||||
},
|
||||
{
|
||||
label: 'Access Token',
|
||||
name: 'config.credential.access_token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
placeholder: 'oauth:xxxx',
|
||||
},
|
||||
{
|
||||
label: 'Client ID',
|
||||
name: 'config.credential.client_id',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Channel',
|
||||
name: 'config.credential.channel',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'target-channel-name',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.WECOM]: [
|
||||
{
|
||||
label: 'Corp ID',
|
||||
name: 'config.credential.corp_id',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'ww1234567890abcdef',
|
||||
},
|
||||
{
|
||||
label: 'Agent ID',
|
||||
name: 'config.credential.agent_id',
|
||||
type: FormFieldType.Number,
|
||||
required: true,
|
||||
placeholder: '1000001',
|
||||
},
|
||||
{
|
||||
label: 'Secret',
|
||||
name: 'config.credential.secret',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Token',
|
||||
name: 'config.credential.token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'AES Key',
|
||||
name: 'config.credential.aes_key',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
placeholder: '43 chars',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.WHATSAPP]: [],
|
||||
[ChatChannelKey.YUANBAO]: [
|
||||
{
|
||||
label: 'App Key',
|
||||
name: 'config.credential.app_key',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'App Secret',
|
||||
name: 'config.credential.app_secret',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Token',
|
||||
name: 'config.credential.token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
placeholder: 'Optional pre-signed token',
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.ZALO]: [
|
||||
{
|
||||
label: 'Bot Token',
|
||||
name: 'config.credential.bot_token',
|
||||
type: FormFieldType.Password,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
[ChatChannelKey.ZALOUSER]: [
|
||||
{
|
||||
label: 'Cookies (JSON)',
|
||||
name: 'config.credential.cookies',
|
||||
type: FormFieldType.Textarea,
|
||||
required: true,
|
||||
placeholder: '{ ... }',
|
||||
},
|
||||
{
|
||||
label: 'IMEI',
|
||||
name: 'config.credential.imei',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'User Agent',
|
||||
name: 'config.credential.user_agent',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'Mozilla/5.0 ...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const ChatChannelFormDefaultValues: Record<
|
||||
ChatChannelKey,
|
||||
Record<string, any>
|
||||
> = Object.values(ChatChannelKey).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = {
|
||||
name: '',
|
||||
channel: key,
|
||||
config: { credential: {} },
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<ChatChannelKey, Record<string, any>>,
|
||||
);
|
||||
|
||||
// googlechat carries a non-credential discriminator (auth_mode).
|
||||
ChatChannelFormDefaultValues[ChatChannelKey.GOOGLECHAT].config.auth_mode =
|
||||
'webhook_url';
|
||||
|
||||
export const getChatChannelFields = (
|
||||
key?: ChatChannelKey,
|
||||
): FormFieldConfig[] => {
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
return [...ChatChannelFormBaseFields, ...(ChatChannelFormFields[key] || [])];
|
||||
};
|
||||
196
web/src/pages/user-setting/chat-channel/hooks.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import message from '@/components/ui/message';
|
||||
import { useSetModalState } from '@/hooks/common-hooks';
|
||||
import chatChannelService, {
|
||||
deleteChatChannel,
|
||||
fetchChatChannelDetail,
|
||||
updateChatChannel,
|
||||
} from '@/services/chat-channel-service';
|
||||
import chatService from '@/services/next-chat-service';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ChatChannelKey, useChatChannelInfo } from './constant';
|
||||
import { IChatChannel, IChatChannelBase, IChatChannelInfo } from './interface';
|
||||
|
||||
export const ChatChannelKeys = {
|
||||
list: () => ['chat-channel'] as const,
|
||||
detail: (id?: string) => ['chat-channel-detail', id] as const,
|
||||
dialogs: () => ['chat-channel-dialogs'] as const,
|
||||
};
|
||||
|
||||
export const useListChatChannel = () => {
|
||||
const { chatChannelInfo } = useChatChannelInfo();
|
||||
const { data: list, isFetching } = useQuery<IChatChannelBase[]>({
|
||||
queryKey: ChatChannelKeys.list(),
|
||||
queryFn: async () => {
|
||||
const { data } = await chatChannelService.chatChannelList();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const categorizedList = useMemo(() => {
|
||||
const grouped: Partial<Record<ChatChannelKey, IChatChannelBase[]>> = {};
|
||||
(list || []).forEach((item) => {
|
||||
const channel = item.channel;
|
||||
if (!grouped[channel]) {
|
||||
grouped[channel] = [];
|
||||
}
|
||||
grouped[channel]!.push(item);
|
||||
});
|
||||
|
||||
const result: Array<IChatChannelInfo & { list: IChatChannelBase[] }> = [];
|
||||
(Object.keys(grouped) as ChatChannelKey[]).forEach((key) => {
|
||||
if (chatChannelInfo[key]) {
|
||||
result.push({
|
||||
id: key,
|
||||
name: chatChannelInfo[key].name,
|
||||
description: chatChannelInfo[key].description,
|
||||
icon: chatChannelInfo[key].icon,
|
||||
list: grouped[key] || [],
|
||||
});
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [list, chatChannelInfo]);
|
||||
|
||||
return { list, categorizedList, isFetching };
|
||||
};
|
||||
|
||||
export const useAddChatChannel = () => {
|
||||
const [activeChannel, setActiveChannel] = useState<
|
||||
IChatChannelInfo | undefined
|
||||
>(undefined);
|
||||
const [editingRecord, setEditingRecord] = useState<IChatChannel | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { visible: modalVisible, hideModal, showModal } = useSetModalState();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const showAddingModal = useCallback(
|
||||
(channel: IChatChannelInfo) => {
|
||||
setEditingRecord(undefined);
|
||||
setActiveChannel(channel);
|
||||
showModal();
|
||||
},
|
||||
[showModal],
|
||||
);
|
||||
|
||||
const showEditingModal = useCallback(
|
||||
(channel: IChatChannelInfo, record: IChatChannel) => {
|
||||
setEditingRecord(record);
|
||||
setActiveChannel(channel);
|
||||
showModal();
|
||||
},
|
||||
[showModal],
|
||||
);
|
||||
|
||||
const handleOk = useCallback(
|
||||
async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const isEdit = Boolean(values?.id);
|
||||
const { data: res } = isEdit
|
||||
? await updateChatChannel(values.id, {
|
||||
name: values.name,
|
||||
config: values.config,
|
||||
})
|
||||
: await chatChannelService.chatChannelSet(values);
|
||||
if (res.code === 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ChatChannelKeys.list() });
|
||||
if (isEdit) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ChatChannelKeys.detail(values.id),
|
||||
});
|
||||
}
|
||||
message.success(t('message.operated'));
|
||||
hideModal();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[hideModal, queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
activeChannel,
|
||||
editingRecord,
|
||||
loading,
|
||||
modalVisible,
|
||||
hideModal,
|
||||
showAddingModal,
|
||||
showEditingModal,
|
||||
handleOk,
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeleteChatChannel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationKey: ['delete-chat-channel'],
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await deleteChatChannel(id);
|
||||
if (data.code === 0) {
|
||||
message.success(t('message.deleted'));
|
||||
queryClient.invalidateQueries({ queryKey: ChatChannelKeys.list() });
|
||||
}
|
||||
return data;
|
||||
},
|
||||
});
|
||||
return { handleDelete: mutateAsync, deleteLoading: isPending };
|
||||
};
|
||||
|
||||
export const useFetchChatChannelDetail = () => {
|
||||
const fetchDetail = useCallback(
|
||||
async (id: string): Promise<IChatChannel | undefined> => {
|
||||
const { data } = await fetchChatChannelDetail(id);
|
||||
if (data.code === 0) {
|
||||
return data.data;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return { fetchDetail };
|
||||
};
|
||||
|
||||
// Connect (or disconnect) a chat channel to an assistant (dialog).
|
||||
export const useConnectChatChannelDialog = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationKey: ['connect-chat-channel-dialog'],
|
||||
mutationFn: async (params: {
|
||||
channelId: string;
|
||||
dialogId: string | null;
|
||||
}) => {
|
||||
const { data } = await updateChatChannel(params.channelId, {
|
||||
dialog_id: params.dialogId,
|
||||
});
|
||||
if (data.code === 0) {
|
||||
message.success(t('message.operated'));
|
||||
queryClient.invalidateQueries({ queryKey: ChatChannelKeys.list() });
|
||||
}
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
return { connect: mutateAsync, connecting: isPending };
|
||||
};
|
||||
|
||||
// Assistants (dialogs) available to connect a channel to.
|
||||
export const useChatChannelDialogList = () => {
|
||||
const { data, isFetching } = useQuery<Array<{ id: string; name: string }>>({
|
||||
queryKey: ChatChannelKeys.dialogs(),
|
||||
initialData: [],
|
||||
queryFn: async () => {
|
||||
const { data } = await chatService.listChats(
|
||||
{ params: { page_size: 100, page: 1 }, data: {} },
|
||||
true,
|
||||
);
|
||||
return data?.data?.chats ?? [];
|
||||
},
|
||||
});
|
||||
return { dialogs: data, isFetching };
|
||||
};
|
||||
143
web/src/pages/user-setting/chat-channel/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProfileSettingWrapperCard } from '../components/user-setting-header';
|
||||
import AddChatChannelModal from './add-channel-modal';
|
||||
import { AddedChannelCard } from './component/added-channel-card';
|
||||
import { ChatChannelKey, useChatChannelInfo } from './constant';
|
||||
import { useAddChatChannel, useListChatChannel } from './hooks';
|
||||
import { IChatChannelInfo } from './interface';
|
||||
|
||||
const AvailableChannelCard = ({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
onAdd,
|
||||
}: IChatChannelInfo & { onAdd: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<article
|
||||
className="
|
||||
size-full p-2.5 border-0.5 border-border-button rounded-lg relative group hover:bg-bg-card focus-within:bg-bg-card
|
||||
grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-x-2.5 gap-y-1"
|
||||
style={{ gridTemplateAreas: '"icon title" "icon description"' }}
|
||||
onClick={() => onAdd()}
|
||||
>
|
||||
<span className="w-6" style={{ gridArea: 'icon' }}>
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
<header className="flex items-center gap-2" style={{ gridArea: 'title' }}>
|
||||
<h3 className="text-base text-text-primary">{name}</h3>
|
||||
<Button
|
||||
size="auto"
|
||||
className="ml-auto px-1 py-0.5 gap-0.5 text-xs items-center opacity-0 transition-all group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
onClick={(e: any) => {
|
||||
e.stopPropagation();
|
||||
onAdd();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-[1em]" />
|
||||
{t('setting.add')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<p
|
||||
style={{ gridArea: 'description' }}
|
||||
className="text-xs text-text-secondary"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatChannel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { chatChannelInfo } = useChatChannelInfo();
|
||||
const channelTemplates: IChatChannelInfo[] = Object.values(
|
||||
ChatChannelKey,
|
||||
).map((id) => ({
|
||||
id,
|
||||
name: chatChannelInfo[id].name,
|
||||
description: chatChannelInfo[id].description,
|
||||
icon: chatChannelInfo[id].icon,
|
||||
}));
|
||||
|
||||
const { categorizedList } = useListChatChannel();
|
||||
|
||||
const {
|
||||
activeChannel,
|
||||
editingRecord,
|
||||
loading,
|
||||
modalVisible,
|
||||
hideModal,
|
||||
showAddingModal,
|
||||
showEditingModal,
|
||||
handleOk,
|
||||
} = useAddChatChannel();
|
||||
|
||||
return (
|
||||
<ProfileSettingWrapperCard
|
||||
header={
|
||||
<header>
|
||||
<h2 className="text-2xl font-medium text-text-primary">
|
||||
{t('setting.chatChannels')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{t('setting.chatChannelsDescription')}
|
||||
</p>
|
||||
</header>
|
||||
}
|
||||
>
|
||||
<div className="h-full p-5 overflow-x-hidden overflow-y-auto">
|
||||
<section className="flex flex-col gap-3">
|
||||
{categorizedList?.length <= 0 && (
|
||||
<div className="text-text-secondary w-full flex justify-center items-center h-20">
|
||||
{t('setting.channelEmptyTip')}
|
||||
</div>
|
||||
)}
|
||||
{categorizedList.map((item, index) => (
|
||||
<AddedChannelCard key={index} {...item} onEdit={showEditingModal} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<header className="flex flex-row items-center justify-between space-y-0 p-0 pb-4">
|
||||
<h2 className="text-2xl font-medium">
|
||||
{t('setting.availableChannels')}
|
||||
<div className="text-sm text-text-secondary font-normal mt-1.5">
|
||||
{t('setting.availableChannelsDescription')}
|
||||
</div>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<ul className="@container grid sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-4 3xl:grid-cols-4 gap-4">
|
||||
{channelTemplates.map((item) => (
|
||||
<li key={item.id} className="h-full">
|
||||
<AvailableChannelCard
|
||||
{...item}
|
||||
onAdd={() => showAddingModal(item)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{modalVisible && (
|
||||
<AddChatChannelModal
|
||||
visible
|
||||
loading={loading}
|
||||
hideModal={hideModal}
|
||||
onOk={handleOk}
|
||||
channel={activeChannel}
|
||||
record={editingRecord}
|
||||
/>
|
||||
)}
|
||||
</ProfileSettingWrapperCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatChannel;
|
||||
33
web/src/pages/user-setting/chat-channel/interface.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChatChannelKey } from './constant';
|
||||
|
||||
export interface IChatChannelInfo {
|
||||
id: ChatChannelKey;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface IChatChannelBase {
|
||||
id: string;
|
||||
name: string;
|
||||
channel: ChatChannelKey;
|
||||
// Connected assistant (dialog), joined in by the list endpoint.
|
||||
dialog_id?: string | null;
|
||||
dialog_name?: string | null;
|
||||
}
|
||||
|
||||
export type IChatChannel = IChatChannelBase & {
|
||||
config: Record<string, any>;
|
||||
status: string;
|
||||
tenant_id: string;
|
||||
create_date?: string;
|
||||
update_date?: string;
|
||||
};
|
||||
|
||||
interface IChatChannelInfoItem {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
export type IChatChannelInfoMap = Record<ChatChannelKey, IChatChannelInfoItem>;
|
||||
@@ -13,6 +13,7 @@ import { Routes } from '@/routes';
|
||||
import { TFunction } from 'i18next';
|
||||
import {
|
||||
LucideBox,
|
||||
LucideMessagesSquare,
|
||||
LucideLogOut,
|
||||
LucideServer,
|
||||
LucideUnplug,
|
||||
@@ -29,6 +30,11 @@ const menuItems = (t: TFunction) => [
|
||||
label: t('setting.dataSources'),
|
||||
key: Routes.DataSource,
|
||||
},
|
||||
{
|
||||
icon: <LucideMessagesSquare className="size-[1em]" />,
|
||||
label: t('setting.chatChannels'),
|
||||
key: Routes.ChatChannel,
|
||||
},
|
||||
{
|
||||
icon: <LucideBox className="size-[1em]" />,
|
||||
label: t('setting.model'),
|
||||
|
||||
@@ -45,6 +45,7 @@ export enum Routes {
|
||||
Prompt = '/prompt',
|
||||
DataSource = '/data-source',
|
||||
DataSourceDetailPage = '/data-source-detail-page',
|
||||
ChatChannel = '/chat-channel',
|
||||
ProfileMcp = `${ProfileSetting}${Mcp}`,
|
||||
ProfileTeam = `${ProfileSetting}${Team}`,
|
||||
ProfilePlan = `${ProfileSetting}${Plan}`,
|
||||
@@ -294,6 +295,10 @@ const routeConfigOptions = [
|
||||
path: `${Routes.UserSetting}${Routes.DataSource}`,
|
||||
Component: () => import('@/pages/user-setting/data-source'),
|
||||
},
|
||||
{
|
||||
path: `${Routes.UserSetting}${Routes.ChatChannel}`,
|
||||
Component: () => import('@/pages/user-setting/chat-channel'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
31
web/src/services/chat-channel-service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import api from '@/utils/api';
|
||||
import registerServer from '@/utils/register-server';
|
||||
import request from '@/utils/request';
|
||||
|
||||
const { chatChannelSet, chatChannelList } = api;
|
||||
const methods = {
|
||||
chatChannelSet: {
|
||||
url: chatChannelSet,
|
||||
method: 'post',
|
||||
},
|
||||
chatChannelList: {
|
||||
url: chatChannelList,
|
||||
method: 'get',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const chatChannelService = registerServer<keyof typeof methods>(
|
||||
methods,
|
||||
request,
|
||||
);
|
||||
|
||||
export const fetchChatChannelDetail = (id: string) =>
|
||||
request.get(api.chatChannelDetail(id));
|
||||
|
||||
export const updateChatChannel = (id: string, data: Record<string, any>) =>
|
||||
request.patch(api.chatChannelUpdate(id), { data });
|
||||
|
||||
export const deleteChatChannel = (id: string) =>
|
||||
request.delete(api.chatChannelDel(id));
|
||||
|
||||
export default chatChannelService;
|
||||
@@ -88,6 +88,13 @@ export default {
|
||||
boxWebAuthStart: () => `${restAPIv1}/connectors/box/oauth/web/start`,
|
||||
boxWebAuthResult: () => `${restAPIv1}/connectors/box/oauth/web/result`,
|
||||
|
||||
// chat channel
|
||||
chatChannelSet: `${restAPIv1}/chat_channels`,
|
||||
chatChannelList: `${restAPIv1}/chat_channels`,
|
||||
chatChannelDetail: (id: string) => `${restAPIv1}/chat_channels/${id}`,
|
||||
chatChannelUpdate: (id: string) => `${restAPIv1}/chat_channels/${id}`,
|
||||
chatChannelDel: (id: string) => `${restAPIv1}/chat_channels/${id}`,
|
||||
|
||||
// plugin
|
||||
llmTools: `${restAPIv1}/plugin/tools`,
|
||||
|
||||
|
||||