mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 19:09:27 +02:00
Compare commits
35 Commits
agent/lamb
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a98e2cdca | ||
|
|
4aaa5ee412 | ||
|
|
20b50230bb | ||
|
|
cdc67694ce | ||
|
|
66794fc4f3 | ||
|
|
4708dba978 | ||
|
|
506f2df7ad | ||
|
|
3c61f729d4 | ||
|
|
aa4268c1e2 | ||
|
|
81291e334e | ||
|
|
59cb534e87 | ||
|
|
f892e03e41 | ||
|
|
d970b68ce7 | ||
|
|
5d79696fb5 | ||
|
|
de7f3cb9e3 | ||
|
|
b336f07617 | ||
|
|
f8405a931d | ||
|
|
10b33b14f5 | ||
|
|
9f1766cdb3 | ||
|
|
2b940046d7 | ||
|
|
f59cb2f494 | ||
|
|
d2bc85e01a | ||
|
|
63eb6f73ad | ||
|
|
c2e8892194 | ||
|
|
5206d7c613 | ||
|
|
e444698a09 | ||
|
|
658e63d9be | ||
|
|
51ae12604c | ||
|
|
11a3cf206b | ||
|
|
6e2d2c003c | ||
|
|
b933d9fd41 | ||
|
|
4fb6c0fb0e | ||
|
|
0c2f93bcd1 | ||
|
|
78d668a2f2 | ||
|
|
e2103a240d |
22
Makefile
22
Makefile
@@ -37,25 +37,27 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# Self-hosting requires Docker Compose v2 (the `docker compose` CLI plugin).
|
||||
# Self-hosting requires the Docker Compose CLI plugin (`docker compose`).
|
||||
# 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 >/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."; \
|
||||
@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."; \
|
||||
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
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
|
||||
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
|
||||
endef
|
||||
|
||||
# Default target changed from selfhost to help: bare `make` now prints this help
|
||||
|
||||
93
apps/docs/content/docs/channels.ja.mdx
Normal file
93
apps/docs/content/docs/channels.ja.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
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)
|
||||
93
apps/docs/content/docs/channels.ko.mdx
Normal file
93
apps/docs/content/docs/channels.ko.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
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)
|
||||
93
apps/docs/content/docs/channels.mdx
Normal file
93
apps/docs/content/docs/channels.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
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)
|
||||
93
apps/docs/content/docs/channels.zh.mdx
Normal file
93
apps/docs/content/docs/channels.zh.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
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)、[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 つだけです: ローカルに [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. アカウントを作成する
|
||||
|
||||
|
||||
@@ -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), [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) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
|
||||
전제 조건은 하나뿐입니다: 로컬에 [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. 계정 만들기
|
||||
|
||||
|
||||
@@ -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), [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.
|
||||
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.
|
||||
|
||||
## 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)、[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))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
前置只有一个:你本地已经装了至少一款 [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. 注册账号
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
起動時にデーモンは 4 つのことを行います。
|
||||
|
||||
1. ログイン時に保存された認証情報を読み込みます
|
||||
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))
|
||||
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))
|
||||
3. 検出した各ツールに対するランタイムとともに、自身をサーバーに登録します
|
||||
4. **3 秒ごと**に取得すべきタスクがないかポーリングし、**15 秒ごとにハートビートを送信**し続けます
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
시작 시 데몬은 네 가지 일을 합니다.
|
||||
|
||||
1. 로그인할 때 저장된 인증 정보를 읽습니다
|
||||
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))
|
||||
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))
|
||||
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), [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))
|
||||
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))
|
||||
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)、[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))
|
||||
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))
|
||||
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)、[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)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
|
||||
- **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)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
|
||||
|
||||
ツールチェーンがローカルに留まるため、**あなたの API キー、コードディレクトリ、認可されたツール**は、あなたのマシン上でのみ使用されます。Multica サーバーはそのいずれも目にすることはありません。これはセルフホストでも Cloud でも同じように適用されます。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica는 **분산형** 플랫폼입니다. 여러분이 보는 웹 인터페
|
||||
|
||||
- **Multica 서버** — 여러분이 보는 워크스페이스, 이슈 목록, 댓글 스레드는 모두 이곳의 데이터베이스에 저장됩니다. 또한 여러분과 동료 사이의 실시간 업데이트를 푸시하는 WebSocket 허브이기도 합니다. 에이전트 작업은 **실행하지 않습니다.**
|
||||
- **데몬** — Multica CLI의 일부로, 여러분 자신의 기기에서 실행됩니다. 시작 시 로컬에 설치된 AI 코딩 도구를 감지하고, 서버에 등록한 다음, 3초마다 작업을 폴링하고 15초마다 하트비트를 전송하기 시작합니다.
|
||||
- **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). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
|
||||
- **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). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
|
||||
|
||||
도구 체인이 로컬에 유지되므로 **여러분의 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), [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.
|
||||
- **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.
|
||||
|
||||
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)、[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 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
- **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 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
|
||||
工具链在本地的结果:**你的 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)、[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 キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
|
||||
- **ローカル[デーモン](/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 キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
|
||||
|
||||
<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), [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 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
|
||||
- **로컬 [데몬](/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 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
|
||||
|
||||
<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), [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.
|
||||
- **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.
|
||||
|
||||
<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)、[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 密钥、工具链、代码目录都保留在本地。
|
||||
- **本地 [守护进程](/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 密钥、工具链、代码目录都保留在本地。
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
|
||||
@@ -159,14 +159,14 @@ Agentic coding CLI using the ACP protocol over stdio (shares the transport with
|
||||
|
||||
### Antigravity (Google)
|
||||
|
||||
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
|
||||
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Multica launches it with `agy -p`, the daemon-compatible non-interactive mode; current Antigravity CLI releases can execute tools from that mode, while `agy -i` requires an attached TTY. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `agy` |
|
||||
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
|
||||
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. |
|
||||
|
||||
## After installing
|
||||
|
||||
|
||||
@@ -159,14 +159,14 @@ ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用,MCP 配置
|
||||
|
||||
### Antigravity(Google)
|
||||
|
||||
Google 的 Antigravity CLI(`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
|
||||
Google 的 Antigravity CLI(`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动它,这是适合 daemon 后台任务的一次性非交互模式;当前 Antigravity CLI 在这个模式下仍可执行工具,而 `agy -i` 需要连接 TTY,不适合 daemon 驱动。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `agy` |
|
||||
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
|
||||
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
|
||||
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
|
||||
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 过程和最终回复都会作为 text 消息送回 Multica,目前无法展示 Antigravity 的逐工具 telemetry。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---インボックス---",
|
||||
"inbox",
|
||||
"---連携---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---セルフホスト & 運用---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---인박스---",
|
||||
"inbox",
|
||||
"---연동---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---자체 호스팅 & 운영---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -31,7 +31,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|
||||
### Antigravity
|
||||
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. Multica launches Antigravity with `agy -p` because that is the daemon-compatible non-interactive mode; `agy -i` needs an attached TTY and is not suitable for background task execution. Current Antigravity CLI releases can still execute tools from this mode, but stdout is plain assistant text rather than a structured event stream, so Multica relays the transcript as text and cannot show per-tool telemetry for Antigravity today. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
### Claude Code
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flag(agy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug;而且 agy 遇到无法识别的值会静默空跑,所以优先从发现列表里挑选,不要手填。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动 Antigravity,因为这是适合 daemon 后台任务的一次性非交互模式;`agy -i` 需要连接 TTY,不适合后台执行。当前 Antigravity CLI 在 `agy -p` 下仍可执行工具,但 stdout 是纯文本而非结构化事件流,所以 Multica 会把 transcript 作为 text 转发,暂时无法展示逐工具 telemetry。**会话恢复真用**——通过 `--conversation <id>`,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flag(agy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug;而且 agy 遇到无法识别的值会静默空跑,所以优先从发现列表里挑选,不要手填。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
### Claude Code
|
||||
|
||||
|
||||
175
apps/docs/content/docs/slack-bot-integration.ja.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.ja.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
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) — セルフホスト構成の完全なリファレンス
|
||||
175
apps/docs/content/docs/slack-bot-integration.ko.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.ko.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
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) — 전체 자체 호스팅 구성 참조
|
||||
175
apps/docs/content/docs/slack-bot-integration.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
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
|
||||
175
apps/docs/content/docs/slack-bot-integration.zh.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.zh.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
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) —— 完整的自部署配置参考
|
||||
23
apps/web/app/slack/bind/page.tsx
Normal file
23
apps/web/app/slack/bind/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"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,6 +293,26 @@ 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,6 +269,26 @@ 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,6 +268,26 @@ 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,6 +293,26 @@ 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",
|
||||
|
||||
193
e2e/agent-mcp.spec.ts
Normal file
193
e2e/agent-mcp.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
import { waitForPageText } from "./helpers";
|
||||
|
||||
// Stage 3.2 (MUL-3870): the creator-only MCP tab on the agent detail page.
|
||||
//
|
||||
// Auth + workspace bootstrap go through the real backend (same as every other
|
||||
// spec), but the agent list and the Composio connection/catalog endpoints are
|
||||
// mocked at the network boundary so the test runs without a configured
|
||||
// COMPOSIO_API_KEY or a live runtime to bind an agent to. The PUT /api/agents
|
||||
// write is intercepted so we can assert the exact allowlist body the toggle
|
||||
// produces — the heart of the data contract — instead of depending on the
|
||||
// backend persisting it.
|
||||
|
||||
const E2E_WORKER =
|
||||
process.env.TEST_PARALLEL_INDEX ?? process.env.TEST_WORKER_INDEX ?? "0";
|
||||
const E2E_RUN_ID =
|
||||
process.env.E2E_RUN_ID ?? `${Date.now().toString(36)}-${process.pid.toString(36)}`;
|
||||
const EMAIL = `e2e-mcp-${E2E_WORKER}-${E2E_RUN_ID}@multica.ai`;
|
||||
const NAME = "E2E MCP User";
|
||||
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_USER_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
interface SetupResult {
|
||||
slug: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** Log in via the real API, capture the authed user id, inject the token, and
|
||||
* return the workspace slug + user id so the test can mock an agent owned
|
||||
* (or not) by this exact user. */
|
||||
async function loginCapturingUser(page: Page): Promise<SetupResult> {
|
||||
const api = new TestApiClient();
|
||||
const data = await api.login(EMAIL, NAME);
|
||||
const userId: string | undefined = data?.user?.id;
|
||||
if (!userId) throw new Error("login did not return a user id");
|
||||
const workspace = await api.ensureWorkspace(
|
||||
`E2E MCP WS ${E2E_WORKER}`,
|
||||
`e2e-mcp-${E2E_WORKER}-${E2E_RUN_ID}`,
|
||||
);
|
||||
await api.markUserOnboarded();
|
||||
const token = api.getToken();
|
||||
if (!token) throw new Error("login did not return a token");
|
||||
await page.addInitScript((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
localStorage.setItem("multica:chat:isOpen", "false");
|
||||
}, token);
|
||||
return { slug: workspace.slug, userId };
|
||||
}
|
||||
|
||||
function mockAgent(ownerId: string, workspaceId: string) {
|
||||
return {
|
||||
id: AGENT_ID,
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "MCP Test Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_args: [],
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: ownerId,
|
||||
skills: [],
|
||||
created_at: "2026-06-30T00:00:00Z",
|
||||
updated_at: "2026-06-30T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
composio_toolkit_allowlist: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock the Composio catalog + the current user's active connections (Notion +
|
||||
* Slack), the agent list (owned by `ownerId`), and capture any PUT
|
||||
* /api/agents/<id> body. Returns a getter for the last captured allowlist. */
|
||||
async function mockApis(page: Page, ownerId: string) {
|
||||
const captured: { allowlist?: unknown } = {};
|
||||
|
||||
await page.route("**/api/integrations/composio/toolkits", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ slug: "notion", name: "Notion", connectable: true },
|
||||
{ slug: "slack", name: "Slack", connectable: true },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/integrations/composio/connections", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "conn-notion",
|
||||
toolkit_slug: "notion",
|
||||
status: "active",
|
||||
connected_at: "2026-06-30T00:00:00Z",
|
||||
last_used_at: null,
|
||||
},
|
||||
{
|
||||
id: "conn-slack",
|
||||
toolkit_slug: "slack",
|
||||
status: "active",
|
||||
connected_at: "2026-06-30T00:00:00Z",
|
||||
last_used_at: null,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// One handler for both the list (GET, query string) and the write
|
||||
// (PUT /api/agents/<id>). Other agent sub-routes fall through.
|
||||
await page.route("**/api/agents**", (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
const workspaceId = url.searchParams.get("workspace_id") ?? "ws-mock";
|
||||
|
||||
if (req.method() === "PUT" && url.pathname.endsWith(`/api/agents/${AGENT_ID}`)) {
|
||||
const body = req.postDataJSON?.() ?? {};
|
||||
captured.allowlist = body.composio_toolkit_allowlist;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...mockAgent(ownerId, workspaceId),
|
||||
composio_toolkit_allowlist: body.composio_toolkit_allowlist ?? [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method() === "GET" && url.pathname.endsWith("/api/agents")) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([mockAgent(ownerId, workspaceId)]),
|
||||
});
|
||||
}
|
||||
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
return () => captured.allowlist;
|
||||
}
|
||||
|
||||
test.describe("Agent MCP tab (creator-only)", () => {
|
||||
test("creator sees the MCP Apps tab and toggling a toolkit writes the allowlist", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { slug, userId } = await loginCapturingUser(page);
|
||||
const getAllowlist = await mockApis(page, userId);
|
||||
|
||||
await page.goto(`/${slug}/agents/${AGENT_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await waitForPageText(page, "MCP Test Agent");
|
||||
|
||||
// The creator-only tab entry is present and opens the connection list.
|
||||
const tab = page.getByRole("button", { name: "MCP Apps" });
|
||||
await expect(tab).toBeVisible({ timeout: 15000 });
|
||||
await tab.click();
|
||||
|
||||
await expect(page.getByText("Notion")).toBeVisible();
|
||||
await expect(page.getByText("Slack")).toBeVisible();
|
||||
|
||||
// Allow Notion → the PUT body carries exactly ["notion"].
|
||||
await page.getByLabel(/Allow Notion for this agent/i).click();
|
||||
await expect.poll(() => getAllowlist()).toEqual(["notion"]);
|
||||
});
|
||||
|
||||
test("a non-creator viewer does not see the MCP Apps tab", async ({ page }) => {
|
||||
const { slug } = await loginCapturingUser(page);
|
||||
// Agent owned by someone else → the creator gate hides the tab entry.
|
||||
await mockApis(page, OTHER_USER_ID);
|
||||
|
||||
await page.goto(`/${slug}/agents/${AGENT_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await waitForPageText(page, "MCP Test Agent");
|
||||
|
||||
// Other tabs render, but the creator-only MCP Apps entry must not.
|
||||
await expect(page.getByRole("button", { name: "Activity" })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "MCP Apps" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./types";
|
||||
export * from "./derive-presence";
|
||||
export * from "./queries";
|
||||
export * from "./use-agent-presence";
|
||||
export * from "./use-update-agent-allowlist";
|
||||
export * from "./use-agent-activity";
|
||||
export * from "./use-workspace-presence-prefetch";
|
||||
export * from "./constants";
|
||||
|
||||
53
packages/core/agents/use-update-agent-allowlist.ts
Normal file
53
packages/core/agents/use-update-agent-allowlist.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { Agent } from "../types";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
|
||||
/**
|
||||
* Mutation hook for the creator-only MCP tab: writes an agent's Composio
|
||||
* toolkit allowlist via `PUT /api/agents/:id` ({ composio_toolkit_allowlist })
|
||||
* — no dedicated endpoint, the existing agent PATCH path carries it (MUL-3870).
|
||||
*
|
||||
* The hook is optimistic: it patches the matching agent in the cached
|
||||
* workspace list before the round-trip so the checkbox flips instantly, then
|
||||
* rolls back to the captured snapshot on error and always invalidates on
|
||||
* settle so the cache reconverges with the server's normalised slugs
|
||||
* (lowercase / trimmed / deduped). The server silently drops the write for
|
||||
* non-owners, which is why this is only wired into the owner-gated tab.
|
||||
*
|
||||
* Accepts the full desired allowlist (`string[]`) — callers compute the next
|
||||
* array (add / remove a slug) and pass it wholesale, matching the backend's
|
||||
* replace semantics. Pass `[]` to clear every toolkit.
|
||||
*/
|
||||
export function useUpdateAgentAllowlist(agentId: string) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation<Agent, Error, string[], { previous?: Agent[] }>({
|
||||
mutationFn: (allowlist) =>
|
||||
api.updateAgent(agentId, { composio_toolkit_allowlist: allowlist }),
|
||||
onMutate: async (allowlist) => {
|
||||
const queryKey = workspaceKeys.agents(wsId);
|
||||
// Cancel in-flight refetches so they can't clobber the optimistic write.
|
||||
await qc.cancelQueries({ queryKey });
|
||||
const previous = qc.getQueryData<Agent[]>(queryKey);
|
||||
qc.setQueryData<Agent[]>(queryKey, (old) =>
|
||||
old?.map((a) =>
|
||||
a.id === agentId
|
||||
? ({ ...a, composio_toolkit_allowlist: allowlist } as Agent)
|
||||
: a,
|
||||
),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _allowlist, context) => {
|
||||
if (context?.previous) {
|
||||
qc.setQueryData(workspaceKeys.agents(wsId), context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -100,6 +100,7 @@ import type {
|
||||
UpdateAutopilotTriggerRequest,
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
AutopilotCollaboratorsResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
@@ -112,6 +113,13 @@ import type {
|
||||
BeginLarkInstallResponse,
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
ComposioToolkit,
|
||||
ComposioConnection,
|
||||
ComposioConnectInitResponse,
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
@@ -2107,6 +2115,22 @@ 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" });
|
||||
}
|
||||
@@ -2270,4 +2294,67 @@ export class ApiClient {
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
/** 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({ toolkit_slug: toolkitSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Disconnects a Composio connection the caller owns. */
|
||||
async deleteComposioConnection(connectionId: string): Promise<void> {
|
||||
await this.fetch(`/api/integrations/composio/connections/${connectionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Slack integration (MUL-3666)
|
||||
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
|
||||
}
|
||||
|
||||
// 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()}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
|
||||
return this.fetch(`/api/slack/binding/redeem`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +840,10 @@ 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,6 +104,30 @@ 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,13 +14,17 @@ 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,
|
||||
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,
|
||||
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,
|
||||
/** 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. */
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
taskMessagesAll: () => ["task-messages"] as const,
|
||||
taskMessages: (taskId: string) => [...chatKeys.taskMessagesAll(), 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,6 +85,10 @@
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./lark/queries": "./lark/queries.ts",
|
||||
"./composio": "./composio/index.ts",
|
||||
"./composio/queries": "./composio/queries.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./slack/queries": "./slack/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 + 1 workspaceKeys.list()
|
||||
// + 1 cross-workspace inbox unread summary = 23 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(23);
|
||||
// (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);
|
||||
});
|
||||
|
||||
it("does not re-invalidate when rerendered with the same ws instance", () => {
|
||||
@@ -164,4 +164,26 @@ 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,6 +23,7 @@ import {
|
||||
} from "../agents/queries";
|
||||
import { githubKeys } from "../github/queries";
|
||||
import { larkKeys } from "../lark/queries";
|
||||
import { slackKeys } from "../slack/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -339,6 +340,14 @@ 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() });
|
||||
}
|
||||
|
||||
@@ -484,6 +493,10 @@ 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
packages/core/slack/index.ts
Normal file
1
packages/core/slack/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { slackKeys, slackInstallationsOptions } from "./queries";
|
||||
18
packages/core/slack/queries.ts
Normal file
18
packages/core/slack/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
});
|
||||
@@ -281,6 +281,24 @@ export interface Agent {
|
||||
* Older backends omit this field; treat `undefined` as false.
|
||||
*/
|
||||
mcp_config_redacted?: boolean;
|
||||
/**
|
||||
* The subset of Composio toolkit slugs this agent is allowed to mount as
|
||||
* MCP servers at task dispatch — but only when the run originator is the
|
||||
* agent owner (MUL-3869 / MUL-3721). `null`/`[]`/omitted all mean "no
|
||||
* overlay regardless of who triggers". Owner-only data: the server hands
|
||||
* it through verbatim to the owner and redacts it to `undefined` +
|
||||
* `composio_toolkit_allowlist_redacted=true` for everyone else (same
|
||||
* contract as `mcp_config`). Treat `undefined` as "unknown — assume none".
|
||||
*/
|
||||
composio_toolkit_allowlist?: string[];
|
||||
/**
|
||||
* True when the server stripped `composio_toolkit_allowlist` from this
|
||||
* response because the caller is not the agent owner. The MCP tab is
|
||||
* creator-only so a redacted value should never reach the editor, but the
|
||||
* UI renders a "hidden" fallback defensively. Older backends omit this
|
||||
* field; treat `undefined` as false.
|
||||
*/
|
||||
composio_toolkit_allowlist_redacted?: boolean;
|
||||
visibility: AgentVisibility;
|
||||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
@@ -432,6 +450,18 @@ export interface UpdateAgentRequest {
|
||||
* validate / translate it according to their own MCP integration
|
||||
*/
|
||||
mcp_config?: unknown | null;
|
||||
/**
|
||||
* Composio toolkit allowlist. Tri-state semantics, mirroring the backend
|
||||
* gate (MUL-3869):
|
||||
* - field omitted → no change
|
||||
* - `null` → clear the column (no MCP overlay for anyone)
|
||||
* - string[] → wholesale replace; the server lowercases / trims / dedupes
|
||||
* the slugs before persisting
|
||||
* Writes are silently dropped server-side unless the caller is the agent
|
||||
* owner, so the UI only ever exposes this field through the creator-only
|
||||
* MCP tab.
|
||||
*/
|
||||
composio_toolkit_allowlist?: string[] | null;
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
|
||||
@@ -49,6 +49,16 @@ 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 {
|
||||
@@ -62,6 +72,19 @@ 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;
|
||||
@@ -164,6 +187,9 @@ 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;
|
||||
}
|
||||
@@ -119,6 +119,17 @@ export type {
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
} from "./lark";
|
||||
export type {
|
||||
ComposioToolkit,
|
||||
ComposioConnection,
|
||||
ComposioConnectInitResponse,
|
||||
} from "./composio";
|
||||
export type {
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
} from "./slack";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
@@ -126,6 +137,8 @@ export type {
|
||||
AutopilotAssigneeType,
|
||||
AutopilotSubscriber,
|
||||
AutopilotSubscriberInput,
|
||||
AutopilotCollaborator,
|
||||
AutopilotCollaboratorsResponse,
|
||||
AutopilotTrigger,
|
||||
AutopilotTriggerKind,
|
||||
AutopilotRun,
|
||||
|
||||
50
packages/core/types/slack.ts
Normal file
50
packages/core/types/slack.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @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,7 +61,11 @@ export function AgentActivityHoverContent({
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.agent_activity.hover_header, { count: tasks.length })}
|
||||
{/* 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 })}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{tasks.map((task) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ 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;
|
||||
@@ -215,13 +216,12 @@ export function AgentDetailInspector({
|
||||
</div>
|
||||
|
||||
{/* Integrations — surfaces external-channel bind entry points
|
||||
(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. */}
|
||||
(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. */}
|
||||
{canEdit && (
|
||||
<div className="flex flex-col px-5 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -235,6 +235,11 @@ export function AgentDetailInspector({
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
<SlackAgentBindButton
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -301,6 +301,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
agent={agent}
|
||||
runtimes={runtimes}
|
||||
onUpdate={handleUpdate}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
navIntent={tabNavIntent}
|
||||
onNavIntentHandled={() => setTabNavIntent(null)}
|
||||
/>
|
||||
|
||||
@@ -182,8 +182,9 @@ export function AgentListToolbar({
|
||||
);
|
||||
|
||||
return (
|
||||
<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
|
||||
<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
|
||||
(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. */}
|
||||
@@ -546,6 +547,7 @@ export function AgentListToolbar({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ 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",
|
||||
}));
|
||||
@@ -54,6 +57,12 @@ 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";
|
||||
|
||||
@@ -119,6 +128,7 @@ function renderPane(runtimes: AgentRuntime[]) {
|
||||
|
||||
beforeEach(() => {
|
||||
larkListingRef.current = { installations: [], configured: false };
|
||||
slackListingRef.current = { installations: [], configured: false };
|
||||
});
|
||||
|
||||
describe("AgentOverviewPane MCP tab visibility", () => {
|
||||
@@ -163,9 +173,19 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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.
|
||||
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.
|
||||
renderPane([makeRuntime("claude")]);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^Integrations$/i }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Blocks,
|
||||
BookOpenText,
|
||||
FileText,
|
||||
KeyRound,
|
||||
@@ -17,6 +18,7 @@ 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,
|
||||
@@ -33,6 +35,7 @@ import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
import { CustomArgsTab } from "./tabs/custom-args-tab";
|
||||
import { McpConfigTab } from "./tabs/mcp-config-tab";
|
||||
import { AgentMcpTab } from "./tabs/agent-mcp-tab";
|
||||
import { IntegrationsTab } from "./tabs/integrations-tab";
|
||||
import { RuntimeConfigTab } from "./tabs/runtime-config-tab";
|
||||
import { ActorIssuesPanel } from "../../common/actor-issues-panel";
|
||||
@@ -46,10 +49,11 @@ export type DetailTab =
|
||||
| "env"
|
||||
| "custom_args"
|
||||
| "mcp_config"
|
||||
| "composio_mcp"
|
||||
| "integrations"
|
||||
| "runtime_config";
|
||||
|
||||
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args" | "mcp_config" | "integrations" | "runtime_config"> = {
|
||||
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args" | "mcp_config" | "composio_mcp" | "integrations" | "runtime_config"> = {
|
||||
activity: "activity",
|
||||
tasks: "tasks",
|
||||
instructions: "instructions",
|
||||
@@ -57,6 +61,7 @@ const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "
|
||||
env: "environment",
|
||||
custom_args: "custom_args",
|
||||
mcp_config: "mcp_config",
|
||||
composio_mcp: "composio_mcp",
|
||||
integrations: "integrations",
|
||||
runtime_config: "runtime_config",
|
||||
};
|
||||
@@ -72,6 +77,7 @@ const detailTabs: {
|
||||
{ id: "env", icon: KeyRound },
|
||||
{ id: "custom_args", icon: Terminal },
|
||||
{ id: "mcp_config", icon: Plug },
|
||||
{ id: "composio_mcp", icon: Blocks },
|
||||
{ id: "integrations", icon: Webhook },
|
||||
{ id: "runtime_config", icon: Router },
|
||||
];
|
||||
@@ -80,6 +86,13 @@ interface AgentOverviewPaneProps {
|
||||
agent: Agent;
|
||||
runtimes: AgentRuntime[];
|
||||
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
/**
|
||||
* The viewer's user id. Gates the creator-only MCP tab — the tab entry is
|
||||
* only rendered when the viewer is the agent owner (`agent.owner_id`),
|
||||
* matching the backend's owner-only read/write of the toolkit allowlist
|
||||
* (MUL-3870). `null` while auth is still loading hides the tab.
|
||||
*/
|
||||
currentUserId?: string | null;
|
||||
/**
|
||||
* One-shot request from a sibling (the inspector's compact Lark status
|
||||
* row) to focus a specific tab. Routed through the same `requestTabChange`
|
||||
@@ -117,6 +130,7 @@ export function AgentOverviewPane({
|
||||
agent,
|
||||
runtimes,
|
||||
onUpdate,
|
||||
currentUserId,
|
||||
navIntent,
|
||||
onNavIntentHandled,
|
||||
}: AgentOverviewPaneProps) {
|
||||
@@ -141,16 +155,24 @@ 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 only appears once the deployment has Lark wired
|
||||
// The Integrations tab appears once the deployment has Lark OR Slack wired
|
||||
// (configured). Unlike MCP we default to HIDING while the listing loads:
|
||||
// deployments without Lark are the common case, so flashing the tab on
|
||||
// then off would be the worse flicker.
|
||||
// deployments without either channel 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
|
||||
@@ -159,13 +181,20 @@ export function AgentOverviewPane({
|
||||
const visibleTabs = useMemo(() => {
|
||||
const showMcp = runtime ? providerSupportsMcpConfig(runtime.provider) : true;
|
||||
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
|
||||
// The Composio MCP tab is creator-only: it edits the agent owner's own
|
||||
// toolkit allowlist, which the backend reads/writes for the owner alone
|
||||
// (redacted + write-dropped for everyone else — MUL-3870 / MUL-3869).
|
||||
// Hide the entry entirely for non-owners, and while auth is still loading.
|
||||
const showComposioMcp =
|
||||
!!currentUserId && !!agent.owner_id && agent.owner_id === currentUserId;
|
||||
return detailTabs.filter((tab) => {
|
||||
if (tab.id === "mcp_config") return showMcp;
|
||||
if (tab.id === "integrations") return larkConfigured;
|
||||
if (tab.id === "composio_mcp") return showComposioMcp;
|
||||
if (tab.id === "integrations") return integrationsConfigured;
|
||||
if (tab.id === "runtime_config") return showRuntimeConfig;
|
||||
return true;
|
||||
});
|
||||
}, [runtime, larkConfigured]);
|
||||
}, [runtime, integrationsConfigured, currentUserId, agent.owner_id]);
|
||||
|
||||
// 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
|
||||
@@ -278,6 +307,11 @@ export function AgentOverviewPane({
|
||||
/>
|
||||
</TabContent>
|
||||
)}
|
||||
{effectiveTab === "composio_mcp" && (
|
||||
<TabContent>
|
||||
<AgentMcpTab agent={agent} />
|
||||
</TabContent>
|
||||
)}
|
||||
{effectiveTab === "integrations" && (
|
||||
<TabContent>
|
||||
<IntegrationsTab agent={agent} />
|
||||
|
||||
@@ -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-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-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"
|
||||
>
|
||||
<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_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]";
|
||||
"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]";
|
||||
|
||||
// Two-line rows; the virtualizer's fixed-size contract.
|
||||
const ROW_HEIGHT = 64;
|
||||
@@ -128,7 +128,8 @@ function columnTrackVars(
|
||||
0,
|
||||
);
|
||||
return {
|
||||
"--agc-status": width("status"),
|
||||
"--agc-status-mobile": isVisible("status") ? "96px" : "0px",
|
||||
"--agc-status-desktop": width("status"),
|
||||
"--agc-owner": width("owner"),
|
||||
"--agc-runtime": width("runtime"),
|
||||
"--agc-lastactive": width("lastActive"),
|
||||
@@ -290,7 +291,7 @@ function CheckboxCell({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ListGridCell className="justify-center px-0">
|
||||
<ListGridCell className="hidden justify-center px-0 @2xl:flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
@@ -488,7 +489,7 @@ function AgentListHeader({
|
||||
const anySelected = allSelected || someSelected;
|
||||
return (
|
||||
<ListGridHeader>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="hidden items-center justify-center @2xl:flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={allSelected}
|
||||
@@ -584,7 +585,7 @@ function LoadingSkeleton() {
|
||||
)}
|
||||
>
|
||||
<ListGridHeader>
|
||||
<span aria-hidden="true" />
|
||||
<span aria-hidden="true" className="hidden @2xl:inline" />
|
||||
<ListGridHeaderCell>
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</ListGridHeaderCell>
|
||||
@@ -609,7 +610,7 @@ function LoadingSkeleton() {
|
||||
</ListGridHeader>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<ListGridRow key={i} className="h-16 hover:bg-transparent">
|
||||
<span aria-hidden="true" />
|
||||
<span aria-hidden="true" className="hidden @2xl:inline" />
|
||||
<ListGridCell className="gap-3">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
|
||||
177
packages/views/agents/components/tabs/agent-mcp-tab.test.tsx
Normal file
177
packages/views/agents/components/tabs/agent-mcp-tab.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../../locales/en/common.json";
|
||||
import enAgents from "../../../locales/en/agents.json";
|
||||
|
||||
// AgentMcpTab reads its connection list + toolkit catalog from two queries and
|
||||
// writes through the useUpdateAgentAllowlist mutation. We stub all three at the
|
||||
// module boundary so the tests assert the tab's own logic (which slugs are
|
||||
// selectable, what the toggle computes, the empty/redacted branches) rather
|
||||
// than the query/mutation plumbing, which is covered elsewhere.
|
||||
const connectionsRef = vi.hoisted(() => ({
|
||||
current: [] as { toolkit_slug: string; status: string }[],
|
||||
}));
|
||||
const toolkitsRef = vi.hoisted(() => ({
|
||||
current: [] as { slug: string; name: string }[],
|
||||
}));
|
||||
const queryStateRef = vi.hoisted(() => ({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
const mutateSpy = vi.hoisted(() => vi.fn());
|
||||
const isPendingRef = vi.hoisted(() => ({ current: false }));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: { queryKey: unknown[] }) => {
|
||||
const key = JSON.stringify(opts.queryKey);
|
||||
if (queryStateRef.isLoading) return { data: undefined, isLoading: true, isError: false };
|
||||
if (queryStateRef.isError) return { data: undefined, isLoading: false, isError: true };
|
||||
if (key.includes("connections"))
|
||||
return { data: connectionsRef.current, isLoading: false, isError: false };
|
||||
if (key.includes("toolkits"))
|
||||
return { data: toolkitsRef.current, isLoading: false, isError: false };
|
||||
return { data: undefined, isLoading: false, isError: false };
|
||||
},
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/composio", () => ({
|
||||
composioConnectionsOptions: () => ({ queryKey: ["composio", "connections"] }),
|
||||
composioToolkitsOptions: () => ({ queryKey: ["composio", "toolkits"] }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
useUpdateAgentAllowlist: () => ({
|
||||
mutate: mutateSpy,
|
||||
isPending: isPendingRef.current,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
useWorkspacePaths: () => ({ settings: () => "/ws/settings" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../navigation", () => ({
|
||||
AppLink: ({ href, children }: { href: string; children: ReactNode }) => (
|
||||
<a href={href} data-testid="app-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
import { AgentMcpTab } from "./agent-mcp-tab";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
|
||||
|
||||
const baseAgent: Agent = {
|
||||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: "runtime-1",
|
||||
name: "Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_args: [],
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: "user-1",
|
||||
skills: [],
|
||||
created_at: "2026-06-30T00:00:00Z",
|
||||
updated_at: "2026-06-30T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
};
|
||||
|
||||
function renderTab(overrides: Partial<Agent> = {}) {
|
||||
const agent = { ...baseAgent, ...overrides };
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<AgentMcpTab agent={agent} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AgentMcpTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
connectionsRef.current = [
|
||||
{ toolkit_slug: "notion", status: "active" },
|
||||
{ toolkit_slug: "slack", status: "active" },
|
||||
];
|
||||
toolkitsRef.current = [
|
||||
{ slug: "notion", name: "Notion" },
|
||||
{ slug: "slack", name: "Slack" },
|
||||
];
|
||||
queryStateRef.isLoading = false;
|
||||
queryStateRef.isError = false;
|
||||
isPendingRef.current = false;
|
||||
});
|
||||
|
||||
it("lists active connections with checkbox state reflecting the allowlist", () => {
|
||||
renderTab({ composio_toolkit_allowlist: ["notion"] });
|
||||
|
||||
const notion = screen.getByLabelText(/Allow Notion for this agent/i);
|
||||
const slack = screen.getByLabelText(/Allow Slack for this agent/i);
|
||||
expect(notion.getAttribute("aria-checked")).toBe("true");
|
||||
expect(slack.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("checking a toolkit writes the augmented allowlist via the mutation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTab({ composio_toolkit_allowlist: [] });
|
||||
|
||||
await user.click(screen.getByLabelText(/Allow Notion for this agent/i));
|
||||
|
||||
expect(mutateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mutateSpy.mock.calls[0]?.[0]).toEqual(["notion"]);
|
||||
});
|
||||
|
||||
it("unchecking a toolkit removes only that slug", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTab({ composio_toolkit_allowlist: ["notion", "slack"] });
|
||||
|
||||
await user.click(screen.getByLabelText(/Allow Notion for this agent/i));
|
||||
|
||||
expect(mutateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mutateSpy.mock.calls[0]?.[0]).toEqual(["slack"]);
|
||||
});
|
||||
|
||||
it("only offers active connections — expired/revoked are not selectable", () => {
|
||||
connectionsRef.current = [
|
||||
{ toolkit_slug: "notion", status: "active" },
|
||||
{ toolkit_slug: "github", status: "expired" },
|
||||
];
|
||||
renderTab({ composio_toolkit_allowlist: [] });
|
||||
|
||||
expect(screen.getByLabelText(/Allow Notion for this agent/i)).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Allow github for this agent/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an empty state with a Settings link when there are no active connections", () => {
|
||||
connectionsRef.current = [];
|
||||
renderTab({ composio_toolkit_allowlist: [] });
|
||||
|
||||
expect(screen.getByText(/No connected apps yet/i)).toBeTruthy();
|
||||
const link = screen.getByTestId("app-link");
|
||||
expect(link.getAttribute("href")).toBe("/ws/settings?tab=integrations");
|
||||
});
|
||||
|
||||
it("renders a defensive hidden state when the allowlist is redacted", () => {
|
||||
renderTab({ composio_toolkit_allowlist_redacted: true });
|
||||
|
||||
expect(screen.getByText(/hidden from your view/i)).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Allow Notion for this agent/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
167
packages/views/agents/components/tabs/agent-mcp-tab.tsx
Normal file
167
packages/views/agents/components/tabs/agent-mcp-tab.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Lock, Plug } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent, ComposioToolkit } from "@multica/core/types";
|
||||
import { useUpdateAgentAllowlist } from "@multica/core/agents";
|
||||
import {
|
||||
composioConnectionsOptions,
|
||||
composioToolkitsOptions,
|
||||
} from "@multica/core/composio";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
import { ComposioToolkitLogo } from "../../../common/composio-toolkit-logo";
|
||||
import { AppLink } from "../../../navigation";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
/**
|
||||
* Creator-only MCP tab on the agent detail page (MUL-3870). Lets the agent
|
||||
* owner pick which of *their own* active Composio connections this agent may
|
||||
* mount as MCP servers — the selection is written to
|
||||
* `agent.composio_toolkit_allowlist` and only takes effect at dispatch when
|
||||
* the run originator is the owner (the backend gate, MUL-3869).
|
||||
*
|
||||
* Visibility is enforced by the parent (the tab entry isn't rendered unless
|
||||
* `agent.owner_id === viewer.id`), so this component assumes the owner. It
|
||||
* still renders a defensive "hidden" state if the server redacted the
|
||||
* allowlist, and reads the checked state straight from the agent prop so the
|
||||
* optimistic cache write in `useUpdateAgentAllowlist` flips each box
|
||||
* instantly.
|
||||
*/
|
||||
export function AgentMcpTab({ agent }: { agent: Agent }) {
|
||||
const { t } = useT("agents");
|
||||
const paths = useWorkspacePaths();
|
||||
const updateAllowlist = useUpdateAgentAllowlist(agent.id);
|
||||
|
||||
const connectionsQuery = useQuery(composioConnectionsOptions());
|
||||
const toolkitsQuery = useQuery(composioToolkitsOptions());
|
||||
|
||||
// Toolkit metadata (name / logo) keyed by slug, so each connection row can
|
||||
// render a friendly label instead of the bare slug. The catalog is a
|
||||
// best-effort enrichment — a missing entry just falls back to the slug.
|
||||
const toolkitBySlug = useMemo(() => {
|
||||
const m = new Map<string, ComposioToolkit>();
|
||||
for (const tk of toolkitsQuery.data ?? []) m.set(tk.slug, tk);
|
||||
return m;
|
||||
}, [toolkitsQuery.data]);
|
||||
|
||||
// Only ACTIVE connections are selectable — an expired / revoked connection
|
||||
// can't back an MCP mount, so offering its checkbox would be a dead toggle.
|
||||
// Dedupe by slug (a user could in theory hold two rows for one toolkit).
|
||||
const activeSlugs = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const c of connectionsQuery.data ?? []) {
|
||||
if (c.status !== "active") continue;
|
||||
if (seen.has(c.toolkit_slug)) continue;
|
||||
seen.add(c.toolkit_slug);
|
||||
out.push(c.toolkit_slug);
|
||||
}
|
||||
return out;
|
||||
}, [connectionsQuery.data]);
|
||||
|
||||
const allowlist = useMemo(
|
||||
() => agent.composio_toolkit_allowlist ?? [],
|
||||
[agent.composio_toolkit_allowlist],
|
||||
);
|
||||
|
||||
const settingsHref = `${paths.settings()}?tab=integrations`;
|
||||
|
||||
const handleToggle = (slug: string, checked: boolean) => {
|
||||
const set = new Set(allowlist);
|
||||
if (checked) set.add(slug);
|
||||
else set.delete(slug);
|
||||
const next = Array.from(set);
|
||||
updateAllowlist.mutate(next, {
|
||||
onError: () => toast.error(t(($) => $.tab_body.composio_mcp.save_failed_toast)),
|
||||
});
|
||||
};
|
||||
|
||||
// Defensive: the tab is owner-gated, so a redacted allowlist should never
|
||||
// reach here. If it somehow does (stale cache, future fan-out), show the
|
||||
// same "configured but hidden" affordance as the MCP config tab rather than
|
||||
// an empty editor that a Save could clobber.
|
||||
if (agent.composio_toolkit_allowlist_redacted === true) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{t(($) => $.tab_body.composio_mcp.redacted_title)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.composio_mcp.redacted_hint)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.composio_mcp.subtitle)}
|
||||
</p>
|
||||
|
||||
{connectionsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.tab_body.composio_mcp.loading)}
|
||||
</p>
|
||||
) : connectionsQuery.isError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{t(($) => $.tab_body.composio_mcp.load_failed)}
|
||||
</p>
|
||||
) : activeSlugs.length === 0 ? (
|
||||
<div className="space-y-2 rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
{t(($) => $.tab_body.composio_mcp.empty_title)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.composio_mcp.empty_hint)}
|
||||
</p>
|
||||
<AppLink
|
||||
href={settingsHref}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
<Plug className="h-3 w-3" />
|
||||
{t(($) => $.tab_body.composio_mcp.empty_link_to_settings)}
|
||||
</AppLink>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{activeSlugs.map((slug) => {
|
||||
const tk = toolkitBySlug.get(slug);
|
||||
const name = tk?.name || slug;
|
||||
const checked = allowlist.includes(slug);
|
||||
return (
|
||||
<li key={slug} className="flex items-center gap-3 p-3">
|
||||
<ComposioToolkitLogo slug={slug} name={name} fallbackLogo={tk?.logo} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<p className="truncate text-[10px] uppercase tracking-wide text-emerald-600">
|
||||
{t(($) => $.tab_body.composio_mcp.connected)}
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={updateAllowlist.isPending}
|
||||
onCheckedChange={(value) => handleToggle(slug, value === true)}
|
||||
aria-label={t(($) => $.tab_body.composio_mcp.toggle_aria, {
|
||||
toolkit: name,
|
||||
})}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{updateAllowlist.isPending && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t(($) => $.tab_body.composio_mcp.saving)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ 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,
|
||||
}));
|
||||
|
||||
@@ -53,6 +54,13 @@ 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) =>
|
||||
@@ -68,6 +76,14 @@ 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 = {
|
||||
@@ -118,11 +134,12 @@ function resetFixtures() {
|
||||
describe("IntegrationsTab", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("renders the shared bind entry for an owner when Lark is configured and supported", () => {
|
||||
it("renders the shared bind entry for both platforms for an owner when configured and supported", () => {
|
||||
renderTab(<IntegrationsTab agent={agent} />);
|
||||
expect(screen.getByText("Lark")).toBeTruthy();
|
||||
const button = screen.getByTestId("lark-bind-button");
|
||||
expect(button.getAttribute("data-agent-id")).toBe("agent-1");
|
||||
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");
|
||||
});
|
||||
|
||||
it("shows the coming-soon notice when the install transport is not wired", () => {
|
||||
@@ -147,13 +164,16 @@ describe("IntegrationsTab", () => {
|
||||
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("points members at Settings instead of a dead button when they can't manage", () => {
|
||||
it("points members at Settings with one role notice (not per-platform) 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 bind a Lark Bot/i),
|
||||
screen.getByText(/Only workspace owners and admins can connect an agent/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,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { MessagesSquare, 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";
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,10 @@ 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,
|
||||
@@ -52,6 +58,30 @@ 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">
|
||||
@@ -78,14 +108,6 @@ 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,
|
||||
@@ -107,6 +129,39 @@ 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,
|
||||
Webhook, Copy, Check, RotateCw, Users,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
|
||||
@@ -62,6 +62,7 @@ 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";
|
||||
@@ -256,7 +257,7 @@ function SkippedRunsGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
|
||||
function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrigger; autopilotId: string; canWrite: boolean }) {
|
||||
const { t } = useT("autopilots");
|
||||
const deleteTrigger = useDeleteAutopilotTrigger();
|
||||
const rotateToken = useRotateAutopilotTriggerWebhookToken();
|
||||
@@ -329,7 +330,7 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
// — 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 = (
|
||||
const deleteButton = canWrite ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -339,7 +340,7 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
>
|
||||
<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">
|
||||
@@ -386,16 +387,18 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
{deleteButton}
|
||||
</div>
|
||||
)}
|
||||
@@ -632,6 +635,7 @@ 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);
|
||||
|
||||
@@ -683,6 +687,15 @@ 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 {
|
||||
@@ -745,30 +758,38 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<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" />
|
||||
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>
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{triggerAutopilot.isPending
|
||||
? t(($) => $.detail.running)
|
||||
: t(($) => $.detail.run_now)}
|
||||
</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" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{triggerAutopilot.isPending
|
||||
? t(($) => $.detail.running)
|
||||
: t(($) => $.detail.run_now)}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -868,10 +889,12 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t(($) => $.detail.section_triggers)}
|
||||
</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setTriggerDialogOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t(($) => $.detail.add_trigger)}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<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">
|
||||
@@ -880,7 +903,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{triggers.map((trig) => (
|
||||
<TriggerRow key={trig.id} trigger={trig} autopilotId={autopilotId} />
|
||||
<TriggerRow key={trig.id} trigger={trig} autopilotId={autopilotId} canWrite={canWrite} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -919,15 +942,17 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -957,6 +982,14 @@ 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,6 +146,11 @@ 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()}
|
||||
|
||||
177
packages/views/autopilots/components/manage-access-dialog.tsx
Normal file
177
packages/views/autopilots/components/manage-access-dialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
45
packages/views/common/composio-toolkit-logo.test.tsx
Normal file
45
packages/views/common/composio-toolkit-logo.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { ComposioToolkitLogo, composioToolkitLogoUrl } from "./composio-toolkit-logo";
|
||||
|
||||
describe("ComposioToolkitLogo", () => {
|
||||
it("builds Composio logo URLs from toolkit slugs", () => {
|
||||
expect(composioToolkitLogoUrl(" GitHub ")).toBe("https://logos.composio.dev/api/github");
|
||||
expect(composioToolkitLogoUrl("vercel", "dark")).toBe(
|
||||
"https://logos.composio.dev/api/vercel?theme=dark",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses backend logo for light mode and Composio dark logo for dark mode", () => {
|
||||
const { container } = render(
|
||||
<ComposioToolkitLogo
|
||||
slug="slack"
|
||||
name="Slack"
|
||||
fallbackLogo="https://cdn.example/slack.svg"
|
||||
/>,
|
||||
);
|
||||
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
expect(images).toHaveLength(2);
|
||||
expect(images[0]?.getAttribute("src")).toBe("https://cdn.example/slack.svg");
|
||||
expect(images[1]?.getAttribute("src")).toBe(
|
||||
"https://logos.composio.dev/api/slack?theme=dark",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the dark logo when the hidden light logo fails", () => {
|
||||
const { container } = render(<ComposioToolkitLogo slug="notion" name="Notion" />);
|
||||
|
||||
const light = container.querySelector("img.dark\\:hidden");
|
||||
expect(light?.getAttribute("src")).toBe("https://logos.composio.dev/api/notion");
|
||||
|
||||
fireEvent.error(light!);
|
||||
|
||||
const dark = Array.from(container.querySelectorAll("img")).find((img) =>
|
||||
img.className.includes("dark:block"),
|
||||
);
|
||||
expect(dark?.getAttribute("src")).toBe(
|
||||
"https://logos.composio.dev/api/notion?theme=dark",
|
||||
);
|
||||
});
|
||||
});
|
||||
84
packages/views/common/composio-toolkit-logo.tsx
Normal file
84
packages/views/common/composio-toolkit-logo.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
const COMPOSIO_LOGO_BASE_URL = "https://logos.composio.dev/api";
|
||||
|
||||
export function composioToolkitLogoUrl(slug: string, theme?: "dark") {
|
||||
const normalized = slug.trim().toLowerCase();
|
||||
if (!normalized) return "";
|
||||
const base = `${COMPOSIO_LOGO_BASE_URL}/${encodeURIComponent(normalized)}`;
|
||||
return theme === "dark" ? `${base}?theme=dark` : base;
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ComposioToolkitLogo({
|
||||
slug,
|
||||
name,
|
||||
fallbackLogo,
|
||||
className,
|
||||
}: {
|
||||
slug: string;
|
||||
name?: string;
|
||||
fallbackLogo?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const label = name || slug;
|
||||
const initial = label.charAt(0).toUpperCase();
|
||||
const dynamicLightLogo = composioToolkitLogoUrl(slug);
|
||||
const dynamicDarkLogo = composioToolkitLogoUrl(slug, "dark");
|
||||
const lightSources = uniqueNonEmpty([fallbackLogo, dynamicLightLogo]);
|
||||
const darkSources = uniqueNonEmpty([dynamicDarkLogo, fallbackLogo, dynamicLightLogo]);
|
||||
const [failedLightSources, setFailedLightSources] = useState(0);
|
||||
const [failedDarkSources, setFailedDarkSources] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFailedLightSources(0);
|
||||
setFailedDarkSources(0);
|
||||
}, [slug, fallbackLogo]);
|
||||
|
||||
const imgClassName = cn("h-8 w-8 shrink-0 rounded bg-muted object-contain", className);
|
||||
const fallbackClassName = cn(
|
||||
"h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground",
|
||||
className,
|
||||
);
|
||||
const lightSrc = lightSources[failedLightSources];
|
||||
const darkSrc = darkSources[failedDarkSources];
|
||||
|
||||
return (
|
||||
<>
|
||||
{lightSrc ? (
|
||||
<img
|
||||
src={lightSrc}
|
||||
alt=""
|
||||
className={cn(imgClassName, "dark:hidden")}
|
||||
onError={() => setFailedLightSources((n) => n + 1)}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(fallbackClassName, "flex dark:hidden")}>{initial}</div>
|
||||
)}
|
||||
{darkSrc ? (
|
||||
<img
|
||||
src={darkSrc}
|
||||
alt=""
|
||||
className={cn(imgClassName, "hidden dark:block")}
|
||||
onError={() => setFailedDarkSources((n) => n + 1)}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(fallbackClassName, "hidden dark:flex")}>{initial}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { BarChart3, FolderKanban } from "lucide-react";
|
||||
import { BarChart3, FolderKanban, Trash2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
@@ -52,8 +52,9 @@ import {
|
||||
aggregateDailyTokens,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
bucketUnknownAgentRows,
|
||||
computeDailyTotals,
|
||||
filterKnownAgentRows,
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
type AgentDashboardRow,
|
||||
@@ -314,17 +315,29 @@ export function DashboardPage() {
|
||||
[agentTokenRows, runTimeRows],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const knownAgentIds = useMemo(
|
||||
() => (agentsQuery.isSuccess ? new Set(agents.map((a) => a.id)) : null),
|
||||
[agentsQuery.isSuccess, agents],
|
||||
);
|
||||
const visibleAgentRows = useMemo(
|
||||
() => filterKnownAgentRows(agentRows, knownAgentIds),
|
||||
() => 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,
|
||||
[agentRows, knownAgentIds],
|
||||
);
|
||||
|
||||
@@ -431,6 +444,7 @@ export function DashboardPage() {
|
||||
<Leaderboard
|
||||
rows={visibleAgentRows}
|
||||
agents={agents}
|
||||
deletedAgentCount={deletedAgentCount}
|
||||
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
|
||||
/>
|
||||
</>
|
||||
@@ -640,10 +654,12 @@ 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");
|
||||
@@ -684,7 +700,12 @@ function Leaderboard({
|
||||
<div className="flex items-center gap-3">
|
||||
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
{deletedAgentCount > 0
|
||||
? t(($) => $.leaderboard.caption_with_deleted, {
|
||||
count: rows.length - 1,
|
||||
deleted: deletedAgentCount,
|
||||
})
|
||||
: t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,6 +725,11 @@ 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;
|
||||
@@ -713,15 +739,28 @@ 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">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={row.agentId}
|
||||
size={22}
|
||||
enableHoverCard
|
||||
/>
|
||||
<span className="cursor-pointer truncate text-sm font-medium">
|
||||
{agent?.name ?? row.agentId}
|
||||
</span>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
@@ -742,12 +781,14 @@ function Leaderboard({
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
{isDeletedBucket
|
||||
? "—"
|
||||
: formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.taskCount}
|
||||
{isDeletedBucket ? "—" : row.taskCount}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
aggregateDailyCost,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
bucketUnknownAgentRows,
|
||||
computeDailyTotals,
|
||||
filterKnownAgentRows,
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
} from "./utils";
|
||||
@@ -202,26 +203,81 @@ describe("mergeAgentDashboardRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
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,
|
||||
};
|
||||
|
||||
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("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("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 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("drops every row when the known set is empty", () => {
|
||||
expect(filterKnownAgentRows(rows, new Set())).toEqual([]);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -227,21 +227,54 @@ export function mergeAgentDashboardRows(
|
||||
});
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
//
|
||||
// `knownAgentIds` is empty while the agent list is still loading; callers
|
||||
// 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
|
||||
// pass `null` in that case so the rows pass through untouched instead of the
|
||||
// whole leaderboard blanking on a slow fetch.
|
||||
export function filterKnownAgentRows(
|
||||
// whole leaderboard collapsing into one bucket on a slow fetch.
|
||||
export function bucketUnknownAgentRows(
|
||||
rows: AgentDashboardRow[],
|
||||
knownAgentIds: ReadonlySet<string> | null,
|
||||
): AgentDashboardRow[] {
|
||||
if (!knownAgentIds) return rows;
|
||||
return rows.filter((r) => knownAgentIds.has(r.agentId));
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -262,6 +262,40 @@ 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,12 +237,17 @@ 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. `record.markdown_url` — the durable, server-policy-aligned URL.
|
||||
// 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.
|
||||
// Beats raw `record.url` because it never points at a private
|
||||
// bucket (must-fix 2 from MUL-3192 review).
|
||||
// 4. `record.url` — legacy fallback for responses that omit
|
||||
// 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
|
||||
// `markdown_url` (a backend old enough to predate MUL-3192).
|
||||
// 5. The input URL — when there's no record at all.
|
||||
// 6. The input URL — when there's no record at all.
|
||||
function pickInlineMediaURL(
|
||||
record: AttachmentRecord,
|
||||
fallback: string,
|
||||
@@ -257,11 +262,18 @@ 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,8 +46,6 @@ 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,
|
||||
@@ -67,9 +65,8 @@ function StickyHeaderShell({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"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)]",
|
||||
highlighted ? highlightedCommentBackgroundClass : "bg-card",
|
||||
highlighted ? highlightedCommentFadeClass : "after:from-card",
|
||||
)}
|
||||
>
|
||||
<div className={className}>
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
"environment": "Environment",
|
||||
"custom_args": "Custom Args",
|
||||
"mcp_config": "MCP",
|
||||
"composio_mcp": "MCP Apps",
|
||||
"integrations": "Integrations",
|
||||
"runtime_config": "Routing",
|
||||
"discard_dialog_title": "Discard unsaved changes?",
|
||||
@@ -329,6 +330,20 @@
|
||||
"redacted_title": "Configured — hidden from your view",
|
||||
"redacted_hint": "Only the agent owner or a workspace admin can read this config."
|
||||
},
|
||||
"composio_mcp": {
|
||||
"subtitle": "Check a toolkit to let this agent mount it as an MCP server — but only when you (its creator) are the one who triggered the run, directly or down a sub-agent chain.",
|
||||
"loading": "Loading your connections…",
|
||||
"load_failed": "Couldn't load your connected apps. Try again shortly.",
|
||||
"empty_title": "No connected apps yet",
|
||||
"empty_hint": "You haven't connected any third-party services. Authorize one first, then come back here to allow it.",
|
||||
"empty_link_to_settings": "Connect one in Settings → Integrations",
|
||||
"connected": "Connected",
|
||||
"toggle_aria": "Allow {{toolkit}} for this agent",
|
||||
"saving": "Saving…",
|
||||
"save_failed_toast": "Couldn't save — please try again",
|
||||
"redacted_title": "Configured — hidden from your view",
|
||||
"redacted_hint": "Only the agent's creator can view or change which apps it may use."
|
||||
},
|
||||
"runtime_config": {
|
||||
"intro": "Choose how the OpenClaw runtime executes this agent's turns. Local mode runs the agent inside the daemon process. Gateway mode forwards each turn to an OpenClaw Gateway — useful when the daemon host is a lightweight coordinator and the agent should run on a more powerful machine.",
|
||||
"mode_label": "Routing mode",
|
||||
@@ -370,7 +385,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 bind a Lark Bot to an agent. You can view connected bots in Settings → Integrations."
|
||||
"members_note": "Only workspace owners and admins can connect an agent to an external chat platform. You can view connected bots in Settings → Integrations."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "Now",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"pause_aria": "Pause autopilot",
|
||||
"activate_aria": "Activate autopilot",
|
||||
"edit": "Edit",
|
||||
"manage_access": "Manage access",
|
||||
"run_now": "Run now",
|
||||
"running": "Running...",
|
||||
"toast_triggered": "Autopilot triggered",
|
||||
@@ -101,6 +102,20 @@
|
||||
"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,5 +24,20 @@
|
||||
"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,6 +332,8 @@
|
||||
"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,6 +300,81 @@
|
||||
"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."
|
||||
},
|
||||
"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": "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"
|
||||
},
|
||||
"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",
|
||||
"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}}",
|
||||
"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.",
|
||||
"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"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "Repositories",
|
||||
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"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",
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"environment": "環境",
|
||||
"custom_args": "カスタム引数",
|
||||
"mcp_config": "MCP",
|
||||
"composio_mcp": "MCP アプリ",
|
||||
"integrations": "連携",
|
||||
"runtime_config": "ルーティング",
|
||||
"discard_dialog_title": "保存していない変更を破棄しますか?",
|
||||
@@ -314,6 +315,20 @@
|
||||
"redacted_title": "設定済み — 現在の表示では非表示",
|
||||
"redacted_hint": "この config を読み取れるのは、エージェントのオーナーまたはワークスペースの admin のみです。"
|
||||
},
|
||||
"composio_mcp": {
|
||||
"subtitle": "ツールキットにチェックを入れると、あなた(この agent の作成者)が直接または下位 agent のチェーン経由でこの agent をトリガーしたときに限り、MCP サーバーとしてマウントされます。",
|
||||
"loading": "接続を読み込み中…",
|
||||
"load_failed": "接続済みアプリを読み込めませんでした。しばらくしてから再試行してください。",
|
||||
"empty_title": "接続済みのアプリがありません",
|
||||
"empty_hint": "サードパーティサービスをまだ接続していません。先に 1 つ認可してから、ここで許可してください。",
|
||||
"empty_link_to_settings": "設定 → 連携 で接続する",
|
||||
"connected": "接続済み",
|
||||
"toggle_aria": "この agent に {{toolkit}} を許可",
|
||||
"saving": "保存中…",
|
||||
"save_failed_toast": "保存できませんでした。もう一度お試しください",
|
||||
"redacted_title": "設定済み — あなたには非表示",
|
||||
"redacted_hint": "この agent が使用できるアプリを閲覧・変更できるのは作成者のみです。"
|
||||
},
|
||||
"runtime_config": {
|
||||
"intro": "OpenClaw ランタイムがこのエージェントの各ターンをどのように実行するかを選択します。Local モードはエージェントを daemon プロセス内で実行します。Gateway モードは各ターンを OpenClaw Gateway に転送します — daemon のホストが軽量な調整役で、エージェントの実作業はより強力なマシンで動かしたい場合に有用です。",
|
||||
"mode_label": "ルーティングモード",
|
||||
@@ -354,7 +369,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "このエージェントを外部のチャットプラットフォームに接続し、普段使っているツールから直接やり取りできるようにします。",
|
||||
"members_note": "エージェントに Lark Bot を紐付けできるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
"members_note": "エージェントを外部チャットプラットフォームに接続できるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "現在",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"pause_aria": "オートパイロットを一時停止",
|
||||
"activate_aria": "オートパイロットを有効化",
|
||||
"edit": "編集",
|
||||
"manage_access": "アクセス管理",
|
||||
"run_now": "今すぐ実行",
|
||||
"running": "実行中...",
|
||||
"toast_triggered": "オートパイロットを実行しました",
|
||||
@@ -101,6 +102,20 @@
|
||||
"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,5 +24,20 @@
|
||||
"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,6 +319,7 @@
|
||||
},
|
||||
"agent_activity": {
|
||||
"hover_header_other": "作業中のエージェント {{count}} 件",
|
||||
"hover_header_tasks_other": "作業中のタスク {{count}} 件",
|
||||
"hover_header_queued_other": "待機中のエージェント {{count}} 件",
|
||||
"status_running": "作業中",
|
||||
"status_queued": "待機中",
|
||||
|
||||
@@ -300,6 +300,81 @@
|
||||
"install_error_forbidden": "このワークスペースに Lark ボットを設置する権限がなくなりました。ワークスペース管理者にお問い合わせください。",
|
||||
"install_error_generic": "設置に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"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": "再接続"
|
||||
},
|
||||
"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 ボットを接続できませんでした"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "リポジトリ",
|
||||
"description": "このワークスペースに関連付けられた Git リポジトリです。エージェントはこれらをクローンしてコードを作業します。",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "リーダーボード",
|
||||
"caption": "{{count}} 件のエージェント",
|
||||
"caption_with_deleted": "{{count}} 件のエージェント · 削除済み {{deleted}} 件",
|
||||
"deleted_agents": "削除済みエージェント",
|
||||
"header_agent": "エージェント",
|
||||
"header_tokens": "トークン",
|
||||
"header_cost": "コスト",
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
"environment": "환경",
|
||||
"custom_args": "사용자 지정 인자",
|
||||
"mcp_config": "MCP",
|
||||
"composio_mcp": "MCP 앱",
|
||||
"integrations": "연동",
|
||||
"runtime_config": "라우팅",
|
||||
"discard_dialog_title": "저장하지 않은 변경사항을 버릴까요?",
|
||||
@@ -329,6 +330,20 @@
|
||||
"redacted_title": "설정됨 - 현재 보기에서는 숨김",
|
||||
"redacted_hint": "에이전트 소유자 또는 워크스페이스 관리자만 이 config를 읽을 수 있습니다."
|
||||
},
|
||||
"composio_mcp": {
|
||||
"subtitle": "툴킷을 선택하면, 본인(이 에이전트의 생성자)이 직접 또는 하위 에이전트 체인을 통해 이 에이전트를 트리거할 때만 MCP 서버로 마운트됩니다.",
|
||||
"loading": "연결을 불러오는 중…",
|
||||
"load_failed": "연결된 앱을 불러오지 못했습니다. 잠시 후 다시 시도하세요.",
|
||||
"empty_title": "아직 연결된 앱이 없습니다",
|
||||
"empty_hint": "아직 서드파티 서비스를 연결하지 않았습니다. 먼저 하나를 인증한 뒤 여기서 허용하세요.",
|
||||
"empty_link_to_settings": "설정 → 연동에서 연결하기",
|
||||
"connected": "연결됨",
|
||||
"toggle_aria": "이 에이전트에 {{toolkit}} 허용",
|
||||
"saving": "저장 중…",
|
||||
"save_failed_toast": "저장하지 못했습니다. 다시 시도하세요",
|
||||
"redacted_title": "설정됨 — 보기에서 숨김",
|
||||
"redacted_hint": "이 에이전트가 사용할 수 있는 앱은 생성자만 보거나 변경할 수 있습니다."
|
||||
},
|
||||
"runtime_config": {
|
||||
"intro": "OpenClaw 런타임이 이 에이전트의 각 턴을 어떻게 실행할지 선택하세요. Local 모드는 에이전트를 daemon 프로세스 내부에서 실행합니다. Gateway 모드는 각 턴을 OpenClaw Gateway로 전달합니다 — daemon 호스트가 가벼운 조율기 역할을 하고 실제 작업은 더 강력한 머신에서 돌리고 싶을 때 유용합니다.",
|
||||
"mode_label": "라우팅 모드",
|
||||
@@ -370,7 +385,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "이 에이전트를 외부 채팅 플랫폼에 연결해 팀원이 평소 사용하는 도구에서 바로 함께 작업할 수 있도록 합니다.",
|
||||
"members_note": "에이전트에 Lark 봇을 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
"members_note": "에이전트를 외부 채팅 플랫폼에 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "현재",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"pause_aria": "오토파일럿 일시 중지",
|
||||
"activate_aria": "오토파일럿 활성화",
|
||||
"edit": "수정",
|
||||
"manage_access": "접근 권한 관리",
|
||||
"run_now": "지금 실행",
|
||||
"running": "실행 중...",
|
||||
"toast_triggered": "오토파일럿을 실행했습니다",
|
||||
@@ -101,6 +102,20 @@
|
||||
"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,5 +24,20 @@
|
||||
"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,6 +331,8 @@
|
||||
"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": "작업 중",
|
||||
|
||||
@@ -376,5 +376,80 @@
|
||||
"install_error_session_lost": "설치 세션이 만료되었거나 유실되었어요. 다시 스캔해 처음부터 진행하세요.",
|
||||
"install_error_forbidden": "이 워크스페이스에 Lark 봇을 설치할 권한이 더 이상 없어요. 워크스페이스 관리자에게 문의하세요.",
|
||||
"install_error_generic": "설치에 실패했어요. 다시 시도하세요."
|
||||
},
|
||||
"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": "다시 연결"
|
||||
},
|
||||
"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 봇을 연결하지 못했어요"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "리더보드",
|
||||
"caption": "에이전트 {{count}}개",
|
||||
"caption_with_deleted": "에이전트 {{count}}개 · 삭제됨 {{deleted}}개",
|
||||
"deleted_agents": "삭제된 에이전트",
|
||||
"header_agent": "에이전트",
|
||||
"header_tokens": "토큰",
|
||||
"header_cost": "비용",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user