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"
/>

---------
This commit is contained in:
Kevin Hu
2026-06-12 18:21:30 +08:00
committed by GitHub
parent 5a7d7771a3
commit b5a426e6e0
68 changed files with 8232 additions and 5138 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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
);

View File

@@ -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',

View File

@@ -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: '可选模型',

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
),
});
};

View 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] || [])];
};

View 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 };
};

View 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;

View 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>;

View File

@@ -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'),

View File

@@ -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'),
},
],
},
{

View 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;

View File

@@ -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`,