diff --git a/apps/web/package.json b/apps/web/package.json index 95b1b67c..0badea42 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3001", + "dev": "next dev --port 3001 --experimental-https", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index fb362281..cf7bf932 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -234,7 +234,7 @@ export class GatewayClient { } /** Hub 验证成功回调 */ - onVerified(callback: (result: { hubId: string; agentId: string }) => void): this { + onVerified(callback: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void): this { this.callbacks.onVerified = callback; return this; } @@ -312,12 +312,13 @@ export class GatewayClient { if (this.options.hubId) { // Set internal state to allow send/request during verify this._state = "registered"; + this.callbacks.onStateChange?.("verifying"); const meta = typeof navigator !== "undefined" ? { userAgent: navigator.userAgent, platform: navigator.platform, language: navigator.language, } : undefined; - this.request<{ hubId: string; agentId: string }>( + this.request<{ hubId: string; agentId: string; isNewDevice?: boolean }>( this.options.hubId, "verify", { token: this.options.token, meta }, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 88118021..f8a3f693 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -102,6 +102,7 @@ export type ConnectionState = | "disconnected" | "connecting" | "connected" + | "verifying" | "registered"; /** Event callback types */ @@ -109,7 +110,7 @@ export interface GatewayClientCallbacks { onConnect?: (socketId: string) => void; onDisconnect?: (reason: string) => void; onRegistered?: (deviceId: string) => void; - onVerified?: (result: { hubId: string; agentId: string }) => void; + onVerified?: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void; onMessage?: (message: RoutedMessage) => void; onSendError?: (error: SendErrorResponse) => void; onPong?: (data: string) => void; diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index fd5c09e0..17b41a51 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -35,6 +35,8 @@ interface ConnectionStoreState { agentId: string | null connectionState: ConnectionState lastError: { code: string; message: string } | null + /** Whether the current connection required Owner approval (new device) */ + isNewDevice: boolean | null } interface ConnectionStoreActions { @@ -158,6 +160,8 @@ function createClient( useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId) } }) + .onVerified((result) => set({ isNewDevice: result.isNewDevice ?? false })) + .onError((error) => set({ lastError: { code: "VERIFY_ERROR", message: error.message } })) .onSendError((error) => set({ lastError: { code: error.code, message: error.error } })) } @@ -252,6 +256,7 @@ export const useConnectionStore = create()( agentId: null, connectionState: "disconnected", lastError: null, + isNewDevice: null, // Connect using a connection code (disconnect existing connection first) connect: (code) => { @@ -284,6 +289,7 @@ export const useConnectionStore = create()( hubId: null, agentId: null, lastError: null, + isNewDevice: null, }) }, diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index ea51d036..e862eac8 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -1,11 +1,14 @@ "use client"; -import { useRef, useCallback } from "react"; +import { useRef, useCallback, useState, useEffect } from "react"; import { Button } from "@multica/ui/components/ui/button"; import { ChatInput } from "@multica/ui/components/chat-input"; import { useConnectionStore, useMessagesStore, useAutoConnect } from "@multica/store"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; +import { useIsMobile } from "@multica/ui/hooks/use-mobile"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { CheckmarkCircle02Icon } from "@hugeicons/core-free-icons"; import { ConnectPrompt } from "./connect-prompt"; import { MessageList } from "./message-list"; import { ChatSkeleton } from "./chat-skeleton"; @@ -17,10 +20,25 @@ export function Chat() { const gwState = useConnectionStore((s) => s.connectionState) const hubId = useConnectionStore((s) => s.hubId) const lastError = useConnectionStore((s) => s.lastError) + const isNewDevice = useConnectionStore((s) => s.isNewDevice) + const isMobile = useIsMobile() const messages = useMessagesStore((s) => s.messages) const streamingIds = useMessagesStore((s) => s.streamingIds) + // Show success overlay for 2s when a new device is approved by Owner + const [showVerifySuccess, setShowVerifySuccess] = useState(false) + useEffect(() => { + if (gwState === "registered" && isNewDevice === true) { + setShowVerifySuccess(true) + const timer = setTimeout(() => { + setShowVerifySuccess(false) + useConnectionStore.setState({ isNewDevice: null }) + }, 2000) + return () => clearTimeout(timer) + } + }, [gwState, isNewDevice]) + const isConnected = gwState === "registered" && !!hubId && !!agentId const handleSend = useCallback((text: string) => { @@ -39,6 +57,26 @@ export function Chat() { return (
+ {/* Verify success overlay — shown for 2s when new device approved */} + {showVerifySuccess && ( +
+ +
+

Connected

+

+ Your device has been approved +

+
+
+ )} + {isConnected && (
+
+ ); +} + +/** Shown when Owner rejects the connection, auto-dismisses after 2s */ +function RejectedStatus({ fullscreen, onDismiss }: { fullscreen?: boolean; onDismiss: () => void }) { + useEffect(() => { + const timer = setTimeout(onDismiss, 2000); + return () => clearTimeout(timer); + }, [onDismiss]); + + const wrapper = fullscreen + ? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6" + : "flex flex-col items-center justify-center h-full gap-5 px-4"; + + return ( +
+ +
+

Connection rejected

+

+ The device owner declined this connection +

+
+
+ ); +} + export function ConnectPrompt() { const gwState = useConnectionStore((s) => s.connectionState); + const lastError = useConnectionStore((s) => s.lastError); const [mode, setMode] = useState("scan"); const [codeInput, setCodeInput] = useState(""); const [pasteState, setPasteState] = useState("idle"); const [pasteError, setPasteError] = useState(null); + const [showRejected, setShowRejected] = useState(false); const isMobile = useIsMobile(); const validatingRef = useRef(false); + // Detect verify rejection: lastError appears while disconnected + useEffect(() => { + if (lastError?.code === "VERIFY_ERROR" && gwState === "disconnected") { + setShowRejected(true); + } + }, [lastError, gwState]); + + const handleDismissRejected = useCallback(() => { + setShowRejected(false); + useConnectionStore.setState({ lastError: null }); + }, []); + const tryConnect = useCallback((raw: string) => { const trimmed = raw.trim(); if (!trimmed || validatingRef.current) return; @@ -75,7 +152,20 @@ export function ConnectPrompt() { useConnectionStore.getState().connect(info); }, []); - const isConnecting = gwState === "connecting" || gwState === "connected"; + const isInProgress = + gwState === "connecting" || + gwState === "connected" || + gwState === "verifying"; + + // Verification rejected — show rejection feedback + if (showRejected) { + return ; + } + + // Connection in progress — show status (replaces scanner/paste) + if (isInProgress) { + return ; + } // Mobile: scanner only, no tabs, no paste if (isMobile) { @@ -86,11 +176,6 @@ export function ConnectPrompt() {

Scan a Multica QR code to start chatting

- {isConnecting && ( -

- Connecting to Agent... -

- )}
@@ -109,11 +194,6 @@ export function ConnectPrompt() { ? "Scan a Multica QR code to start chatting" : "Paste a Multica connection code to start chatting"}

- {isConnecting && ( -

- Connecting to Agent... -

- )} {/* Mode toggle */} diff --git a/packages/ui/src/components/qr-scanner-view.tsx b/packages/ui/src/components/qr-scanner-view.tsx index 568d26fa..41dcaef8 100644 --- a/packages/ui/src/components/qr-scanner-view.tsx +++ b/packages/ui/src/components/qr-scanner-view.tsx @@ -31,6 +31,7 @@ export interface QrScannerProps { } const ACTIVE_STATES: ScannerState[] = [ + "requesting", "scanning", "detected", "success", diff --git a/src/hub/rpc/handlers/verify.ts b/src/hub/rpc/handlers/verify.ts index 53b98735..67bcfda8 100644 --- a/src/hub/rpc/handlers/verify.ts +++ b/src/hub/rpc/handlers/verify.ts @@ -21,7 +21,7 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { // 1. Already in whitelist → pass through (reconnection, no confirmation needed) const allowed = ctx.deviceStore.isAllowed(from); if (allowed) { - return { hubId: ctx.hubId, agentId: allowed.agentId }; + return { hubId: ctx.hubId, agentId: allowed.agentId, isNewDevice: false }; } // 2. Validate token @@ -42,6 +42,6 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { // 4. User confirmed → add to whitelist (with device metadata) ctx.deviceStore.allowDevice(from, result.agentId, meta); - return { hubId: ctx.hubId, agentId: result.agentId }; + return { hubId: ctx.hubId, agentId: result.agentId, isNewDevice: true }; }; }