mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 10:59:31 +02:00
Compare commits
3 Commits
agent/lamb
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21cd617d16 | ||
|
|
51ae12604c | ||
|
|
b933d9fd41 |
22
Makefile
22
Makefile
@@ -37,27 +37,25 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# Self-hosting requires the Docker Compose CLI plugin (`docker compose`).
|
||||
# Self-hosting requires Docker Compose v2 (the `docker compose` CLI plugin).
|
||||
# The self-host compose files use compose-spec syntax (top-level `name:`, no
|
||||
# `version:`) that the legacy v1 `docker-compose` standalone cannot parse, so we
|
||||
# fail early with an actionable message instead of a cryptic CLI parse error
|
||||
# (e.g. "unknown shorthand flag: 'f' in -f") when the plugin is missing or v1.
|
||||
# Keep the message short and OS-agnostic: per-OS install steps belong in docs.
|
||||
define REQUIRE_COMPOSE
|
||||
@if ! compose_version=$$($(COMPOSE) version --short 2>/dev/null); then \
|
||||
echo "Docker Compose ('docker compose') was not found."; \
|
||||
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
|
||||
@if ! $(COMPOSE) version >/dev/null 2>&1; then \
|
||||
echo "Docker Compose v2 ('docker compose') was not found."; \
|
||||
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
|
||||
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
case "$$compose_version" in \
|
||||
1.*|v1.*) \
|
||||
echo "'$(COMPOSE)' is legacy Docker Compose v1 ($$compose_version)."; \
|
||||
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
|
||||
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac
|
||||
if ! $(COMPOSE) version --short 2>/dev/null | grep -Eq '^v?2\.'; then \
|
||||
echo "'$(COMPOSE)' is not Docker Compose v2."; \
|
||||
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
|
||||
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
|
||||
exit 1; \
|
||||
fi
|
||||
endef
|
||||
|
||||
# Default target changed from selfhost to help: bare `make` now prints this help
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
title: Chat 連携(channels)
|
||||
description: Multica がどのようにエージェントをチャットプラットフォームに接続するか——1 つのチャンネルエンジンと、Lark(飞书)および Slack 向けのプラットフォーム別アダプター——受信パイプライン、セッション、認可までを解説します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**チャンネル**は、Multica の[エージェント](/agents)をチャットプラットフォームに接続し、チームが普段やり取りしている場所でそのまま使えるようにします。現在チャンネルは 2 つあり——[Lark(飞书)](/lark-bot-integration)と [Slack](/slack-bot-integration)——どちらも**同じエンジン**で動いています。プラットフォームに依存しないコアと、薄いプラットフォーム別アダプターの組み合わせです。プラットフォームを追加するということは「アダプターを実装する」ことであり、「パイプラインを作り直す」ことではありません。
|
||||
|
||||
**インストール**は、それらを結びつける単位です。1 つの Bot が 1 つの `(workspace, agent)` に紐づきます。受信メッセージはまずインストールにルーティングされ、その後共有パイプラインを通り、エージェントの返信は同じチャットに送り返されます。
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["チャットプラットフォーム"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["チャンネルエンジン(プラットフォーム非依存)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>インストールごとに 1 本のライブ接続"]
|
||||
ROU["Router パイプライン:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|長時間接続| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|生イベント| ADP["プラットフォーム別アダプター<br/>変換 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|エージェントタスク| RUN["デーモンがエージェントを実行"]
|
||||
RUN -->|返信| OUT["プラットフォーム別の送信<br/>(bot token → プラットフォーム API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 受信パイプライン(共通)
|
||||
|
||||
すべての受信メッセージは——Lark でも Slack でも——エンジンの `Router` 内で同じ順序のステップを通ります。プラットフォームアダプターが供給するのはプラットフォーム別の部品(`ResolverSet`)だけで、ポリシーはエンジンの中にあります。
|
||||
|
||||
1. **インストールへのルーティング** —— イベントを `channel_installation`(→ ワークスペース + エージェント)に対応づけます。Lark は `app_id` でルーティングし、Slack はイベントに含まれる app id でルーティングします。
|
||||
2. **宛先フィルター** —— グループ/チャンネルでは、**Bot を @ メンション**したメッセージだけが先へ進みます。アイドル状態のグループの雑談は破棄されます(読み取られません)。
|
||||
3. **重複排除(dedup)** —— 2 フェーズの `(installation, message_id)` クレームにより、サーバーのレプリカをまたいでも厳密に 1 回だけ処理されることを保証します。
|
||||
4. **アイデンティティ + 認可** —— 送信者のプラットフォームユーザー id を Multica ユーザーに解決し([アカウントの紐づけ](#認可))、その上でワークスペースのメンバーシップを再チェックします。紐づいていない送信者には「アカウントを紐づける」プロンプトが返され、メンバーでない場合は破棄されます。
|
||||
5. **セッション** —— この会話に対応する[chat セッション](/chat)を見つけるか作成し、メッセージを追加します([セッション](#セッションとコンテキスト)を参照)。
|
||||
6. **トリガー** —— エージェントの[タスク](/tasks)をエンキューします。[デーモン](/daemon-runtimes)がエージェントを実行し、返信がチャットに送り返されます。
|
||||
|
||||
## セッションとコンテキスト
|
||||
|
||||
エージェントのコンテキストは、**chat セッションのトランスクリプト**——そのセッションに時間をかけて取り込まれてきたメッセージ——です。このトランスクリプトのモデルは共通です(すべてのチャンネルで共有されます)。プラットフォームごとに異なるのは、アダプターが組み立てる**セッション分離キー**です。
|
||||
|
||||
| プラットフォーム | 分離キー | 効果 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | チャット id | チャット/グループごとに 1 セッション——同じチャット内の連続したやり取りが 1 つのトランスクリプトに蓄積されます(複数ターンの記憶)。 |
|
||||
| **Slack** | DM: チャンネル/チャンネル: `channel + thread root` | 各 DM が 1 セッション。**各 @bot スレッドがそれぞれ独立したセッション**になるので、同じチャンネル内の 2 つのスレッドが混ざりません。 |
|
||||
|
||||
<Callout type="info">
|
||||
グループでは、**Bot を @ メンション**したメッセージだけが取り込まれます。どちらのチャンネルも、現時点ではチャンネルの他の(@ されていない)メッセージや過去ログを読まないため、エージェントは自分が宛先になっていないメッセージを見ることはありません。前後の履歴をコンテキストとして取得することは、今後の拡張として計画されています。
|
||||
</Callout>
|
||||
|
||||
## 認可
|
||||
|
||||
共有グループ内で Bot を守るために、2 つの独立したゲートがあります——どちらもエンジンであらゆるメッセージに対し、Lark と Slack で同一に適用されます。
|
||||
|
||||
- **アカウントの紐づけ(認証)** —— 送信者のプラットフォームユーザー id が Multica ユーザーにリンクされている必要があります。誰かが初めて Bot にメッセージを送ると、**自分自身の** Multica アカウントにアイデンティティを紐づけるための使い切りリンクを受け取ります。それまではエージェントは実行されません。
|
||||
- **ワークスペースのメンバーシップ(認可)** —— 紐づいた Multica ユーザーが、そのインストールのワークスペースのメンバーである必要があり、これはメッセージごとに再チェックされます。メンバーでない場合は黙って破棄されます。
|
||||
|
||||
そのため、Bot を公開チャンネルに追加しても安全です。アイデンティティを紐づけたワークスペースメンバーだけがエージェントを動かせ、各送信者は独立してチェックされます。ユーザー向けのプロンプトについては、各プラットフォームのページを参照してください。
|
||||
|
||||
## 2 つのチャンネル
|
||||
|
||||
<Callout type="info">
|
||||
**Lark(飞书) — スキャンしてインストール。** ワークスペースの admin が Lark アプリで QR をスキャンするだけでエージェントを紐づけられます。開発者コンソールでの操作は不要です。エージェントごとに 1 つの Bot。[Lark Bot 連携](/lark-bot-integration)を参照してください。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — 自分のアプリを持ち込む。** ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、その bot token + app-level token を Multica に貼り付けます。エージェントごとに専用の Slack アプリを持つため、1 つの Slack ワークスペース内で複数のエージェントがそれぞれ異なる Bot を持てます。マニフェストと手順は [Slack Bot 連携](/slack-bot-integration)を参照してください。
|
||||
</Callout>
|
||||
|
||||
## セルフホスト
|
||||
|
||||
各チャンネルは、**保存時の暗号化キーを設定するまでオフ**です(このキーは、各 Bot のトークンがデータベースに触れる前にそれを暗号化します)。
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
Multica Cloud では両方ともすでに設定済みです。完全なリファレンスは[環境変数](/environment-variables)を参照してください。
|
||||
|
||||
## 次に
|
||||
|
||||
- [Lark Bot 連携](/lark-bot-integration) — スキャンしてインストール、DM / @ メンション / `/issue`
|
||||
- [Slack Bot 連携](/slack-bot-integration) — 自分のアプリを持ち込むセットアップ(マニフェスト + トークン)、エージェントごとの Bot
|
||||
- [エージェント](/agents) · [Chat](/chat) · [タスク](/tasks)
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
title: Chat 연동 (channels)
|
||||
description: Multica가 에이전트를 채팅 플랫폼에 어떻게 연결하는지 — 하나의 channel 엔진과 Lark(飞书) 및 Slack을 위한 플랫폼별 어댑터 — 인바운드 파이프라인, 세션, 권한을 다룹니다.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**channel**은 Multica [에이전트](/agents)를 채팅 플랫폼에 연결하여, 팀이 이미 대화하고 있는 곳에서 그 에이전트와 함께 일할 수 있게 합니다. 현재 두 개의 channel이 있습니다 — [Lark (飞书)](/lark-bot-integration)와 [Slack](/slack-bot-integration) — 그리고 둘 다 **같은 엔진** 위에서 동작합니다: 플랫폼 중립적인 코어에 얇은 플랫폼별 어댑터가 더해진 구조입니다. 플랫폼을 추가하는 일은 "어댑터를 구현하는 것"이지, "파이프라인을 다시 만드는 것"이 아닙니다.
|
||||
|
||||
**installation**은 이 모든 것을 하나로 묶는 단위입니다: 하나의 봇이 하나의 `(workspace, agent)`에 바인딩됩니다. 인바운드 메시지는 installation으로 라우팅된 다음 공유 파이프라인을 거치며, 에이전트의 답변은 동일한 채팅으로 돌아갑니다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["채팅 플랫폼"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel 엔진 (플랫폼 중립적)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>installation당 하나의 활성 연결"]
|
||||
ROU["Router 파이프라인:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|long connection| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|raw event| ADP["플랫폼별 어댑터<br/>변환 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|agent task| RUN["Daemon이 에이전트를 실행"]
|
||||
RUN -->|reply| OUT["플랫폼별 아웃바운드<br/>(bot token → platform API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 인바운드 파이프라인 (공통)
|
||||
|
||||
모든 인바운드 메시지는 — Lark든 Slack이든 — 엔진의 `Router`에서 동일하게 정해진 순서의 단계를 거칩니다. 플랫폼 어댑터는 플랫폼별 조각(`ResolverSet`)만 공급하며, 정책은 엔진 안에 있습니다.
|
||||
|
||||
1. **Route to installation** — 이벤트를 `channel_installation`(→ workspace + agent)에 매핑합니다. Lark는 `app_id`로 라우팅하고, Slack은 이벤트에 실린 app id로 라우팅합니다.
|
||||
2. **Addressing filter** — 그룹/채널에서는 **봇을 @로 멘션한** 메시지만 계속 진행되며, 한가한 그룹 잡담은 폐기됩니다(읽지 않음).
|
||||
3. **Dedup** — 두 단계로 이루어진 `(installation, message_id)` 클레임이 서버 레플리카가 여러 개여도 정확히 한 번만 처리됨을 보장합니다.
|
||||
4. **Identity + authorization** — 보낸 사람의 플랫폼 사용자 id를 Multica 사용자([계정 바인딩](#권한))로 해석한 다음, 워크스페이스 멤버십을 다시 확인합니다. 바인딩되지 않은 발신자에게는 "계정을 연결하세요" 안내가 표시되고, 멤버가 아닌 사람은 폐기됩니다.
|
||||
5. **Session** — 이 대화에 대한 [chat 세션](/chat)을 찾거나 생성하고 메시지를 추가합니다([세션](#세션과-컨텍스트) 참조).
|
||||
6. **Trigger** — 에이전트 [task](/tasks)를 큐에 넣습니다. [daemon](/daemon-runtimes)이 에이전트를 실행하고 그 답변이 채팅으로 돌아갑니다.
|
||||
|
||||
## 세션과 컨텍스트
|
||||
|
||||
에이전트의 컨텍스트는 **chat 세션 트랜스크립트**입니다 — 시간이 지나며 그 세션에 수집된 메시지들입니다. 이 트랜스크립트 모델은 공통(모든 channel이 공유)입니다. 플랫폼마다 다른 것은 어댑터가 구성하는 **세션 격리 키**입니다:
|
||||
|
||||
| 플랫폼 | 격리 키 | 효과 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | 채팅 id | 채팅/그룹당 하나의 세션 — 같은 채팅에서의 연속된 턴이 하나의 트랜스크립트로 쌓입니다(멀티턴 메모리). |
|
||||
| **Slack** | DM: 채널; 채널: `channel + thread root` | 각 DM이 하나의 세션이고, **각 @bot 스레드가 자체 세션**이므로, 한 채널의 두 스레드는 섞이지 않습니다. |
|
||||
|
||||
<Callout type="info">
|
||||
그룹에서는 **봇을 @로 멘션한** 메시지만 수집됩니다. 어느 channel도 현재 채널의 다른(멘션되지 않은) 메시지나 스크롤백을 읽지 않으므로, 에이전트는 자신이 호출되지 않은 메시지를 보지 못합니다. 주변 기록을 컨텍스트로 가져오는 기능은 향후 개선 사항으로 계획되어 있습니다.
|
||||
</Callout>
|
||||
|
||||
## 권한
|
||||
|
||||
공유 그룹에서 봇을 보호하는 두 개의 독립적인 관문이 있으며 — 둘 다 모든 메시지에 대해 엔진에서, Lark와 Slack에 동일하게 적용됩니다:
|
||||
|
||||
- **계정 바인딩(인증)** — 보낸 사람의 플랫폼 사용자 id가 Multica 사용자에 연결되어 있어야 합니다. 누군가 봇에게 처음 메시지를 보내면 **자기 자신의** Multica 계정에 신원을 바인딩하는 일회용 링크를 받으며, 그 전까지는 어떤 에이전트도 실행되지 않습니다.
|
||||
- **워크스페이스 멤버십(권한)** — 바인딩된 Multica 사용자는 installation의 워크스페이스 멤버여야 하며, 이는 모든 메시지마다 다시 확인됩니다. 멤버가 아닌 사람은 조용히 폐기됩니다.
|
||||
|
||||
따라서 공개 채널에 봇을 추가해도 안전합니다: 신원을 바인딩한 워크스페이스 멤버만 에이전트를 움직일 수 있고, 각 발신자는 독립적으로 확인됩니다. 사용자에게 표시되는 안내는 플랫폼별 페이지를 참고하세요.
|
||||
|
||||
## 두 개의 channel
|
||||
|
||||
<Callout type="info">
|
||||
**Lark (飞书) — 스캔하여 설치.** 워크스페이스 admin이 Lark 앱으로 QR을 스캔하여 에이전트를 바인딩합니다. 개발자 콘솔 작업이 없습니다. 에이전트당 하나의 Bot. [Lark Bot 연동](/lark-bot-integration)을 참고하세요.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — 자체 앱 사용.** 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, bot token과 app-level token을 Multica에 붙여넣습니다. 각 에이전트가 자체 Slack 앱을 갖기 때문에, 하나의 Slack 워크스페이스에서 여러 에이전트가 각각 별개의 봇을 가질 수 있습니다. 매니페스트와 단계별 설정은 [Slack Bot 연동](/slack-bot-integration)을 참고하세요.
|
||||
</Callout>
|
||||
|
||||
## 자체 호스팅
|
||||
|
||||
각 channel은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**(이 키는 각 봇의 토큰이 데이터베이스에 닿기 전에 암호화합니다):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
Multica Cloud에서는 둘 다 이미 구성되어 있습니다. 전체 참조는 [환경 변수](/environment-variables)를 참고하세요.
|
||||
|
||||
## 다음
|
||||
|
||||
- [Lark Bot 연동](/lark-bot-integration) — 스캔하여 설치, DM / @-멘션 / `/issue`
|
||||
- [Slack Bot 연동](/slack-bot-integration) — 자체 앱 사용 설정(매니페스트 + 토큰), 에이전트별 봇
|
||||
- [에이전트](/agents) · [Chat](/chat) · [Tasks](/tasks)
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
title: Chat integrations (channels)
|
||||
description: How Multica connects agents to chat platforms — one channel engine, per-platform adapters for Lark (飞书) and Slack — covering the inbound pipeline, sessions, and authorization.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
A **channel** connects a Multica [agent](/agents) to a chat platform so your team can work with it where they already talk. Today there are two channels — [Lark (飞书)](/lark-bot-integration) and [Slack](/slack-bot-integration) — and both run on the **same engine**: a platform-neutral core plus a thin per-platform adapter. Adding a platform is "implement the adapter," not "rebuild the pipeline."
|
||||
|
||||
An **installation** is the unit that ties it together: one bot bound to one `(workspace, agent)`. Inbound messages are routed to an installation, then through a shared pipeline; the agent's reply is sent back to the same chat.
|
||||
|
||||
## Architecture
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["Chat platforms"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel engine (platform-neutral)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>one live connection per installation"]
|
||||
ROU["Router pipeline:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|long connection| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|raw event| ADP["Per-platform adapter<br/>translate + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|agent task| RUN["Daemon runs the agent"]
|
||||
RUN -->|reply| OUT["Per-platform outbound<br/>(bot token → platform API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## The inbound pipeline (generic)
|
||||
|
||||
Every inbound message — Lark or Slack — runs through the same ordered steps in the engine `Router`. A platform adapter only supplies the per-platform pieces (the `ResolverSet`); the policy lives in the engine.
|
||||
|
||||
1. **Route to installation** — map the event to a `channel_installation` (→ workspace + agent). Lark routes by `app_id`; Slack routes by the app id carried on the event.
|
||||
2. **Addressing filter** — in a group/channel, only messages that **@-mention the bot** continue; idle group chatter is dropped (not read).
|
||||
3. **Dedup** — a two-phase `(installation, message_id)` claim guarantees exactly-once processing, even across server replicas.
|
||||
4. **Identity + authorization** — resolve the sender's platform user id to a Multica user (the [account binding](#authorization)), then re-check workspace membership. Unbound senders get a "link your account" prompt; non-members are dropped.
|
||||
5. **Session** — find or create a [chat session](/chat) for this conversation and append the message (see [Sessions](#sessions-and-context)).
|
||||
6. **Trigger** — enqueue an agent [task](/tasks); a [daemon](/daemon-runtimes) runs the agent and the reply is sent back into the chat.
|
||||
|
||||
## Sessions and context
|
||||
|
||||
The agent's context is the **chat-session transcript** — the messages that have been ingested into that session over time. This transcript model is generic (shared by every channel). What differs per platform is the **session-isolation key** the adapter composes:
|
||||
|
||||
| Platform | Isolation key | Effect |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | the chat id | One session per chat/group — consecutive turns in the same chat accumulate into one transcript (multi-turn memory). |
|
||||
| **Slack** | DM: the channel; channel: `channel + thread root` | Each DM is one session; **each @bot thread is its own session**, so two threads in one channel don't mix. |
|
||||
|
||||
<Callout type="info">
|
||||
In a group, only messages that **@-mention the bot** are ingested. Neither channel reads the channel's other (un-@'d) messages or scrollback today, so the agent won't see messages it wasn't addressed in. Fetching surrounding history as context is a planned enhancement.
|
||||
</Callout>
|
||||
|
||||
## Authorization
|
||||
|
||||
Two independent gates protect a bot in a shared group — both enforced in the engine for every message, identically for Lark and Slack:
|
||||
|
||||
- **Account binding (authentication)** — the sender's platform user id must be linked to a Multica user. The first time someone messages the bot they get a one-time link to bind their identity to **their own** Multica account; until then no agent runs.
|
||||
- **Workspace membership (authorization)** — the bound Multica user must be a member of the installation's workspace, re-checked on every message. Non-members are silently dropped.
|
||||
|
||||
So adding a bot to a public channel is safe: only workspace members who have bound their identity can drive the agent, and each sender is checked independently. See the per-platform pages for the user-facing prompts.
|
||||
|
||||
## The two channels
|
||||
|
||||
<Callout type="info">
|
||||
**Lark (飞书) — scan to install.** A workspace admin binds an agent by scanning a QR with the Lark app; no developer console steps. One Bot per agent. See [Lark Bot integration](/lark-bot-integration).
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — bring your own app.** A workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its bot token + app-level token into Multica. Each agent gets its own Slack app, so several agents can each have a distinct bot in one Slack workspace. See [Slack Bot integration](/slack-bot-integration) for the manifest and step-by-step setup.
|
||||
</Callout>
|
||||
|
||||
## Self-host
|
||||
|
||||
Each channel is **off until you set its at-rest encryption key** (the key encrypts each bot's tokens before they touch the database):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
On Multica Cloud both are already configured. See [Environment variables](/environment-variables) for the full reference.
|
||||
|
||||
## Next
|
||||
|
||||
- [Lark Bot integration](/lark-bot-integration) — scan-to-install, DM / @-mention / `/issue`
|
||||
- [Slack Bot integration](/slack-bot-integration) — bring-your-own-app setup (manifest + tokens), per-agent bots
|
||||
- [Agents](/agents) · [Chat](/chat) · [Tasks](/tasks)
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
title: 聊天集成(channels)
|
||||
description: Multica 如何把智能体接入聊天平台——一个统一的 channel 引擎,加上针对飞书(Lark)和 Slack 的各平台适配器——涵盖入站流水线、会话与授权。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**channel** 把一个 Multica [智能体](/agents)接入聊天平台,团队就能在他们日常沟通的地方直接使用它。目前有两个 channel——[Lark(飞书)](/lark-bot-integration) 和 [Slack](/slack-bot-integration)——两者都跑在**同一个引擎**上:一个平台无关的内核,加上一层很薄的各平台适配器。新增一个平台是「实现适配器」,而不是「重建流水线」。
|
||||
|
||||
**安装(installation)** 是把这一切串起来的单元:一个 Bot 绑定到一个 `(workspace, agent)`。入站消息被路由到某个安装,再经过共享的流水线;智能体的回复会被发回同一个聊天里。
|
||||
|
||||
## 架构
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["聊天平台"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel 引擎(平台无关)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>每个安装一条实时连接"]
|
||||
ROU["路由流水线:<br/>路由 → 去重 → 鉴权 → 会话 → 触发"]
|
||||
end
|
||||
LK -->|长连接| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|原始事件| ADP["各平台适配器<br/>转换 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|智能体任务| RUN["守护进程运行智能体"]
|
||||
RUN -->|回复| OUT["各平台出站<br/>(bot token → 平台 API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 入站流水线(通用)
|
||||
|
||||
每一条入站消息——无论来自 Lark 还是 Slack——都会走引擎 `Router` 里同一套有序步骤。平台适配器只提供各平台特有的部分(即 `ResolverSet`);策略本身住在引擎里。
|
||||
|
||||
1. **路由到安装** —— 把事件映射到一个 `channel_installation`(→ workspace + agent)。Lark 按 `app_id` 路由;Slack 按事件携带的 app id 路由。
|
||||
2. **寻址过滤** —— 在群 / 频道里,只有 **@ 了 Bot** 的消息才会继续往下走;无关的群聊闲谈会被丢弃(不读取)。
|
||||
3. **去重** —— 一个两阶段的 `(installation, message_id)` 认领机制保证恰好处理一次,即便跨多个服务器副本也成立。
|
||||
4. **身份 + 授权** —— 把发送者的平台用户 id 解析成一个 Multica 用户(即[账号绑定](#账号绑定)),然后再次校验 workspace 成员身份。未绑定的发送者会收到一条「绑定你的账号」提示;非成员会被丢弃。
|
||||
5. **会话** —— 为这段对话找到或创建一个 [chat 会话](/chat),并把消息追加进去(见[会话](#会话与上下文))。
|
||||
6. **触发** —— 入队一个智能体[任务](/tasks);一个[守护进程](/daemon-runtimes)运行智能体,回复会被发回聊天里。
|
||||
|
||||
## 会话与上下文
|
||||
|
||||
智能体的上下文就是**这段 chat 会话的对话记录**——也就是随时间被纳入该会话的那些消息。这套对话记录模型是通用的(每个 channel 共用)。各平台不同的地方在于适配器拼出来的**会话隔离键**:
|
||||
|
||||
| 平台 | 隔离键 | 效果 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | 聊天 id | 每个聊天 / 群一个会话——同一个聊天里连续的几轮会累积成一份对话记录(多轮记忆)。 |
|
||||
| **Slack** | 私聊:频道;频道:`channel + thread root` | 每段私聊是一个会话;**每个 @bot 的 thread 是它自己的会话**,所以同一个频道里的两个 thread 不会混在一起。 |
|
||||
|
||||
<Callout type="info">
|
||||
在群里,只有 **@ 了 Bot** 的消息才会被纳入。目前两个 channel 都不会读取频道里其他(没 @ 的)消息或历史滚动记录,所以智能体看不到那些没有点名它的消息。把周边历史作为上下文拉取进来,是计划中的增强功能。
|
||||
</Callout>
|
||||
|
||||
## 账号绑定
|
||||
|
||||
在共享群里,有两道相互独立的关卡保护着 Bot——两者都在引擎里对每一条消息强制执行,且 Lark 和 Slack 一视同仁:
|
||||
|
||||
- **账号绑定(认证)** —— 发送者的平台用户 id 必须关联到一个 Multica 用户。某人第一次给 Bot 发消息时,会拿到一个一次性链接,把自己的身份绑定到**他自己的** Multica 账号;在那之前不会有任何智能体运行。
|
||||
- **Workspace 成员身份(授权)** —— 绑定后的 Multica 用户必须是该安装所属 workspace 的成员,每条消息都会重新校验。非成员会被静默丢弃。
|
||||
|
||||
所以把 Bot 加进一个公开频道是安全的:只有已绑定身份的 workspace 成员才能驱动智能体,而且每个发送者都会被独立校验。面向用户的提示文案请见各平台的页面。
|
||||
|
||||
## 两个 channel
|
||||
|
||||
<Callout type="info">
|
||||
**Lark(飞书)—— 扫码安装。** workspace 管理员用飞书 App 扫一个二维码就能绑定一个智能体;无需任何开发者后台步骤。一个智能体一个 Bot。见 [Lark Bot 接入](/lark-bot-integration)。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack —— 自带应用。** workspace 管理员创建一个 Slack app,把它安装到自己的 Slack workspace,再把它的 bot token + app-level token 粘贴进 Multica。每个智能体都有自己的 Slack app,所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立的 Bot。manifest 和分步设置见 [Slack Bot 接入](/slack-bot-integration)。
|
||||
</Callout>
|
||||
|
||||
## 自部署
|
||||
|
||||
每个 channel 在**你设置好它的静态加密密钥之前都是关闭的**(这个密钥会在每个 Bot 的 token 落库之前对其加密):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
在 Multica Cloud 上两者都已配置好。完整参考见[环境变量](/environment-variables)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Lark Bot 接入](/lark-bot-integration) —— 扫码安装,私聊 / @ 提及 / `/issue`
|
||||
- [Slack Bot 接入](/slack-bot-integration) —— 自带应用的设置(manifest + token),每个智能体一个 Bot
|
||||
- [智能体](/agents) · [Chat](/chat) · [任务](/tasks)
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
このページは Multica Cloud を最初から最後まで案内します — **サインアップ → [CLI](/cli) のインストール → [デーモン](/daemon-runtimes)の起動 → [エージェント](/agents)の作成 → 最初の[タスク](/tasks)の割り当て**。約 5 分かかります。
|
||||
|
||||
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) のいずれか)を少なくとも 1 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
|
||||
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) のいずれか)を少なくとも 1 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
|
||||
|
||||
## 1. アカウントを作成する
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
이 페이지는 Multica Cloud를 처음부터 끝까지 안내합니다 — **가입 → [CLI](/cli) 설치 → [데몬](/daemon-runtimes) 시작 → [에이전트](/agents) 생성 → 첫 [작업](/tasks) 할당**. 약 5분이 걸립니다.
|
||||
|
||||
전제 조건은 하나뿐입니다: 로컬에 [AI 코딩 도구](/providers)([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
|
||||
전제 조건은 하나뿐입니다: 로컬에 [AI 코딩 도구](/providers)([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
|
||||
|
||||
## 1. 계정 만들기
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
|
||||
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
|
||||
## 1. Create an account
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
|
||||
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
|
||||
## 1. 注册账号
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
起動時にデーモンは 4 つのことを行います。
|
||||
|
||||
1. ログイン時に保存された認証情報を読み込みます
|
||||
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 検出した各ツールに対するランタイムとともに、自身をサーバーに登録します
|
||||
4. **3 秒ごと**に取得すべきタスクがないかポーリングし、**15 秒ごとにハートビートを送信**し続けます
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
시작 시 데몬은 네 가지 일을 합니다.
|
||||
|
||||
1. 로그인할 때 저장된 인증 정보를 읽습니다
|
||||
2. `PATH`에 설치된 AI 코딩 도구를 감지합니다(내장 12종: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
2. `PATH`에 설치된 AI 코딩 도구를 감지합니다(내장 12종: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
3. 감지된 각 도구에 대한 런타임과 함께 자신을 서버에 등록합니다
|
||||
4. **3초마다** 가져올 작업이 있는지 폴링하고, **15초마다 하트비트를 전송**합니다
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
On startup it does four things:
|
||||
|
||||
1. Reads the credentials saved when you logged in
|
||||
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
3. Registers itself with the server, along with a runtime for each detected tool
|
||||
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
启动后它会做四件事:
|
||||
|
||||
1. 读取你登录时保存的凭证
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
|
||||
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica は**分散型**プラットフォームです。あなたが目にす
|
||||
|
||||
- **Multica サーバー** — あなたが目にするワークスペース、イシュー一覧、コメントスレッドは、すべてここのデータベースに保存されます。また、あなたと同僚の間でリアルタイム更新をプッシュする WebSocket ハブでもあります。エージェントのタスクは**実行しません**。
|
||||
- **デーモン** — Multica CLI の一部であり、あなた自身のマシンで実行されます。起動時にローカルにインストールされた AI コーディングツールを検出し、サーバーに登録したうえで、3 秒ごとにタスクをポーリングし、15 秒ごとにハートビートを送信し始めます。
|
||||
- **AI コーディングツール** — 次の 12 種類のうちの 1 つ(または複数を並列で): [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
|
||||
- **AI コーディングツール** — 次の 12 種類のうちの 1 つ(または複数を並列で): [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
|
||||
|
||||
ツールチェーンがローカルに留まるため、**あなたの API キー、コードディレクトリ、認可されたツール**は、あなたのマシン上でのみ使用されます。Multica サーバーはそのいずれも目にすることはありません。これはセルフホストでも Cloud でも同じように適用されます。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica는 **분산형** 플랫폼입니다. 여러분이 보는 웹 인터페
|
||||
|
||||
- **Multica 서버** — 여러분이 보는 워크스페이스, 이슈 목록, 댓글 스레드는 모두 이곳의 데이터베이스에 저장됩니다. 또한 여러분과 동료 사이의 실시간 업데이트를 푸시하는 WebSocket 허브이기도 합니다. 에이전트 작업은 **실행하지 않습니다.**
|
||||
- **데몬** — Multica CLI의 일부로, 여러분 자신의 기기에서 실행됩니다. 시작 시 로컬에 설치된 AI 코딩 도구를 감지하고, 서버에 등록한 다음, 3초마다 작업을 폴링하고 15초마다 하트비트를 전송하기 시작합니다.
|
||||
- **AI 코딩 도구** — 다음 열두 가지 중 하나(또는 여러 개를 병렬로): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
|
||||
- **AI 코딩 도구** — 다음 열두 가지 중 하나(또는 여러 개를 병렬로): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
|
||||
|
||||
도구 체인이 로컬에 유지되므로 **여러분의 API 키, 코드 디렉터리, 인증된 도구**는 오직 여러분의 기기에서만 사용됩니다. Multica 서버는 그중 어떤 것도 보지 못합니다. 이는 자체 호스팅을 하든 Cloud를 사용하든 동일하게 적용됩니다.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
|
||||
|
||||
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
|
||||
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
|
||||
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
|
||||
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
|
||||
|
||||
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub,把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
|
||||
- **守护进程**(daemon)——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server,开始每 3 秒领一次任务、每 15 秒发一次心跳。
|
||||
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
|
||||
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用;Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica は、人間と AI [エージェント](/agents)が同じ[ワークス
|
||||
|
||||
エージェントは Multica のサーバー上でタスクを実行**しません**。現在 Multica は 1 つのランタイムモデルをサポートしています。
|
||||
|
||||
- **ローカル[デーモン](/daemon-runtimes)** — 自分のマシンで `multica daemon` を実行すると、デーモンがローカルにインストールされた [AI コーディングツール](/providers)を駆動します。現在 12 種類が標準で組み込まれています: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。API キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
|
||||
- **ローカル[デーモン](/daemon-runtimes)** — 自分のマシンで `multica daemon` を実行すると、デーモンがローカルにインストールされた [AI コーディングツール](/providers)を駆動します。現在 12 種類が標準で組み込まれています: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。API キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
|
||||
|
||||
<Callout type="info">
|
||||
**クラウドランタイムが近日提供予定です。** 現在はウェイトリストのみで運用されています。提供が開始されればローカルデーモンは不要になり、エージェントのタスクは Multica Cloud 上で直接実行されます。[ダウンロード](https://multica.ai/download)ページで登録すると通知を受け取れます。
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica는 인간과 AI [에이전트](/agents)가 같은 [워크스페이스](/
|
||||
|
||||
에이전트는 Multica 서버에서 작업을 실행하지 **않습니다**. 현재 Multica는 하나의 런타임 모델을 지원합니다:
|
||||
|
||||
- **로컬 [데몬](/daemon-runtimes)** — 자신의 기기에서 `multica daemon`을 실행하면, 데몬이 로컬에 설치된 [AI 코딩 도구](/providers)를 구동합니다. 현재 열두 가지가 기본 내장되어 있습니다: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). API 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
|
||||
- **로컬 [데몬](/daemon-runtimes)** — 자신의 기기에서 `multica daemon`을 실행하면, 데몬이 로컬에 설치된 [AI 코딩 도구](/providers)를 구동합니다. 현재 열두 가지가 기본 내장되어 있습니다: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). API 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
|
||||
|
||||
<Callout type="info">
|
||||
**클라우드 런타임이 곧 제공됩니다.** 현재는 대기 명단으로만 운영됩니다. 출시되면 로컬 데몬이 필요 없어지며 — 에이전트 작업이 Multica Cloud에서 직접 실행됩니다. [다운로드](https://multica.ai/download) 페이지에서 등록하면 알림을 받을 수 있습니다.
|
||||
|
||||
@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
|
||||
|
||||
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
|
||||
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
|
||||
|
||||
<Callout type="info">
|
||||
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.
|
||||
|
||||
@@ -15,7 +15,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在
|
||||
|
||||
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
|
||||
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
|
||||
@@ -30,10 +30,8 @@
|
||||
"---インボックス---",
|
||||
"inbox",
|
||||
"---連携---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---セルフホスト & 運用---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,10 +30,8 @@
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,10 +30,8 @@
|
||||
"---인박스---",
|
||||
"inbox",
|
||||
"---연동---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---자체 호스팅 & 운영---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,10 +30,8 @@
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: Slack Bot 連携
|
||||
description: Multica エージェントをあなた自身の Slack アプリに接続します——マニフェストからアプリを作成し、インストールして、bot トークンと app-level トークンを貼り付ければ、Slack の中から @ メンションしたり、DM したり、/issue と入力したりできます。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
任意の[エージェント](/agents)を Slack Bot に接続すれば、チームは Slack の中から直接それを使えます——Bot に DM したり、チャンネルで @ メンションしたり、`/issue` と入力してアプリを開かずに [Multica イシュー](/issues)を起票したりできます。
|
||||
|
||||
Slack は**自分のアプリを持ち込む(BYO: bring-your-own-app)**モデルを採用しています。ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、そのトークンを Multica に貼り付けます。エージェントごとに**専用の** Slack アプリを持つため、同じ Slack ワークスペース内で複数のエージェントがそれぞれ別個に @ メンションできる異なる Bot を持てます。(これは紐づけがスキャンしてインストールするフローである [Lark](/lark-bot-integration) とは異なります。)
|
||||
|
||||
セットアップ全体は以下のとおりで、所要時間は約 5 分です。最終的に、Multica に貼り付ける 2 つのトークンが得られます。
|
||||
|
||||
- **Bot トークン** —— `xoxb-` で始まります
|
||||
- **App-level トークン** —— `xapp-` で始まります
|
||||
|
||||
## Slack アプリをセットアップする
|
||||
|
||||
### 1. マニフェストからアプリを作成する
|
||||
|
||||
1. [https://api.slack.com/apps](https://api.slack.com/apps) を開き、**Create New App** をクリックします。
|
||||
2. **From a manifest** を選びます。
|
||||
3. アプリをインストールする Slack ワークスペースを選びます。
|
||||
4. **YAML** タブに切り替え、下記のマニフェストを貼り付けて、内容を確認しアプリを作成します。
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
このマニフェストは Multica が必要とするものをすべて設定するので、手作業で何かを設定する必要はありません。
|
||||
|
||||
| セクション | なぜそこにあるか |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | メンバーが Bot を開いて **DM** できるようにします。これがないと、Bot に直接メッセージを送れません。 |
|
||||
| `bot_user` | @ メンションされ、返信を投稿する Bot のアイデンティティを作成します。 |
|
||||
| `chat:write` | エージェントの返信を Slack に投稿し返します。 |
|
||||
| `app_mentions:read` + `app_mention` イベント | チャンネルでの @ メンションを受け取ります。 |
|
||||
| `im:history` + `message.im` | Bot への **DM** を受け取ります(すべての DM メッセージが読み取られます)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 対応する `message.*` イベント | パブリックチャンネル、プライベートチャンネル、グループ DM のメッセージを受け取ります。これらの中では、Bot は自分を **@ メンション**したメッセージにのみ反応します。 |
|
||||
| `users:read` | Multica が(`bots.info` を介して)あなたの 2 つのトークンが同じアプリのものであることを検証するために必要です。 |
|
||||
| `socket_mode_enabled: true` | Bot は Socket Mode 経由で外向きに接続します——**公開 URL/リクエスト URL は不要**です。 |
|
||||
| `interactivity.is_enabled: false` | Multica のプロンプトはボタンではなくプレーンなリンクなので、インタラクティビティは不要です。 |
|
||||
|
||||
**OAuth リダイレクト URL はありません**。BYO は OAuth を使わないからです。
|
||||
|
||||
<Callout type="info">
|
||||
Slack で特定の名前を表示したいですか? 作成前に `display_information.name` と `features.bot_user.display_name`(たとえばエージェントの名前に)を変更するか、あとで **App Home** で編集してください。Slack は Bot をその **bot display name** で表示しますが、これはアプリ名と異なる場合があります。
|
||||
</Callout>
|
||||
|
||||
### 2. アプリをインストールして Bot トークンをコピーする
|
||||
|
||||
1. アプリの左ナビで **Install App**(または **OAuth & Permissions**)を開きます。
|
||||
2. **Install to Workspace** をクリックして承認します。
|
||||
3. **Bot User OAuth Token** をコピーします——`xoxb-` で始まります。これがあなたの **Bot トークン**です。
|
||||
|
||||
### 3. App-level トークンを作成する
|
||||
|
||||
app-level トークンは Socket Mode 接続を認可します。これはコンソールでしか作成できません(OAuth の一部ではありません)。
|
||||
|
||||
1. **Basic Information → App-Level Tokens** を開き、**Generate Token and Scopes** をクリックします。
|
||||
2. 任意の名前を付けます。
|
||||
3. **Add Scope** をクリックし、リストから **`connections:write`** を選びます(これはピッカーなので、入力せずに選択してください)。
|
||||
4. **Generate** をクリックし、トークンをコピーします——`xapp-` で始まります。これがあなたの **App-level トークン**です。
|
||||
|
||||
### 4. Multica で接続する
|
||||
|
||||
1. **Agents → _あなたのエージェント_** からそのエージェントを開き、**Integrations** タブ(または左サイドバーの **Integrations** 区画)を開きます。
|
||||
2. **Connect Slack** をクリックします。
|
||||
3. **Bot トークン**(`xoxb-`)と **App-level トークン**(`xapp-`)を貼り付け、**Connect** をクリックします。
|
||||
4. エージェントに **Connected to Slack** と表示されます。Bot はこれで、自身の Socket Mode 接続を通じて待ち受けています。
|
||||
|
||||
<Callout type="warning">
|
||||
2 つのトークンは**同じ** Slack アプリのものでなければならず、そのアプリはちょうど **1 つ**のエージェントに対応します。すでに別のエージェントやワークスペースに接続されているアプリを接続しようとすると拒否されます。アプリを別のエージェントへ移すには、まず切断してください。**新しい**アプリでエージェントを再接続すると、そのエージェントの Bot がその場で更新されます。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**複数のエージェント**でこれを設定しますか? フロー全体をエージェントごとに 1 回ずつ繰り返してください——各エージェントが専用の Slack アプリと専用のトークンのペアを持ち、Slack ワークスペース内で別々の Bot として表示されます。
|
||||
</Callout>
|
||||
|
||||
## この連携でできること
|
||||
|
||||
| 場所 | 動作 |
|
||||
|---|---|
|
||||
| **エージェント → Integrations** | owner と admin には **Connect Slack** が表示され、接続すると **Connected to Slack** バッジと **Disconnect** コントロールに切り替わります。 |
|
||||
| **Bot に DM** | ワークスペースメンバーが Bot に直接メッセージを送ります。会話はそのエージェントとの Multica [chat](/chat) セッションになり、すべての DM メッセージが読み取られます。 |
|
||||
| **チャンネルで @ メンション** | Bot を招待し(`/invite @your-bot`)、@ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はチャンネル全体を聞いているわけではありません。各 @bot **スレッド**がそれぞれ独立したセッションになります。 |
|
||||
| **`/issue` コマンド** | `/issue <タイトル>`(続く行に本文を足してもよい)でメッセージを始めると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。 |
|
||||
| **返信** | エージェントの回答は、同じ DM またはスレッドに投稿し返されます。 |
|
||||
|
||||
## Bot を使う(メンバー)
|
||||
|
||||
### 最初のメッセージ:アカウントを紐づける
|
||||
|
||||
初めて Bot を @ メンションするか DM すると、Bot は **アカウントを紐づける** プロンプトで返信します。リンクをタップして Multica にサインインすると、あなたの Slack アイデンティティがあなたの Multica メンバーシップに紐づきます——これによって、エージェントがあなたとして振る舞えるようになります(たとえば `/issue` はあなたの名義でイシューを起票します)。このリンクは使い切りで、約 15 分で失効します。新しいものが必要なら、もう一度 Bot にメッセージを送るだけです。
|
||||
|
||||
<Callout type="warning">
|
||||
Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は実行されません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。
|
||||
</Callout>
|
||||
|
||||
### 対話と `/issue`
|
||||
|
||||
- **チャンネルで** —— Bot は自動では参加しません。一度 `/invite @your-bot` を実行してから、`@your-bot <あなたのメッセージ>` とします。フォローアップのたびに再度メンションしてください(Bot は自分をメンションしたメッセージだけを読みます)。
|
||||
- **DM で** —— Slack サイドバーの **Apps** 区画から Bot を開いて直接メッセージを送ります。メンションは不要です。
|
||||
- **イシューを起票する** —— `/issue Fix the login redirect` と送ります。タイトルの後ろに行を足せば、それが説明になります。
|
||||
|
||||
## 管理と切断
|
||||
|
||||
ワークスペース全体の管理は **Settings → Integrations** にあります。
|
||||
|
||||
- **Connected bots** は、ワークスペース内のすべての Bot と、それぞれが紐づくエージェントを一覧表示します(すべてのメンバーから見えます)。
|
||||
- **Disconnect** は **owner / admin 専用** です。切断すると Bot は Slack メッセージの受信を停止し、その接続が破棄されます。インストール記録は監査のために保持され、あとで再接続できます。
|
||||
|
||||
## 権限
|
||||
|
||||
- **接続 / 切断** にはワークスペースの **owner** または **admin** が必要です。
|
||||
- **Bot との対話** には、Slack アイデンティティを紐づけたワークスペースメンバーであることが必要です。それ以外の人は一律に破棄されます。
|
||||
- 破棄されたメッセージの本文が保存されることはありません——監査のために破棄理由だけが記録されます。
|
||||
|
||||
## セルフホストのセットアップ
|
||||
|
||||
Multica Cloud では連携はすでに利用可能です——このセクションは飛ばしてください。
|
||||
|
||||
セルフホストの場合、Slack は**保存時の暗号化キーを設定するまでオフ**です。このキーは、各アプリの bot トークン + app-level トークンがデータベースに触れる前にそれを暗号化します。BYO には OAuth の client id/secret は**不要**で、デプロイレベルの app トークンも**不要**です——各インストールは admin が貼り付けたトークンを使います。
|
||||
|
||||
1. 32 バイトのキーを生成し、API サーバーに設定します。
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
たとえば: `openssl rand -base64 32`。
|
||||
|
||||
2. API を再起動します。キーを設定するまで、**Settings → Integrations** には「Slack integration not enabled」という通知が表示され、**Connect Slack** のエントリポイントは非表示のままになります。
|
||||
|
||||
<Callout type="info">
|
||||
キーはちょうど 32 バイトにデコードされなければなりません——`openssl rand -base64 32` はそれを満たします。これは長く使い続けるシークレットとして扱ってください。ローテーションしたり紛失したりすると、すでに保存済みのトークンが復号できなくなり、すべての Bot を再接続せざるを得なくなります。「アカウントを紐づける」リンクは、Web アプリの URL(`MULTICA_APP_URL`、未設定時は `FRONTEND_ORIGIN` にフォールバック)から生成されます。通常のデプロイではこれは既に設定されているため、追加で設定するものはありません。
|
||||
</Callout>
|
||||
|
||||
## 次に
|
||||
|
||||
- [Chat 連携](/channels) — チャンネルエンジン、セッション、認可の仕組み
|
||||
- [エージェント](/agents) · [Chat](/chat) · [イシュー](/issues)
|
||||
- [環境変数](/environment-variables) — セルフホスト構成の完全なリファレンス
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: Slack Bot 연동
|
||||
description: Multica 에이전트를 자체 Slack 앱에 연결하세요 — 매니페스트로 앱을 만들고, 설치한 다음, bot + app-level 토큰을 붙여넣고, Slack 안에서 @로 멘션하거나 DM하거나 /issue를 입력하세요.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
아무 [에이전트](/agents)나 Slack 봇에 연결하면, 팀이 Slack 안에서 바로 그 에이전트와 함께 일할 수 있습니다 — 봇에게 DM을 보내거나, 채널에서 `@`로 멘션하거나, `/issue`를 입력해 앱을 열지 않고도 [Multica 이슈](/issues)를 생성하세요.
|
||||
|
||||
Slack은 **자체 앱 사용(BYO)** 모델을 따릅니다: 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, 토큰을 Multica에 붙여넣습니다. 각 에이전트가 **자체** Slack 앱을 갖습니다 — 그래서 하나의 Slack 워크스페이스 안에서 여러 에이전트가 각각 별개로 `@`로 멘션할 수 있는 봇을 가질 수 있습니다. (바인딩이 스캔하여 설치하는 방식인 [Lark](/lark-bot-integration)와는 다릅니다.)
|
||||
|
||||
전체 설정은 아래에 있으며 약 5분이 걸립니다. 마지막에는 Multica에 붙여넣을 두 개의 토큰을 얻게 됩니다:
|
||||
|
||||
- **Bot token** — `xoxb-`로 시작
|
||||
- **App-level token** — `xapp-`로 시작
|
||||
|
||||
## Slack 앱 설정하기
|
||||
|
||||
### 1. 매니페스트로 앱 만들기
|
||||
|
||||
1. [https://api.slack.com/apps](https://api.slack.com/apps)로 이동해 **Create New App**을 클릭합니다.
|
||||
2. **From a manifest**를 선택합니다.
|
||||
3. 앱을 설치할 Slack 워크스페이스를 고릅니다.
|
||||
4. **YAML** 탭으로 전환해 아래 매니페스트를 붙여넣고, 검토한 뒤 앱을 생성합니다.
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
이 매니페스트는 Multica에 필요한 모든 것을 구성하므로, 직접 손으로 설정할 것이 없습니다:
|
||||
|
||||
| 섹션 | 이유 |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | 멤버가 봇을 열어 **DM**할 수 있게 합니다. 이것이 없으면 봇에게 직접 메시지를 보낼 수 없습니다. |
|
||||
| `bot_user` | `@`로 멘션되고 답변을 게시하는 봇 신원을 생성합니다. |
|
||||
| `chat:write` | 에이전트의 답변을 Slack으로 다시 게시합니다. |
|
||||
| `app_mentions:read` + `app_mention` 이벤트 | 채널에서 `@`-멘션을 받습니다. |
|
||||
| `im:history` + `message.im` | 봇에게 보내는 **DM**을 받습니다(모든 DM 메시지를 읽습니다). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 대응하는 `message.*` 이벤트 | 공개 채널, 비공개 채널, 그룹 DM의 메시지를 받습니다. 이런 곳에서 봇은 자신을 **@로 멘션한** 메시지에만 반응합니다. |
|
||||
| `users:read` | Multica가 두 토큰이 같은 앱에 속하는지 (`bots.info`를 통해) 확인하는 데 필요합니다. |
|
||||
| `socket_mode_enabled: true` | 봇이 Socket Mode로 밖으로 연결합니다 — **공개 URL / request URL이 필요 없습니다**. |
|
||||
| `interactivity.is_enabled: false` | Multica의 안내는 버튼이 아니라 일반 링크라서, interactivity가 필요 없습니다. |
|
||||
|
||||
**OAuth redirect URL은 없습니다.** BYO는 OAuth를 사용하지 않기 때문입니다.
|
||||
|
||||
<Callout type="info">
|
||||
Slack에서 특정 이름을 쓰고 싶나요? 생성하기 전에 `display_information.name`과 `features.bot_user.display_name`을 (예: 에이전트 이름으로) 변경하거나, 나중에 **App Home**에서 편집하세요. Slack은 봇을 **bot display name**으로 표시하며, 이는 앱 이름과 다를 수 있습니다.
|
||||
</Callout>
|
||||
|
||||
### 2. 앱 설치하고 Bot token 복사하기
|
||||
|
||||
1. 앱의 왼쪽 내비게이션에서 **Install App**(또는 **OAuth & Permissions**)을 엽니다.
|
||||
2. **Install to Workspace**를 클릭하고 승인합니다.
|
||||
3. **Bot User OAuth Token**을 복사합니다 — `xoxb-`로 시작합니다. 이것이 당신의 **Bot token**입니다.
|
||||
|
||||
### 3. App-level token 생성하기
|
||||
|
||||
app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성할 수 있습니다(OAuth의 일부가 아닙니다).
|
||||
|
||||
1. **Basic Information → App-Level Tokens**를 열고 **Generate Token and Scopes**를 클릭합니다.
|
||||
2. 아무 이름이나 지정합니다.
|
||||
3. **Add Scope**를 클릭하고 목록에서 **`connections:write`**를 고릅니다(선택기이므로 — 입력하지 말고 선택하세요).
|
||||
4. **Generate**를 클릭한 다음 토큰을 복사합니다 — `xapp-`로 시작합니다. 이것이 당신의 **App-level token**입니다.
|
||||
|
||||
### 4. Multica에서 연결하기
|
||||
|
||||
1. **Agents → _당신의 에이전트_** → **Integrations** 탭(또는 왼쪽 사이드바의 **Integrations** 섹션)에서 에이전트를 엽니다.
|
||||
2. **Connect Slack**을 클릭합니다.
|
||||
3. **Bot token**(`xoxb-`)과 **App-level token**(`xapp-`)을 붙여넣은 다음 **Connect**를 클릭합니다.
|
||||
4. 에이전트에 **Connected to Slack**이 표시됩니다. 봇은 이제 자체 Socket Mode 연결로 수신 대기합니다.
|
||||
|
||||
<Callout type="warning">
|
||||
두 토큰은 **같은** Slack 앱에서 와야 하며, 그 앱은 정확히 **하나의** 에이전트에 매핑됩니다. 이미 다른 에이전트나 워크스페이스에 연결된 앱을 연결하는 것은 거부됩니다. 앱을 다른 에이전트로 옮기려면 먼저 연결을 해제하세요. **새** 앱으로 에이전트를 다시 연결하면 그 에이전트의 봇이 그 자리에서 갱신됩니다.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**여러 에이전트**에 설정하나요? 에이전트당 전체 과정을 한 번씩 반복하세요 — 각 에이전트가 자체 Slack 앱과 자체 토큰 한 쌍을 가지며, Slack 워크스페이스에 별개의 봇으로 나타납니다.
|
||||
</Callout>
|
||||
|
||||
## 연동이 하는 일
|
||||
|
||||
| 위치 | 동작 |
|
||||
|---|---|
|
||||
| **Agent → Integrations** | owner와 admin에게는 **Connect Slack**이 보이며, 연결되면 **Connected to Slack** 배지와 **Disconnect** 컨트롤로 바뀝니다. |
|
||||
| **봇에게 DM** | 워크스페이스 멤버가 봇에게 직접 메시지를 보냅니다. 그 대화는 에이전트와의 Multica [chat](/chat) 세션이 되며, 모든 DM 메시지를 읽습니다. |
|
||||
| **채널에서 `@`-멘션** | 봇을 초대(`/invite @your-bot`)하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 채널 전체를 듣지는 않습니다. 각 @bot **스레드**가 자체 세션입니다. |
|
||||
| **`/issue` 명령** | `/issue <제목>`(다음 줄에 본문 추가 가능)으로 메시지를 시작하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. |
|
||||
| **답변** | 에이전트의 답변은 같은 DM 또는 스레드로 다시 게시됩니다. |
|
||||
|
||||
## 봇 사용하기 (멤버)
|
||||
|
||||
### 첫 메시지: 계정 연결하기
|
||||
|
||||
봇을 처음 `@`로 멘션하거나 DM하면, **계정을 연결하라**는 안내로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Slack 신원이 Multica 멤버십에 바인딩됩니다 — 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다(예: `/issue`는 당신 이름으로 이슈를 생성합니다). 이 링크는 일회용이며 약 15분 후에 만료됩니다. 새 링크가 필요하면 봇에게 다시 메시지를 보내세요.
|
||||
|
||||
<Callout type="warning">
|
||||
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 연결을 건너뛰면 봇은 실행되지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
|
||||
</Callout>
|
||||
|
||||
### 대화와 `/issue`
|
||||
|
||||
- **채널에서** — 봇은 자동으로 참여하지 않습니다. `/invite @your-bot`을 한 번 실행한 다음 `@your-bot <당신의 메시지>`로 보내세요. 후속 메시지마다 다시 멘션하세요(봇은 자신을 멘션한 메시지만 읽습니다).
|
||||
- **DM에서** — Slack 사이드바의 **Apps** 섹션에서 봇을 열고 직접 메시지를 보내세요. 멘션이 필요 없습니다.
|
||||
- **이슈 생성** — `/issue Fix the login redirect`를 보내세요. 제목 뒤에 줄을 더 추가하면 설명이 됩니다.
|
||||
|
||||
## 관리 및 연결 해제
|
||||
|
||||
워크스페이스 전체 관리는 **Settings → Integrations**에 있습니다:
|
||||
|
||||
- **Connected bots**는 워크스페이스 내 모든 봇과 각 봇이 바인딩된 에이전트를 나열합니다(모든 멤버에게 보입니다).
|
||||
- **Disconnect**는 **owner / admin 전용**입니다. 봇이 Slack 메시지 수신을 멈추고 연결이 해체됩니다. 설치 기록은 감사용으로 유지되며, 이후 다시 연결할 수 있습니다.
|
||||
|
||||
## 권한
|
||||
|
||||
- **연결 / 연결 해제**에는 워크스페이스 **owner** 또는 **admin**이 필요합니다.
|
||||
- **봇과 대화하기**에는 Slack 신원이 연결된 워크스페이스 멤버여야 합니다. 그 외의 사람은 모두 폐기됩니다.
|
||||
- 폐기된 메시지의 본문은 절대 저장되지 않으며 — 감사용 폐기 사유만 기록됩니다.
|
||||
|
||||
## 자체 호스팅 설정
|
||||
|
||||
Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은 건너뛰세요.
|
||||
|
||||
자체 호스팅의 경우, Slack은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**. 이 키는 각 앱의 bot + app-level 토큰이 데이터베이스에 닿기 전에 암호화합니다. BYO에는 OAuth client id/secret이 **필요 없고**, 배포 수준의 app token도 **필요 없습니다** — 각 installation은 admin이 붙여넣은 토큰을 사용합니다.
|
||||
|
||||
1. 32바이트 키를 생성해 API 서버에 설정합니다:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
예를 들면: `openssl rand -base64 32`.
|
||||
|
||||
2. API를 재시작하세요. 키가 설정되기 전까지 **Settings → Integrations**에는 "Slack integration not enabled" 안내가 표시되고, **Connect Slack** 진입점은 숨겨진 채로 유지됩니다.
|
||||
|
||||
<Callout type="info">
|
||||
키는 정확히 32바이트로 디코딩되어야 하며 — `openssl rand -base64 32`가 이를 충족합니다. 오래 유지되는 시크릿으로 다루세요: 키를 회전하거나 잃으면 이미 저장된 토큰을 복호화할 수 없게 되어, 모든 봇이 다시 연결해야 합니다. "계정을 연결하세요" 링크는 웹 앱 URL(`MULTICA_APP_URL`, 없으면 `FRONTEND_ORIGIN`으로 폴백)에서 만들어집니다. 일반적인 배포에서는 이미 설정되어 있으므로 추가로 구성할 것은 없습니다.
|
||||
</Callout>
|
||||
|
||||
## 다음
|
||||
|
||||
- [Chat 연동](/channels) — channel 엔진, 세션, 권한이 어떻게 동작하는지
|
||||
- [에이전트](/agents) · [Chat](/chat) · [이슈](/issues)
|
||||
- [환경 변수](/environment-variables) — 전체 자체 호스팅 구성 참조
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: Slack Bot integration
|
||||
description: Connect a Multica agent to your own Slack app — create the app from a manifest, install it, paste the bot + app-level tokens, then @-mention it, DM it, or type /issue from inside Slack.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect any [agent](/agents) to a Slack bot and your team can work with it from inside Slack — DM the bot, @-mention it in a channel, or type `/issue` to file a [Multica issue](/issues) without opening the app.
|
||||
|
||||
Slack uses a **bring-your-own-app (BYO)** model: a workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its tokens into Multica. Each agent gets **its own** Slack app — so several agents can each have a distinct, separately @-mentionable bot in the same Slack workspace. (This differs from [Lark](/lark-bot-integration), where binding is a scan-to-install flow.)
|
||||
|
||||
The whole setup is below and takes about five minutes. You'll end up with two tokens to paste into Multica:
|
||||
|
||||
- a **Bot token** — starts with `xoxb-`
|
||||
- an **App-level token** — starts with `xapp-`
|
||||
|
||||
## Set up your Slack app
|
||||
|
||||
### 1. Create the app from a manifest
|
||||
|
||||
1. Go to [https://api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**.
|
||||
2. Choose **From a manifest**.
|
||||
3. Pick the Slack workspace to install the app into.
|
||||
4. Switch to the **YAML** tab, paste the manifest below, review, and create the app.
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
This manifest configures everything Multica needs, so you don't set anything by hand:
|
||||
|
||||
| Section | Why it's there |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | Lets members open the bot and **DM** it. Without it, the bot can't be messaged directly. |
|
||||
| `bot_user` | Creates the bot identity that gets @-mentioned and posts replies. |
|
||||
| `chat:write` | Post the agent's replies back into Slack. |
|
||||
| `app_mentions:read` + `app_mention` event | Receive @-mentions in channels. |
|
||||
| `im:history` + `message.im` | Receive **DMs** to the bot (every DM message is read). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + the matching `message.*` events | Receive messages in public channels, private channels, and group DMs. In these, the bot only acts on messages that **@-mention** it. |
|
||||
| `users:read` | Required so Multica can verify (via `bots.info`) that your two tokens belong to the same app. |
|
||||
| `socket_mode_enabled: true` | The bot connects out over Socket Mode — **no public URL / request URL needed**. |
|
||||
| `interactivity.is_enabled: false` | Multica's prompts are plain links, not buttons, so interactivity isn't needed. |
|
||||
|
||||
There is **no OAuth redirect URL**, because BYO doesn't use OAuth.
|
||||
|
||||
<Callout type="info">
|
||||
Want a specific name in Slack? Change `display_information.name` and `features.bot_user.display_name` (e.g. to your agent's name) before creating, or edit it later under **App Home**. Slack shows the bot by its **bot display name**, which can differ from the app name.
|
||||
</Callout>
|
||||
|
||||
### 2. Install the app and copy the Bot token
|
||||
|
||||
1. In the app's left nav, open **Install App** (or **OAuth & Permissions**).
|
||||
2. Click **Install to Workspace** and approve.
|
||||
3. Copy the **Bot User OAuth Token** — it starts with `xoxb-`. This is your **Bot token**.
|
||||
|
||||
### 3. Create the App-level token
|
||||
|
||||
The app-level token authorizes the Socket Mode connection. It can only be created in the console (it isn't part of OAuth).
|
||||
|
||||
1. Open **Basic Information → App-Level Tokens** and click **Generate Token and Scopes**.
|
||||
2. Give it any name.
|
||||
3. Click **Add Scope** and pick **`connections:write`** from the list (it's a picker — select it, don't type it).
|
||||
4. Click **Generate**, then copy the token — it starts with `xapp-`. This is your **App-level token**.
|
||||
|
||||
### 4. Connect it in Multica
|
||||
|
||||
1. Open the agent in **Agents → _your agent_** → the **Integrations** tab (or the **Integrations** section in the left sidebar).
|
||||
2. Click **Connect Slack**.
|
||||
3. Paste the **Bot token** (`xoxb-`) and the **App-level token** (`xapp-`), then click **Connect**.
|
||||
4. The agent shows **Connected to Slack**. The bot is now listening over its own Socket Mode connection.
|
||||
|
||||
<Callout type="warning">
|
||||
The two tokens must be from the **same** Slack app, and that app maps to exactly **one** agent. Connecting an app that's already connected to a different agent or workspace is refused. To move an app to another agent, disconnect it first; re-connecting an agent with a **new** app updates that agent's bot in place.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Setting this up for **multiple agents**? Repeat the whole flow once per agent — each agent gets its own Slack app and its own pair of tokens, and they show up as separate bots in your Slack workspace.
|
||||
</Callout>
|
||||
|
||||
## What the integration does
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Agent → Integrations** | Owners and admins see **Connect Slack**; once connected it flips to a **Connected to Slack** badge with a **Disconnect** control. |
|
||||
| **DM the bot** | A workspace member messages the bot directly. The conversation becomes a Multica [chat](/chat) session with the agent; every DM message is read. |
|
||||
| **@-mention in a channel** | Invite the bot (`/invite @your-bot`) and @-mention it. Only the mentioning message is read — the bot does not listen to the whole channel. Each @bot **thread** is its own session. |
|
||||
| **`/issue` command** | Starting a message with `/issue <title>` (optionally with a body on the next lines) creates a new Multica issue in the workspace, attributed to you. |
|
||||
| **Reply** | The agent's answer is posted back into the same DM or thread. |
|
||||
|
||||
## Use the bot (members)
|
||||
|
||||
### First message: link your account
|
||||
|
||||
The first time you @-mention or DM the bot, it replies with a **link your account** prompt. Tap the link, sign in to Multica, and your Slack identity is bound to your Multica membership — this is what lets the agent act as you (e.g. `/issue` files under your name). The link is single-use and expires in about 15 minutes; just message the bot again for a fresh one.
|
||||
|
||||
<Callout type="warning">
|
||||
Only **members of the workspace** can use the bot. If you aren't a member, or you skip the identity link, the bot won't run — your message is dropped (recorded for audit, without its contents).
|
||||
</Callout>
|
||||
|
||||
### Chat and `/issue`
|
||||
|
||||
- **In a channel** — the bot isn't auto-joined. Run `/invite @your-bot` once, then `@your-bot <your message>`. Re-mention it for each follow-up (the bot only reads messages that mention it).
|
||||
- **In a DM** — open the bot from the Slack sidebar's **Apps** section and message it directly; no mention needed.
|
||||
- **File an issue** — send `/issue Fix the login redirect`; add more lines after the title for a description.
|
||||
|
||||
## Manage and disconnect
|
||||
|
||||
Workspace-wide management lives in **Settings → Integrations**:
|
||||
|
||||
- **Connected bots** lists every bot in the workspace and the agent each is bound to (visible to all members).
|
||||
- **Disconnect** is **owner / admin only**. It stops the bot from receiving Slack messages and tears down its connection; the installation record is kept for audit, and you can re-connect later.
|
||||
|
||||
## Permissions
|
||||
|
||||
- **Connect / disconnect** require workspace **owner** or **admin**.
|
||||
- **Talking to the bot** requires being a workspace member with a linked Slack identity. Everyone else is dropped.
|
||||
- Message bodies for dropped messages are never stored — only a drop reason, for audit.
|
||||
|
||||
## Self-host setup
|
||||
|
||||
On Multica Cloud the integration is already available — skip this section.
|
||||
|
||||
For self-host, Slack is **off until you set an at-rest encryption key**. The key encrypts each app's bot + app-level tokens before they touch the database. BYO needs **no** OAuth client id/secret and **no** deployment-level app token — each installation uses the tokens the admin pastes.
|
||||
|
||||
1. Generate a 32-byte key and set it on the API server:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
For example: `openssl rand -base64 32`.
|
||||
|
||||
2. Restart the API. Until the key is set, **Settings → Integrations** shows a "Slack integration not enabled" notice and the **Connect Slack** entry points stay hidden.
|
||||
|
||||
<Callout type="info">
|
||||
The key must decode to exactly 32 bytes — `openssl rand -base64 32` does this. Treat it as a long-lived secret: rotating or losing it makes already-stored tokens undecryptable, forcing every bot to reconnect. The "link your account" link is built from your web app URL (`MULTICA_APP_URL`, falling back to `FRONTEND_ORIGIN`) — a normal deployment already sets this, so there's nothing extra to configure.
|
||||
</Callout>
|
||||
|
||||
## Next
|
||||
|
||||
- [Chat integrations](/channels) — how the channel engine, sessions, and authorization work
|
||||
- [Agents](/agents) · [Chat](/chat) · [Issues](/issues)
|
||||
- [Environment variables](/environment-variables) — full self-host configuration reference
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: Slack Bot 接入
|
||||
description: 把 Multica 智能体接入你自己的 Slack app——用 manifest 创建 app、安装它、粘贴 bot token 与 app-level token,然后就能在 Slack 里 @ 它、私聊它,或输入 /issue。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把任意[智能体](/agents)接入一个 Slack Bot,团队就能在 Slack 里直接使用它——私聊 Bot、在频道里 @ 它,或者输入 `/issue` 直接创建一个 [Multica issue](/issues),不用打开应用。
|
||||
|
||||
Slack 走的是**自带应用(bring-your-own-app,BYO)**模式:workspace 管理员创建一个 Slack app,把它安装到自己的 Slack workspace,再把它的 token 粘贴进 Multica。每个智能体都有**它自己的** Slack app——所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立、可单独 @ 的 Bot。(这一点和 [Lark](/lark-bot-integration) 不同,Lark 的绑定是扫码安装流程。)
|
||||
|
||||
整个设置流程在下面,大约五分钟。最后你会得到两个 token 粘贴进 Multica:
|
||||
|
||||
- 一个 **Bot token** —— 以 `xoxb-` 开头
|
||||
- 一个 **App-level token** —— 以 `xapp-` 开头
|
||||
|
||||
## 设置你的 Slack app
|
||||
|
||||
### 1. 用 manifest 创建 app
|
||||
|
||||
1. 打开 [https://api.slack.com/apps](https://api.slack.com/apps),点击 **Create New App**。
|
||||
2. 选择 **From a manifest**。
|
||||
3. 选定要把 app 安装进去的那个 Slack workspace。
|
||||
4. 切到 **YAML** tab,粘贴下面的 manifest,检查一遍,然后创建这个 app。
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
这个 manifest 已经把 Multica 所需的一切都配好了,所以你不用手动设置任何东西:
|
||||
|
||||
| 配置项 | 为什么需要它 |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | 让成员能打开 Bot 并**私聊**它。没有它,Bot 就无法被直接发消息。 |
|
||||
| `bot_user` | 创建被 @ 和发回复用的那个 Bot 身份。 |
|
||||
| `chat:write` | 把智能体的回复发回 Slack。 |
|
||||
| `app_mentions:read` + `app_mention` 事件 | 接收频道里的 @ 提及。 |
|
||||
| `im:history` + `message.im` | 接收发给 Bot 的**私聊**(每一条私聊消息都会被读取)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 对应的 `message.*` 事件 | 接收公开频道、私有频道和群组私聊里的消息。在这些场景里,Bot 只对 **@ 了**它的消息做出响应。 |
|
||||
| `users:read` | 必需,这样 Multica 才能(通过 `bots.info`)核实你的两个 token 属于同一个 app。 |
|
||||
| `socket_mode_enabled: true` | Bot 通过 Socket Mode 向外连接——**无需任何公网 URL / request URL**。 |
|
||||
| `interactivity.is_enabled: false` | Multica 的提示是纯链接,不是按钮,所以不需要交互性。 |
|
||||
|
||||
这里**没有 OAuth 重定向 URL**,因为 BYO 不使用 OAuth。
|
||||
|
||||
<Callout type="info">
|
||||
想在 Slack 里用一个特定的名字?在创建之前改 `display_information.name` 和 `features.bot_user.display_name`(比如改成你智能体的名字),或者之后在 **App Home** 里编辑。Slack 是按 Bot 的**显示名(bot display name)**来展示它的,这个名字可以和 app 名不一样。
|
||||
</Callout>
|
||||
|
||||
### 2. 安装 app 并复制 Bot token
|
||||
|
||||
1. 在 app 的左侧导航里,打开 **Install App**(或 **OAuth & Permissions**)。
|
||||
2. 点击 **Install to Workspace** 并批准。
|
||||
3. 复制 **Bot User OAuth Token**——它以 `xoxb-` 开头。这就是你的 **Bot token**。
|
||||
|
||||
### 3. 创建 App-level token
|
||||
|
||||
App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建(它不属于 OAuth)。
|
||||
|
||||
1. 打开 **Basic Information → App-Level Tokens**,点击 **Generate Token and Scopes**。
|
||||
2. 随便起个名字。
|
||||
3. 点击 **Add Scope**,从列表里选 **`connections:write`**(这是一个选择器——选中它,不要手打)。
|
||||
4. 点击 **Generate**,然后复制这个 token——它以 `xapp-` 开头。这就是你的 **App-level token**。
|
||||
|
||||
### 4. 在 Multica 里连接它
|
||||
|
||||
1. 在 **Agents → _你的智能体_** 打开该智能体 → **Integrations** tab(或左侧栏的 **Integrations** 区块)。
|
||||
2. 点击 **Connect Slack**。
|
||||
3. 粘贴 **Bot token**(`xoxb-`)和 **App-level token**(`xapp-`),然后点击 **Connect**。
|
||||
4. 智能体显示 **Connected to Slack**。Bot 现在通过它自己的 Socket Mode 连接在监听了。
|
||||
|
||||
<Callout type="warning">
|
||||
这两个 token 必须来自**同一个** Slack app,而那个 app 恰好对应**一个**智能体。连接一个已经连到别的智能体或 workspace 的 app 会被拒绝。要把一个 app 挪到另一个智能体,先断开它;用一个**新的** app 重新连接某个智能体,会就地更新那个智能体的 Bot。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
要给**多个智能体**做这套设置?每个智能体都把整套流程走一遍——每个智能体都有自己的 Slack app 和自己的一对 token,它们会在你的 Slack workspace 里显示成各自独立的 Bot。
|
||||
</Callout>
|
||||
|
||||
## 这个集成能做什么
|
||||
|
||||
| 入口 | 行为 |
|
||||
|---|---|
|
||||
| **智能体 → Integrations** | 所有者和管理员能看到 **Connect Slack**;连接后它会变成一个 **Connected to Slack** 徽标,并带一个 **Disconnect** 操作。 |
|
||||
| **私聊 Bot** | 工作区成员直接给 Bot 发消息。这段对话会成为该智能体的一个 Multica [chat](/chat) 会话;每一条私聊消息都会被读取。 |
|
||||
| **频道里 @ 它** | 把 Bot 邀请进来(`/invite @your-bot`)再 @ 它。只有 @ 它的那条消息会被读取——Bot 不会监听整个频道。每个 @bot 的 **thread** 都是它自己的会话。 |
|
||||
| **`/issue` 命令** | 以 `/issue <标题>` 开头的消息(可在后面几行附上正文)会在工作区创建一个新的 Multica issue,记在你名下。 |
|
||||
| **回复** | 智能体的答复会被发回同一段私聊或 thread 里。 |
|
||||
|
||||
## 使用 Bot(成员)
|
||||
|
||||
### 第一条消息:绑定你的账号
|
||||
|
||||
第一次 @ 或私聊 Bot 时,它会回一条 **绑定你的账号** 提示。点开链接、登录 Multica,你的 Slack 身份就会绑定到你的 Multica 成员身份——正是这一步让智能体能以你的身份行事(比如 `/issue` 会把 issue 记在你名下)。这个链接是一次性的,大约 15 分钟后过期;再给 Bot 发条消息就能拿到一个新的。
|
||||
|
||||
<Callout type="warning">
|
||||
只有**工作区成员**才能使用 Bot。如果你不是成员,或者跳过了身份绑定,Bot 不会运行——你的消息会被丢弃(仅出于审计目的记录,不保存消息内容)。
|
||||
</Callout>
|
||||
|
||||
### 对话与 `/issue`
|
||||
|
||||
- **在频道里** —— Bot 不会自动加入。先运行一次 `/invite @your-bot`,然后 `@your-bot <你的消息>`。每次追问都要重新 @ 它一下(Bot 只读取 @ 了它的消息)。
|
||||
- **在私聊里** —— 从 Slack 侧栏的 **Apps** 区块打开 Bot 并直接给它发消息;不用 @。
|
||||
- **创建 issue** —— 发送 `/issue Fix the login redirect`;在标题后面再加几行就是描述。
|
||||
|
||||
## 管理与断开
|
||||
|
||||
工作区级别的管理在 **Settings → Integrations**:
|
||||
|
||||
- **Connected bots** 列出工作区里每个 Bot 以及它各自绑定的智能体(所有成员都能看到)。
|
||||
- **Disconnect** 仅限 **所有者 / 管理员**。它会让 Bot 停止接收 Slack 消息并拆掉它的连接;安装记录会保留以便审计,之后你可以重新连接。
|
||||
|
||||
## 权限
|
||||
|
||||
- **连接 / 断开** 需要工作区**所有者**或**管理员**。
|
||||
- **和 Bot 对话** 需要你是工作区成员且已绑定 Slack 身份。其余的人一律被丢弃。
|
||||
- 对于被丢弃的消息,绝不保存消息内容——只记录一个丢弃原因,用于审计。
|
||||
|
||||
## 自部署配置
|
||||
|
||||
在 Multica Cloud 上这个集成已经可用——可跳过本节。
|
||||
|
||||
自部署时,**在你设置好静态加密密钥之前,Slack 是关闭的**。这个密钥会在每个 app 的 bot token + app-level token 落库之前对其加密。BYO **不需要** OAuth client id/secret,也**不需要**部署级的 app token——每个安装用的都是管理员粘贴进来的那对 token。
|
||||
|
||||
1. 生成一个 32 字节的密钥并设置到 API 服务器:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
例如:`openssl rand -base64 32`。
|
||||
|
||||
2. 重启 API。在密钥设置好之前,**Settings → Integrations** 会显示一条「Slack integration not enabled」提示,**Connect Slack** 入口也会保持隐藏。
|
||||
|
||||
<Callout type="info">
|
||||
这个密钥必须正好解码出 32 字节——`openssl rand -base64 32` 就能做到。把它当成一个长期有效的密钥:轮换或丢失它会让已存储的 token 无法解密,迫使每个 Bot 重新连接。「绑定你的账号」链接是用你的 Web 应用地址(`MULTICA_APP_URL`,未设置时回退到 `FRONTEND_ORIGIN`)拼出来的——正常部署里这个值本来就有,不需要额外配置。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [聊天集成](/channels) —— channel 引擎、会话与授权是怎么运作的
|
||||
- [智能体](/agents) · [Chat](/chat) · [Issues](/issues)
|
||||
- [环境变量](/environment-variables) —— 完整的自部署配置参考
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { SlackBindPage } from "@multica/views/slack";
|
||||
|
||||
// /slack/bind?token=<raw> is the bot's "link your account" destination. Suspense
|
||||
// wraps useSearchParams per Next.js 15's CSR-bailout rule; the loading text
|
||||
// never paints in practice because the redemption page itself renders the
|
||||
// "redeeming…" state immediately.
|
||||
function SlackBindPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
return <SlackBindPage token={token} />;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SlackBindPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -293,26 +293,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "Detach sub-Issues, sturdier daemon reconnects, and friendlier attachment previews",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issues now have a Remove parent action, so you can detach a sub-Issue without first having to pick a different parent.",
|
||||
],
|
||||
improvements: [
|
||||
"The local daemon reconnects to Multica through a more resilient WebSocket flow with bounded backoff, so brief network drops recover smoothly instead of stalling.",
|
||||
"The daemon now bounds each runtime probe with its own timeout, so a single wedged CLI can no longer block every other runtime from coming online.",
|
||||
],
|
||||
fixes: [
|
||||
"Scheduled autopilots advance their next-run time the moment a run is dispatched, so a slow runner can no longer cause back-to-back duplicate dispatches.",
|
||||
"Attachment previews open correctly whether the URL redirects inside a frame, comes back from the same origin, or was uploaded locally — and local upload URLs are now preferred when available.",
|
||||
"When the failed-task handler unsticks an Issue, the Issue view refreshes immediately instead of waiting for a manual reload.",
|
||||
"Sticky Issue comment headers share the same background fade as the highlight, so settling on a comment no longer looks out of sync.",
|
||||
"Chat conversations refresh their message cache when reconnecting, so you no longer see stale messages right after coming back online.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
|
||||
@@ -269,26 +269,6 @@ export function createJaDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "バグ修正",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "サブ Issue の切り離し、より堅牢なデーモン再接続、どこからでも開ける添付プレビュー",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue のアクションに「親 Issue を解除」が追加され、別の親を選び直さなくても子 Issue を直接切り離せます。",
|
||||
],
|
||||
improvements: [
|
||||
"ローカル デーモンの WebSocket 再接続が、上限付きのバックオフを備えたより堅牢な流れに見直され、瞬断にもスムーズに復帰します。",
|
||||
"デーモンはランタイムのバージョン確認に個別のタイムアウトを設けるようになり、応答しない 1 つの CLI が他のランタイム起動を巻き込んで止めることがなくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"予約オートパイロットはディスパッチ直後に次回実行時刻を進めるようになり、遅いランナーが同じ実行を続けて送り出すことがなくなりました。",
|
||||
"添付プレビューは、フレーム内リダイレクト、同一オリジン、ローカル アップロードのいずれの場合も正しく開き、ローカル アップロード URL があるときはそちらを優先します。",
|
||||
"失敗タスク ハンドラーが詰まった Issue を解除すると、Issue 表示が即座に更新され、手動リロードが不要になりました。",
|
||||
"Issue コメントの sticky ヘッダーがハイライトのフェードと同じ背景遷移を共有し、固定切り替えの違和感がなくなりました。",
|
||||
"Chat の会話は再接続時にメッセージ キャッシュを更新するため、オンラインに戻った直後に古いメッセージが残らなくなりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
|
||||
@@ -268,26 +268,6 @@ export function createKoDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "버그 수정",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "하위 Issue 분리, 더 견고한 데몬 재연결, 어디서나 열리는 첨부 미리보기",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 액션에 '상위 Issue 해제'가 추가되어, 다른 상위를 먼저 고르지 않고도 하위 Issue를 즉시 분리할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"로컬 데몬이 더 견고한 WebSocket 흐름과 상한이 있는 백오프로 재연결해, 짧은 네트워크 단절에도 매끄럽게 복구됩니다.",
|
||||
"데몬이 각 런타임의 버전 점검에 별도 타임아웃을 두어, 멈춰 버린 단 하나의 CLI가 다른 런타임의 기동을 막지 못합니다.",
|
||||
],
|
||||
fixes: [
|
||||
"예약 오토파일럿은 디스패치되자마자 다음 실행 시각을 앞당겨, 느린 러너가 같은 실행을 중복으로 내보내지 않습니다.",
|
||||
"첨부 미리보기는 프레임 내 리다이렉트, 동일 출처, 로컬 업로드 어떤 경우에도 정상적으로 열리며, 로컬 업로드 URL이 있으면 그쪽을 우선 사용합니다.",
|
||||
"실패 작업 핸들러가 멈춘 Issue를 풀어 줄 때 화면이 즉시 갱신되어, 수동 새로고침이 필요 없습니다.",
|
||||
"Issue 댓글의 sticky 헤더가 하이라이트 페이드와 같은 배경 전환을 공유해, 고정 표시 전환이 더 이상 어색하지 않습니다.",
|
||||
"Chat 대화가 재연결 시 메시지 캐시를 새로 받아, 오프라인에서 돌아왔을 때 오래된 메시지가 남지 않습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
|
||||
@@ -293,26 +293,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "支持解除父子 Issue、守护进程重连更稳,附件预览处处可开",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 操作菜单新增「移除父级 Issue」,可以直接断开父子关系,不用先去挑一个新的父级。",
|
||||
],
|
||||
improvements: [
|
||||
"本地守护进程的 WebSocket 重连改为带上限的退避策略,短暂断网时恢复更顺滑,不再原地空转。",
|
||||
"守护进程在探测各个智能体运行时版本时加上了独立超时,单个卡死的 CLI 不会再连累其他运行时。",
|
||||
],
|
||||
fixes: [
|
||||
"定时 Autopilot 调度后会立即推进下一次运行时间,避免慢节点造成重复触发。",
|
||||
"附件预览在框架内重定向、同源资源、本地上传等场景下都能正常打开;有本地上传 URL 时会优先使用本地链接。",
|
||||
"失败任务处理器解开卡住的 Issue 时,前端视图会立即刷新,无需手动重新加载。",
|
||||
"Issue 评论吸顶头与高亮渐隐使用了同一套背景过渡,吸顶切换不再有错位感。",
|
||||
"Chat 在重新连上后会刷新消息缓存,掉线再回来时不再看到陈旧消息。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
|
||||
@@ -37,4 +37,83 @@ test.describe("Settings", () => {
|
||||
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole("button", { name: new RegExp(originalName) }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// Composio connect flow, fully mocked at the network boundary so it runs
|
||||
// without a configured COMPOSIO_API_KEY or a live Composio project. The
|
||||
// backend redirect is simulated by pointing the init endpoint's redirect_url
|
||||
// straight back at the settings page with ?connected=<slug> — exercising the
|
||||
// frontend's callback toast + connections refresh (MUL-3718) end to end.
|
||||
test("connecting a Composio toolkit shows a toast and refreshes the list", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspaceSlug = await loginAsDefault(page);
|
||||
const settingsUrl = `/${workspaceSlug}/settings?tab=integrations`;
|
||||
|
||||
// Stateful: connections is empty until the (mocked) connect flow lands.
|
||||
let connected = false;
|
||||
|
||||
await page.route("**/api/integrations/composio/toolkits", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ slug: "notion", name: "Notion", connectable: true },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/integrations/composio/connections", (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
connected
|
||||
? [
|
||||
{
|
||||
id: "conn-notion-1",
|
||||
toolkit_slug: "notion",
|
||||
status: "active",
|
||||
connected_at: new Date().toISOString(),
|
||||
last_used_at: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/integrations/composio/connect/init", (route) => {
|
||||
// Composio would 302 through its hosted consent and back to our callback,
|
||||
// which emits CallbackRedirect's slug-less shape:
|
||||
// `/settings?tab=integrations&connected=<slug>`. The web proxy's
|
||||
// legacy-route redirect then prepends the last workspace slug, landing on
|
||||
// the real settings route. Mock that exact backend shape (NOT the final
|
||||
// slugged URL) so the test exercises the same redirect path real users hit.
|
||||
connected = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
redirect_url: `/settings?tab=integrations&connected=notion`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(settingsUrl, { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Composio");
|
||||
|
||||
// Notion starts disconnected → click Connect.
|
||||
await page.getByRole("button", { name: /^Connect$/ }).first().click();
|
||||
|
||||
// Success toast from the simulated callback redirect.
|
||||
await expect(page.getByText("Connected").first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// List refreshed without a manual reload: the Notion card now offers
|
||||
// Disconnect, and the one-shot ?connected param has been stripped.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Disconnect/ }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page).not.toHaveURL(/connected=notion/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,6 @@ import type {
|
||||
UpdateAutopilotTriggerRequest,
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
AutopilotCollaboratorsResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
@@ -113,10 +112,9 @@ import type {
|
||||
BeginLarkInstallResponse,
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
ComposioToolkit,
|
||||
ComposioConnection,
|
||||
ComposioConnectInitResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
@@ -2112,22 +2110,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Grant a workspace member explicit write access to the autopilot. Both
|
||||
// grant and revoke return the full updated collaborator list so callers can
|
||||
// refresh without a second round-trip.
|
||||
async grantAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
|
||||
return this.fetch(`/api/autopilots/${id}/collaborators`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
|
||||
return this.fetch(`/api/autopilots/${id}/collaborators/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async triggerAutopilot(id: string): Promise<AutopilotRun> {
|
||||
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
|
||||
}
|
||||
@@ -2292,36 +2274,33 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Slack integration (MUL-3666)
|
||||
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
|
||||
// Composio integration (MUL-3720). All routes are user-scoped (a connection
|
||||
// belongs to a user, not a workspace), so none take a workspaceId.
|
||||
|
||||
/** Full Composio toolkit catalog, each annotated with `connectable`
|
||||
* (whether the project has an enabled auth config for it). */
|
||||
async listComposioToolkits(): Promise<ComposioToolkit[]> {
|
||||
return this.fetch(`/api/integrations/composio/toolkits`);
|
||||
}
|
||||
|
||||
// registerSlackBYO performs a bring-your-own-app install: the admin pastes the
|
||||
// bot token (xoxb-) + app-level token (xapp-) of the Slack app they created,
|
||||
// and the backend validates + persists it, returning the new installation.
|
||||
async registerSlackBYO(
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
body: RegisterSlackBYORequest,
|
||||
): Promise<SlackInstallation> {
|
||||
const search = new URLSearchParams({ agent_id: agentId });
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/slack/install/byo?${search.toString()}`, {
|
||||
/** The caller's active Composio connections. */
|
||||
async listComposioConnections(): Promise<ComposioConnection[]> {
|
||||
return this.fetch(`/api/integrations/composio/connections`);
|
||||
}
|
||||
|
||||
/** Starts a hosted Composio connect flow for a toolkit and returns the
|
||||
* redirect URL the browser should be sent to. */
|
||||
async beginComposioConnect(toolkitSlug: string): Promise<ComposioConnectInitResponse> {
|
||||
return this.fetch(`/api/integrations/composio/connect/init`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ toolkit_slug: toolkitSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
|
||||
/** Disconnects a Composio connection the caller owns. */
|
||||
async deleteComposioConnection(connectionId: string): Promise<void> {
|
||||
await this.fetch(`/api/integrations/composio/connections/${connectionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
|
||||
return this.fetch(`/api/slack/binding/redeem`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,10 +840,6 @@ const AutopilotListItemSchema = z.object({
|
||||
trigger_kinds: z.array(z.string()).optional(),
|
||||
next_run_at: z.string().nullable().optional(),
|
||||
last_run_status: z.string().nullable().optional(),
|
||||
// Per-caller write capability; absent on older servers (treated as unknown).
|
||||
can_write: z.boolean().optional(),
|
||||
// Narrower per-caller access-management capability (detail endpoint only).
|
||||
can_manage_access: z.boolean().optional(),
|
||||
}).loose();
|
||||
|
||||
export const ListAutopilotsResponseSchema = z.object({
|
||||
|
||||
@@ -104,30 +104,6 @@ export function useTriggerAutopilot() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrantAutopilotAccess() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, userId }: { autopilotId: string; userId: string }) =>
|
||||
api.grantAutopilotAccess(autopilotId, userId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeAutopilotAccess() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, userId }: { autopilotId: string; userId: string }) =>
|
||||
api.revokeAutopilotAccess(autopilotId, userId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAutopilotTrigger() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
@@ -14,17 +14,13 @@ export const chatKeys = {
|
||||
/** Full sessions list (active + archived); the dropdown splits locally. */
|
||||
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messagesAll: () => ["chat", "messages"] as const,
|
||||
messages: (sessionId: string) => [...chatKeys.messagesAll(), sessionId] as const,
|
||||
messagesPageAll: () => ["chat", "messages-page"] as const,
|
||||
messagesPage: (sessionId: string) => [...chatKeys.messagesPageAll(), sessionId] as const,
|
||||
pendingTaskAll: () => ["chat", "pending-task"] as const,
|
||||
pendingTask: (sessionId: string) => [...chatKeys.pendingTaskAll(), sessionId] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
messagesPage: (sessionId: string) => ["chat", "messages-page", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
|
||||
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
|
||||
/** Per-task execution messages — shared with issue agent cards. */
|
||||
taskMessagesAll: () => ["task-messages"] as const,
|
||||
taskMessages: (taskId: string) => [...chatKeys.taskMessagesAll(), taskId] as const,
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
};
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
1
packages/core/composio/index.ts
Normal file
1
packages/core/composio/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { composioKeys, composioToolkitsOptions, composioConnectionsOptions } from "./queries";
|
||||
26
packages/core/composio/queries.ts
Normal file
26
packages/core/composio/queries.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
/** Query-key namespace for Composio integration data. */
|
||||
export const composioKeys = {
|
||||
all: ["composio"] as const,
|
||||
toolkits: () => [...composioKeys.all, "toolkits"] as const,
|
||||
connections: () => [...composioKeys.all, "connections"] as const,
|
||||
};
|
||||
|
||||
/** The full Composio toolkit catalog (with per-toolkit `connectable`). The
|
||||
* catalog changes rarely, so a long staleTime avoids refetching it every time
|
||||
* the Settings tab mounts. */
|
||||
export const composioToolkitsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: composioKeys.toolkits(),
|
||||
queryFn: () => api.listComposioToolkits(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
/** The current user's active Composio connections. */
|
||||
export const composioConnectionsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: composioKeys.connections(),
|
||||
queryFn: () => api.listComposioConnections(),
|
||||
});
|
||||
@@ -85,8 +85,8 @@
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./lark/queries": "./lark/queries.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./slack/queries": "./slack/queries.ts",
|
||||
"./composio": "./composio/index.ts",
|
||||
"./composio/queries": "./composio/queries.ts",
|
||||
"./feedback": "./feedback/index.ts",
|
||||
"./feedback/mutations": "./feedback/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
|
||||
@@ -102,9 +102,9 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
rerender({ ws: ws2 });
|
||||
|
||||
// Should have called invalidateQueries for all workspace-scoped keys
|
||||
// (15 workspace-scoped + 6 per-issue prefixes + 4 per-chat prefixes
|
||||
// + 1 workspaceKeys.list() + 1 cross-workspace inbox unread summary = 27 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(27);
|
||||
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
|
||||
// + 1 cross-workspace inbox unread summary = 23 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(23);
|
||||
});
|
||||
|
||||
it("does not re-invalidate when rerendered with the same ws instance", () => {
|
||||
@@ -164,26 +164,4 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
expect(calls).toContainEqual(["issues", "attachments"]);
|
||||
expect(calls).toContainEqual(["issues", "tasks"]);
|
||||
});
|
||||
|
||||
it("invalidates per-chat-session caches (no wsId in key) on ws instance change", () => {
|
||||
// These keys are not under the ["chat", wsId] prefix, so they need their
|
||||
// own recovery invalidation when reconnecting after missed chat/task events.
|
||||
const ws1 = createMockWs();
|
||||
const { rerender } = renderHook(
|
||||
({ ws }) => useRealtimeSync(ws, stores),
|
||||
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
invalidateSpy.mockClear();
|
||||
rerender({ ws: null });
|
||||
|
||||
const ws2 = createMockWs();
|
||||
rerender({ ws: ws2 });
|
||||
|
||||
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
|
||||
expect(calls).toContainEqual(["chat", "messages"]);
|
||||
expect(calls).toContainEqual(["chat", "messages-page"]);
|
||||
expect(calls).toContainEqual(["chat", "pending-task"]);
|
||||
expect(calls).toContainEqual(["task-messages"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "../agents/queries";
|
||||
import { githubKeys } from "../github/queries";
|
||||
import { larkKeys } from "../lark/queries";
|
||||
import { slackKeys } from "../slack/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -340,14 +339,6 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.usageAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachmentsAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.tasksAll() });
|
||||
// Per-chat-session caches are also keyed without wsId, so the
|
||||
// chatKeys.all(wsId) prefix above only reaches session lists / aggregates.
|
||||
// Message streams rely on WS invalidation with staleTime: Infinity; recover
|
||||
// sessions that missed chat/task events while the socket was disconnected.
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messagesAll() });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messagesPageAll() });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTaskAll() });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.taskMessagesAll() });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
}
|
||||
|
||||
@@ -493,10 +484,6 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: larkKeys.installations(wsId) });
|
||||
},
|
||||
slack_installation: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
},
|
||||
pull_request: () => {
|
||||
// PR list is keyed by issue id, not workspace, so we invalidate all
|
||||
// PR queries — the open issue detail page will refetch its own list.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { slackKeys, slackInstallationsOptions } from "./queries";
|
||||
@@ -1,18 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
/** Query key namespace for everything Slack-installation-related. Realtime
|
||||
* sync invalidates `installations(wsId)` on `slack_installation:*` events so
|
||||
* the Settings panel updates without a manual refetch (e.g. after the OAuth
|
||||
* callback lands the install in another tab / the system browser). */
|
||||
export const slackKeys = {
|
||||
all: (wsId: string) => ["slack", wsId] as const,
|
||||
installations: (wsId: string) => [...slackKeys.all(wsId), "installations"] as const,
|
||||
};
|
||||
|
||||
export const slackInstallationsOptions = (wsId: string) =>
|
||||
queryOptions({
|
||||
queryKey: slackKeys.installations(wsId),
|
||||
queryFn: () => api.listSlackInstallations(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
@@ -49,16 +49,6 @@ export interface Autopilot {
|
||||
// List endpoint returns []; only the detail endpoint populates this.
|
||||
// Treat undefined as empty on older servers.
|
||||
subscribers?: AutopilotSubscriber[];
|
||||
// Whether the requesting user may edit / delete / trigger / manage this
|
||||
// autopilot (creator, workspace owner/admin, or a granted collaborator).
|
||||
// Present on list and detail responses; absent on older servers — treat
|
||||
// undefined as "unknown" rather than "denied" (the server is the gate).
|
||||
can_write?: boolean;
|
||||
// Whether the requesting user may manage the collaborator (access) list —
|
||||
// narrower than can_write: held only by the creator and workspace
|
||||
// owners/admins, NOT by granted collaborators. Detail-endpoint-only; absent
|
||||
// on older servers (fall back to can_write).
|
||||
can_manage_access?: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookEventFilter {
|
||||
@@ -72,19 +62,6 @@ export interface AutopilotSubscriber {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// A workspace member explicitly granted write access to an autopilot, on top
|
||||
// of the implicit "creator ∪ owner/admin" set. Members-only for now.
|
||||
export interface AutopilotCollaborator {
|
||||
user_type: "member";
|
||||
user_id: string;
|
||||
granted_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AutopilotCollaboratorsResponse {
|
||||
collaborators: AutopilotCollaborator[];
|
||||
}
|
||||
|
||||
export interface AutopilotTrigger {
|
||||
id: string;
|
||||
autopilot_id: string;
|
||||
@@ -187,9 +164,6 @@ export interface ListAutopilotsResponse {
|
||||
export interface GetAutopilotResponse {
|
||||
autopilot: Autopilot;
|
||||
triggers: AutopilotTrigger[];
|
||||
// Members explicitly granted write access. Absent on older servers — treat
|
||||
// undefined as an empty list.
|
||||
collaborators?: AutopilotCollaborator[];
|
||||
}
|
||||
|
||||
export interface ListAutopilotRunsResponse {
|
||||
|
||||
36
packages/core/types/composio.ts
Normal file
36
packages/core/types/composio.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** A Composio toolkit as surfaced by GET /api/integrations/composio/toolkits.
|
||||
*
|
||||
* Wire shape mirrors `ComposioToolkitResponse` in
|
||||
* `server/internal/handler/integrations_composio.go`. New fields the backend
|
||||
* adds later MUST stay optional so older desktop builds keep parsing — see
|
||||
* CLAUDE.md → API Response Compatibility. */
|
||||
export interface ComposioToolkit {
|
||||
slug: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
category?: string;
|
||||
/** Whether the project has an enabled auth config for this toolkit. When
|
||||
* false the UI must not offer a working Connect button — BeginConnect would
|
||||
* 400 with "toolkit not supported". */
|
||||
connectable: boolean;
|
||||
}
|
||||
|
||||
/** A user's Composio connected account, as returned by
|
||||
* GET /api/integrations/composio/connections. Mirrors
|
||||
* `ComposioConnectionResponse` server-side. */
|
||||
export interface ComposioConnection {
|
||||
id: string;
|
||||
toolkit_slug: string;
|
||||
/** Connection lifecycle state. `expired` surfaces a Reconnect affordance in
|
||||
* the UI; the backend only starts emitting it once Stage 4 webhook handling
|
||||
* lands (MUL-3719), but the client renders the branch ahead of that. */
|
||||
status: "active" | "expired" | "revoked" | string;
|
||||
connected_at: string;
|
||||
last_used_at?: string | null;
|
||||
}
|
||||
|
||||
/** Response of POST /api/integrations/composio/connect/init — the hosted
|
||||
* Composio Connect Link the browser is redirected to. */
|
||||
export interface ComposioConnectInitResponse {
|
||||
redirect_url: string;
|
||||
}
|
||||
@@ -120,11 +120,10 @@ export type {
|
||||
RedeemLarkBindingTokenResponse,
|
||||
} from "./lark";
|
||||
export type {
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
} from "./slack";
|
||||
ComposioToolkit,
|
||||
ComposioConnection,
|
||||
ComposioConnectInitResponse,
|
||||
} from "./composio";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
@@ -132,8 +131,6 @@ export type {
|
||||
AutopilotAssigneeType,
|
||||
AutopilotSubscriber,
|
||||
AutopilotSubscriberInput,
|
||||
AutopilotCollaborator,
|
||||
AutopilotCollaboratorsResponse,
|
||||
AutopilotTrigger,
|
||||
AutopilotTriggerKind,
|
||||
AutopilotRun,
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/** A Slack bot installation bound to a single Multica agent (MUL-3666).
|
||||
*
|
||||
* Wire shape mirrors `SlackInstallationResponse` in
|
||||
* `server/internal/handler/slack.go`. New fields the backend adds in the
|
||||
* future MUST default to optional so older desktop builds keep parsing the
|
||||
* response — see CLAUDE.md → API Compatibility. */
|
||||
export interface SlackInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
/** The Slack workspace (team) id this bot is installed in. */
|
||||
team_id: string;
|
||||
/** The installed bot's Slack user id. */
|
||||
bot_user_id: string;
|
||||
installer_user_id: string;
|
||||
status: "active" | "revoked" | string;
|
||||
installed_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListSlackInstallationsResponse {
|
||||
installations: SlackInstallation[];
|
||||
/** Whether the deployment has the at-rest secret key configured. When false
|
||||
* the connect entry points are hidden and the panel renders an "ask the
|
||||
* operator to enable Slack" state. */
|
||||
configured: boolean;
|
||||
/** Whether the install path is available (true whenever Slack is configured,
|
||||
* i.e. the at-rest key is set — a bring-your-own-app install needs no hosted
|
||||
* OAuth credentials). Kept as a separate flag for forward/backward compat;
|
||||
* optional so an older desktop build that predates it treats it as off. */
|
||||
install_supported?: boolean;
|
||||
}
|
||||
|
||||
/** Request body for a bring-your-own-app (BYO) install: the two tokens the
|
||||
* admin pastes from the Slack app they created. The backend validates that both
|
||||
* belong to the same Slack app (and that the app token is live) before
|
||||
* persisting, then returns the created SlackInstallation. */
|
||||
export interface RegisterSlackBYORequest {
|
||||
bot_token: string;
|
||||
app_token: string;
|
||||
}
|
||||
|
||||
/** Post-redemption echo: the Slack user id the token carried is now bound to
|
||||
* the logged-in Multica user in this workspace/installation. */
|
||||
export interface RedeemSlackBindingTokenResponse {
|
||||
workspace_id: string;
|
||||
installation_id: string;
|
||||
slack_user_id: string;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentTask } from "@multica/core/types";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
|
||||
// The hover card renders one row per task and counts tasks, so its header
|
||||
// must describe tasks — not agents. A single agent can run several tasks at
|
||||
// once (e.g. the workspace chip reads "2 working" for two unique agents while
|
||||
// the card lists three task rows). An agent-worded header here would print
|
||||
// "3 agents working" for those two agents, contradicting the chip. MUL-3872.
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getActorName: (_type: string, id: string) =>
|
||||
({ "agent-1": "Niko", "agent-2": "J" })[id] ?? "Unknown Agent",
|
||||
getActorInitials: (_type: string, id: string) =>
|
||||
({ "agent-1": "NI", "agent-2": "J" })[id] ?? "UA",
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The card only reads these query results for avatars / availability, never
|
||||
// for the header count, so empty lists keep the row chrome inert while the
|
||||
// header still derives from the task array.
|
||||
vi.mock("@multica/core/runtimes/queries", () => ({
|
||||
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
agentListOptions: () => ({ queryKey: ["agents"] }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
deriveAgentAvailability: () => "online",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ name }: { name: string }) => (
|
||||
<span data-testid="actor-avatar">{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return { ...actual, useQuery: () => ({ data: [] }) };
|
||||
});
|
||||
|
||||
import { AgentActivityHoverContent } from "./agent-activity-hover-content";
|
||||
|
||||
function makeTask(overrides: Partial<AgentTask>): AgentTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "issue-1",
|
||||
status: "running",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: "2026-06-08T08:00:00Z",
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-06-08T08:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AgentActivityHoverContent", () => {
|
||||
// Two agents, three running tasks (Niko runs two at once). The header must
|
||||
// count the three task rows, not the two agents.
|
||||
const threeTasksTwoAgents = [
|
||||
makeTask({ id: "t1", agent_id: "agent-1" }),
|
||||
makeTask({ id: "t2", agent_id: "agent-1" }),
|
||||
makeTask({ id: "t3", agent_id: "agent-2" }),
|
||||
];
|
||||
|
||||
it("counts tasks, not agents, in the header", () => {
|
||||
renderWithI18n(<AgentActivityHoverContent tasks={threeTasksTwoAgents} />);
|
||||
|
||||
expect(screen.getByText("3 tasks working")).toBeInTheDocument();
|
||||
// The old agent-worded copy would have read "3 agents working" here and
|
||||
// disagreed with the chip's unique-agent count.
|
||||
expect(screen.queryByText(/agents? working/)).not.toBeInTheDocument();
|
||||
// One row per task — three avatars for three tasks.
|
||||
expect(screen.getAllByTestId("actor-avatar")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("uses the singular task copy for a single task", () => {
|
||||
renderWithI18n(<AgentActivityHoverContent tasks={[makeTask({})]} />);
|
||||
|
||||
expect(screen.getByText("1 task working")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the requested Chinese task copy", () => {
|
||||
renderWithI18n(<AgentActivityHoverContent tasks={threeTasksTwoAgents} />, {
|
||||
locale: "zh-Hans",
|
||||
});
|
||||
|
||||
expect(screen.getByText("3 个 task 工作中")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -61,11 +61,7 @@ export function AgentActivityHoverContent({
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{/* One row per task, so count tasks — not agents. A single agent can
|
||||
run several tasks at once, so an agent-worded header here would
|
||||
disagree with the workspace chip's unique-agent count (e.g. chip
|
||||
"2 working" but header "3 agents working"). */}
|
||||
{t(($) => $.agent_activity.hover_header_tasks, { count: tasks.length })}
|
||||
{t(($) => $.agent_activity.hover_header, { count: tasks.length })}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{tasks.map((task) => {
|
||||
|
||||
@@ -47,7 +47,6 @@ import { SkillAttach } from "./inspector/skill-attach";
|
||||
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
|
||||
import { VisibilityPicker } from "./inspector/visibility-picker";
|
||||
import { LarkAgentBindButton } from "../../settings/components/lark-tab";
|
||||
import { SlackAgentBindButton } from "../../settings/components/slack-tab";
|
||||
|
||||
interface InspectorProps {
|
||||
agent: Agent;
|
||||
@@ -216,12 +215,13 @@ export function AgentDetailInspector({
|
||||
</div>
|
||||
|
||||
{/* Integrations — surfaces external-channel bind entry points
|
||||
(Lark + Slack today; Discord in the future). Each bind button
|
||||
self-hides when its server-side install capability gate is
|
||||
closed, so this section may render empty on deployments without
|
||||
a configured channel — that's intentional and matches the
|
||||
"don't surface a flow that will fail" guarantee. We only mount
|
||||
it for editors: viewers shouldn't see a CTA they can't action. */}
|
||||
(Lark Bot today; Slack / Discord in the future). The bind
|
||||
button self-hides when the server-side device-flow install
|
||||
capability gate is closed, so this section may render empty
|
||||
on deployments without a configured Lark app — that's
|
||||
intentional and matches the "don't surface a flow that will
|
||||
fail" guarantee. We only mount it for editors: viewers
|
||||
shouldn't see a CTA they can't action. */}
|
||||
{canEdit && (
|
||||
<div className="flex flex-col px-5 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -235,11 +235,6 @@ export function AgentDetailInspector({
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
<SlackAgentBindButton
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -182,9 +182,8 @@ export function AgentListToolbar({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-12 shrink-0 overflow-x-auto px-5 [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex h-full w-max min-w-full items-center justify-between gap-2">
|
||||
{/* Left: scope buttons + result count. Scope mixes the ownership lens
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-2 px-5">
|
||||
{/* Left: scope buttons + result count. Scope mixes the ownership lens
|
||||
(mine/all) with the archived lifecycle stage; no search box (scope
|
||||
partitions the small set). Button styling and the <md dropdown
|
||||
collapse follow the issues header's scope buttons. */}
|
||||
@@ -547,7 +546,6 @@ export function AgentListToolbar({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,9 +45,6 @@ vi.mock("../../common/actor-issues-panel", () => ({
|
||||
const larkListingRef = vi.hoisted(() => ({
|
||||
current: { installations: [] as unknown[], configured: false },
|
||||
}));
|
||||
const slackListingRef = vi.hoisted(() => ({
|
||||
current: { installations: [] as unknown[], configured: false },
|
||||
}));
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
@@ -57,12 +54,6 @@ vi.mock("@multica/core/lark", () => ({
|
||||
queryFn: () => Promise.resolve(larkListingRef.current),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: () => Promise.resolve(slackListingRef.current),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AgentOverviewPane } from "./agent-overview-pane";
|
||||
|
||||
@@ -128,7 +119,6 @@ function renderPane(runtimes: AgentRuntime[]) {
|
||||
|
||||
beforeEach(() => {
|
||||
larkListingRef.current = { installations: [], configured: false };
|
||||
slackListingRef.current = { installations: [], configured: false };
|
||||
});
|
||||
|
||||
describe("AgentOverviewPane MCP tab visibility", () => {
|
||||
@@ -173,19 +163,9 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Integrations tab when only Slack is configured (Lark off)", async () => {
|
||||
// Regression: the tab gate must consider Slack too, not just Lark —
|
||||
// a Slack-only deployment was hiding the tab (and its bind entry).
|
||||
slackListingRef.current = { installations: [], configured: true };
|
||||
renderPane([makeRuntime("claude")]);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /^Integrations$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Integrations tab when neither Lark nor Slack is configured", () => {
|
||||
// Default refs are configured:false; the tab must not appear on
|
||||
// deployments without either integration, the common case.
|
||||
it("hides the Integrations tab when Lark is not configured", () => {
|
||||
// Default ref is configured:false; the tab must not appear on
|
||||
// deployments without the integration, which are the common case.
|
||||
renderPane([makeRuntime("claude")]);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^Integrations$/i }),
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
import { providerSupportsMcpConfig } from "@multica/core/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { larkInstallationsOptions } from "@multica/core/lark";
|
||||
import { slackInstallationsOptions } from "@multica/core/slack";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -142,24 +141,16 @@ export function AgentOverviewPane({
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const larkConfigured = larkListing?.configured === true;
|
||||
const { data: slackListing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const slackConfigured = slackListing?.configured === true;
|
||||
// The Integrations tab appears once EITHER channel is wired on the
|
||||
// deployment, so a Slack-only deployment (no Lark) still surfaces it.
|
||||
const integrationsConfigured = larkConfigured || slackConfigured;
|
||||
|
||||
// The MCP tab is only shown when the agent's runtime backend actually
|
||||
// consumes mcp_config — see providerSupportsMcpConfig. We default to
|
||||
// showing it when the runtime row hasn't loaded yet so a slow fetch
|
||||
// can't transiently flicker the tab off and then on.
|
||||
//
|
||||
// The Integrations tab appears once the deployment has Lark OR Slack wired
|
||||
// The Integrations tab only appears once the deployment has Lark wired
|
||||
// (configured). Unlike MCP we default to HIDING while the listing loads:
|
||||
// deployments without either channel are the common case, so flashing the
|
||||
// tab on then off would be the worse flicker.
|
||||
// deployments without Lark are the common case, so flashing the tab on
|
||||
// then off would be the worse flicker.
|
||||
//
|
||||
// The Runtime Config tab is openclaw-only today (gateway mode lives there,
|
||||
// issue #3260). Other providers' runtime_config is freeform JSONB that no
|
||||
@@ -170,11 +161,11 @@ export function AgentOverviewPane({
|
||||
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
|
||||
return detailTabs.filter((tab) => {
|
||||
if (tab.id === "mcp_config") return showMcp;
|
||||
if (tab.id === "integrations") return integrationsConfigured;
|
||||
if (tab.id === "integrations") return larkConfigured;
|
||||
if (tab.id === "runtime_config") return showRuntimeConfig;
|
||||
return true;
|
||||
});
|
||||
}, [runtime, integrationsConfigured]);
|
||||
}, [runtime, larkConfigured]);
|
||||
|
||||
// If the active tab disappears (e.g. user just switched the agent's
|
||||
// runtime to one that doesn't read mcp_config), fall back to Activity
|
||||
|
||||
@@ -138,7 +138,7 @@ export function AgentRowActions({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $.row.actions_aria)}
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-100 @2xl:opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/row:opacity-100 data-popup-open:bg-accent data-popup-open:opacity-100 data-popup-open:text-accent-foreground"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/row:opacity-100 data-popup-open:bg-accent data-popup-open:opacity-100 data-popup-open:text-accent-foreground"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -91,8 +91,8 @@ import { useT } from "../../i18n";
|
||||
// the TWO-LINE form: avatar left, name + description right, 64px tall —
|
||||
// the documented exception to the single-line management-list rule.
|
||||
const GRID_COLS =
|
||||
"grid-cols-[0.75rem_minmax(120px,1fr)_var(--agc-status-mobile)_1.75rem_0.75rem] " +
|
||||
"@2xl:grid-cols-[0.75rem_1rem_minmax(200px,1fr)_var(--agc-status-desktop)_var(--agc-owner)_var(--agc-runtime)_var(--agc-lastactive)_var(--agc-runs)_var(--agc-model)_var(--agc-created)_1.75rem_0.75rem]";
|
||||
"grid-cols-[0.75rem_1rem_minmax(120px,1fr)_var(--agc-status)_1.75rem_0.75rem] " +
|
||||
"@2xl:grid-cols-[0.75rem_1rem_minmax(200px,1fr)_var(--agc-status)_var(--agc-owner)_var(--agc-runtime)_var(--agc-lastactive)_var(--agc-runs)_var(--agc-model)_var(--agc-created)_1.75rem_0.75rem]";
|
||||
|
||||
// Two-line rows; the virtualizer's fixed-size contract.
|
||||
const ROW_HEIGHT = 64;
|
||||
@@ -128,8 +128,7 @@ function columnTrackVars(
|
||||
0,
|
||||
);
|
||||
return {
|
||||
"--agc-status-mobile": isVisible("status") ? "96px" : "0px",
|
||||
"--agc-status-desktop": width("status"),
|
||||
"--agc-status": width("status"),
|
||||
"--agc-owner": width("owner"),
|
||||
"--agc-runtime": width("runtime"),
|
||||
"--agc-lastactive": width("lastActive"),
|
||||
@@ -291,7 +290,7 @@ function CheckboxCell({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ListGridCell className="hidden justify-center px-0 @2xl:flex">
|
||||
<ListGridCell className="justify-center px-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
@@ -489,7 +488,7 @@ function AgentListHeader({
|
||||
const anySelected = allSelected || someSelected;
|
||||
return (
|
||||
<ListGridHeader>
|
||||
<div className="hidden items-center justify-center @2xl:flex">
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={allSelected}
|
||||
@@ -585,7 +584,7 @@ function LoadingSkeleton() {
|
||||
)}
|
||||
>
|
||||
<ListGridHeader>
|
||||
<span aria-hidden="true" className="hidden @2xl:inline" />
|
||||
<span aria-hidden="true" />
|
||||
<ListGridHeaderCell>
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</ListGridHeaderCell>
|
||||
@@ -610,7 +609,7 @@ function LoadingSkeleton() {
|
||||
</ListGridHeader>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<ListGridRow key={i} className="h-16 hover:bg-transparent">
|
||||
<span aria-hidden="true" className="hidden @2xl:inline" />
|
||||
<span aria-hidden="true" />
|
||||
<ListGridCell className="gap-3">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
|
||||
@@ -35,7 +35,6 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
if (key.includes("installations")) return { data: installationsRef.current };
|
||||
return { data: undefined };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
@@ -54,13 +53,6 @@ vi.mock("@multica/core/lark", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(sel?: (s: { user: { id: string } }) => unknown) =>
|
||||
@@ -76,14 +68,6 @@ vi.mock("../../../settings/components/lark-tab", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// SlackAgentBindButton is the shared bind entry covered in slack-tab.test.tsx;
|
||||
// here it is a marker so the tests assert branch selection, not the OAuth flow.
|
||||
vi.mock("../../../settings/components/slack-tab", () => ({
|
||||
SlackAgentBindButton: ({ agentId }: { agentId: string }) => (
|
||||
<div data-testid="slack-bind-button" data-agent-id={agentId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { IntegrationsTab } from "./integrations-tab";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
@@ -134,12 +118,11 @@ function resetFixtures() {
|
||||
describe("IntegrationsTab", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("renders the shared bind entry for both platforms for an owner when configured and supported", () => {
|
||||
it("renders the shared bind entry for an owner when Lark is configured and supported", () => {
|
||||
renderTab(<IntegrationsTab agent={agent} />);
|
||||
expect(screen.getByText("Lark")).toBeTruthy();
|
||||
expect(screen.getByText("Slack")).toBeTruthy();
|
||||
expect(screen.getByTestId("lark-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
|
||||
expect(screen.getByTestId("slack-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
|
||||
const button = screen.getByTestId("lark-bind-button");
|
||||
expect(button.getAttribute("data-agent-id")).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("shows the coming-soon notice when the install transport is not wired", () => {
|
||||
@@ -164,16 +147,13 @@ describe("IntegrationsTab", () => {
|
||||
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("points members at Settings with one role notice (not per-platform) when they can't manage", () => {
|
||||
it("points members at Settings instead of a dead button when they can't manage", () => {
|
||||
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
||||
renderTab(<IntegrationsTab agent={agent} />);
|
||||
// The role gate is hoisted above the per-platform sections, so the notice
|
||||
// appears exactly once and neither bind entry renders.
|
||||
expect(
|
||||
screen.getByText(/Only workspace owners and admins can connect an agent/i),
|
||||
screen.getByText(/Only workspace owners and admins can bind a Lark Bot/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
|
||||
expect(screen.queryByTestId("slack-bind-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the bind entry (not coming-soon) when installs are unavailable but the agent is already bound", () => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MessagesSquare, Webhook } from "lucide-react";
|
||||
import { Webhook } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { larkInstallationsOptions } from "@multica/core/lark";
|
||||
import { slackInstallationsOptions } from "@multica/core/slack";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { LarkAgentBindButton } from "../../../settings/components/lark-tab";
|
||||
import { SlackAgentBindButton } from "../../../settings/components/slack-tab";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
/**
|
||||
@@ -39,10 +37,6 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
...larkInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: slackListing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: members = [] } = useQuery({
|
||||
...memberListOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
@@ -58,30 +52,6 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
(inst) => inst.agent_id === agent.id && inst.status === "active",
|
||||
) ?? false;
|
||||
|
||||
const slackConfigured = slackListing?.configured === true;
|
||||
const slackInstallSupported = slackListing?.install_supported === true;
|
||||
const slackHasActiveInstall =
|
||||
slackListing?.installations.some(
|
||||
(inst) => inst.agent_id === agent.id && inst.status === "active",
|
||||
) ?? false;
|
||||
|
||||
// Install / manage is gated on workspace owner/admin for every platform, so
|
||||
// the role notice is hoisted above the per-platform sections — one note
|
||||
// instead of repeating it under each integration. Members can still view
|
||||
// connected bots in the (member-visible) Settings → Integrations listing.
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.intro)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.members_note)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -108,6 +78,14 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.lark.not_enabled_title)}
|
||||
</p>
|
||||
) : !canManage ? (
|
||||
// The backend gates install / manage on workspace owner/admin.
|
||||
// Members can still view connected bots in the (member-visible)
|
||||
// Settings listing, so point them there rather than show a dead
|
||||
// button.
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.members_note)}
|
||||
</p>
|
||||
) : !installSupported && !hasActiveInstall ? (
|
||||
// Key is set but the device-flow transport isn't wired in this
|
||||
// build — a fresh scan would fail at the post-poll bot-info step,
|
||||
@@ -129,39 +107,6 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-sm font-medium">{ts(($) => $.slack.section_title)}</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{ts(($) => $.slack.page_description)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-4 py-3">
|
||||
{!slackConfigured ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.slack.not_enabled_title)}
|
||||
</p>
|
||||
) : !slackInstallSupported && !slackHasActiveInstall ? (
|
||||
// Secret key is set but the OAuth client credentials aren't, so a
|
||||
// fresh "Connect Slack" would 503. Surface the "coming soon" notice
|
||||
// instead of a broken CTA; an already-bound agent still renders.
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">{ts(($) => $.slack.preview_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.slack.preview_description)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<SlackAgentBindButton agentId={agent.id} agentName={agent.name} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import {
|
||||
Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil,
|
||||
Ban, ChevronDown, ChevronRight,
|
||||
Webhook, Copy, Check, RotateCw, Users,
|
||||
Webhook, Copy, Check, RotateCw,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
|
||||
@@ -62,7 +62,6 @@ import type { AgentTask } from "@multica/core/types/agent";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
import { ManageAccessDialog } from "./manage-access-dialog";
|
||||
import { WebhookPayloadPreview } from "./webhook-payload-preview";
|
||||
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
@@ -257,7 +256,7 @@ function SkippedRunsGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrigger; autopilotId: string; canWrite: boolean }) {
|
||||
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
|
||||
const { t } = useT("autopilots");
|
||||
const deleteTrigger = useDeleteAutopilotTrigger();
|
||||
const rotateToken = useRotateAutopilotTriggerWebhookToken();
|
||||
@@ -330,7 +329,7 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
|
||||
// — keep it pinned to the row's top-right corner. Without this the
|
||||
// trash icon visually floats above the URL action buttons because the
|
||||
// outer flex uses `items-start`.
|
||||
const deleteButton = canWrite ? (
|
||||
const deleteButton = (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -340,7 +339,7 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null;
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
|
||||
@@ -387,18 +386,16 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setRotateOpen(true)}
|
||||
title={t(($) => $.trigger_row.rotate_url)}
|
||||
disabled={rotateToken.isPending}
|
||||
>
|
||||
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setRotateOpen(true)}
|
||||
title={t(($) => $.trigger_row.rotate_url)}
|
||||
disabled={rotateToken.isPending}
|
||||
>
|
||||
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
|
||||
</Button>
|
||||
{deleteButton}
|
||||
</div>
|
||||
)}
|
||||
@@ -635,7 +632,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
|
||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [accessDialogOpen, setAccessDialogOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -687,15 +683,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
}
|
||||
|
||||
const { autopilot, triggers } = data;
|
||||
const collaborators = data.collaborators ?? [];
|
||||
// Treat an absent can_write (older server) as "allowed" — the backend is the
|
||||
// real gate, so the UI only hides controls when the server explicitly says
|
||||
// the caller cannot write.
|
||||
const canWrite = autopilot.can_write !== false;
|
||||
// Managing the access list is narrower than write: granted collaborators can
|
||||
// edit/run but cannot grant/revoke. Fall back to canWrite when the server
|
||||
// doesn't send the field (older backend).
|
||||
const canManageAccess = autopilot.can_manage_access ?? canWrite;
|
||||
|
||||
const handleRunNow = async () => {
|
||||
try {
|
||||
@@ -758,38 +745,30 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<>
|
||||
{canManageAccess && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAccessDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.manage_access)}>
|
||||
<Users className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{t(($) => $.detail.manage_access)}</span>
|
||||
</Button>
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.edit)}>
|
||||
<Pencil className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{t(($) => $.detail.edit)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunNow}
|
||||
disabled={autopilot.status !== "active" || triggerAutopilot.isPending}
|
||||
className="px-2 sm:px-2.5"
|
||||
aria-label={triggerAutopilot.isPending ? t(($) => $.detail.running) : t(($) => $.detail.run_now)}
|
||||
>
|
||||
{triggerAutopilot.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 sm:mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.edit)}>
|
||||
<Pencil className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{t(($) => $.detail.edit)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunNow}
|
||||
disabled={autopilot.status !== "active" || triggerAutopilot.isPending}
|
||||
className="px-2 sm:px-2.5"
|
||||
aria-label={triggerAutopilot.isPending ? t(($) => $.detail.running) : t(($) => $.detail.run_now)}
|
||||
>
|
||||
{triggerAutopilot.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 sm:mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{triggerAutopilot.isPending
|
||||
? t(($) => $.detail.running)
|
||||
: t(($) => $.detail.run_now)}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
<span className="hidden sm:inline">
|
||||
{triggerAutopilot.isPending
|
||||
? t(($) => $.detail.running)
|
||||
: t(($) => $.detail.run_now)}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -889,12 +868,10 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t(($) => $.detail.section_triggers)}
|
||||
</h2>
|
||||
{canWrite && (
|
||||
<Button size="sm" variant="outline" onClick={() => setTriggerDialogOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t(($) => $.detail.add_trigger)}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setTriggerDialogOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t(($) => $.detail.add_trigger)}
|
||||
</Button>
|
||||
</div>
|
||||
{triggers.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
@@ -903,7 +880,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{triggers.map((trig) => (
|
||||
<TriggerRow key={trig.id} trigger={trig} autopilotId={autopilotId} canWrite={canWrite} />
|
||||
<TriggerRow key={trig.id} trigger={trig} autopilotId={autopilotId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -942,17 +919,15 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
{canWrite && (
|
||||
<section className="space-y-3 pt-4 border-t">
|
||||
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">
|
||||
{t(($) => $.detail.section_danger)}
|
||||
</h2>
|
||||
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirmOpen(true)}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
{t(($) => $.detail.delete_button)}
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
<section className="space-y-3 pt-4 border-t">
|
||||
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">
|
||||
{t(($) => $.detail.section_danger)}
|
||||
</h2>
|
||||
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirmOpen(true)}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
{t(($) => $.detail.delete_button)}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -982,14 +957,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
triggers={triggers}
|
||||
/>
|
||||
)}
|
||||
{accessDialogOpen && (
|
||||
<ManageAccessDialog
|
||||
open={accessDialogOpen}
|
||||
onOpenChange={setAccessDialogOpen}
|
||||
autopilotId={autopilot.id}
|
||||
collaborators={collaborators}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(v) => { if (!v && !deleting) setDeleteConfirmOpen(false); }}
|
||||
|
||||
@@ -146,11 +146,6 @@ export function AutopilotRowActions({ row }: { row: Autopilot }) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const setStatus = useSetStatus();
|
||||
|
||||
// The kebab only holds write actions (pause/resume/delete). Hide it entirely
|
||||
// for members without write access; an absent can_write (older server) keeps
|
||||
// the menu visible and lets the backend remain the gate.
|
||||
if (row.can_write === false) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import {
|
||||
useGrantAutopilotAccess,
|
||||
useRevokeAutopilotAccess,
|
||||
} from "@multica/core/autopilots/mutations";
|
||||
import type { AutopilotCollaborator } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
PickerEmpty,
|
||||
} from "../../issues/components/pickers/property-picker";
|
||||
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Grant / revoke explicit write access to an autopilot. Members-only, mirroring
|
||||
// the subscriber picker. Creators and workspace admins always have access and
|
||||
// are not listed here — this manages the additional, explicitly-granted set.
|
||||
export function ManageAccessDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
autopilotId,
|
||||
collaborators,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
autopilotId: string;
|
||||
collaborators: AutopilotCollaborator[];
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
const grant = useGrantAutopilotAccess();
|
||||
const revoke = useRevokeAutopilotAccess();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const grantedIds = useMemo(
|
||||
() => new Set(collaborators.map((c) => c.user_id)),
|
||||
[collaborators],
|
||||
);
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
members.filter(
|
||||
(m) =>
|
||||
!grantedIds.has(m.user_id) &&
|
||||
(query === "" ||
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
matchesPinyin(m.name, query)),
|
||||
),
|
||||
[members, grantedIds, query],
|
||||
);
|
||||
|
||||
const handleGrant = async (userId: string) => {
|
||||
try {
|
||||
await grant.mutateAsync({ autopilotId, userId });
|
||||
toast.success(t(($) => $.access.toast_granted));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t(($) => $.access.toast_failed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (userId: string) => {
|
||||
try {
|
||||
await revoke.mutateAsync({ autopilotId, userId });
|
||||
toast.success(t(($) => $.access.toast_revoked));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t(($) => $.access.toast_failed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogTitle>{t(($) => $.access.title)}</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.access.description)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.access.current_label)}
|
||||
</span>
|
||||
<PropertyPicker
|
||||
open={pickerOpen}
|
||||
onOpenChange={(v) => {
|
||||
setPickerOpen(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-64"
|
||||
align="start"
|
||||
searchable
|
||||
searchPlaceholder={t(($) => $.access.search_placeholder)}
|
||||
onSearchChange={setFilter}
|
||||
trigger={
|
||||
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
|
||||
<Plus className="size-3" />
|
||||
{t(($) => $.access.add)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{candidates.length === 0 ? (
|
||||
<PickerEmpty />
|
||||
) : (
|
||||
candidates.map((m) => (
|
||||
<PickerItem
|
||||
key={m.user_id}
|
||||
selected={false}
|
||||
onClick={() => {
|
||||
void handleGrant(m.user_id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
||||
<span className="truncate">{m.name}</span>
|
||||
</PickerItem>
|
||||
))
|
||||
)}
|
||||
</PropertyPicker>
|
||||
</div>
|
||||
|
||||
{collaborators.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{t(($) => $.access.empty)}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{collaborators.map((c) => (
|
||||
<li
|
||||
key={c.user_id}
|
||||
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
|
||||
<span className="truncate text-sm">
|
||||
{getActorName("member", c.user_id)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevoke(c.user_id)}
|
||||
disabled={revoke.isPending}
|
||||
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
aria-label={t(($) => $.access.remove_tooltip)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.access.owner_note)}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { BarChart3, FolderKanban, Trash2 } from "lucide-react";
|
||||
import { BarChart3, FolderKanban } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
@@ -52,9 +52,8 @@ import {
|
||||
aggregateDailyTokens,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
bucketUnknownAgentRows,
|
||||
computeDailyTotals,
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
filterKnownAgentRows,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
type AgentDashboardRow,
|
||||
@@ -315,29 +314,17 @@ export function DashboardPage() {
|
||||
[agentTokenRows, runTimeRows],
|
||||
);
|
||||
|
||||
// Fold rollup rows for hard-deleted agents into one aggregated "Deleted
|
||||
// agents" row instead of showing them as a bare UUID (MUL-3771) or dropping
|
||||
// them outright — dropping made the per-agent breakdown stop reconciling
|
||||
// with the top-line Cost/Tokens KPIs, which still count that spend (MUL-3776,
|
||||
// #4640). Archived agents stay as themselves (the agent list is fetched with
|
||||
// archived included); only truly-removed agents collapse into the bucket.
|
||||
// Skip bucketing until the agent list has loaded so a slow agents fetch
|
||||
// doesn't transiently merge every row.
|
||||
// Hide rollup rows for agents that were hard-deleted from the workspace —
|
||||
// they'd otherwise show up as a bare UUID on the leaderboard (MUL-3771).
|
||||
// Archived agents stay (the agent list is fetched with archived included);
|
||||
// only truly-removed agents drop out. Skip filtering until the agent list
|
||||
// has loaded so a slow agents fetch doesn't transiently blank the list.
|
||||
const knownAgentIds = useMemo(
|
||||
() => (agentsQuery.isSuccess ? new Set(agents.map((a) => a.id)) : null),
|
||||
[agentsQuery.isSuccess, agents],
|
||||
);
|
||||
const visibleAgentRows = useMemo(
|
||||
() => bucketUnknownAgentRows(agentRows, knownAgentIds),
|
||||
[agentRows, knownAgentIds],
|
||||
);
|
||||
// Distinct hard-deleted agents folded into the bucket — drives the caption's
|
||||
// "· N deleted" suffix (the bucket itself is a single row).
|
||||
const deletedAgentCount = useMemo(
|
||||
() =>
|
||||
knownAgentIds
|
||||
? agentRows.filter((r) => !knownAgentIds.has(r.agentId)).length
|
||||
: 0,
|
||||
() => filterKnownAgentRows(agentRows, knownAgentIds),
|
||||
[agentRows, knownAgentIds],
|
||||
);
|
||||
|
||||
@@ -444,7 +431,6 @@ export function DashboardPage() {
|
||||
<Leaderboard
|
||||
rows={visibleAgentRows}
|
||||
agents={agents}
|
||||
deletedAgentCount={deletedAgentCount}
|
||||
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
|
||||
/>
|
||||
</>
|
||||
@@ -654,12 +640,10 @@ const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
|
||||
function Leaderboard({
|
||||
rows,
|
||||
agents,
|
||||
deletedAgentCount,
|
||||
lessThanMinuteLabel,
|
||||
}: {
|
||||
rows: AgentDashboardRow[];
|
||||
agents: { id: string; name: string }[];
|
||||
deletedAgentCount: number;
|
||||
lessThanMinuteLabel: string;
|
||||
}) {
|
||||
const { t } = useT("usage");
|
||||
@@ -700,12 +684,7 @@ function Leaderboard({
|
||||
<div className="flex items-center gap-3">
|
||||
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{deletedAgentCount > 0
|
||||
? t(($) => $.leaderboard.caption_with_deleted, {
|
||||
count: rows.length - 1,
|
||||
deleted: deletedAgentCount,
|
||||
})
|
||||
: t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
{t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,11 +704,6 @@ function Leaderboard({
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{sortedRows.map((row) => {
|
||||
// The deleted-agents bucket is a synthetic row, not a real agent:
|
||||
// render a neutral placeholder (no avatar fetch / hover card / UUID)
|
||||
// and dash out Time/Tasks, which it never carries (see
|
||||
// bucketUnknownAgentRows).
|
||||
const isDeletedBucket = row.agentId === DELETED_AGENTS_ROW_ID;
|
||||
const agent = agents.find((a) => a.id === row.agentId);
|
||||
const value = SORT_METRIC[sortBy](row);
|
||||
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
|
||||
@@ -739,28 +713,15 @@ function Leaderboard({
|
||||
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{isDeletedBucket ? (
|
||||
<>
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium italic text-muted-foreground">
|
||||
{t(($) => $.leaderboard.deleted_agents)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={row.agentId}
|
||||
size={22}
|
||||
enableHoverCard
|
||||
/>
|
||||
<span className="cursor-pointer truncate text-sm font-medium">
|
||||
{agent?.name ?? row.agentId}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={row.agentId}
|
||||
size={22}
|
||||
enableHoverCard
|
||||
/>
|
||||
<span className="cursor-pointer truncate text-sm font-medium">
|
||||
{agent?.name ?? row.agentId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
@@ -781,14 +742,12 @@ function Leaderboard({
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{isDeletedBucket
|
||||
? "—"
|
||||
: formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
{formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{isDeletedBucket ? "—" : row.taskCount}
|
||||
{row.taskCount}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
aggregateDailyCost,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
bucketUnknownAgentRows,
|
||||
computeDailyTotals,
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
filterKnownAgentRows,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
} from "./utils";
|
||||
@@ -203,81 +202,26 @@ describe("mergeAgentDashboardRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketUnknownAgentRows", () => {
|
||||
const live = { agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 };
|
||||
const archived = {
|
||||
agentId: "archived",
|
||||
tokens: 80,
|
||||
cost: 0.8,
|
||||
seconds: 8,
|
||||
taskCount: 2,
|
||||
};
|
||||
const deletedA = {
|
||||
agentId: "deleted-a",
|
||||
tokens: 50,
|
||||
cost: 0.5,
|
||||
seconds: 5,
|
||||
taskCount: 1,
|
||||
};
|
||||
const deletedB = {
|
||||
agentId: "deleted-b",
|
||||
tokens: 30,
|
||||
cost: 0.25,
|
||||
seconds: 3,
|
||||
taskCount: 4,
|
||||
};
|
||||
describe("filterKnownAgentRows", () => {
|
||||
const rows = [
|
||||
{ agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 },
|
||||
{ agentId: "deleted", tokens: 50, cost: 0.5, seconds: 5, taskCount: 1 },
|
||||
];
|
||||
|
||||
it("folds every hard-deleted agent into one aggregated bucket row", () => {
|
||||
// "deleted-a" / "deleted-b" are absent from the known set — they'd otherwise
|
||||
// render as bare UUIDs. They collapse into a single sentinel row.
|
||||
const out = bucketUnknownAgentRows(
|
||||
[live, deletedA, deletedB],
|
||||
new Set(["live"]),
|
||||
);
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", DELETED_AGENTS_ROW_ID]);
|
||||
const bucket = out.find((r) => r.agentId === DELETED_AGENTS_ROW_ID)!;
|
||||
expect(bucket.tokens).toBe(80);
|
||||
expect(bucket.cost).toBeCloseTo(0.75);
|
||||
// Time/Tasks never attach to the bucket — the run-time rollup inner-joins
|
||||
// `agent`, so deleted agents contribute nothing to those columns.
|
||||
expect(bucket.seconds).toBe(0);
|
||||
expect(bucket.taskCount).toBe(0);
|
||||
it("drops rows whose agent is no longer in the workspace", () => {
|
||||
// "deleted" is absent from the known set — it's a hard-deleted agent whose
|
||||
// legacy rollup row would otherwise render as a bare UUID.
|
||||
const out = filterKnownAgentRows(rows, new Set(["live"]));
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live"]);
|
||||
});
|
||||
|
||||
it("keeps the bucket total reconciled with the top-line spend", () => {
|
||||
// The KPI total counts deleted-agent spend; sum(visible rows) must match it
|
||||
// so the breakdown reconciles (MUL-3776).
|
||||
const out = bucketUnknownAgentRows(
|
||||
[live, deletedA, deletedB],
|
||||
new Set(["live"]),
|
||||
);
|
||||
const visibleCost = out.reduce((s, r) => s + r.cost, 0);
|
||||
const kpiCost = [live, deletedA, deletedB].reduce((s, r) => s + r.cost, 0);
|
||||
expect(visibleCost).toBeCloseTo(kpiCost);
|
||||
it("keeps every row while the agent list is still loading (null set)", () => {
|
||||
const out = filterKnownAgentRows(rows, null);
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted"]);
|
||||
});
|
||||
|
||||
it("keeps archived agents as themselves, never in the bucket", () => {
|
||||
// The agent list is fetched with archived included, so archived agents are
|
||||
// in the known set and stay on the board under their own id.
|
||||
const out = bucketUnknownAgentRows(
|
||||
[live, archived, deletedA],
|
||||
new Set(["live", "archived"]),
|
||||
);
|
||||
expect(out.map((r) => r.agentId)).toEqual([
|
||||
"live",
|
||||
"archived",
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds no bucket row when every agent is known", () => {
|
||||
const out = bucketUnknownAgentRows([live, archived], new Set(["live", "archived"]));
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", "archived"]);
|
||||
});
|
||||
|
||||
it("keeps every row untouched while the agent list is still loading (null set)", () => {
|
||||
const out = bucketUnknownAgentRows([live, deletedA], null);
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted-a"]);
|
||||
it("drops every row when the known set is empty", () => {
|
||||
expect(filterKnownAgentRows(rows, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -227,54 +227,21 @@ export function mergeAgentDashboardRows(
|
||||
});
|
||||
}
|
||||
|
||||
// Synthetic agentId for the row that aggregates all hard-deleted agents.
|
||||
// Sentinel (not a real UUID) so the component can detect it and render a
|
||||
// placeholder instead of looking the id up in the agent list.
|
||||
export const DELETED_AGENTS_ROW_ID = "__deleted_agents__";
|
||||
|
||||
// Fold usage rows whose agent no longer exists in the workspace into a single
|
||||
// aggregated "Deleted agents" row instead of dropping them. The agent list is
|
||||
// fetched with `include_archived: true`, so archived agents keep their names
|
||||
// and stay on the leaderboard as themselves; only hard-deleted agents fall out
|
||||
// of `knownAgentIds` and collapse into the bucket.
|
||||
// Drop usage rows whose agent no longer exists in the workspace. The agent
|
||||
// list is fetched with `include_archived: true`, so archived agents keep
|
||||
// their names and stay on the leaderboard; only hard-deleted agents fall out
|
||||
// of `knownAgentIds`. Those are legacy rollup rows that would otherwise
|
||||
// render as a bare UUID (MUL-3771).
|
||||
//
|
||||
// MUL-3771 (PR #4637) originally *dropped* these rows so they'd stop rendering
|
||||
// as a bare UUID — but the top-line Cost/Tokens KPIs still count their spend
|
||||
// (those totals aggregate `task_usage_hourly` without joining `agent`), so the
|
||||
// per-agent breakdown no longer reconciled with the totals (MUL-3776, #4640).
|
||||
// Aggregating instead of dropping keeps `sum(visible rows) == KPI total` while
|
||||
// still never exposing a UUID. The bucket carries tokens + cost only; seconds
|
||||
// and taskCount stay 0 because the run-time rollups inner-join `agent`, so
|
||||
// deleted agents already contribute nothing to the Time/Tasks KPIs — the
|
||||
// component renders those two columns as "—" for this row.
|
||||
//
|
||||
// `knownAgentIds` is `null` while the agent list is still loading; callers
|
||||
// `knownAgentIds` is empty while the agent list is still loading; callers
|
||||
// pass `null` in that case so the rows pass through untouched instead of the
|
||||
// whole leaderboard collapsing into one bucket on a slow fetch.
|
||||
export function bucketUnknownAgentRows(
|
||||
// whole leaderboard blanking on a slow fetch.
|
||||
export function filterKnownAgentRows(
|
||||
rows: AgentDashboardRow[],
|
||||
knownAgentIds: ReadonlySet<string> | null,
|
||||
): AgentDashboardRow[] {
|
||||
if (!knownAgentIds) return rows;
|
||||
const known: AgentDashboardRow[] = [];
|
||||
const bucket: AgentDashboardRow = {
|
||||
agentId: DELETED_AGENTS_ROW_ID,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
seconds: 0,
|
||||
taskCount: 0,
|
||||
};
|
||||
let hasDeleted = false;
|
||||
for (const r of rows) {
|
||||
if (knownAgentIds.has(r.agentId)) {
|
||||
known.push(r);
|
||||
continue;
|
||||
}
|
||||
hasDeleted = true;
|
||||
bucket.tokens += r.tokens;
|
||||
bucket.cost += r.cost;
|
||||
}
|
||||
return hasDeleted ? [...known, bucket] : known;
|
||||
return rows.filter((r) => knownAgentIds.has(r.agentId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -262,40 +262,6 @@ describe("Attachment — image dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers a local disk /uploads URL over API markdown in split-origin self-host", () => {
|
||||
getBaseUrlMock.mockReturnValue("https://api.example.test");
|
||||
const id = "11111111-2222-3333-4444-555555555555";
|
||||
const markdownUrl = `https://api.example.test/api/attachments/${id}/download`;
|
||||
const mediaUrl = "https://api.example.test/uploads/workspaces/ws-1/shot.png";
|
||||
const att = makeRecord({
|
||||
id,
|
||||
url: "/uploads/workspaces/ws-1/shot.png",
|
||||
markdown_url: markdownUrl,
|
||||
download_url: `/api/attachments/${id}/download`,
|
||||
});
|
||||
resolverState.attachments = [att];
|
||||
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: markdownUrl,
|
||||
filename: "shot.png",
|
||||
forceKind: "image",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector("img")?.getAttribute("src")).toBe(mediaUrl);
|
||||
|
||||
fireEvent.click(screen.getByTitle("View"));
|
||||
|
||||
const imageSrcs = [...document.querySelectorAll("img")].map((img) =>
|
||||
img.getAttribute("src"),
|
||||
);
|
||||
expect(imageSrcs).toEqual([mediaUrl, mediaUrl]);
|
||||
});
|
||||
|
||||
it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => {
|
||||
configStore.setState({ cdnDomain: "cdn.example.test" });
|
||||
const id = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
@@ -237,17 +237,12 @@ function absolutizeMediaURL(rawUrl: string): string {
|
||||
// reports `cdn_signed` — in CloudFront signed-URL mode the same
|
||||
// domain serves PRIVATE content and a raw (unsigned) storage URL is
|
||||
// a guaranteed 403 (MUL-3254).
|
||||
// 3. Local disk `record.url` — self-host LocalStorage without
|
||||
// LOCAL_UPLOAD_BASE_URL stores a site-relative `/uploads/...` path.
|
||||
// It is the direct static object URL and is loadable once
|
||||
// `absolutizeMediaURL` prefixes apiBaseUrl in split-origin clients.
|
||||
// 4. `record.markdown_url` — the durable, server-policy-aligned URL.
|
||||
// 3. `record.markdown_url` — the durable, server-policy-aligned URL.
|
||||
// Beats raw `record.url` because it never points at a private
|
||||
// bucket (must-fix 2 from MUL-3192 review), except for the explicit
|
||||
// site-relative local upload path above.
|
||||
// 5. `record.url` — legacy fallback for responses that omit
|
||||
// bucket (must-fix 2 from MUL-3192 review).
|
||||
// 4. `record.url` — legacy fallback for responses that omit
|
||||
// `markdown_url` (a backend old enough to predate MUL-3192).
|
||||
// 6. The input URL — when there's no record at all.
|
||||
// 5. The input URL — when there's no record at all.
|
||||
function pickInlineMediaURL(
|
||||
record: AttachmentRecord,
|
||||
fallback: string,
|
||||
@@ -262,18 +257,11 @@ function pickInlineMediaURL(
|
||||
return dl;
|
||||
}
|
||||
if (!cdnSigned && storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
|
||||
if (isSiteRelativeLocalUploadURL(record.url)) return record.url;
|
||||
if (record.markdown_url) return record.markdown_url;
|
||||
if (record.url) return record.url;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isSiteRelativeLocalUploadURL(rawURL: string): boolean {
|
||||
if (!rawURL || !rawURL.startsWith("/")) return false;
|
||||
const path = rawURL.split(/[?#]/, 1)[0] ?? "";
|
||||
return path === "/uploads" || path.startsWith("/uploads/");
|
||||
}
|
||||
|
||||
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
|
||||
const expected = normalizeHost(cdnDomain);
|
||||
if (!rawURL || !expected) return false;
|
||||
|
||||
@@ -46,6 +46,8 @@ import { deriveThreadResolution } from "./thread-utils";
|
||||
|
||||
const highlightedCommentBackgroundClass =
|
||||
"bg-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
|
||||
const highlightedCommentFadeClass =
|
||||
"after:from-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
|
||||
|
||||
function StickyHeaderShell({
|
||||
className,
|
||||
@@ -65,8 +67,9 @@ function StickyHeaderShell({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-10 transition-colors duration-700 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-[inherit] after:[mask-image:linear-gradient(to_bottom,#000,transparent)] after:[-webkit-mask-image:linear-gradient(to_bottom,#000,transparent)]",
|
||||
"sticky top-0 z-10 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-gradient-to-b after:to-transparent",
|
||||
highlighted ? highlightedCommentBackgroundClass : "bg-card",
|
||||
highlighted ? highlightedCommentFadeClass : "after:from-card",
|
||||
)}
|
||||
>
|
||||
<div className={className}>
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentTask } from "@multica/core/types";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
snapshot: [] as unknown[],
|
||||
// Captures the agent ids handed to the avatar stack so a test can assert
|
||||
// the stack still reflects distinct agents even when the count counts issues.
|
||||
avatarAgentIds: undefined as string[] | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
agentTaskSnapshotOptions: (wsId: string) => ({
|
||||
queryKey: ["agents", "task-snapshot", wsId],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/components/agent-avatar-stack", () => ({
|
||||
AgentAvatarStack: ({ agentIds }: { agentIds: string[] }) => {
|
||||
mockState.avatarAgentIds = agentIds;
|
||||
return <div data-testid="agent-avatar-stack">{agentIds.length}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/components/agent-activity-hover-content", () => ({
|
||||
AgentActivityHoverContent: ({ tasks }: { tasks: AgentTask[] }) => (
|
||||
<div data-testid="activity-hover">{tasks.length}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (opts: { queryKey?: readonly unknown[] }) => {
|
||||
if (opts.queryKey?.[1] === "task-snapshot") {
|
||||
return { data: mockState.snapshot };
|
||||
}
|
||||
return { data: undefined };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip";
|
||||
|
||||
function makeTask(overrides: Partial<AgentTask>): AgentTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "issue-1",
|
||||
status: "running",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: "2026-06-08T08:00:00Z",
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-06-08T08:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockState.snapshot = [];
|
||||
mockState.avatarAgentIds = undefined;
|
||||
});
|
||||
|
||||
describe("WorkspaceAgentWorkingChip", () => {
|
||||
it("counts distinct active issues, not running agents", () => {
|
||||
// Two agents working the SAME issue: the count is about issues, so it
|
||||
// must read "1", not "2" (the old unique-agent behavior). MUL-3875.
|
||||
mockState.snapshot = [
|
||||
makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }),
|
||||
makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-1" }),
|
||||
];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("1");
|
||||
// The avatar stack still shows both distinct agents behind that work.
|
||||
expect(mockState.avatarAgentIds).toEqual(["agent-1", "agent-2"]);
|
||||
});
|
||||
|
||||
it("counts each distinct issue once when agents span several issues", () => {
|
||||
mockState.snapshot = [
|
||||
makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }),
|
||||
makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-2" }),
|
||||
makeTask({ id: "t-3", agent_id: "agent-1", issue_id: "issue-3" }),
|
||||
];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
it("ignores non-running tasks and respects scopedIssueIds", () => {
|
||||
mockState.snapshot = [
|
||||
makeTask({ id: "t-1", issue_id: "issue-1", status: "running" }),
|
||||
makeTask({ id: "t-2", issue_id: "issue-2", status: "queued" }),
|
||||
makeTask({ id: "t-3", issue_id: "issue-3", status: "running" }),
|
||||
];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip
|
||||
value={false}
|
||||
onToggle={() => {}}
|
||||
scopedIssueIds={new Set(["issue-1"])}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only the running task within scope counts → "1".
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("shows 0 when no agents are running", () => {
|
||||
mockState.snapshot = [];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("0");
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ export function WorkspaceAgentWorkingChip({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
const { runningTasks, agentIds, issueIds } = useMemo(() => {
|
||||
const { runningTasks, agentIds } = useMemo(() => {
|
||||
const running: AgentTask[] = [];
|
||||
for (const task of snapshot) {
|
||||
if (task.status !== "running") continue;
|
||||
@@ -75,21 +75,11 @@ export function WorkspaceAgentWorkingChip({
|
||||
if (scopedIssueIds && !scopedIssueIds.has(task.issue_id)) continue;
|
||||
running.push(task);
|
||||
}
|
||||
// The count tracks active *issues*, not active agents: several agents
|
||||
// can work the same issue at once, and the chip answers "how many
|
||||
// issues are agents working on right now?" (its filter narrows the
|
||||
// list to exactly those issues). The avatar stack still shows the
|
||||
// distinct agents behind that work.
|
||||
const uniqueIssues = [...new Set(running.map((tk) => tk.issue_id))];
|
||||
const uniqueAgents = [...new Set(running.map((tk) => tk.agent_id))];
|
||||
return {
|
||||
runningTasks: running,
|
||||
agentIds: uniqueAgents,
|
||||
issueIds: uniqueIssues,
|
||||
};
|
||||
const unique = [...new Set(running.map((tk) => tk.agent_id))];
|
||||
return { runningTasks: running, agentIds: unique };
|
||||
}, [snapshot, scopedIssueIds]);
|
||||
|
||||
const hasAgents = issueIds.length > 0;
|
||||
const hasAgents = agentIds.length > 0;
|
||||
// Active (brand-filled) class — must explicitly re-pin text and bg in
|
||||
// every interactive state. Button's `outline` variant ships
|
||||
// `hover:text-foreground` + `aria-expanded:bg-muted aria-expanded:text-foreground`,
|
||||
@@ -150,7 +140,7 @@ export function WorkspaceAgentWorkingChip({
|
||||
max={3}
|
||||
opacity="full"
|
||||
/>
|
||||
<span className="tabular-nums">{issueIds.length}</span>
|
||||
<span className="tabular-nums">{agentIds.length}</span>
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "Connect this agent to external chat platforms so people can work with it where they already are.",
|
||||
"members_note": "Only workspace owners and admins can connect an agent to an external chat platform. You can view connected bots in Settings → Integrations."
|
||||
"members_note": "Only workspace owners and admins can bind a Lark Bot to an agent. You can view connected bots in Settings → Integrations."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "Now",
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "Pause autopilot",
|
||||
"activate_aria": "Activate autopilot",
|
||||
"edit": "Edit",
|
||||
"manage_access": "Manage access",
|
||||
"run_now": "Run now",
|
||||
"running": "Running...",
|
||||
"toast_triggered": "Autopilot triggered",
|
||||
@@ -102,20 +101,6 @@
|
||||
"deleting": "Deleting..."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"title": "Manage access",
|
||||
"description": "Members you add can edit, run, and manage this autopilot's triggers and webhooks.",
|
||||
"current_label": "With access",
|
||||
"add": "Add member",
|
||||
"search_placeholder": "Search members…",
|
||||
"no_results": "No members found",
|
||||
"remove_tooltip": "Remove access",
|
||||
"empty": "No one has been granted access yet.",
|
||||
"toast_granted": "Access granted",
|
||||
"toast_revoked": "Access removed",
|
||||
"toast_failed": "Couldn't update access",
|
||||
"owner_note": "The creator and workspace admins always have access."
|
||||
},
|
||||
"run_status": {
|
||||
"issue_created": "Issue Created",
|
||||
"running": "Running",
|
||||
|
||||
@@ -24,20 +24,5 @@
|
||||
"error_already_bound": "This Lark account is already bound to a different Multica user. Account transfers must go through an explicit unbind first.",
|
||||
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
|
||||
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Link your Slack account",
|
||||
"redeeming": "Linking your account…",
|
||||
"needs_auth_description": "Sign in to Multica to complete the link. The token in the link binds your Slack account to this Multica user, so you must be logged in first.",
|
||||
"sign_in": "Sign in",
|
||||
"done_title": "You're linked.",
|
||||
"done_description": "Your next message to the bot in Slack will go straight to the agent. You can close this tab.",
|
||||
"error_title": "Couldn't complete the link",
|
||||
"error_admin_hint": "If this keeps happening, message the bot again in Slack to get a fresh link.",
|
||||
"error_missing_token": "The link is missing its token. Message the bot again in Slack to get a new one.",
|
||||
"error_expired": "This link is invalid or expired (links are valid for 15 minutes). Message the bot again to get a new one.",
|
||||
"error_already_bound": "This Slack account is already linked to a different Multica user. Account transfers must go through an explicit unbind first.",
|
||||
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
|
||||
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,8 +332,6 @@
|
||||
"agent_activity": {
|
||||
"hover_header_one": "{{count}} agent working",
|
||||
"hover_header_other": "{{count}} agents working",
|
||||
"hover_header_tasks_one": "{{count}} task working",
|
||||
"hover_header_tasks_other": "{{count}} tasks working",
|
||||
"hover_header_queued_one": "{{count}} agent queued",
|
||||
"hover_header_queued_other": "{{count}} agents queued",
|
||||
"status_running": "Working",
|
||||
|
||||
@@ -300,48 +300,37 @@
|
||||
"install_error_forbidden": "You no longer have permission to install Lark Bots in this workspace. Ask a workspace admin to continue.",
|
||||
"install_error_generic": "Install failed. Try again."
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "Connect each Multica Agent to its own Slack bot. Members can DM the bot, @mention it in a channel, and type /issue to spin up a new Multica issue.",
|
||||
"not_enabled_title": "Slack integration not enabled",
|
||||
"composio": {
|
||||
"section_title": "Composio",
|
||||
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
|
||||
"not_enabled_title": "Composio integration not enabled",
|
||||
"not_enabled_description_prefix": "Set",
|
||||
"not_enabled_description_suffix": "on the server to enable Slack bot installations.",
|
||||
"not_enabled_self_host_hint": "Self-hosters: see the project README for details.",
|
||||
"preview_title": "Slack install coming soon",
|
||||
"preview_description": "The at-rest key is set, but the hosted Slack app's OAuth credentials are not configured in this deployment. The Connect button will appear here once they are set.",
|
||||
"connected_bots": "Connected bots",
|
||||
"loading": "Loading…",
|
||||
"empty_title": "No bots connected yet",
|
||||
"empty_description_prefix": "Open an Agent in this workspace and click",
|
||||
"empty_description_cta": "Connect Slack",
|
||||
"empty_description_suffix": "to install a bot for it.",
|
||||
"revoked_badge": "revoked",
|
||||
"installed_at_label": "Installed {{when}}",
|
||||
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
|
||||
"loading": "Loading toolkits…",
|
||||
"load_failed": "Failed to load Composio toolkits.",
|
||||
"empty_title": "No toolkits available",
|
||||
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
|
||||
"search_placeholder": "Search toolkits…",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnecting": "Disconnecting…",
|
||||
"disconnect_confirm_title": "Disconnect this Slack bot?",
|
||||
"disconnect_confirm_description": "The bot will stop receiving Slack messages for this workspace. The installation row is kept for audit; you can re-install later from the same Agent.",
|
||||
"not_connectable": "Not configured",
|
||||
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
|
||||
"connect_failed": "Couldn't start the connection. Please try again.",
|
||||
"disconnect_failed": "Couldn't disconnect. Please try again.",
|
||||
"toast_disconnected": "Disconnected",
|
||||
"disconnect_confirm_title": "Disconnect this app?",
|
||||
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
|
||||
"disconnect_confirm_cancel": "Cancel",
|
||||
"toast_disconnected": "Disconnected Slack bot",
|
||||
"toast_disconnect_failed": "Disconnect failed",
|
||||
"bind_button": "Connect Slack",
|
||||
"bind_button_title": "Connect {{agent}} to a Slack bot",
|
||||
"connecting": "Opening Slack…",
|
||||
"connect_failed_toast": "Could not start the Slack install",
|
||||
"agent_bot_connected_label": "Connected to Slack",
|
||||
"agent_bot_disconnect_tooltip": "Unbind this Slack bot from the Agent. The bot will stop receiving Slack messages.",
|
||||
"agent_bot_manage_link": "Open in Slack",
|
||||
"agent_bot_manage_tooltip": "Open this bot's Slack workspace.",
|
||||
"byo_dialog_title": "Connect a Slack bot",
|
||||
"byo_video_cta": "Watch the setup walkthrough",
|
||||
"byo_docs_link": "Step-by-step: connect your Multica agent to Slack",
|
||||
"byo_bot_token_label": "Bot token (xoxb-)",
|
||||
"byo_app_token_label": "App-level token (xapp-)",
|
||||
"byo_submit": "Connect",
|
||||
"byo_submitting": "Connecting…",
|
||||
"byo_cancel": "Cancel",
|
||||
"byo_success_toast": "Slack bot connected",
|
||||
"byo_failed_toast": "Could not connect the Slack bot"
|
||||
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
|
||||
"toast_connected": "Connected",
|
||||
"toast_connect_failed": "Couldn't complete the connection. Please try again.",
|
||||
"last_used": "Last used {{when}}",
|
||||
"last_used_never": "Never used",
|
||||
"expired": "Token expired",
|
||||
"reconnect": "Reconnect"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "Repositories",
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"leaderboard": {
|
||||
"title": "Leaderboard",
|
||||
"caption": "{{count}} agents",
|
||||
"caption_with_deleted": "{{count}} agents · {{deleted}} deleted",
|
||||
"deleted_agents": "Deleted agents",
|
||||
"header_agent": "Agent",
|
||||
"header_tokens": "Tokens",
|
||||
"header_cost": "Cost",
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "このエージェントを外部のチャットプラットフォームに接続し、普段使っているツールから直接やり取りできるようにします。",
|
||||
"members_note": "エージェントを外部チャットプラットフォームに接続できるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
"members_note": "エージェントに Lark Bot を紐付けできるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "現在",
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "オートパイロットを一時停止",
|
||||
"activate_aria": "オートパイロットを有効化",
|
||||
"edit": "編集",
|
||||
"manage_access": "アクセス管理",
|
||||
"run_now": "今すぐ実行",
|
||||
"running": "実行中...",
|
||||
"toast_triggered": "オートパイロットを実行しました",
|
||||
@@ -102,20 +101,6 @@
|
||||
"deleting": "削除中..."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"title": "アクセス管理",
|
||||
"description": "追加したメンバーは、このオートパイロットの編集・実行や、トリガー・Webhook の管理ができます。",
|
||||
"current_label": "アクセス権あり",
|
||||
"add": "メンバーを追加",
|
||||
"search_placeholder": "メンバーを検索…",
|
||||
"no_results": "メンバーが見つかりません",
|
||||
"remove_tooltip": "アクセス権を削除",
|
||||
"empty": "まだ誰にもアクセス権が付与されていません。",
|
||||
"toast_granted": "アクセス権を付与しました",
|
||||
"toast_revoked": "アクセス権を削除しました",
|
||||
"toast_failed": "アクセス権を更新できませんでした",
|
||||
"owner_note": "作成者とワークスペース管理者は常にアクセスできます。"
|
||||
},
|
||||
"run_status": {
|
||||
"issue_created": "イシュー作成済み",
|
||||
"running": "実行中",
|
||||
|
||||
@@ -24,20 +24,5 @@
|
||||
"error_already_bound": "この Lark アカウントはすでに別の Multica ユーザーに連携されています。アカウントを移すには、まず明示的に連携を解除する必要があります。",
|
||||
"error_not_member": "現在ログイン中の Multica アカウントは、このワークスペースのメンバーではありません。",
|
||||
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Slack アカウントを連携",
|
||||
"redeeming": "アカウントを連携しています…",
|
||||
"needs_auth_description": "連携を完了するには Multica にサインインしてください。リンク内のトークンが、あなたの Slack アカウントをこの Multica ユーザーに紐付けるため、先にログインが必要です。",
|
||||
"sign_in": "サインイン",
|
||||
"done_title": "連携が完了しました。",
|
||||
"done_description": "次に Slack でボットへ送るメッセージは、そのままエージェントに届きます。このタブは閉じて構いません。",
|
||||
"error_title": "連携を完了できませんでした",
|
||||
"error_admin_hint": "繰り返し発生する場合は、Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_missing_token": "リンクにトークンがありません。Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_expired": "このリンクは無効か期限切れです(有効期限は 15 分)。ボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_already_bound": "この Slack アカウントは別の Multica ユーザーに連携済みです。移行するにはまず明示的に解除する必要があります。",
|
||||
"error_not_member": "サインインしている Multica アカウントはこのワークスペースのメンバーではありません。",
|
||||
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +319,6 @@
|
||||
},
|
||||
"agent_activity": {
|
||||
"hover_header_other": "作業中のエージェント {{count}} 件",
|
||||
"hover_header_tasks_other": "作業中のタスク {{count}} 件",
|
||||
"hover_header_queued_other": "待機中のエージェント {{count}} 件",
|
||||
"status_running": "作業中",
|
||||
"status_queued": "待機中",
|
||||
|
||||
@@ -300,48 +300,37 @@
|
||||
"install_error_forbidden": "このワークスペースに Lark ボットを設置する権限がなくなりました。ワークスペース管理者にお問い合わせください。",
|
||||
"install_error_generic": "設置に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。メンバーはボットに DM したり、チャンネルで @メンションしたり、/issue と入力して新しい Multica イシューを起こすことができます。",
|
||||
"not_enabled_title": "Slack 連携が有効になっていません",
|
||||
"not_enabled_description_prefix": "サーバーで",
|
||||
"not_enabled_description_suffix": "を設定すると Slack ボットのインストールが有効になります。",
|
||||
"not_enabled_self_host_hint": "セルフホストの場合: 詳細はプロジェクトの README を参照してください。",
|
||||
"preview_title": "Slack インストールは近日対応",
|
||||
"preview_description": "保存用キーは設定済みですが、このデプロイではホスト型 Slack アプリの OAuth 認証情報が未設定です。設定すると接続ボタンがここに表示されます。",
|
||||
"connected_bots": "接続済みのボット",
|
||||
"loading": "読み込み中…",
|
||||
"empty_title": "まだボットが接続されていません",
|
||||
"empty_description_prefix": "このワークスペースのエージェントを開き、",
|
||||
"empty_description_cta": "Slack を接続",
|
||||
"empty_description_suffix": "をクリックしてボットをインストールします。",
|
||||
"revoked_badge": "取り消し済み",
|
||||
"installed_at_label": "{{when}} にインストール",
|
||||
"disconnect": "切断",
|
||||
"disconnecting": "切断中…",
|
||||
"disconnect_confirm_title": "この Slack ボットを切断しますか?",
|
||||
"disconnect_confirm_description": "このボットはこのワークスペースの Slack メッセージを受信しなくなります。インストール記録は監査のため保持され、同じエージェントから再インストールできます。",
|
||||
"disconnect_confirm_cancel": "キャンセル",
|
||||
"toast_disconnected": "Slack ボットを切断しました",
|
||||
"toast_disconnect_failed": "切断に失敗しました",
|
||||
"bind_button": "Slack を接続",
|
||||
"bind_button_title": "{{agent}} を Slack ボットに接続",
|
||||
"connecting": "Slack を開いています…",
|
||||
"connect_failed_toast": "Slack のインストールを開始できませんでした",
|
||||
"agent_bot_connected_label": "Slack に接続済み",
|
||||
"agent_bot_disconnect_tooltip": "この Slack ボットをエージェントから解除します。ボットは Slack メッセージを受信しなくなります。",
|
||||
"agent_bot_manage_link": "Slack で開く",
|
||||
"agent_bot_manage_tooltip": "このボットの Slack ワークスペースを開きます。",
|
||||
"byo_dialog_title": "Slack ボットを接続",
|
||||
"byo_video_cta": "セットアップ手順の動画を見る",
|
||||
"byo_docs_link": "Step-by-step:Multica エージェントを Slack に接続する",
|
||||
"byo_bot_token_label": "Bot トークン(xoxb-)",
|
||||
"byo_app_token_label": "App レベルトークン(xapp-)",
|
||||
"byo_submit": "接続",
|
||||
"byo_submitting": "接続中…",
|
||||
"byo_cancel": "キャンセル",
|
||||
"byo_success_toast": "Slack ボットを接続しました",
|
||||
"byo_failed_toast": "Slack ボットを接続できませんでした"
|
||||
"composio": {
|
||||
"section_title": "Composio",
|
||||
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
|
||||
"not_enabled_title": "Composio integration not enabled",
|
||||
"not_enabled_description_prefix": "Set",
|
||||
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
|
||||
"loading": "Loading toolkits…",
|
||||
"load_failed": "Failed to load Composio toolkits.",
|
||||
"empty_title": "No toolkits available",
|
||||
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
|
||||
"search_placeholder": "Search toolkits…",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnecting": "Disconnecting…",
|
||||
"not_connectable": "Not configured",
|
||||
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
|
||||
"connect_failed": "Couldn't start the connection. Please try again.",
|
||||
"disconnect_failed": "Couldn't disconnect. Please try again.",
|
||||
"toast_disconnected": "Disconnected",
|
||||
"disconnect_confirm_title": "Disconnect this app?",
|
||||
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
|
||||
"disconnect_confirm_cancel": "Cancel",
|
||||
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
|
||||
"toast_connected": "接続しました",
|
||||
"toast_connect_failed": "接続を完了できませんでした。もう一度お試しください。",
|
||||
"last_used": "最終使用 {{when}}",
|
||||
"last_used_never": "未使用",
|
||||
"expired": "トークンの有効期限切れ",
|
||||
"reconnect": "再接続"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "リポジトリ",
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"leaderboard": {
|
||||
"title": "リーダーボード",
|
||||
"caption": "{{count}} 件のエージェント",
|
||||
"caption_with_deleted": "{{count}} 件のエージェント · 削除済み {{deleted}} 件",
|
||||
"deleted_agents": "削除済みエージェント",
|
||||
"header_agent": "エージェント",
|
||||
"header_tokens": "トークン",
|
||||
"header_cost": "コスト",
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "이 에이전트를 외부 채팅 플랫폼에 연결해 팀원이 평소 사용하는 도구에서 바로 함께 작업할 수 있도록 합니다.",
|
||||
"members_note": "에이전트를 외부 채팅 플랫폼에 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
"members_note": "에이전트에 Lark 봇을 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "현재",
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "오토파일럿 일시 중지",
|
||||
"activate_aria": "오토파일럿 활성화",
|
||||
"edit": "수정",
|
||||
"manage_access": "접근 권한 관리",
|
||||
"run_now": "지금 실행",
|
||||
"running": "실행 중...",
|
||||
"toast_triggered": "오토파일럿을 실행했습니다",
|
||||
@@ -102,20 +101,6 @@
|
||||
"deleting": "삭제하는 중..."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"title": "접근 권한 관리",
|
||||
"description": "추가한 멤버는 이 오토파일럿을 수정·실행하고 트리거와 webhook을 관리할 수 있습니다.",
|
||||
"current_label": "접근 가능",
|
||||
"add": "멤버 추가",
|
||||
"search_placeholder": "멤버 검색…",
|
||||
"no_results": "멤버를 찾을 수 없습니다",
|
||||
"remove_tooltip": "접근 권한 제거",
|
||||
"empty": "아직 아무에게도 접근 권한을 부여하지 않았습니다.",
|
||||
"toast_granted": "접근 권한을 부여했습니다",
|
||||
"toast_revoked": "접근 권한을 제거했습니다",
|
||||
"toast_failed": "접근 권한을 업데이트하지 못했습니다",
|
||||
"owner_note": "작성자와 워크스페이스 관리자는 항상 접근할 수 있습니다."
|
||||
},
|
||||
"run_status": {
|
||||
"issue_created": "이슈 생성됨",
|
||||
"running": "실행 중",
|
||||
|
||||
@@ -24,20 +24,5 @@
|
||||
"error_already_bound": "이 Lark 계정은 이미 다른 Multica 사용자에 연결되어 있습니다. 계정 이전은 먼저 명시적으로 연결을 해제해야 합니다.",
|
||||
"error_not_member": "현재 로그인한 Multica 계정이 이 워크스페이스의 멤버가 아닙니다.",
|
||||
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Slack 계정 연결",
|
||||
"redeeming": "계정을 연결하는 중…",
|
||||
"needs_auth_description": "연결을 완료하려면 Multica에 로그인하세요. 링크의 토큰이 Slack 계정을 이 Multica 사용자와 연결하므로 먼저 로그인해야 해요.",
|
||||
"sign_in": "로그인",
|
||||
"done_title": "연결되었어요.",
|
||||
"done_description": "이제 Slack에서 봇에게 보내는 다음 메시지는 바로 에이전트로 전달돼요. 이 탭은 닫아도 됩니다.",
|
||||
"error_title": "연결을 완료하지 못했어요",
|
||||
"error_admin_hint": "계속 발생하면 Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_missing_token": "링크에 토큰이 없어요. Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_expired": "이 링크는 유효하지 않거나 만료됐어요(유효 기간 15분). 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_already_bound": "이 Slack 계정은 이미 다른 Multica 사용자에 연결되어 있어요. 이전하려면 먼저 명시적으로 연결을 해제해야 합니다.",
|
||||
"error_not_member": "로그인한 Multica 계정이 이 워크스페이스의 멤버가 아니에요.",
|
||||
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,8 +331,6 @@
|
||||
"agent_activity": {
|
||||
"hover_header_one": "작업 중인 에이전트 {{count}}개",
|
||||
"hover_header_other": "작업 중인 에이전트 {{count}}개",
|
||||
"hover_header_tasks_one": "작업 중인 태스크 {{count}}개",
|
||||
"hover_header_tasks_other": "작업 중인 태스크 {{count}}개",
|
||||
"hover_header_queued_one": "대기 중인 에이전트 {{count}}개",
|
||||
"hover_header_queued_other": "대기 중인 에이전트 {{count}}개",
|
||||
"status_running": "작업 중",
|
||||
|
||||
@@ -377,47 +377,36 @@
|
||||
"install_error_forbidden": "이 워크스페이스에 Lark 봇을 설치할 권한이 더 이상 없어요. 워크스페이스 관리자에게 문의하세요.",
|
||||
"install_error_generic": "설치에 실패했어요. 다시 시도하세요."
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결하세요. 멤버는 봇과 1:1로 대화하거나, 채널에서 @ 멘션하거나, /issue 를 입력해 새 Multica 이슈를 만들 수 있습니다.",
|
||||
"not_enabled_title": "Slack 연동이 활성화되지 않았어요",
|
||||
"not_enabled_description_prefix": "서버에서",
|
||||
"not_enabled_description_suffix": "를 설정하면 Slack 봇 설치가 활성화됩니다.",
|
||||
"not_enabled_self_host_hint": "셀프 호스팅: 자세한 내용은 프로젝트 README를 참고하세요.",
|
||||
"preview_title": "Slack 설치 곧 지원 예정",
|
||||
"preview_description": "저장용 키는 설정되어 있지만, 이 배포에는 호스팅 Slack 앱의 OAuth 자격 증명이 설정되지 않았어요. 설정하면 연결 버튼이 여기에 표시됩니다.",
|
||||
"connected_bots": "연결된 봇",
|
||||
"loading": "불러오는 중…",
|
||||
"empty_title": "아직 연결된 봇이 없어요",
|
||||
"empty_description_prefix": "이 워크스페이스의 에이전트를 열고",
|
||||
"empty_description_cta": "Slack 연결",
|
||||
"empty_description_suffix": "을(를) 클릭해 봇을 설치하세요.",
|
||||
"revoked_badge": "해제됨",
|
||||
"installed_at_label": "{{when}}에 설치됨",
|
||||
"disconnect": "연결 해제",
|
||||
"disconnecting": "연결 해제 중…",
|
||||
"disconnect_confirm_title": "이 Slack 봇을 연결 해제할까요?",
|
||||
"disconnect_confirm_description": "봇이 이 워크스페이스의 Slack 메시지를 더 이상 받지 않습니다. 설치 기록은 감사를 위해 보관되며, 같은 에이전트에서 다시 설치할 수 있어요.",
|
||||
"disconnect_confirm_cancel": "취소",
|
||||
"toast_disconnected": "Slack 봇을 연결 해제했어요",
|
||||
"toast_disconnect_failed": "연결 해제에 실패했어요",
|
||||
"bind_button": "Slack 연결",
|
||||
"bind_button_title": "{{agent}}을(를) Slack 봇에 연결",
|
||||
"connecting": "Slack 여는 중…",
|
||||
"connect_failed_toast": "Slack 설치를 시작할 수 없었어요",
|
||||
"agent_bot_connected_label": "Slack에 연결됨",
|
||||
"agent_bot_disconnect_tooltip": "이 Slack 봇을 에이전트에서 연결 해제합니다. 봇이 Slack 메시지를 받지 않게 됩니다.",
|
||||
"agent_bot_manage_link": "Slack에서 열기",
|
||||
"agent_bot_manage_tooltip": "이 봇의 Slack 워크스페이스를 엽니다.",
|
||||
"byo_dialog_title": "Slack 봇 연결",
|
||||
"byo_video_cta": "설정 안내 영상 보기",
|
||||
"byo_docs_link": "Step-by-step: Multica 에이전트를 Slack에 연결하기",
|
||||
"byo_bot_token_label": "Bot 토큰(xoxb-)",
|
||||
"byo_app_token_label": "App 레벨 토큰(xapp-)",
|
||||
"byo_submit": "연결",
|
||||
"byo_submitting": "연결 중…",
|
||||
"byo_cancel": "취소",
|
||||
"byo_success_toast": "Slack 봇을 연결했어요",
|
||||
"byo_failed_toast": "Slack 봇을 연결하지 못했어요"
|
||||
"composio": {
|
||||
"section_title": "Composio",
|
||||
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
|
||||
"not_enabled_title": "Composio integration not enabled",
|
||||
"not_enabled_description_prefix": "Set",
|
||||
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
|
||||
"loading": "Loading toolkits…",
|
||||
"load_failed": "Failed to load Composio toolkits.",
|
||||
"empty_title": "No toolkits available",
|
||||
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
|
||||
"search_placeholder": "Search toolkits…",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnecting": "Disconnecting…",
|
||||
"not_connectable": "Not configured",
|
||||
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
|
||||
"connect_failed": "Couldn't start the connection. Please try again.",
|
||||
"disconnect_failed": "Couldn't disconnect. Please try again.",
|
||||
"toast_disconnected": "Disconnected",
|
||||
"disconnect_confirm_title": "Disconnect this app?",
|
||||
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
|
||||
"disconnect_confirm_cancel": "Cancel",
|
||||
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
|
||||
"toast_connected": "연결되었습니다",
|
||||
"toast_connect_failed": "연결을 완료하지 못했습니다. 다시 시도해 주세요.",
|
||||
"last_used": "마지막 사용 {{when}}",
|
||||
"last_used_never": "사용한 적 없음",
|
||||
"expired": "토큰 만료됨",
|
||||
"reconnect": "다시 연결"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"leaderboard": {
|
||||
"title": "리더보드",
|
||||
"caption": "에이전트 {{count}}개",
|
||||
"caption_with_deleted": "에이전트 {{count}}개 · 삭제됨 {{deleted}}개",
|
||||
"deleted_agents": "삭제된 에이전트",
|
||||
"header_agent": "에이전트",
|
||||
"header_tokens": "토큰",
|
||||
"header_cost": "비용",
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "把这个智能体连接到外部聊天平台,让大家在自己熟悉的工具里直接与它协作。",
|
||||
"members_note": "只有工作区的所有者和管理员才能把智能体连接到外部聊天平台。你可以在「设置 → 集成」中查看已连接的 Bot。"
|
||||
"members_note": "只有工作区的所有者和管理员才能为智能体绑定飞书 Bot。你可以在「设置 → 集成」中查看已连接的 Bot。"
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "当前",
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "暂停自动化",
|
||||
"activate_aria": "启用自动化",
|
||||
"edit": "编辑",
|
||||
"manage_access": "管理访问",
|
||||
"run_now": "立即运行",
|
||||
"running": "运行中...",
|
||||
"toast_triggered": "已触发自动化",
|
||||
@@ -102,20 +101,6 @@
|
||||
"deleting": "删除中..."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"title": "管理访问",
|
||||
"description": "你添加的成员可以编辑、运行并管理这个自动化的触发器和 webhook。",
|
||||
"current_label": "已授权",
|
||||
"add": "添加成员",
|
||||
"search_placeholder": "搜索成员…",
|
||||
"no_results": "未找到成员",
|
||||
"remove_tooltip": "移除访问权限",
|
||||
"empty": "还没有授权任何人。",
|
||||
"toast_granted": "已授予访问权限",
|
||||
"toast_revoked": "已移除访问权限",
|
||||
"toast_failed": "更新访问权限失败",
|
||||
"owner_note": "创建者和工作区管理员始终拥有访问权限。"
|
||||
},
|
||||
"run_status": {
|
||||
"issue_created": "已创建 issue",
|
||||
"running": "运行中",
|
||||
|
||||
@@ -24,20 +24,5 @@
|
||||
"error_already_bound": "该飞书账号已绑定到其他 Multica 用户。账户转移需要先显式解绑。",
|
||||
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
|
||||
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "关联 Slack 账号",
|
||||
"redeeming": "正在关联账号…",
|
||||
"needs_auth_description": "需要登录 Multica 才能完成关联。链接中的 token 会将你的 Slack 账号绑定到当前登录的 Multica 用户。",
|
||||
"sign_in": "登录",
|
||||
"done_title": "已关联。",
|
||||
"done_description": "下次在 Slack 向机器人发送消息时,会直接送达绑定的智能体。可以关闭此页面。",
|
||||
"error_title": "关联未完成",
|
||||
"error_admin_hint": "如果反复失败,请在 Slack 重新向机器人发消息以获取新的链接。",
|
||||
"error_missing_token": "链接缺少 token。请在 Slack 重新向机器人发消息以获取新的链接。",
|
||||
"error_expired": "链接无效或已过期(有效期 15 分钟)。请重新向机器人发消息获取新的链接。",
|
||||
"error_already_bound": "该 Slack 账号已关联到其他 Multica 用户。账户转移需要先显式解绑。",
|
||||
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
|
||||
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +324,6 @@
|
||||
},
|
||||
"agent_activity": {
|
||||
"hover_header_other": "{{count}} 个智能体正在工作",
|
||||
"hover_header_tasks_other": "{{count}} 个 task 工作中",
|
||||
"hover_header_queued_other": "{{count}} 个智能体排队中",
|
||||
"status_running": "正在工作",
|
||||
"status_queued": "排队中",
|
||||
|
||||
@@ -300,48 +300,37 @@
|
||||
"install_error_forbidden": "你已没有在此工作区安装飞书 Bot 的权限,请联系工作区管理员。",
|
||||
"install_error_generic": "安装失败,请重试。"
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "将每个 Multica 智能体连接到专属的 Slack 机器人。成员可私聊机器人、在频道中 @ 它,或输入 /issue 直接创建 Multica issue。",
|
||||
"not_enabled_title": "Slack 集成未启用",
|
||||
"composio": {
|
||||
"section_title": "Composio",
|
||||
"page_description": "浏览 Composio 的全部 toolkit,连接你的 agent 可以操作的应用。目前只有已配置 auth config 的 toolkit 可以连接。",
|
||||
"not_enabled_title": "Composio 集成未启用",
|
||||
"not_enabled_description_prefix": "在服务器上设置",
|
||||
"not_enabled_description_suffix": "以启用 Slack 机器人安装。",
|
||||
"not_enabled_self_host_hint": "自部署用户:详见项目 README。",
|
||||
"preview_title": "Slack 安装即将上线",
|
||||
"preview_description": "静态加密密钥已设置,但本部署尚未配置托管 Slack 应用的 OAuth 凭据。配置后,连接按钮会出现在这里。",
|
||||
"connected_bots": "已连接的机器人",
|
||||
"loading": "加载中…",
|
||||
"empty_title": "尚未连接机器人",
|
||||
"empty_description_prefix": "在本工作区打开一个 Agent,点击",
|
||||
"empty_description_cta": "连接 Slack",
|
||||
"empty_description_suffix": "为其安装机器人。",
|
||||
"revoked_badge": "已撤销",
|
||||
"installed_at_label": "安装于 {{when}}",
|
||||
"disconnect": "断开连接",
|
||||
"disconnecting": "正在断开…",
|
||||
"disconnect_confirm_title": "断开此 Slack 机器人?",
|
||||
"disconnect_confirm_description": "该机器人将停止接收此工作区的 Slack 消息。安装记录会保留以备审计;你之后可以从同一个 Agent 重新安装。",
|
||||
"not_enabled_description_suffix": "以启用 Composio toolkit 连接。",
|
||||
"loading": "正在加载 toolkit…",
|
||||
"load_failed": "加载 Composio toolkit 失败。",
|
||||
"empty_title": "没有可用的 toolkit",
|
||||
"empty_description": "Composio 未返回任何 toolkit。请检查 API key 和项目配置。",
|
||||
"search_placeholder": "搜索 toolkit…",
|
||||
"connect": "连接",
|
||||
"connecting": "连接中…",
|
||||
"connected": "已连接",
|
||||
"disconnect": "断开",
|
||||
"disconnecting": "断开中…",
|
||||
"not_connectable": "未配置",
|
||||
"not_connectable_hint": "该 toolkit 在你的 Composio 项目中还没有 auth config,因此无法连接。请在 Composio 后台为它添加一个 auth config 后再连接。",
|
||||
"connect_failed": "无法发起连接,请重试。",
|
||||
"disconnect_failed": "无法断开连接,请重试。",
|
||||
"toast_disconnected": "已断开",
|
||||
"disconnect_confirm_title": "断开这个应用?",
|
||||
"disconnect_confirm_description": "你的已连接账号将在 Composio 侧被撤销,你的 agent 将失去对该 toolkit 的访问权限。你可以稍后重新连接。",
|
||||
"disconnect_confirm_cancel": "取消",
|
||||
"toast_disconnected": "已断开 Slack 机器人",
|
||||
"toast_disconnect_failed": "断开失败",
|
||||
"bind_button": "连接 Slack",
|
||||
"bind_button_title": "把 {{agent}} 连接到 Slack 机器人",
|
||||
"connecting": "正在打开 Slack…",
|
||||
"connect_failed_toast": "无法开始 Slack 安装",
|
||||
"agent_bot_connected_label": "已连接到 Slack",
|
||||
"agent_bot_disconnect_tooltip": "将此 Slack 机器人从 Agent 解绑。机器人将停止接收 Slack 消息。",
|
||||
"agent_bot_manage_link": "在 Slack 中打开",
|
||||
"agent_bot_manage_tooltip": "打开此机器人所在的 Slack 工作区。",
|
||||
"byo_dialog_title": "连接 Slack 机器人",
|
||||
"byo_video_cta": "观看配置教程视频",
|
||||
"byo_docs_link": "Step-by-step:把你的 Multica 智能体连接到 Slack",
|
||||
"byo_bot_token_label": "Bot token(xoxb-)",
|
||||
"byo_app_token_label": "App-level token(xapp-)",
|
||||
"byo_submit": "连接",
|
||||
"byo_submitting": "连接中…",
|
||||
"byo_cancel": "取消",
|
||||
"byo_success_toast": "Slack 机器人已连接",
|
||||
"byo_failed_toast": "无法连接 Slack 机器人"
|
||||
"connections_load_failed": "无法加载你已有的连接,连接状态可能不完整。",
|
||||
"toast_connected": "已连接",
|
||||
"toast_connect_failed": "连接未能完成,请重试。",
|
||||
"last_used": "最近使用 {{when}}",
|
||||
"last_used_never": "从未使用",
|
||||
"expired": "令牌已过期",
|
||||
"reconnect": "重新连接"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "代码仓库",
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"leaderboard": {
|
||||
"title": "排行榜",
|
||||
"caption": "{{count}} 个智能体",
|
||||
"caption_with_deleted": "{{count}} 个智能体 · {{deleted}} 个已删除",
|
||||
"deleted_agents": "已删除的智能体",
|
||||
"header_agent": "智能体",
|
||||
"header_tokens": "Token",
|
||||
"header_cost": "费用",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"./settings": "./settings/index.ts",
|
||||
"./settings/lark-tab": "./settings/components/lark-tab.tsx",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./invite": "./invite/index.ts",
|
||||
"./invitations": "./invitations/index.ts",
|
||||
"./onboarding": "./onboarding/index.ts",
|
||||
|
||||
193
packages/views/settings/components/composio-tab.test.tsx
Normal file
193
packages/views/settings/components/composio-tab.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enSettings from "../../locales/en/settings.json";
|
||||
import type { ComposioConnection, ComposioToolkit } from "@multica/core/types";
|
||||
|
||||
// --- Mutable refs the mocked hooks read from, so each test can shape the data
|
||||
// without re-mocking the modules. ---
|
||||
const toolkitsRef = vi.hoisted(() => ({
|
||||
current: { data: [] as ComposioToolkit[], isLoading: false, isError: false },
|
||||
}));
|
||||
const connectionsRef = vi.hoisted(() => ({
|
||||
current: { data: [] as ComposioConnection[], isError: false },
|
||||
}));
|
||||
const searchParamsRef = vi.hoisted(() => ({ current: new URLSearchParams("tab=integrations") }));
|
||||
|
||||
const mockInvalidate = vi.hoisted(() => vi.fn());
|
||||
const mockReplace = vi.hoisted(() => vi.fn());
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: { queryKey: unknown[] }) => {
|
||||
const key = JSON.stringify(opts.queryKey);
|
||||
if (key.includes("toolkits")) return toolkitsRef.current;
|
||||
if (key.includes("connections")) return connectionsRef.current;
|
||||
return { data: undefined };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: mockInvalidate }),
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/composio", () => ({
|
||||
composioKeys: {
|
||||
all: ["composio"],
|
||||
toolkits: () => ["composio", "toolkits"],
|
||||
connections: () => ["composio", "connections"],
|
||||
},
|
||||
composioToolkitsOptions: () => ({ queryKey: ["composio", "toolkits"], queryFn: vi.fn() }),
|
||||
composioConnectionsOptions: () => ({ queryKey: ["composio", "connections"], queryFn: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
beginComposioConnect: vi.fn(),
|
||||
deleteComposioConnection: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
replace: mockReplace,
|
||||
back: vi.fn(),
|
||||
pathname: "/acme/settings",
|
||||
searchParams: searchParamsRef.current,
|
||||
getShareableUrl: (p: string) => `https://app.example${p}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: mockToastSuccess, error: mockToastError },
|
||||
}));
|
||||
|
||||
import { ComposioTab } from "./composio-tab";
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={{ en: { common: enCommon, settings: enSettings } }}>
|
||||
<ComposioTab />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// StrictMode reproduces React's dev-mode mount → cleanup → mount double-invoke,
|
||||
// which is exactly what would double-fire the callback toast without the
|
||||
// consumed-key ref guard.
|
||||
function renderTabStrict() {
|
||||
return render(
|
||||
<StrictMode>
|
||||
<I18nProvider locale="en" resources={{ en: { common: enCommon, settings: enSettings } }}>
|
||||
<ComposioTab />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
const NOTION: ComposioToolkit = {
|
||||
slug: "notion",
|
||||
name: "Notion",
|
||||
connectable: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
toolkitsRef.current = { data: [NOTION], isLoading: false, isError: false };
|
||||
connectionsRef.current = { data: [], isError: false };
|
||||
searchParamsRef.current = new URLSearchParams("tab=integrations");
|
||||
});
|
||||
|
||||
describe("ComposioTab", () => {
|
||||
it("renders a connected card with a 'never used' placeholder when last_used_at is null", () => {
|
||||
connectionsRef.current = {
|
||||
data: [
|
||||
{
|
||||
id: "conn-1",
|
||||
toolkit_slug: "notion",
|
||||
status: "active",
|
||||
connected_at: "2026-06-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
renderTab();
|
||||
expect(screen.getByText(enSettings.composio.connected)).toBeInTheDocument();
|
||||
expect(screen.getByText(enSettings.composio.last_used_never)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'Last used' line when last_used_at is present", () => {
|
||||
connectionsRef.current = {
|
||||
data: [
|
||||
{
|
||||
id: "conn-1",
|
||||
toolkit_slug: "notion",
|
||||
status: "active",
|
||||
connected_at: "2026-06-01T00:00:00Z",
|
||||
last_used_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
renderTab();
|
||||
// "Last used {{when}}" → relative time formatter yields "2m ago"
|
||||
expect(screen.getByText(/Last used/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(enSettings.composio.last_used_never)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the expired branch with a Reconnect button", () => {
|
||||
connectionsRef.current = {
|
||||
data: [
|
||||
{
|
||||
id: "conn-1",
|
||||
toolkit_slug: "notion",
|
||||
status: "expired",
|
||||
connected_at: "2026-06-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
renderTab();
|
||||
expect(screen.getByText(enSettings.composio.expired)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: new RegExp(enSettings.composio.reconnect) }),
|
||||
).toBeInTheDocument();
|
||||
// Not treated as connected, so no Connected badge.
|
||||
expect(screen.queryByText(enSettings.composio.connected)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toasts success and clears the ?connected param on a successful callback", async () => {
|
||||
searchParamsRef.current = new URLSearchParams("tab=integrations&connected=notion");
|
||||
renderTab();
|
||||
await waitFor(() => {
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(enSettings.composio.toast_connected);
|
||||
});
|
||||
expect(mockInvalidate).toHaveBeenCalledWith({ queryKey: ["composio", "connections"] });
|
||||
// The one-shot param is stripped while ?tab is preserved.
|
||||
expect(mockReplace).toHaveBeenCalledWith("/acme/settings?tab=integrations");
|
||||
});
|
||||
|
||||
it("toasts error on a failed callback", async () => {
|
||||
searchParamsRef.current = new URLSearchParams("tab=integrations&error=composio_connect_failed");
|
||||
renderTab();
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(enSettings.composio.toast_connect_failed);
|
||||
});
|
||||
expect(mockReplace).toHaveBeenCalledWith("/acme/settings?tab=integrations");
|
||||
});
|
||||
|
||||
it("fires the success callback exactly once under StrictMode double-invoke", async () => {
|
||||
searchParamsRef.current = new URLSearchParams("tab=integrations&connected=notion");
|
||||
renderTabStrict();
|
||||
await waitFor(() => {
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
// The consumed-key ref must suppress the second (cleanup → re-mount) run.
|
||||
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockInvalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
378
packages/views/settings/components/composio-tab.tsx
Normal file
378
packages/views/settings/components/composio-tab.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, Check, Loader2, Plug, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
composioConnectionsOptions,
|
||||
composioKeys,
|
||||
composioToolkitsOptions,
|
||||
} from "@multica/core/composio";
|
||||
import type { ComposioToolkit } from "@multica/core/types";
|
||||
import { useT, useTimeAgo } from "../../i18n";
|
||||
import { useNavigation } from "../../navigation";
|
||||
|
||||
// ComposioTab renders the full Composio toolkit catalog and lets the user
|
||||
// connect / disconnect the apps their agents can act on.
|
||||
//
|
||||
// Key UX rule (MUL-3720): listing ≠ connectable. Only toolkits with an enabled
|
||||
// auth config in the Composio project carry `connectable: true`; the rest get a
|
||||
// muted "not configured" hint instead of a dead Connect button that would 400.
|
||||
export function ComposioTab() {
|
||||
const { t } = useT("settings");
|
||||
const qc = useQueryClient();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const toolkitsQuery = useQuery(composioToolkitsOptions());
|
||||
const connectionsQuery = useQuery(composioConnectionsOptions());
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [connectingSlug, setConnectingSlug] = useState<string | null>(null);
|
||||
const [disconnectTarget, setDisconnectTarget] = useState<{
|
||||
connectionId: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
|
||||
// The hosted Composio consent flow is a full-page redirect that lands back
|
||||
// on the settings page carrying either `?connected=<slug>` (success) or
|
||||
// `?error=composio_connect_failed` (any backend-side failure — see
|
||||
// Service.CallbackRedirect, MUL-3720). Consume it exactly once: fire a toast,
|
||||
// refresh the connections list so the freshly-linked card flips to Connected
|
||||
// without a manual reload, then strip the one-shot params via `replace` so a
|
||||
// browser refresh doesn't re-toast.
|
||||
const connectedParam = navigation.searchParams.get("connected");
|
||||
const errorParam = navigation.searchParams.get("error");
|
||||
// React Strict Mode (dev / Next) double-invokes mount effects as
|
||||
// mount → cleanup → mount. On the second invoke the `replace` from the first
|
||||
// hasn't committed yet, so the closure still sees the same params and would
|
||||
// toast + invalidate twice. Guard with a ref keyed on the callback we already
|
||||
// consumed; a genuinely new callback (different slug, or the redirect being a
|
||||
// full page load that resets this ref) still fires.
|
||||
const consumedCallbackKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const callbackKey = connectedParam
|
||||
? `connected:${connectedParam}`
|
||||
: errorParam === "composio_connect_failed"
|
||||
? "error:composio_connect_failed"
|
||||
: null;
|
||||
if (!callbackKey) return;
|
||||
if (consumedCallbackKey.current === callbackKey) return;
|
||||
consumedCallbackKey.current = callbackKey;
|
||||
if (connectedParam) {
|
||||
toast.success(t(($) => $.composio.toast_connected));
|
||||
void qc.invalidateQueries({ queryKey: composioKeys.connections() });
|
||||
} else {
|
||||
toast.error(t(($) => $.composio.toast_connect_failed));
|
||||
}
|
||||
// Drop only the Composio one-shot params; keep everything else (notably
|
||||
// ?tab=integrations) so the user stays on this tab.
|
||||
const params = new URLSearchParams(navigation.searchParams);
|
||||
params.delete("connected");
|
||||
params.delete("error");
|
||||
const qs = params.toString();
|
||||
navigation.replace(qs ? `${navigation.pathname}?${qs}` : navigation.pathname);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connectedParam, errorParam]);
|
||||
|
||||
// Map active connections by toolkit slug so each card knows whether it is
|
||||
// already connected (and which connection id to disconnect).
|
||||
const connectionBySlug = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const c of connectionsQuery.data ?? []) {
|
||||
if (c.status === "active") m.set(c.toolkit_slug, c.id);
|
||||
}
|
||||
return m;
|
||||
}, [connectionsQuery.data]);
|
||||
|
||||
// Toolkits whose latest connection is expired render a Reconnect affordance
|
||||
// instead of Connected/Connect. Backend only emits `expired` once Stage 4
|
||||
// (MUL-3719) lands, but the branch is wired up now so it lights up for free.
|
||||
const expiredBySlug = useMemo(() => {
|
||||
const m = new Set<string>();
|
||||
for (const c of connectionsQuery.data ?? []) {
|
||||
if (c.status === "expired") m.add(c.toolkit_slug);
|
||||
}
|
||||
return m;
|
||||
}, [connectionsQuery.data]);
|
||||
|
||||
// Last-used timestamp per active connection, for the "Last used …" line on a
|
||||
// connected card. Backend leaves this null until tool-call dispatch starts
|
||||
// stamping it (Stage 3, MUL-3721); the card shows a "never used" placeholder
|
||||
// until then.
|
||||
const lastUsedBySlug = useMemo(() => {
|
||||
const m = new Map<string, string | null>();
|
||||
for (const c of connectionsQuery.data ?? []) {
|
||||
if (c.status === "active") m.set(c.toolkit_slug, c.last_used_at ?? null);
|
||||
}
|
||||
return m;
|
||||
}, [connectionsQuery.data]);
|
||||
|
||||
const toolkits = useMemo(() => toolkitsQuery.data ?? [], [toolkitsQuery.data]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return toolkits;
|
||||
return toolkits.filter(
|
||||
(tk) =>
|
||||
tk.name.toLowerCase().includes(q) ||
|
||||
tk.slug.toLowerCase().includes(q) ||
|
||||
(tk.category ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [toolkits, query]);
|
||||
|
||||
// 503 handling lives in the parent IntegrationsTab, which hides the whole
|
||||
// Composio section when COMPOSIO_API_KEY is unset — this component only
|
||||
// mounts when the integration is configured, so it deals with the loaded /
|
||||
// error / empty / list states below.
|
||||
|
||||
async function handleConnect(tk: ComposioToolkit) {
|
||||
if (connectingSlug) return;
|
||||
setConnectingSlug(tk.slug);
|
||||
try {
|
||||
const { redirect_url } = await api.beginComposioConnect(tk.slug);
|
||||
// Hand the browser to Composio's hosted consent flow; it redirects back
|
||||
// to /api/integrations/composio/callback when done.
|
||||
window.location.href = redirect_url;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.composio.connect_failed));
|
||||
setConnectingSlug(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!disconnectTarget || disconnecting) return;
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await api.deleteComposioConnection(disconnectTarget.connectionId);
|
||||
await qc.invalidateQueries({ queryKey: composioKeys.connections() });
|
||||
toast.success(t(($) => $.composio.toast_disconnected));
|
||||
setDisconnectTarget(null);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.composio.disconnect_failed));
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.composio.page_description)}</p>
|
||||
</section>
|
||||
|
||||
{toolkitsQuery.isLoading ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.composio.loading)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : toolkitsQuery.isError ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-destructive">{t(($) => $.composio.load_failed)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : toolkits.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">{t(($) => $.composio.empty_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(($) => $.composio.empty_description)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t(($) => $.composio.search_placeholder)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{connectionsQuery.isError && (
|
||||
// Don't silently treat a failed connections fetch as "nothing
|
||||
// connected" — that would hide real connections and offer Connect
|
||||
// on something already linked. Surface it so the user knows the
|
||||
// connected state may be incomplete; the catalog still renders.
|
||||
<p className="text-xs text-destructive">
|
||||
{t(($) => $.composio.connections_load_failed)}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((tk) => (
|
||||
<ToolkitCard
|
||||
key={tk.slug}
|
||||
toolkit={tk}
|
||||
connectionId={connectionBySlug.get(tk.slug)}
|
||||
expired={expiredBySlug.has(tk.slug)}
|
||||
lastUsedAt={lastUsedBySlug.get(tk.slug) ?? null}
|
||||
connecting={connectingSlug === tk.slug}
|
||||
anyConnecting={connectingSlug !== null}
|
||||
onConnect={() => handleConnect(tk)}
|
||||
onDisconnect={(connectionId, name) =>
|
||||
setDisconnectTarget({ connectionId, name })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={!!disconnectTarget}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && !disconnecting) setDisconnectTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t(($) => $.composio.disconnect_confirm_title)}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.composio.disconnect_confirm_description)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={disconnecting}>
|
||||
{t(($) => $.composio.disconnect_confirm_cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
|
||||
{disconnecting
|
||||
? t(($) => $.composio.disconnecting)
|
||||
: t(($) => $.composio.disconnect)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolkitCard({
|
||||
toolkit,
|
||||
connectionId,
|
||||
expired,
|
||||
lastUsedAt,
|
||||
connecting,
|
||||
anyConnecting,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
toolkit: ComposioToolkit;
|
||||
connectionId?: string;
|
||||
expired: boolean;
|
||||
lastUsedAt: string | null;
|
||||
connecting: boolean;
|
||||
anyConnecting: boolean;
|
||||
onConnect: () => void;
|
||||
onDisconnect: (connectionId: string, name: string) => void;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
const timeAgo = useTimeAgo();
|
||||
const isConnected = !!connectionId;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<ToolkitLogo toolkit={toolkit} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{toolkit.name || toolkit.slug}</p>
|
||||
{isConnected ? (
|
||||
// Last-used line. Backend leaves last_used_at null until Stage 3
|
||||
// dispatch stamps it, so show a localized "never used" placeholder
|
||||
// rather than hiding the line entirely.
|
||||
<p className="truncate text-[10px] text-muted-foreground">
|
||||
{lastUsedAt
|
||||
? t(($) => $.composio.last_used, { when: timeAgo(lastUsedAt) })
|
||||
: t(($) => $.composio.last_used_never)}
|
||||
</p>
|
||||
) : toolkit.category ? (
|
||||
<p className="truncate text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{toolkit.category}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isConnected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
|
||||
<Check className="h-3 w-3" />
|
||||
{t(($) => $.composio.connected)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDisconnect(connectionId!, toolkit.name || toolkit.slug)}
|
||||
aria-label={t(($) => $.composio.disconnect)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : expired ? (
|
||||
// Token-expired connection: surface the failure and let the user
|
||||
// re-run the same connect flow in one click (no disconnect step).
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t(($) => $.composio.expired)}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={onConnect} disabled={anyConnecting}>
|
||||
{connecting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
{connecting ? t(($) => $.composio.connecting) : t(($) => $.composio.reconnect)}
|
||||
</Button>
|
||||
</div>
|
||||
) : toolkit.connectable ? (
|
||||
<Button size="sm" onClick={onConnect} disabled={anyConnecting}>
|
||||
{connecting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plug className="h-3 w-3" />
|
||||
)}
|
||||
{connecting ? t(($) => $.composio.connecting) : t(($) => $.composio.connect)}
|
||||
</Button>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
title={t(($) => $.composio.not_connectable_hint)}
|
||||
>
|
||||
{t(($) => $.composio.not_connectable)}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolkitLogo({ toolkit }: { toolkit: ComposioToolkit }) {
|
||||
const initial = (toolkit.name || toolkit.slug).charAt(0).toUpperCase();
|
||||
if (toolkit.logo) {
|
||||
return (
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="h-8 w-8 shrink-0 rounded bg-muted object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LarkTab } from "./lark-tab";
|
||||
import { SlackTab } from "./slack-tab";
|
||||
import { ComposioTab } from "./composio-tab";
|
||||
import { ApiError } from "@multica/core/api";
|
||||
import { composioToolkitsOptions } from "@multica/core/composio";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Integrations is the umbrella tab for third-party platform connections.
|
||||
// GitHub has its own top-level tab (see github-tab.tsx); everything else
|
||||
// — currently Lark and Slack, with Linear etc. to follow — lives in here
|
||||
// under its own section heading so additional integrations slot in without
|
||||
// changing the IA. IntegrationsTab is just the host; each integration owns
|
||||
// its own description and install flow.
|
||||
// — Lark, Composio, with Slack/Linear etc. to follow — lives in here under
|
||||
// its own section heading so additional integrations slot in without changing
|
||||
// the IA. IntegrationsTab is just the host; each integration owns its own
|
||||
// description and install flow.
|
||||
export function IntegrationsTab() {
|
||||
const { t } = useT("settings");
|
||||
|
||||
// Composio is hidden entirely until a key is configured server-side. A 503
|
||||
// from the toolkits endpoint means COMPOSIO_API_KEY is unset; rather than
|
||||
// render a card that leaks an internal env-var name to every end user, the
|
||||
// whole section (heading + body) is withheld. Admin-only "set this up"
|
||||
// guidance is a later, role-gated affordance (MUL-3720 discussion).
|
||||
const composioToolkits = useQuery(composioToolkitsOptions());
|
||||
const composioUnconfigured =
|
||||
composioToolkits.error instanceof ApiError && composioToolkits.error.status === 503;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.lark.section_title)}</h2>
|
||||
<LarkTab />
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.slack.section_title)}</h2>
|
||||
<SlackTab />
|
||||
</section>
|
||||
{!composioUnconfigured && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.composio.section_title)}</h2>
|
||||
<ComposioTab />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enSettings from "../../locales/en/settings.json";
|
||||
|
||||
type MemberRole = "owner" | "admin" | "member" | "guest";
|
||||
|
||||
const membersRef = vi.hoisted(() => ({
|
||||
current: [{ user_id: "user-1", role: "owner" as MemberRole }],
|
||||
}));
|
||||
const installationsRef = vi.hoisted(() => ({
|
||||
current: {
|
||||
installations: [] as unknown[],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
},
|
||||
}));
|
||||
const mockRegisterBYO = vi.hoisted(() => vi.fn());
|
||||
const mockDeleteInstallation = vi.hoisted(() => vi.fn());
|
||||
const mockOpenExternal = vi.hoisted(() => vi.fn());
|
||||
const mockInvalidate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: { queryKey: unknown[]; enabled?: boolean }) => {
|
||||
if (opts.enabled === false) return { data: undefined, isLoading: false };
|
||||
const key = JSON.stringify(opts.queryKey);
|
||||
if (key.includes("members")) return { data: membersRef.current, isLoading: false };
|
||||
if (key.includes("installations")) return { data: installationsRef.current, isLoading: false };
|
||||
return { data: undefined, isLoading: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: mockInvalidate }),
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "workspace-1" }));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getAgentName: (agentId: string) => `Agent ${agentId}`,
|
||||
getMemberName: () => "Unknown",
|
||||
getSquadName: () => "Unknown Squad",
|
||||
getActorName: () => "Unknown",
|
||||
getActorInitials: () => "??",
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorId }: { actorId: string }) => (
|
||||
<span data-testid="actor-avatar" data-actor-id={actorId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: vi.fn(),
|
||||
}),
|
||||
slackKeys: { installations: (wsId: string) => ["slack", "installations", wsId] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
registerSlackBYO: mockRegisterBYO,
|
||||
deleteSlackInstallation: mockDeleteInstallation,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(sel?: (s: { user: { id: string } }) => unknown) =>
|
||||
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
|
||||
{ getState: () => ({ user: { id: "user-1" } }) },
|
||||
);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../platform", () => ({ openExternal: mockOpenExternal }));
|
||||
|
||||
import { SlackAgentBindButton, SlackTab } from "./slack-tab";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, settings: enSettings } };
|
||||
|
||||
function renderUI(children: ReactNode) {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
{children}
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function resetFixtures() {
|
||||
vi.clearAllMocks();
|
||||
membersRef.current = [{ user_id: "user-1", role: "owner" }];
|
||||
installationsRef.current = { installations: [], configured: true, install_supported: true };
|
||||
}
|
||||
|
||||
describe("SlackAgentBindButton", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("opens the BYO dialog and submits the pasted bot + app tokens", async () => {
|
||||
mockRegisterBYO.mockResolvedValue({ id: "i1", agent_id: "agent-1", status: "active" });
|
||||
renderUI(<SlackAgentBindButton agentId="agent-1" agentName="Bot" />);
|
||||
await userEvent.click(screen.getByTestId("slack-agent-connect"));
|
||||
const botInput = await screen.findByTestId("slack-byo-bot-token");
|
||||
await userEvent.type(botInput, "xoxb-bot");
|
||||
await userEvent.type(screen.getByTestId("slack-byo-app-token"), "xapp-1-A0X-1-secret");
|
||||
await userEvent.click(screen.getByTestId("slack-byo-submit"));
|
||||
await waitFor(() =>
|
||||
expect(mockRegisterBYO).toHaveBeenCalledWith("workspace-1", "agent-1", {
|
||||
bot_token: "xoxb-bot",
|
||||
app_token: "xapp-1-A0X-1-secret",
|
||||
}),
|
||||
);
|
||||
// No OAuth redirect anymore — install is a direct API call.
|
||||
expect(mockOpenExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the connected badge (not the CTA) when the agent already has an active install", () => {
|
||||
installationsRef.current = {
|
||||
installations: [{ id: "i1", agent_id: "agent-1", status: "active", team_id: "T1" }],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
};
|
||||
renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(screen.getByTestId("slack-agent-bot-connected")).toBeTruthy();
|
||||
expect(screen.getByTestId("slack-agent-bot-disconnect")).toBeTruthy();
|
||||
expect(screen.queryByTestId("slack-agent-connect")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for a non-manager", () => {
|
||||
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
||||
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when install is unavailable and the agent is unbound", () => {
|
||||
installationsRef.current = { installations: [], configured: true, install_supported: false };
|
||||
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackTab", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("surfaces the not-enabled notice when the deployment has no Slack key", () => {
|
||||
installationsRef.current = { installations: [], configured: false, install_supported: false };
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText(/Slack integration not enabled/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the empty state when configured but nothing is connected", () => {
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText(/No bots connected yet/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists a connected installation with its agent name and a disconnect control", () => {
|
||||
installationsRef.current = {
|
||||
installations: [{ id: "i1", agent_id: "agent-7", status: "active", team_id: "T1" }],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
};
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText("Agent agent-7")).toBeTruthy();
|
||||
expect(screen.getByText(/Disconnect/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user