docs: add external connector implementation plans

This commit is contained in:
2026-06-03 09:24:06 +08:00
parent feeaccc0e3
commit d335199a64
3 changed files with 3556 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,790 @@
# External Connector Frontend And Deploy Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a connector-driven onboarding UI for Weixin and Feishu/Lark, wire frontend API helpers to backend connector-session APIs, and verify the docker-compose sidecar deployment path.
**Architecture:** The Status page keeps the existing advanced channel config editor, but adds a connector onboarding section backed by `/api/channel-connectors`, `/api/channel-connections`, and `/api/channel-connector-sessions`. Weixin shows QR status; Feishu/Lark shows provider instructions/status. Successful sessions become active without restart through backend dynamic runtime activation.
**Tech Stack:** Next.js 13, React, TypeScript, existing shadcn/Radix UI components, lucide-react, Vitest, Docker Compose.
---
## Dependencies
Execute after:
- `docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md`
- `docs/superpowers/plans/2026-06-03-external-connector-sidecar.md`
## Scope
Included:
- Frontend TypeScript API helpers and types for connectors, connections, and connector sessions.
- Status page connector onboarding UI.
- QR/instruction modal and polling.
- Logout/revoke action using existing connection revoke API.
- Frontend tests for API mapping and UI state helpers.
- Docker compose smoke verification instructions for local sidecar.
Excluded:
- Replacing the advanced `/api/channels` static config editor.
- Live vendor account verification logic inside frontend.
- New top-level navigation route.
## File Structure
- Modify `app-instance/frontend/types/index.ts`
- Add connector and connector-session types.
- Modify `app-instance/frontend/lib/api.ts`
- Add connector API functions.
- Create `app-instance/frontend/lib/channel-connectors.ts`
- Small UI state helpers for connector labels/status.
- Create `app-instance/frontend/components/channel-connector-wizard.tsx`
- Connector cards, session modal, QR/instruction rendering, poll controls.
- Modify `app-instance/frontend/app/(app)/status/page.tsx`
- Fetch connector data and render wizard above advanced Channels list.
- Create `app-instance/frontend/lib/channel-connectors.test.ts`
- Helper tests.
- Create `app-instance/frontend/components/channel-connector-wizard.test.tsx`
- Component tests if the existing Vitest setup supports React Testing Library; otherwise keep helper tests and verify with typecheck/build.
- Review `docker-compose.external-connectors.yml`
- Confirm sidecar env names match backend and frontend assumptions.
---
### Task 1: Frontend Types And API Client
**Files:**
- Modify: `app-instance/frontend/types/index.ts`
- Modify: `app-instance/frontend/lib/api.ts`
- Test: `app-instance/frontend/lib/channel-connectors.test.ts`
- [ ] **Step 1: Add frontend connector types**
Append to `app-instance/frontend/types/index.ts`:
```ts
export interface ChannelConnectorDescriptor {
kind: string;
displayName?: string;
display_name?: string;
authType?: string;
auth_type?: string;
providerId?: string;
provider_id?: string;
capabilities?: string[];
available?: boolean;
unavailableReason?: string | null;
}
export interface ChannelConnectionView {
connection_id: string;
owner_user_id?: string | null;
channel_id: string;
kind: string;
mode: string;
display_name: string;
account_id: string;
status: string;
auth_type: string;
runtime_config: Record<string, unknown>;
capabilities: string[];
created_at: string;
updated_at: string;
last_seen_at?: string | null;
last_error?: string | null;
}
export interface ChannelConnectionResponse {
connection: ChannelConnectionView;
credentials?: Record<string, string>;
}
export interface ConnectorSessionView {
sessionId: string;
kind: string;
status: string;
qrCode?: string | null;
qrImage?: string | null;
instructions?: string[];
accountId?: string | null;
displayName?: string | null;
error?: string | null;
metadata?: Record<string, unknown>;
}
export interface ConnectorSessionResponse {
session: ConnectorSessionView;
connection?: ChannelConnectionView | null;
}
```
- [ ] **Step 2: Add API imports**
Modify the import list in `app-instance/frontend/lib/api.ts` to include:
```ts
ChannelConnectionResponse,
ChannelConnectionView,
ChannelConnectorDescriptor,
ConnectorSessionResponse,
```
- [ ] **Step 3: Add connector API functions**
Append to `app-instance/frontend/lib/api.ts` near the channel API functions:
```ts
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
return fetchJSON('/api/channel-connectors');
}
export async function listChannelConnections(): Promise<ChannelConnectionView[]> {
return fetchJSON('/api/channel-connections');
}
export async function startConnectorSession(params: {
kind: string;
displayName?: string;
ownerUserId?: string;
options?: Record<string, unknown>;
}): Promise<ConnectorSessionResponse> {
return fetchJSON('/api/channel-connector-sessions', {
method: 'POST',
timeoutMs: 45000,
body: JSON.stringify({
kind: params.kind,
displayName: params.displayName,
ownerUserId: params.ownerUserId,
options: params.options || {},
}),
});
}
export async function getConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`, {
timeoutMs: 45000,
});
}
export async function revokeChannelConnection(connectionId: string): Promise<ChannelConnectionResponse> {
return fetchJSON(`/api/channel-connections/${encodeURIComponent(connectionId)}/revoke`, {
method: 'POST',
});
}
```
- [ ] **Step 4: Run frontend typecheck**
Run:
```bash
cd app-instance/frontend
npm run typecheck
```
Expected: typecheck passes. If it fails because these types are appended inside another interface, move them below the closing brace for `SystemStatus`.
- [ ] **Step 5: Commit Task 1**
```bash
git add app-instance/frontend/types/index.ts app-instance/frontend/lib/api.ts
git commit -m "feat: add connector frontend api client"
```
---
### Task 2: Connector UI Helpers
**Files:**
- Create: `app-instance/frontend/lib/channel-connectors.ts`
- Create: `app-instance/frontend/lib/channel-connectors.test.ts`
- [ ] **Step 1: Write helper tests**
Create `app-instance/frontend/lib/channel-connectors.test.ts`:
```ts
import { describe, expect, it } from 'vitest';
import {
connectorDisplayName,
connectorStatusLabel,
isTerminalConnectorSessionStatus,
} from './channel-connectors';
describe('channel connector helpers', () => {
it('returns friendly connector names', () => {
expect(connectorDisplayName({ kind: 'weixin' })).toBe('Weixin');
expect(connectorDisplayName({ kind: 'feishu' })).toBe('Feishu/Lark');
expect(connectorDisplayName({ kind: 'telegram', displayName: 'Telegram' })).toBe('Telegram');
});
it('maps connector session statuses', () => {
expect(connectorStatusLabel('qr_ready')).toBe('QR ready');
expect(connectorStatusLabel('waiting_for_user')).toBe('Waiting for user');
expect(connectorStatusLabel('connected')).toBe('Connected');
});
it('detects terminal statuses', () => {
expect(isTerminalConnectorSessionStatus('connected')).toBe(true);
expect(isTerminalConnectorSessionStatus('expired')).toBe(true);
expect(isTerminalConnectorSessionStatus('qr_ready')).toBe(false);
});
});
```
- [ ] **Step 2: Run tests to verify failure**
Run:
```bash
cd app-instance/frontend
npm run test -- lib/channel-connectors.test.ts
```
Expected: fail with `Cannot find module './channel-connectors'`.
- [ ] **Step 3: Implement helpers**
Create `app-instance/frontend/lib/channel-connectors.ts`:
```ts
import type { ChannelConnectorDescriptor } from '@/types';
export function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
if (connector.displayName) return connector.displayName;
if (connector.display_name) return connector.display_name;
if (connector.kind === 'weixin') return 'Weixin';
if (connector.kind === 'feishu') return 'Feishu/Lark';
if (connector.kind === 'telegram') return 'Telegram';
return connector.kind;
}
export function connectorStatusLabel(status: string): string {
const labels: Record<string, string> = {
pending: 'Pending',
qr_ready: 'QR ready',
scanned: 'Scanned',
confirmed: 'Confirmed',
installing: 'Installing',
waiting_for_user: 'Waiting for user',
connected: 'Connected',
expired: 'Expired',
error: 'Error',
cancelled: 'Cancelled',
};
return labels[status] || status;
}
export function isTerminalConnectorSessionStatus(status: string): boolean {
return ['connected', 'expired', 'error', 'cancelled'].includes(status);
}
```
- [ ] **Step 4: Run helper tests**
Run:
```bash
cd app-instance/frontend
npm run test -- lib/channel-connectors.test.ts
```
Expected: helper tests pass.
- [ ] **Step 5: Commit Task 2**
```bash
git add app-instance/frontend/lib/channel-connectors.ts app-instance/frontend/lib/channel-connectors.test.ts
git commit -m "feat: add channel connector ui helpers"
```
---
### Task 3: Connector Wizard Component
**Files:**
- Create: `app-instance/frontend/components/channel-connector-wizard.tsx`
- Modify: `app-instance/frontend/app/(app)/status/page.tsx`
- [ ] **Step 1: Create wizard component**
Create `app-instance/frontend/components/channel-connector-wizard.tsx`:
```tsx
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Loader2, QrCode, RefreshCw, Unplug } from 'lucide-react';
import type {
ChannelConnectionView,
ChannelConnectorDescriptor,
ConnectorSessionResponse,
ConnectorSessionView,
} from '@/types';
import {
getConnectorSession,
revokeChannelConnection,
startConnectorSession,
} from '@/lib/api';
import {
connectorDisplayName,
connectorStatusLabel,
isTerminalConnectorSessionStatus,
} from '@/lib/channel-connectors';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Props = {
connectors: ChannelConnectorDescriptor[];
connections: ChannelConnectionView[];
onChanged: () => Promise<void> | void;
};
export function ChannelConnectorWizard({ connectors, connections, onChanged }: Props) {
const [activeKind, setActiveKind] = useState<string | null>(null);
const [session, setSession] = useState<ConnectorSessionView | null>(null);
const [connection, setConnection] = useState<ChannelConnectionView | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [feishuDomain, setFeishuDomain] = useState('feishu');
const visibleConnectors = useMemo(
() => connectors.filter((item) => ['telegram', 'weixin', 'feishu'].includes(item.kind)),
[connectors],
);
useEffect(() => {
if (!session || isTerminalConnectorSessionStatus(session.status)) return;
const timer = window.setInterval(async () => {
try {
const next = await getConnectorSession(session.sessionId);
setSession(next.session);
if (next.connection) setConnection(next.connection);
if (next.session.status === 'connected') await onChanged();
} catch (err: any) {
setError(err.message || 'Failed to refresh connector session');
}
}, 2000);
return () => window.clearInterval(timer);
}, [session?.sessionId, session?.status, onChanged]);
const start = async (kind: string) => {
setActiveKind(kind);
setSession(null);
setConnection(null);
setError(null);
setBusy(true);
try {
const options = kind === 'feishu' ? { domain: feishuDomain } : {};
const response: ConnectorSessionResponse = await startConnectorSession({
kind,
displayName: connectorDisplayName({ kind }),
options,
});
setSession(response.session);
setConnection(response.connection || null);
} catch (err: any) {
setError(err.message || 'Failed to start connector session');
} finally {
setBusy(false);
}
};
const revoke = async (item: ChannelConnectionView) => {
setBusy(true);
setError(null);
try {
await revokeChannelConnection(item.connection_id);
await onChanged();
} catch (err: any) {
setError(err.message || 'Failed to logout connector');
} finally {
setBusy(false);
}
};
return (
<section className="space-y-3">
<div className="grid gap-3 md:grid-cols-3">
{visibleConnectors.map((connector) => {
const existing = connections.find((item) => item.kind === connector.kind && item.status !== 'revoked');
return (
<Card key={connector.kind} className="rounded-md">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-base">
<span>{connectorDisplayName(connector)}</span>
{existing ? <Badge variant="secondary">{existing.status}</Badge> : null}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{connector.kind === 'feishu' ? (
<div className="space-y-1">
<Label htmlFor="feishu-domain">Domain</Label>
<Input id="feishu-domain" value={feishuDomain} onChange={(event) => setFeishuDomain(event.target.value)} />
</div>
) : null}
{existing ? (
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate">{existing.display_name || existing.account_id || existing.channel_id}</span>
<Button size="sm" variant="outline" onClick={() => revoke(existing)} disabled={busy}>
<Unplug className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
) : (
<Button size="sm" onClick={() => start(connector.kind)} disabled={busy || connector.kind === 'telegram'}>
{busy && activeKind === connector.kind ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <QrCode className="mr-2 h-4 w-4" />}
{connector.kind === 'telegram' ? 'Use token setup' : 'Connect'}
</Button>
)}
</CardContent>
</Card>
);
})}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Dialog open={Boolean(activeKind && session)} onOpenChange={(open) => !open && setActiveKind(null)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{activeKind ? connectorDisplayName({ kind: activeKind }) : 'Connector'}</DialogTitle>
</DialogHeader>
{session ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Badge variant={session.status === 'connected' ? 'default' : 'secondary'}>
{connectorStatusLabel(session.status)}
</Badge>
{session.status === 'connected' ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <RefreshCw className="h-5 w-5 text-muted-foreground" />}
</div>
{session.qrImage ? (
<img alt="Connector QR code" src={session.qrImage} className="mx-auto aspect-square w-64 rounded-md border object-contain" />
) : null}
{session.instructions && session.instructions.length > 0 ? (
<div className="space-y-2 rounded-md border p-3 text-sm">
{session.instructions.map((item) => <p key={item}>{item}</p>)}
</div>
) : null}
{connection ? <p className="text-sm text-muted-foreground">{connection.display_name || connection.account_id}</p> : null}
{session.error ? <p className="text-sm text-destructive">{session.error}</p> : null}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setActiveKind(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
```
- [ ] **Step 2: Wire Status page imports**
Modify imports in `app-instance/frontend/app/(app)/status/page.tsx`:
```tsx
import { ChannelConnectorWizard } from '@/components/channel-connector-wizard';
import { getChannelConfig, getStatus, listChannelConnections, listChannelConnectors, listChannelEvents, restartRuntime, updateAgentConfig, updateChannelConfig, updateProviderConfig } from '@/lib/api';
import type { ChannelConfigDetail, ChannelConnectionView, ChannelConnectorDescriptor, ChannelEventRecord, ChannelStatus, ProviderStatus, SystemStatus } from '@/types';
```
- [ ] **Step 3: Add connector state to Status page**
Inside `StatusPage()` state declarations:
```tsx
const [channelConnectors, setChannelConnectors] = useState<ChannelConnectorDescriptor[]>([]);
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
```
Add loader:
```tsx
const loadChannelConnectors = async () => {
const [connectors, connections] = await Promise.all([
listChannelConnectors(),
listChannelConnections(),
]);
setChannelConnectors(connectors);
setChannelConnections(connections);
};
```
Call it after status load:
```tsx
useEffect(() => {
loadStatus();
loadChannelConnectors().catch(() => undefined);
}, []);
```
In `handleSaveChannel()` after `await loadStatus();`, add:
```tsx
await loadChannelConnectors();
```
- [ ] **Step 4: Render wizard above advanced Channels list**
In `app-instance/frontend/app/(app)/status/page.tsx`, render before the existing `{/* Channels */}` section:
```tsx
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold">{pickAppText(locale, '连接器', 'Connectors')}</h2>
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '连接微信或飞书后会立即进入运行时。', 'Connected Weixin or Feishu channels activate immediately.')}
</p>
</div>
<ChannelConnectorWizard
connectors={channelConnectors}
connections={channelConnections}
onChanged={async () => {
await loadChannelConnectors();
await loadStatus();
}}
/>
</section>
```
- [ ] **Step 5: Run frontend checks**
Run:
```bash
cd app-instance/frontend
npm run typecheck
npm run test -- lib/channel-connectors.test.ts
```
Expected: typecheck and helper tests pass.
- [ ] **Step 6: Commit Task 3**
```bash
git add app-instance/frontend/components/channel-connector-wizard.tsx app-instance/frontend/app/'(app)'/status/page.tsx
git commit -m "feat: add channel connector wizard"
```
---
### Task 4: Frontend Build And Browser Smoke
**Files:**
- Review: `app-instance/frontend/app/(app)/status/page.tsx`
- Review: `app-instance/frontend/components/channel-connector-wizard.tsx`
- [ ] **Step 1: Run frontend build**
Run:
```bash
cd app-instance/frontend
npm run build
```
Expected: Next build succeeds.
- [ ] **Step 2: Start frontend dev server if visual smoke is needed**
Run:
```bash
cd app-instance/frontend
npm run dev
```
Expected: dev server listens on `http://127.0.0.1:3080`.
- [ ] **Step 3: Browser smoke check**
Open the Status page in the running app instance and verify:
- The Connectors section appears above Channels.
- Telegram shows token setup disabled in the connector wizard.
- Weixin has a Connect button.
- Feishu/Lark has a Domain input and Connect button.
- Starting a fake Weixin session opens a modal with a QR image.
- [ ] **Step 4: Stop frontend dev server**
If Step 2 started a dev server, stop it with `Ctrl-C`.
- [ ] **Step 5: Commit fixes if needed**
If build or smoke required fixes:
```bash
git add app-instance/frontend
git commit -m "fix: stabilize channel connector wizard"
```
If no files changed, do not create an empty commit.
---
### Task 5: Compose Integration Verification
**Files:**
- Review: `docker-compose.external-connectors.yml`
- Review: `.env.example`
- [ ] **Step 1: Build backend and sidecar images**
Run:
```bash
docker build -t beaver/app-instance:latest app-instance
docker compose -f docker-compose.external-connectors.yml build external-connector
```
Expected: both builds succeed.
- [ ] **Step 2: Start sidecar with fake provider**
Run:
```bash
CONNECTOR_PROVIDER=fake \
EXTERNAL_CONNECTOR_TOKEN=dev-token \
BEAVER_BRIDGE_TOKEN=dev-token \
docker compose -f docker-compose.external-connectors.yml up -d external-connector
```
Expected: `external-connector` starts and stays running.
- [ ] **Step 3: Verify sidecar connector API**
Run:
```bash
curl -sS -H 'Authorization: Bearer dev-token' http://127.0.0.1:8787/connectors
```
Expected: JSON contains `weixin` and `feishu`.
- [ ] **Step 4: Attach sidecar to Beaver instance network**
For a local `create-instance.sh` deployment using `beaver-instance-edge`, run:
```bash
docker network connect beaver-instance-edge external-connector 2>/dev/null || true
```
Expected: command succeeds or reports that the endpoint already exists.
- [ ] **Step 5: Restart target app instance with connector env**
For `terminaltest`, ensure the app container has:
```dotenv
EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
EXTERNAL_CONNECTOR_TOKEN=dev-token
BEAVER_BRIDGE_TOKEN=dev-token
```
Then recreate the instance with the deployment script used by this repo. Do not mount `/var/run/docker.sock` into Beaver.
- [ ] **Step 6: Manual fake-provider onboarding**
In `terminaltest`:
- Open Status.
- Click Weixin Connect.
- Confirm QR modal appears.
- Poll until fake status remains visible.
- Confirm backend `/api/channel-connectors` returns `telegram`, `weixin`, and `feishu`.
- [ ] **Step 7: Stop fake sidecar if no longer needed**
Run:
```bash
docker compose -f docker-compose.external-connectors.yml down
```
Expected: sidecar stops; named volume remains.
---
### Task 6: Final Frontend And Deploy Verification
**Files:**
- Review: `docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md`
- [ ] **Step 1: Run frontend verification**
Run:
```bash
cd app-instance/frontend
npm run typecheck
npm run build
npm run test -- lib/channel-connectors.test.ts
```
Expected: all commands pass.
- [ ] **Step 2: Run backend connector smoke tests**
Run:
```bash
cd app-instance/backend
uv run pytest \
tests/unit/test_external_sidecar_connectors.py \
tests/unit/test_external_connector_bridge_api.py \
tests/unit/test_channel_runtime_dynamic_channels.py \
-q
```
Expected: all listed tests pass.
- [ ] **Step 3: Run sidecar verification**
Run:
```bash
cd external-connector
uv run pytest -q
```
Expected: all sidecar tests pass.
- [ ] **Step 4: Scan for provider-runtime naming in new files**
Run:
```bash
rg -n "[Oo]pen[Cc]law" docs/superpowers app-instance/frontend external-connector docker-compose.external-connectors.yml || true
```
Expected: no matches.
- [ ] **Step 5: Commit verification fixes if needed**
If any verification step required fixes:
```bash
git add app-instance/frontend external-connector docker-compose.external-connectors.yml docs/superpowers
git commit -m "fix: stabilize external connector onboarding"
```
If no files changed, do not create an empty commit.

File diff suppressed because it is too large Load Diff