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],