feat: runtime login screen — remove token from build
- Add LoginScreen component with Gateway URL + Token fields - Store credentials in localStorage (not in bundle) - Auto-reconnect with stored credentials on reload - Add logout button (LogOut icon) in Header - Remove VITE_GATEWAY_TOKEN from .env.example - VITE_GATEWAY_WS_URL now only pre-fills the URL field - Dark neon theme consistent with rest of app Closes feedback item #4
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
# Optional: pre-fill the Gateway URL field on the login screen
|
||||
# If not set, defaults to ws://<current-hostname>:18789
|
||||
VITE_GATEWAY_WS_URL=ws://localhost:18789
|
||||
VITE_GATEWAY_TOKEN=your-gateway-token-here
|
||||
|
||||
18
FEEDBACK.md
18
FEEDBACK.md
@@ -12,10 +12,26 @@
|
||||
- **Priority:** high
|
||||
- **Status:** done
|
||||
- **Completed:** 2026-02-11 — commit `8834b2a`
|
||||
- **Description:** Filtrer les messages "NO_REPLY" — ne pas afficher les messages dont le contenu est exactement "NO_REPLY" (ce sont des réponses internes de l'agent qui ne doivent pas être visibles dans le chat)
|
||||
- **Description:** Filtrer les messages "NO_REPLY"
|
||||
|
||||
## Item #3
|
||||
- **Date:** 2026-02-11
|
||||
- **Priority:** medium
|
||||
- **Status:** pending
|
||||
- **Description:** Ajouter le support i18n (internationalisation) — le projet open-source est en anglais, mais le deploy perso de Nicolas doit rester en français. Soit via une config `.env` (ex: `VITE_LOCALE=fr`), soit via un système de traduction léger. Les strings UI (placeholder input, bouton envoyer, statut connexion, etc.) doivent être configurables.
|
||||
|
||||
## Item #4
|
||||
- **Date:** 2026-02-11
|
||||
- **Priority:** high
|
||||
- **Status:** in-progress
|
||||
- **Description:** Supprimer le token du build — implémenter un écran de login au runtime
|
||||
- Au premier lancement (ou si pas de credentials en localStorage), afficher un écran de connexion avec :
|
||||
- Champ "Gateway URL" (ex: `ws://192.168.1.14:18789`)
|
||||
- Champ "Token" (password field)
|
||||
- Bouton "Connect"
|
||||
- Stocker les credentials en `localStorage` (pas dans le bundle JS)
|
||||
- Supprimer `VITE_GATEWAY_TOKEN` du `.env.example` et du code
|
||||
- Garder `VITE_GATEWAY_WS_URL` uniquement comme valeur par défaut optionnelle pour pré-remplir le champ URL
|
||||
- Ajouter un bouton "Disconnect" / "Logout" dans le header qui clear le localStorage et revient à l'écran de login
|
||||
- L'écran de login doit suivre le même thème dark neon que le reste de l'app
|
||||
- ⚠️ Après ce changement, le deploy perso (`~/marlbot-chat/.env`) n'a plus besoin de `VITE_GATEWAY_TOKEN` — l'utilisateur entrera le token via l'UI
|
||||
|
||||
23
src/App.tsx
23
src/App.tsx
@@ -3,11 +3,30 @@ import { useGateway } from './hooks/useGateway';
|
||||
import { Header } from './components/Header';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { Chat } from './components/Chat';
|
||||
import { LoginScreen } from './components/LoginScreen';
|
||||
|
||||
export default function App() {
|
||||
const { status, messages, sessions, activeSession, isGenerating, sendMessage, abort, switchSession } = useGateway();
|
||||
const {
|
||||
status, messages, sessions, activeSession, isGenerating,
|
||||
sendMessage, abort, switchSession,
|
||||
authenticated, login, logout, connectError, isConnecting,
|
||||
} = useGateway();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
// Still checking stored credentials
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="h-dvh flex items-center justify-center bg-[#1e1e24] text-zinc-500">
|
||||
<div className="animate-pulse text-sm">Connecting…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated — show login
|
||||
if (!authenticated) {
|
||||
return <LoginScreen onConnect={login} error={connectError} isConnecting={isConnecting} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial_gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]" role="application" aria-label="PinchChat">
|
||||
<Sidebar
|
||||
@@ -18,7 +37,7 @@ export default function App() {
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} />
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} />
|
||||
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Menu, Bot, Sparkles } from 'lucide-react';
|
||||
import { Menu, Bot, Sparkles, LogOut } from 'lucide-react';
|
||||
import type { ConnectionStatus, Session } from '../types';
|
||||
|
||||
interface Props {
|
||||
@@ -6,9 +6,10 @@ interface Props {
|
||||
sessionKey: string;
|
||||
onToggleSidebar: () => void;
|
||||
activeSessionData?: Session;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData }: Props) {
|
||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout }: Props) {
|
||||
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
|
||||
|
||||
return (
|
||||
@@ -46,6 +47,16 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData
|
||||
<span className="text-xs text-zinc-300 hidden sm:inline">Disconnected</span>
|
||||
</div>
|
||||
)}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
aria-label="Disconnect and logout"
|
||||
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{(() => {
|
||||
|
||||
138
src/components/LoginScreen.tsx
Normal file
138
src/components/LoginScreen.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bot, Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
onConnect: (url: string, token: string) => void;
|
||||
error?: string | null;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'pinchchat_credentials';
|
||||
|
||||
export function getStoredCredentials(): { url: string; token: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.url && parsed.token) return parsed;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function storeCredentials(url: string, token: string) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token }));
|
||||
}
|
||||
|
||||
export function clearCredentials() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
const defaultUrl = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [token, setToken] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredCredentials();
|
||||
if (stored) {
|
||||
setUrl(stored.url);
|
||||
setToken(stored.token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!url.trim() || !token.trim()) return;
|
||||
onConnect(url.trim(), token.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex items-center justify-center bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial-gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]">
|
||||
<div className="w-full max-w-md mx-4">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-3 mb-8">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-zinc-800/60 shadow-lg shadow-cyan-500/5">
|
||||
<Bot className="h-7 w-7 text-cyan-200" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-zinc-200 tracking-wide">PinchChat</h1>
|
||||
<Sparkles className="h-5 w-5 text-cyan-300/60" />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">Connect to your OpenClaw gateway</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="rounded-2xl border border-white/8 bg-[#232329]/80 backdrop-blur-xl p-6 space-y-5 shadow-2xl shadow-black/30">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="gateway-url" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
id="gateway-url"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="ws://192.168.1.14:18789"
|
||||
className="w-full rounded-xl border border-white/8 bg-zinc-800/50 px-4 py-3 text-sm text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20 transition-all"
|
||||
autoComplete="url"
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="gateway-token" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
|
||||
Token
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="gateway-token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
placeholder="Enter your gateway token"
|
||||
className="w-full rounded-xl border border-white/8 bg-zinc-800/50 px-4 py-3 pr-12 text-sm text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20 transition-all"
|
||||
autoComplete="current-password"
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showToken ? 'Hide token' : 'Show token'}
|
||||
>
|
||||
{showToken ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url.trim() || !token.trim() || isConnecting}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-violet-500 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-cyan-500/20 hover:shadow-cyan-500/30 hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Connecting…
|
||||
</>
|
||||
) : (
|
||||
'Connect'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-zinc-600 mt-6">
|
||||
Credentials are stored locally in your browser
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { GatewayClient } from '../lib/gateway';
|
||||
import { genIdempotencyKey } from '../lib/utils';
|
||||
import { getStoredCredentials, storeCredentials, clearCredentials } from '../components/LoginScreen';
|
||||
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
|
||||
|
||||
function extractText(message: any): string {
|
||||
@@ -23,6 +24,10 @@ export function useGateway() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [activeSession, setActiveSession] = useState('agent:main:main');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null); // null = checking
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
@@ -30,21 +35,38 @@ export function useGateway() {
|
||||
const currentRunIdRef = useRef<string | null>(null);
|
||||
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const client = new GatewayClient();
|
||||
const setupClient = useCallback((wsUrl: string, token: string) => {
|
||||
// Tear down existing client
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
|
||||
const client = new GatewayClient(wsUrl, token);
|
||||
clientRef.current = client;
|
||||
|
||||
client.onStatus((s) => {
|
||||
setStatus(s);
|
||||
if (s === 'connected') {
|
||||
setAuthenticated(true);
|
||||
setConnectError(null);
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false;
|
||||
storeCredentials(wsUrl, token);
|
||||
loadSessions();
|
||||
loadHistory(activeSessionRef.current);
|
||||
} else if (s === 'disconnected' && !client.isConnected) {
|
||||
// If we never connected successfully, this is an auth/connection error
|
||||
if (isConnectingRef.current) {
|
||||
setConnectError('Connection failed — check URL and token');
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.onEvent((event, payload) => {
|
||||
if (event === 'agent') {
|
||||
// Tool stream events
|
||||
handleAgentEvent(payload);
|
||||
return;
|
||||
}
|
||||
@@ -52,7 +74,6 @@ export function useGateway() {
|
||||
|
||||
const { state, runId, message, errorMessage, sessionKey: evtSession } = payload;
|
||||
|
||||
// Track active/inactive sessions globally
|
||||
if (evtSession) {
|
||||
if (state === 'delta') {
|
||||
setActiveSessions(prev => {
|
||||
@@ -80,14 +101,12 @@ export function useGateway() {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
|
||||
// Update text block but preserve tool/thinking blocks
|
||||
const updated = { ...last };
|
||||
updated.content = text;
|
||||
const nonTextBlocks = updated.blocks.filter(b => b.type !== 'text');
|
||||
updated.blocks = [...nonTextBlocks, { type: 'text' as const, text }];
|
||||
return [...prev.slice(0, -1), updated];
|
||||
}
|
||||
// Create new streaming message
|
||||
const msg: ChatMessage = {
|
||||
id: runId + '-' + Date.now(),
|
||||
role: 'assistant',
|
||||
@@ -102,7 +121,6 @@ export function useGateway() {
|
||||
} else if (state === 'final') {
|
||||
currentRunIdRef.current = null;
|
||||
setIsGenerating(false);
|
||||
// Reload full history to get the proper final messages with tool calls etc.
|
||||
loadHistory(activeSessionRef.current);
|
||||
} else if (state === 'error') {
|
||||
currentRunIdRef.current = null;
|
||||
@@ -133,12 +151,23 @@ export function useGateway() {
|
||||
}
|
||||
});
|
||||
|
||||
setIsConnecting(true);
|
||||
isConnectingRef.current = true;
|
||||
setConnectError(null);
|
||||
client.connect();
|
||||
return () => client.disconnect();
|
||||
}, []);
|
||||
|
||||
// On mount: try stored credentials
|
||||
useEffect(() => {
|
||||
const stored = getStoredCredentials();
|
||||
if (stored) {
|
||||
setupClient(stored.url, stored.token);
|
||||
} else {
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}, [setupClient]);
|
||||
|
||||
const handleAgentEvent = useCallback((payload: any) => {
|
||||
// Handle tool stream events from agent stream
|
||||
if (payload?.stream !== 'tool') return;
|
||||
const data = payload.data ?? {};
|
||||
const phase = data.phase;
|
||||
@@ -202,10 +231,8 @@ export function useGateway() {
|
||||
for (const block of m.content) {
|
||||
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
|
||||
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
|
||||
// Anthropic format
|
||||
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
|
||||
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
|
||||
// OpenClaw gateway format (toolCall / toolResult)
|
||||
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
|
||||
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
|
||||
}
|
||||
@@ -213,10 +240,8 @@ export function useGateway() {
|
||||
blocks.push({ type: 'text', text: m.content });
|
||||
}
|
||||
}
|
||||
// Map gateway roles to our simplified roles
|
||||
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
// toolResult role messages: convert text blocks to tool_result blocks, then merge into previous assistant
|
||||
if (m.role === 'toolResult') {
|
||||
const toolBlocks: MessageBlock[] = blocks.map(b => {
|
||||
if (b.type === 'text') {
|
||||
@@ -242,7 +267,6 @@ export function useGateway() {
|
||||
blocks,
|
||||
};
|
||||
});
|
||||
// Merge toolResult messages into their preceding assistant message
|
||||
const merged: ChatMessage[] = [];
|
||||
for (const msg of msgs) {
|
||||
if ((msg as any).isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
|
||||
@@ -251,8 +275,7 @@ export function useGateway() {
|
||||
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
|
||||
};
|
||||
} else if ((msg as any).isToolResult) {
|
||||
// Orphan toolResult — skip or show as assistant
|
||||
// skip it
|
||||
// skip orphan
|
||||
} else {
|
||||
merged.push(msg);
|
||||
}
|
||||
@@ -300,6 +323,23 @@ export function useGateway() {
|
||||
loadHistory(key);
|
||||
}, [loadHistory]);
|
||||
|
||||
const login = useCallback((url: string, token: string) => {
|
||||
setupClient(url, token);
|
||||
}, [setupClient]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
}
|
||||
clearCredentials();
|
||||
setAuthenticated(false);
|
||||
setMessages([]);
|
||||
setSessions([]);
|
||||
setStatus('disconnected');
|
||||
setConnectError(null);
|
||||
}, []);
|
||||
|
||||
// Periodic session refresh every 30s
|
||||
useEffect(() => {
|
||||
if (status !== 'connected') return;
|
||||
@@ -307,11 +347,14 @@ export function useGateway() {
|
||||
return () => clearInterval(interval);
|
||||
}, [status, loadSessions]);
|
||||
|
||||
// Merge active state into sessions
|
||||
const enrichedSessions = sessions.map(s => ({
|
||||
...s,
|
||||
isActive: activeSessions.has(s.key),
|
||||
}));
|
||||
|
||||
return { status, messages, sessions: enrichedSessions, activeSession, isGenerating, sendMessage, abort, switchSession, loadSessions };
|
||||
return {
|
||||
status, messages, sessions: enrichedSessions, activeSession, isGenerating,
|
||||
sendMessage, abort, switchSession, loadSessions,
|
||||
authenticated, login, logout, connectError, isConnecting,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import { genId } from './utils';
|
||||
export type GatewayEventHandler = (event: string, payload: any) => void;
|
||||
export type GatewayResponseHandler = (id: string, ok: boolean, payload: any) => void;
|
||||
|
||||
const WS_URL = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
|
||||
const AUTH_TOKEN = import.meta.env.VITE_GATEWAY_TOKEN || '';
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
||||
@@ -13,6 +10,21 @@ export class GatewayClient {
|
||||
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
|
||||
private reconnectTimer: any = null;
|
||||
private connected = false;
|
||||
private autoReconnect = true;
|
||||
|
||||
private wsUrl: string;
|
||||
private authToken: string;
|
||||
|
||||
constructor(wsUrl?: string, authToken?: string) {
|
||||
this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`;
|
||||
this.authToken = authToken || '';
|
||||
}
|
||||
|
||||
/** Update credentials (e.g. after login). Does not reconnect automatically. */
|
||||
setCredentials(wsUrl: string, authToken: string) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
onStatus(fn: (s: 'disconnected' | 'connecting' | 'connected') => void) {
|
||||
this._onStatus = fn;
|
||||
@@ -25,8 +37,9 @@ export class GatewayClient {
|
||||
|
||||
connect() {
|
||||
if (this.ws) return;
|
||||
this.autoReconnect = true;
|
||||
this._onStatus('connecting');
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
|
||||
this.ws.onopen = () => { console.log('[GW] WS open'); };
|
||||
|
||||
@@ -58,7 +71,7 @@ export class GatewayClient {
|
||||
this._onStatus('disconnected');
|
||||
this.pendingRequests.forEach(p => p.reject(new Error('disconnected')));
|
||||
this.pendingRequests.clear();
|
||||
this.scheduleReconnect();
|
||||
if (this.autoReconnect) this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (e) => { console.log('[GW] WS error', e); };
|
||||
@@ -75,8 +88,8 @@ export class GatewayClient {
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: AUTH_TOKEN },
|
||||
locale: 'fr-FR',
|
||||
auth: { token: this.authToken },
|
||||
locale: navigator.language || 'en',
|
||||
userAgent: 'pinchchat/1.0.0',
|
||||
}).then((res) => {
|
||||
console.log('[GW] connected!', res);
|
||||
@@ -84,6 +97,7 @@ export class GatewayClient {
|
||||
this._onStatus('connected');
|
||||
}).catch((err) => {
|
||||
console.log('[GW] connect failed:', err);
|
||||
this.autoReconnect = false;
|
||||
this.disconnect();
|
||||
});
|
||||
}
|
||||
@@ -97,6 +111,7 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autoReconnect = false;
|
||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||||
if (this.ws) { this.ws.close(); this.ws = null; }
|
||||
this.connected = false;
|
||||
|
||||
Reference in New Issue
Block a user