feat: gateway to chat to goose - telegram etc (#7199)

This commit is contained in:
Michael Neale
2026-02-25 17:49:55 +11:00
committed by GitHub
parent d1f4dc947d
commit 19dc192efe
25 changed files with 2794 additions and 18 deletions
+1 -1
View File
@@ -1211,7 +1211,7 @@ export type SessionListResponse = {
sessions: Array<Session>;
};
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal';
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal' | 'gateway';
export type SessionsQuery = {
limit: number;
@@ -11,6 +11,9 @@ import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard, HardDrive } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import TunnelSection from './tunnel/TunnelSection';
import GatewaySettingsSection from './gateways/GatewaySettingsSection';
import { getTunnelStatus } from '../../api/sdk.gen';
import ChatSettingsSection from './chat/ChatSettingsSection';
import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
import LocalInferenceSection from './localInference/LocalInferenceSection';
@@ -33,6 +36,7 @@ export default function SettingsView({
viewOptions: SettingsViewOptions;
}) {
const [activeTab, setActiveTab] = useState('models');
const [tunnelDisabled, setTunnelDisabled] = useState(false);
const hasTrackedInitialTab = useRef(false);
const handleTabChange = (tab: string) => {
@@ -55,6 +59,7 @@ export default function SettingsView({
chat: 'chat',
prompts: 'prompts',
keyboard: 'keyboard',
gateway: 'sharing',
'local-inference': 'local-inference',
};
@@ -72,6 +77,16 @@ export default function SettingsView({
}
}, [activeTab]);
useEffect(() => {
getTunnelStatus()
.then(({ data }) => {
setTunnelDisabled(data?.state === 'disabled');
})
.catch(() => {
setTunnelDisabled(false);
});
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) {
@@ -183,9 +198,15 @@ export default function SettingsView({
value="sharing"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<div className="space-y-8">
<div className="space-y-8 pb-8">
<SessionSharingSection />
<ExternalBackendSection />
{!tunnelDisabled && (
<div className="space-y-4">
<TunnelSection />
<GatewaySettingsSection />
</div>
)}
</div>
</TabsContent>
@@ -4,7 +4,6 @@ import { Button } from '../../ui/button';
import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
import UpdateSection from './UpdateSection';
import TunnelSection from '../tunnel/TunnelSection';
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
@@ -336,7 +335,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
{/* Navigation Settings */}
<NavigationSettingsCard />
<TunnelSection />
<TelemetrySettings isWelcome={false} />
@@ -0,0 +1,419 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '../../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
import { Input } from '../../ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../../ui/dialog';
import { Loader2, Copy, Check, Square, Trash2, ExternalLink, User } from 'lucide-react';
import { getApiUrl } from '../../../config';
interface PairedUserInfo {
platform: string;
user_id: string;
display_name: string | null;
session_id: string;
paired_at: number;
}
interface GatewayStatus {
gateway_type: string;
running: boolean;
configured: boolean;
paired_users: PairedUserInfo[];
info?: Record<string, string>;
}
interface PairingCodeResponse {
code: string;
expires_at: number;
}
async function gatewayFetch(endpoint: string, options: globalThis.RequestInit = {}) {
const secretKey = await window.electron.getSecretKey();
const url = getApiUrl(endpoint);
return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': secretKey,
...options.headers,
},
});
}
export default function GatewaySettingsSection() {
const [gateways, setGateways] = useState<GatewayStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pairingCode, setPairingCode] = useState<PairingCodeResponse | null>(null);
const [pairingGatewayType, setPairingGatewayType] = useState<string | null>(null);
const [copiedCode, setCopiedCode] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const response = await gatewayFetch('/gateway/status');
if (response.ok) {
setGateways(await response.json());
}
} catch (err) {
console.error('Failed to fetch gateway status:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, [fetchStatus]);
const doPost = async (endpoint: string, body: object, errorMsg: string) => {
setError(null);
try {
const response = await gatewayFetch(endpoint, {
method: 'POST',
body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || errorMsg);
}
await fetchStatus();
} catch (err) {
setError(err instanceof Error ? err.message : errorMsg);
}
};
const handleUnpairUser = async (platform: string, userId: string) => {
setError(null);
try {
const response = await gatewayFetch(`/gateway/pair/${platform}/${userId}`, {
method: 'DELETE',
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to unpair user');
}
await fetchStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to unpair user');
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-text-muted py-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
);
}
const telegram = gateways.find((g) => g.gateway_type === 'telegram');
return (
<>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded text-sm text-red-800 dark:text-red-200 mb-4">
{error}
</div>
)}
<TelegramGatewayCard
status={telegram}
onStart={(config) =>
doPost('/gateway/start', { gateway_type: 'telegram', platform_config: config, max_sessions: 0 }, 'Failed to start')
}
onRestart={() => doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')}
onStop={() => doPost('/gateway/stop', { gateway_type: 'telegram' }, 'Failed to stop')}
onRemove={() => doPost('/gateway/remove', { gateway_type: 'telegram' }, 'Failed to remove')}
onGenerateCode={async () => {
setError(null);
try {
const response = await gatewayFetch('/gateway/pair', {
method: 'POST',
body: JSON.stringify({ gateway_type: 'telegram' }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to generate pairing code');
}
const data: PairingCodeResponse = await response.json();
setPairingCode(data);
setPairingGatewayType('telegram');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate pairing code');
}
}}
onUnpairUser={handleUnpairUser}
/>
<PairingCodeModal
open={pairingCode !== null}
onClose={() => {
setPairingCode(null);
setPairingGatewayType(null);
}}
code={pairingCode}
gatewayType={pairingGatewayType}
onCopy={copyToClipboard}
copied={copiedCode}
/>
</>
);
}
function PairedUsersList({
users,
onUnpairUser,
}: {
users: PairedUserInfo[];
onUnpairUser: (platform: string, userId: string) => void;
}) {
if (users.length === 0) return null;
return (
<div className="space-y-1 mt-2">
<h4 className="text-xs text-text-muted font-medium">Paired Users</h4>
{users.map((user) => (
<div
key={`${user.platform}-${user.user_id}`}
className="flex items-center justify-between py-1.5 px-2 bg-background-muted rounded text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<User className="h-3 w-3 text-text-muted flex-shrink-0" />
<span className="truncate">{user.display_name || user.user_id}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onUnpairUser(user.platform, user.user_id)}
className="h-6 w-6 p-0 text-text-muted hover:text-red-600 flex-shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
);
}
function TelegramGatewayCard({
status,
onStart,
onRestart,
onStop,
onRemove,
onGenerateCode,
onUnpairUser,
}: {
status: GatewayStatus | undefined;
onStart: (config: Record<string, unknown>) => Promise<void>;
onRestart: () => Promise<void>;
onStop: () => Promise<void>;
onRemove: () => Promise<void>;
onGenerateCode: () => void;
onUnpairUser: (platform: string, userId: string) => void;
}) {
const [botToken, setBotToken] = useState('');
const [busy, setBusy] = useState(false);
const running = status?.running ?? false;
const configured = status?.configured ?? false;
const wrap = (fn: () => Promise<void>) => async () => {
setBusy(true);
try { await fn(); } finally { setBusy(false); }
};
const handleFirstStart = wrap(async () => {
if (!botToken.trim()) return;
await onStart({ bot_token: botToken.trim() });
setBotToken('');
});
return (
<Card className="rounded-lg">
<CardHeader className="pb-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Telegram
{running && (
<span className="inline-flex items-center text-xs text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-0.5 rounded-full">
Running
</span>
)}
{!running && configured && (
<span className="inline-flex items-center text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 px-2 py-0.5 rounded-full">
Stopped
</span>
)}
</CardTitle>
<div className="flex items-center gap-2">
{running && (
<>
<Button variant="outline" size="sm" onClick={onGenerateCode}>
Pair Device
</Button>
<Button variant="destructive" size="sm" disabled={busy} onClick={wrap(onStop)}>
<Square className="h-3 w-3 mr-1" />
Stop
</Button>
</>
)}
{!running && configured && (
<>
<Button size="sm" disabled={busy} onClick={wrap(onRestart)}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Start'}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={wrap(onRemove)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3 w-3 mr-1" />
Remove
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-3 space-y-2">
{!running && !configured && (
<>
<div className="text-xs text-text-muted space-y-1.5 mb-2">
<p>
Open{' '}
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-blue-600 dark:text-blue-400 hover:underline"
>
@BotFather
<ExternalLink className="h-3 w-3" />
</a>
{' '}on your phone, send <code className="bg-background-muted px-1 py-0.5 rounded">/newbot</code>, and follow
the prompts to name your bot. BotFather will reply with an API token paste it below.
</p>
</div>
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="Paste bot token here"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleFirstStart()}
className="text-sm"
/>
<Button size="sm" onClick={handleFirstStart} disabled={busy || !botToken.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Start'}
</Button>
</div>
</>
)}
{status && <PairedUsersList users={status.paired_users} onUnpairUser={onUnpairUser} />}
</CardContent>
</Card>
);
}
function PairingCodeModal({
open,
onClose,
code,
gatewayType,
onCopy,
copied,
}: {
open: boolean;
onClose: () => void;
code: PairingCodeResponse | null;
gatewayType: string | null;
onCopy: (text: string) => void;
copied: boolean;
}) {
const [timeRemaining, setTimeRemaining] = useState(0);
useEffect(() => {
if (!code) return;
const updateTimer = () => {
const remaining = Math.max(0, code.expires_at - Math.floor(Date.now() / 1000));
setTimeRemaining(remaining);
if (remaining === 0) {
onClose();
}
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [code, onClose]);
if (!code) return null;
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Pairing Code</DialogTitle>
</DialogHeader>
<div className="py-6 space-y-4">
<div className="flex justify-center">
<div className="flex items-center gap-2">
<code className="text-4xl font-mono font-bold tracking-[0.3em] select-all">
{code.code}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => onCopy(code.code)}
className="flex-shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<p className="text-center text-sm text-text-muted">
Send this code to your{' '}
<span className="capitalize font-medium">{gatewayType}</span> bot to pair.
</p>
<div className="text-center text-xs text-text-muted">
Expires in {minutes}:{seconds.toString().padStart(2, '0')}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -127,7 +127,7 @@ export default function TunnelSection() {
<>
<Card className="rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="mb-1">Remote Access</CardTitle>
<CardTitle className="mb-1">Mobile App</CardTitle>
<CardDescription className="flex flex-col gap-2">
<div className="flex items-start gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
@@ -205,7 +205,7 @@ export default function TunnelSection() {
<Dialog open={showQRModal} onOpenChange={setShowQRModal}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Remote Access Connection</DialogTitle>
<DialogTitle>Mobile App Connection</DialogTitle>
</DialogHeader>
{tunnelInfo.state === 'running' && (