Files
beaver_project/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md

24 KiB

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:

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:

  ChannelConnectionResponse,
  ChannelConnectionView,
  ChannelConnectorDescriptor,
  ConnectorSessionResponse,
  • Step 3: Add connector API functions

Append to app-instance/frontend/lib/api.ts near the channel API functions:

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:

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
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:

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:

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:

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:

cd app-instance/frontend
npm run test -- lib/channel-connectors.test.ts

Expected: helper tests pass.

  • Step 5: Commit Task 2
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:

'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:

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:

  const [channelConnectors, setChannelConnectors] = useState<ChannelConnectorDescriptor[]>([]);
  const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);

Add loader:

  const loadChannelConnectors = async () => {
    const [connectors, connections] = await Promise.all([
      listChannelConnectors(),
      listChannelConnections(),
    ]);
    setChannelConnectors(connectors);
    setChannelConnections(connections);
  };

Call it after status load:

  useEffect(() => {
    loadStatus();
    loadChannelConnectors().catch(() => undefined);
  }, []);

In handleSaveChannel() after await loadStatus();, add:

      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:

      <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:

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
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:

cd app-instance/frontend
npm run build

Expected: Next build succeeds.

  • Step 2: Start frontend dev server if visual smoke is needed

Run:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

cd external-connector
uv run pytest -q

Expected: all sidecar tests pass.

  • Step 4: Scan for provider-runtime naming in new files

Run:

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:

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.