diff --git a/client/src/components/ConnectionPlaceholder.tsx b/client/src/components/ConnectionPlaceholder.tsx index 06efcc2..7aca7b9 100644 --- a/client/src/components/ConnectionPlaceholder.tsx +++ b/client/src/components/ConnectionPlaceholder.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useDevice } from "@/contexts/DeviceContext"; +import { isLikelyNetworkFetchError } from "@/lib/networkError"; import { Loader2, Wifi } from "lucide-react"; import BottomNav from "@/components/BottomNav"; import localeZh from "@/locales/data-zh.json"; @@ -50,7 +51,7 @@ export default function ConnectionPlaceholder() { <>

{text.failedTitle}

- {error ?? text.failedDesc} + {error && !isLikelyNetworkFetchError(error) ? error : text.failedDesc}

{text.failedHint}

diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index 35885cd..7bead87 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -10,6 +10,7 @@ import { PeqState, } from "@/lib/luxsinApi"; import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp"; +import { isLikelyNetworkFetchError } from "@/lib/networkError"; interface DeviceContextType { ip: string; @@ -115,7 +116,11 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { setApi(newApi); setIsConnected(true); } catch (e) { - setError(`Connection failed: ${e instanceof Error ? e.message : String(e)}`); + setError( + isLikelyNetworkFetchError(e) + ? null + : `Connection failed: ${e instanceof Error ? e.message : String(e)}`, + ); setIsConnected(false); } finally { setIsConnecting(false); @@ -151,7 +156,11 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { await fetchState(apiInstance); await fetchPeqState(apiInstance).catch(() => undefined); } catch (e) { - setError(`Refresh failed: ${e instanceof Error ? e.message : String(e)}`); + setError( + isLikelyNetworkFetchError(e) + ? null + : `Refresh failed: ${e instanceof Error ? e.message : String(e)}`, + ); } }, [api, isConnected, ip, isDemoMode, fetchState, fetchPeqState]); diff --git a/client/src/lib/aiApi.ts b/client/src/lib/aiApi.ts index deba230..eaa1412 100644 --- a/client/src/lib/aiApi.ts +++ b/client/src/lib/aiApi.ts @@ -11,6 +11,7 @@ import { dbToVolume, volumeToDb, } from "./luxsinApi"; +import { AI_NETWORK_ERROR_CODE, isLikelyNetworkFetchError } from "./networkError"; // Allow override via Vite env / localStorage; default to same origin in prod. export function getAIBaseUrl(): string { @@ -120,7 +121,13 @@ export interface QuestionPayload { // REST helpers // ============================================================ async function jsonFetch(url: string, init?: RequestInit): Promise { - const res = await fetch(url, init); + let res: Response; + try { + res = await fetch(url, init); + } catch (error) { + if (isLikelyNetworkFetchError(error)) throw new Error(AI_NETWORK_ERROR_CODE); + throw error; + } if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); return res.json(); } @@ -293,7 +300,7 @@ export function streamQuestion(payload: QuestionPayload, cb: StreamCallbacks): A cb.onDone?.(); } catch (e: any) { if (e?.name === "AbortError") return; - cb.onError?.(e?.message ?? String(e)); + cb.onError?.(isLikelyNetworkFetchError(e) ? AI_NETWORK_ERROR_CODE : (e?.message ?? String(e))); } })(); @@ -472,6 +479,12 @@ export async function executeFrontendTool( return { ok: false, message: `Unsupported tool: ${block.name}` }; } } catch (e: any) { + if (isLikelyNetworkFetchError(e)) { + return { + ok: false, + message: "Device is not reachable. Ask the user to reconnect the device on the same network, then retry.", + }; + } return { ok: false, message: e?.message ?? String(e) }; } } diff --git a/client/src/lib/networkError.ts b/client/src/lib/networkError.ts new file mode 100644 index 0000000..e0b6de2 --- /dev/null +++ b/client/src/lib/networkError.ts @@ -0,0 +1,33 @@ +/** Browser / WebView fetch failures are usually TypeError with an opaque message. */ + +export const AI_NETWORK_ERROR_CODE = "AI_UNREACHABLE"; + +const NETWORK_FETCH_HINTS = [ + "failed to fetch", + "networkerror", + "network request failed", + "load failed", + "internet connection appears to be offline", +]; + +export function isLikelyNetworkFetchError(error: unknown): boolean { + const name = error instanceof Error ? error.name : ""; + const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase(); + if (msg === AI_NETWORK_ERROR_CODE.toLowerCase()) return true; + if (NETWORK_FETCH_HINTS.some((hint) => msg.includes(hint))) return true; + // Some WebViews surface a bare TypeError when fetch cannot complete. + return name === "TypeError" && !msg; +} + +export function formatNetworkAwareMessage( + template: string, + error: unknown, + networkCopy: string, +): string { + const raw = error instanceof Error ? error.message : String(error ?? ""); + const message = isLikelyNetworkFetchError(error) || isLikelyNetworkFetchError(raw) ? networkCopy : raw; + if (template.includes("{message}")) { + return template.split("{message}").join(String(message)); + } + return isLikelyNetworkFetchError(error) || isLikelyNetworkFetchError(raw) ? networkCopy : template; +} diff --git a/client/src/locales/data-de.json b/client/src/locales/data-de.json index 3d43350..3e10010 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -4,7 +4,7 @@ "ipMissingDesc": "Bitte fügen Sie die Geräte-IP in der Adressleiste hinzu, bevor Sie diese Seite öffnen, zum Beispiel:", "connecting": "Verbindung zu {ip}…", "failedTitle": "Verbindung fehlgeschlagen", - "failedDesc": "Überprüfen Sie die Geräte-IP und Ihre Netzwerkverbindung.", + "failedDesc": "Das Gerät ist nicht erreichbar. Stellen Sie sicher, dass diese Seite und das Gerät im selben Netzwerk sind und die IP stimmt.", "failedHint": "Geben Sie das Gerät in der URL an, z.B. ?ip=10.0.0.119" }, "home": { @@ -623,6 +623,8 @@ "thinking": "Denke…", "stopped": "(Gestoppt)", "requestFailed": "Anfrage fehlgeschlagen. Bitte versuchen Sie es später erneut.", + "deviceUnreachable": "Das Gerät ist nicht erreichbar. Prüfen Sie, ob diese Seite und das Gerät im selben Netzwerk sind, und versuchen Sie es erneut.", + "aiUnreachable": "Der Luxsin-KI-Dienst ist nicht erreichbar. Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "ariaSend": "Senden", "ariaStop": "Stopp", "ariaNewChat": "Neuer Chat", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 1531a2e..034d2f2 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -4,7 +4,7 @@ "ipMissingDesc": "Add the device IP in the address bar before opening this page, for example:", "connecting": "Connecting to {ip}…", "failedTitle": "Connection failed", - "failedDesc": "Check the device IP and your network connection.", + "failedDesc": "Can't reach the device. Make sure this page and the device are on the same network, and that the IP is correct.", "failedHint": "Specify the device in the URL, e.g. ?ip=10.0.0.119" }, "home": { @@ -623,6 +623,8 @@ "thinking": "Thinking…", "stopped": "(Stopped)", "requestFailed": "Request failed. Please try again later.", + "deviceUnreachable": "Can't reach the device. Check that this page and the device are on the same network, then try again.", + "aiUnreachable": "Can't reach the Luxsin AI service. Check your internet connection and try again.", "ariaSend": "Send", "ariaStop": "Stop", "ariaNewChat": "New chat", diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index f0d6bc7..c1b2abc 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -4,7 +4,7 @@ "ipMissingDesc": "Agregue la IP del dispositivo en la barra de direcciones antes de abrir esta página, por ejemplo:", "connecting": "Conectando a {ip}…", "failedTitle": "Error de conexión", - "failedDesc": "Verifique la IP del dispositivo y su conexión de red.", + "failedDesc": "No se puede alcanzar el dispositivo. Compruebe que esta página y el dispositivo estén en la misma red y que la IP sea correcta.", "failedHint": "Especifique el dispositivo en la URL, ej. ?ip=10.0.0.119" }, "home": { @@ -623,6 +623,8 @@ "thinking": "Pensando…", "stopped": "(Detenido)", "requestFailed": "Error en la solicitud. Inténtelo de nuevo más tarde.", + "deviceUnreachable": "No se puede alcanzar el dispositivo. Compruebe que esta página y el dispositivo estén en la misma red e inténtelo de nuevo.", + "aiUnreachable": "No se puede alcanzar el servicio de Luxsin AI. Compruebe su conexión a Internet e inténtelo de nuevo.", "ariaSend": "Enviar", "ariaStop": "Detener", "ariaNewChat": "Nuevo chat", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index 6b4f59c..0f2cd10 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -4,7 +4,7 @@ "ipMissingDesc": "Veuillez ajouter l'IP de l'appareil dans la barre d'adresse avant d'ouvrir cette page, par exemple :", "connecting": "Connexion à {ip}…", "failedTitle": "Échec de la connexion", - "failedDesc": "Vérifiez l'IP de l'appareil et votre connexion réseau.", + "failedDesc": "Impossible d'atteindre l'appareil. Vérifiez que cette page et l'appareil sont sur le même réseau, et que l'IP est correcte.", "failedHint": "Spécifiez l'appareil dans l'URL, par ex. ?ip=10.0.0.119" }, "home": { @@ -623,6 +623,8 @@ "thinking": "Réflexion…", "stopped": "(Arrêté)", "requestFailed": "Échec de la requête. Veuillez réessayer plus tard.", + "deviceUnreachable": "Impossible d'atteindre l'appareil. Vérifiez que cette page et l'appareil sont sur le même réseau, puis réessayez.", + "aiUnreachable": "Impossible d'atteindre le service Luxsin AI. Vérifiez votre connexion Internet, puis réessayez.", "ariaSend": "Envoyer", "ariaStop": "Arrêter", "ariaNewChat": "Nouveau chat", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index be0de66..1c23a7a 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -4,7 +4,7 @@ "ipMissingDesc": "請在網址列加入裝置 IP 參數後再開啟本頁,例如:", "connecting": "正在連線 {ip}…", "failedTitle": "連線失敗", - "failedDesc": "請檢查裝置 IP 與網路連線。", + "failedDesc": "無法連接裝置。請確認本頁與裝置在同一網絡,並檢查裝置 IP 是否正確。", "failedHint": "透過網址列指定裝置,例如 ?ip=10.0.0.119" }, "home": { @@ -617,6 +617,8 @@ "thinking": "思考中…", "stopped": "(已停止)", "requestFailed": "請求失敗,請稍後重試", + "deviceUnreachable": "無法連接裝置。請確認本頁與裝置在同一網絡後再試。", + "aiUnreachable": "無法連接 Luxsin AI 服務。請檢查網絡後再試。", "ariaSend": "發送", "ariaStop": "停止", "ariaNewChat": "新增會話", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index acf495c..c52a9b9 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -4,7 +4,7 @@ "ipMissingDesc": "请在地址栏添加设备 IP 参数后再打开本页,例如:", "connecting": "正在连接 {ip}…", "failedTitle": "连接失败", - "failedDesc": "请检查设备 IP 与网络连接。", + "failedDesc": "无法连接设备。请确认本页与设备在同一网络,并检查设备 IP 是否正确。", "failedHint": "通过地址栏指定设备,例如 ?ip=10.0.0.119" }, "home": { @@ -618,6 +618,8 @@ "thinking": "思考中…", "stopped": "(已停止)", "requestFailed": "请求失败,请稍后重试", + "deviceUnreachable": "无法连接设备。请确认本页与设备在同一网络后再试。", + "aiUnreachable": "无法连接 Luxsin AI 服务。请检查网络后再试。", "ariaSend": "发送", "ariaStop": "停止", "ariaNewChat": "新建会话", diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index 7f018df..5e60368 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -39,6 +39,7 @@ import { streamQuestion, updateMessageApplied, } from "@/lib/aiApi"; +import { formatNetworkAwareMessage, isLikelyNetworkFetchError } from "@/lib/networkError"; import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio"; import { cn } from "@/lib/utils"; @@ -305,7 +306,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) const list = await listChats(mac); setChats(list); } catch (e: any) { - toast.error(formatText(aiText.loadHistoryFailed, { message: e?.message ?? e })); + toast.error(formatNetworkAwareMessage(aiText.loadHistoryFailed, e, aiText.aiUnreachable)); } }, [aiText, mac]); @@ -357,7 +358,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) setChatId(id); if (opts?.closePanel !== false) setHistoryOpen(false); } catch (e: any) { - toast.error(formatText(aiText.loadChatFailed, { message: e?.message ?? e })); + toast.error(formatNetworkAwareMessage(aiText.loadChatFailed, e, aiText.aiUnreachable)); } }, [aiText, mapRowsToUI, settleRiskyConfirm], @@ -429,7 +430,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) newChat(); toast.success(aiText.cleared); } catch (e: any) { - toast.error(formatText(aiText.clearFailed, { message: e?.message ?? e })); + toast.error(formatNetworkAwareMessage(aiText.clearFailed, e, aiText.aiUnreachable)); } }, [aiText, chatId, newChat]); @@ -445,7 +446,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) toast.success(aiText.history.deleted); } catch (e: any) { toast.error( - formatText(aiText.history.deleteFailed, { message: e?.message ?? e }), + formatNetworkAwareMessage(aiText.history.deleteFailed, e, aiText.aiUnreachable), ); } }, [aiText, chatId]); @@ -514,14 +515,15 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) msgCount: deviceState.msgCount ?? 0, }; } catch (e: any) { + const text = isLikelyNetworkFetchError(e) ? aiText.deviceUnreachable : aiText.requestFailed; updateAssistant((m) => ({ ...m, pending: false, error: true, - text: aiText.requestFailed, + text, })); setSending(false); - toast.error(formatText(aiText.requestFailed, { message: e?.message ?? e })); + toast.error(text); return; } @@ -560,15 +562,16 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) try { await sendToolResult(block.id, result); } catch (e: any) { - toast.error(formatText(aiText.toolResultFailed, { message: e?.message ?? e })); + toast.error(formatNetworkAwareMessage(aiText.toolResultFailed, e, aiText.aiUnreachable)); } }, onError: (msg) => { + const text = isLikelyNetworkFetchError(msg) ? aiText.aiUnreachable : (msg || aiText.requestFailed); updateAssistant((m) => ({ ...m, pending: false, error: true, - text: m.text || msg || aiText.requestFailed, + text: m.text || text, })); setSending(false); }, @@ -779,7 +782,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) nextApplied ? aiText.optimize.applySuccess : aiText.optimize.rollbackSuccess, ); } catch (e: any) { - toast.error(formatText(aiText.optimize.applyFailed, { message: e?.message ?? e })); + toast.error(formatNetworkAwareMessage(aiText.optimize.applyFailed, e, aiText.deviceUnreachable)); } finally { setApplyingId(null); } @@ -812,7 +815,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) await updateSetting(bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }); await executeApplyToggle(msg); } catch (e: any) { - toast.error(formatText(aiText.optimize.applyFailed, { message: e?.message ?? e })); + toast.error(formatNetworkAwareMessage(aiText.optimize.applyFailed, e, aiText.deviceUnreachable)); } }, [aiText, deviceState?.dsp_enable, executeApplyToggle, updateSetting],