docs: add external connector implementation plans
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||||
1167
docs/superpowers/plans/2026-06-03-external-connector-sidecar.md
Normal file
1167
docs/superpowers/plans/2026-06-03-external-connector-sidecar.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user