fix(ai): 网络失败时显示友好提示,不再直接展示 Failed to fetch

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fucheng
2026-09-20 09:59:35 +08:00
parent eff59ddd71
commit 4dc2444946
11 changed files with 92 additions and 21 deletions
@@ -1,5 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useDevice } from "@/contexts/DeviceContext"; import { useDevice } from "@/contexts/DeviceContext";
import { isLikelyNetworkFetchError } from "@/lib/networkError";
import { Loader2, Wifi } from "lucide-react"; import { Loader2, Wifi } from "lucide-react";
import BottomNav from "@/components/BottomNav"; import BottomNav from "@/components/BottomNav";
import localeZh from "@/locales/data-zh.json"; import localeZh from "@/locales/data-zh.json";
@@ -50,7 +51,7 @@ export default function ConnectionPlaceholder() {
<> <>
<p className="text-[15px] text-white/80 mb-2">{text.failedTitle}</p> <p className="text-[15px] text-white/80 mb-2">{text.failedTitle}</p>
<p className="text-[13px] text-white/45 text-center max-w-xs"> <p className="text-[13px] text-white/45 text-center max-w-xs">
{error ?? text.failedDesc} {error && !isLikelyNetworkFetchError(error) ? error : text.failedDesc}
</p> </p>
<p className="text-[12px] text-white/30 mt-3 text-center max-w-xs">{text.failedHint}</p> <p className="text-[12px] text-white/30 mt-3 text-center max-w-xs">{text.failedHint}</p>
</> </>
+11 -2
View File
@@ -10,6 +10,7 @@ import {
PeqState, PeqState,
} from "@/lib/luxsinApi"; } from "@/lib/luxsinApi";
import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp"; import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp";
import { isLikelyNetworkFetchError } from "@/lib/networkError";
interface DeviceContextType { interface DeviceContextType {
ip: string; ip: string;
@@ -115,7 +116,11 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
setApi(newApi); setApi(newApi);
setIsConnected(true); setIsConnected(true);
} catch (e) { } 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); setIsConnected(false);
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
@@ -151,7 +156,11 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
await fetchState(apiInstance); await fetchState(apiInstance);
await fetchPeqState(apiInstance).catch(() => undefined); await fetchPeqState(apiInstance).catch(() => undefined);
} catch (e) { } 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]); }, [api, isConnected, ip, isDemoMode, fetchState, fetchPeqState]);
+15 -2
View File
@@ -11,6 +11,7 @@ import {
dbToVolume, dbToVolume,
volumeToDb, volumeToDb,
} from "./luxsinApi"; } from "./luxsinApi";
import { AI_NETWORK_ERROR_CODE, isLikelyNetworkFetchError } from "./networkError";
// Allow override via Vite env / localStorage; default to same origin in prod. // Allow override via Vite env / localStorage; default to same origin in prod.
export function getAIBaseUrl(): string { export function getAIBaseUrl(): string {
@@ -120,7 +121,13 @@ export interface QuestionPayload {
// REST helpers // REST helpers
// ============================================================ // ============================================================
async function jsonFetch<T>(url: string, init?: RequestInit): Promise<T> { async function jsonFetch<T>(url: string, init?: RequestInit): Promise<T> {
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()}`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json(); return res.json();
} }
@@ -293,7 +300,7 @@ export function streamQuestion(payload: QuestionPayload, cb: StreamCallbacks): A
cb.onDone?.(); cb.onDone?.();
} catch (e: any) { } catch (e: any) {
if (e?.name === "AbortError") return; 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}` }; return { ok: false, message: `Unsupported tool: ${block.name}` };
} }
} catch (e: any) { } 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) }; return { ok: false, message: e?.message ?? String(e) };
} }
} }
+33
View File
@@ -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;
}
+3 -1
View File
@@ -4,7 +4,7 @@
"ipMissingDesc": "Bitte fügen Sie die Geräte-IP in der Adressleiste hinzu, bevor Sie diese Seite öffnen, zum Beispiel:", "ipMissingDesc": "Bitte fügen Sie die Geräte-IP in der Adressleiste hinzu, bevor Sie diese Seite öffnen, zum Beispiel:",
"connecting": "Verbindung zu {ip}…", "connecting": "Verbindung zu {ip}…",
"failedTitle": "Verbindung fehlgeschlagen", "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" "failedHint": "Geben Sie das Gerät in der URL an, z.B. ?ip=10.0.0.119"
}, },
"home": { "home": {
@@ -623,6 +623,8 @@
"thinking": "Denke…", "thinking": "Denke…",
"stopped": "(Gestoppt)", "stopped": "(Gestoppt)",
"requestFailed": "Anfrage fehlgeschlagen. Bitte versuchen Sie es später erneut.", "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", "ariaSend": "Senden",
"ariaStop": "Stopp", "ariaStop": "Stopp",
"ariaNewChat": "Neuer Chat", "ariaNewChat": "Neuer Chat",
+3 -1
View File
@@ -4,7 +4,7 @@
"ipMissingDesc": "Add the device IP in the address bar before opening this page, for example:", "ipMissingDesc": "Add the device IP in the address bar before opening this page, for example:",
"connecting": "Connecting to {ip}…", "connecting": "Connecting to {ip}…",
"failedTitle": "Connection failed", "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" "failedHint": "Specify the device in the URL, e.g. ?ip=10.0.0.119"
}, },
"home": { "home": {
@@ -623,6 +623,8 @@
"thinking": "Thinking…", "thinking": "Thinking…",
"stopped": "(Stopped)", "stopped": "(Stopped)",
"requestFailed": "Request failed. Please try again later.", "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", "ariaSend": "Send",
"ariaStop": "Stop", "ariaStop": "Stop",
"ariaNewChat": "New chat", "ariaNewChat": "New chat",
+3 -1
View File
@@ -4,7 +4,7 @@
"ipMissingDesc": "Agregue la IP del dispositivo en la barra de direcciones antes de abrir esta página, por ejemplo:", "ipMissingDesc": "Agregue la IP del dispositivo en la barra de direcciones antes de abrir esta página, por ejemplo:",
"connecting": "Conectando a {ip}…", "connecting": "Conectando a {ip}…",
"failedTitle": "Error de conexión", "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" "failedHint": "Especifique el dispositivo en la URL, ej. ?ip=10.0.0.119"
}, },
"home": { "home": {
@@ -623,6 +623,8 @@
"thinking": "Pensando…", "thinking": "Pensando…",
"stopped": "(Detenido)", "stopped": "(Detenido)",
"requestFailed": "Error en la solicitud. Inténtelo de nuevo más tarde.", "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", "ariaSend": "Enviar",
"ariaStop": "Detener", "ariaStop": "Detener",
"ariaNewChat": "Nuevo chat", "ariaNewChat": "Nuevo chat",
+3 -1
View File
@@ -4,7 +4,7 @@
"ipMissingDesc": "Veuillez ajouter l'IP de l'appareil dans la barre d'adresse avant d'ouvrir cette page, par exemple :", "ipMissingDesc": "Veuillez ajouter l'IP de l'appareil dans la barre d'adresse avant d'ouvrir cette page, par exemple :",
"connecting": "Connexion à {ip}…", "connecting": "Connexion à {ip}…",
"failedTitle": "Échec de la connexion", "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" "failedHint": "Spécifiez l'appareil dans l'URL, par ex. ?ip=10.0.0.119"
}, },
"home": { "home": {
@@ -623,6 +623,8 @@
"thinking": "Réflexion…", "thinking": "Réflexion…",
"stopped": "(Arrêté)", "stopped": "(Arrêté)",
"requestFailed": "Échec de la requête. Veuillez réessayer plus tard.", "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", "ariaSend": "Envoyer",
"ariaStop": "Arrêter", "ariaStop": "Arrêter",
"ariaNewChat": "Nouveau chat", "ariaNewChat": "Nouveau chat",
+3 -1
View File
@@ -4,7 +4,7 @@
"ipMissingDesc": "請在網址列加入裝置 IP 參數後再開啟本頁,例如:", "ipMissingDesc": "請在網址列加入裝置 IP 參數後再開啟本頁,例如:",
"connecting": "正在連線 {ip}…", "connecting": "正在連線 {ip}…",
"failedTitle": "連線失敗", "failedTitle": "連線失敗",
"failedDesc": "請檢查裝置 IP 與網路連線。", "failedDesc": "無法連接裝置。請確認本頁與裝置在同一網絡,並檢查裝置 IP 是否正確。",
"failedHint": "透過網址列指定裝置,例如 ?ip=10.0.0.119" "failedHint": "透過網址列指定裝置,例如 ?ip=10.0.0.119"
}, },
"home": { "home": {
@@ -617,6 +617,8 @@
"thinking": "思考中…", "thinking": "思考中…",
"stopped": "(已停止)", "stopped": "(已停止)",
"requestFailed": "請求失敗,請稍後重試", "requestFailed": "請求失敗,請稍後重試",
"deviceUnreachable": "無法連接裝置。請確認本頁與裝置在同一網絡後再試。",
"aiUnreachable": "無法連接 Luxsin AI 服務。請檢查網絡後再試。",
"ariaSend": "發送", "ariaSend": "發送",
"ariaStop": "停止", "ariaStop": "停止",
"ariaNewChat": "新增會話", "ariaNewChat": "新增會話",
+3 -1
View File
@@ -4,7 +4,7 @@
"ipMissingDesc": "请在地址栏添加设备 IP 参数后再打开本页,例如:", "ipMissingDesc": "请在地址栏添加设备 IP 参数后再打开本页,例如:",
"connecting": "正在连接 {ip}…", "connecting": "正在连接 {ip}…",
"failedTitle": "连接失败", "failedTitle": "连接失败",
"failedDesc": "请检查设备 IP 与网络连接。", "failedDesc": "无法连接设备。请确认本页与设备在同一网络,并检查设备 IP 是否正确。",
"failedHint": "通过地址栏指定设备,例如 ?ip=10.0.0.119" "failedHint": "通过地址栏指定设备,例如 ?ip=10.0.0.119"
}, },
"home": { "home": {
@@ -618,6 +618,8 @@
"thinking": "思考中…", "thinking": "思考中…",
"stopped": "(已停止)", "stopped": "(已停止)",
"requestFailed": "请求失败,请稍后重试", "requestFailed": "请求失败,请稍后重试",
"deviceUnreachable": "无法连接设备。请确认本页与设备在同一网络后再试。",
"aiUnreachable": "无法连接 Luxsin AI 服务。请检查网络后再试。",
"ariaSend": "发送", "ariaSend": "发送",
"ariaStop": "停止", "ariaStop": "停止",
"ariaNewChat": "新建会话", "ariaNewChat": "新建会话",
+13 -10
View File
@@ -39,6 +39,7 @@ import {
streamQuestion, streamQuestion,
updateMessageApplied, updateMessageApplied,
} from "@/lib/aiApi"; } from "@/lib/aiApi";
import { formatNetworkAwareMessage, isLikelyNetworkFetchError } from "@/lib/networkError";
import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio"; import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -305,7 +306,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
const list = await listChats(mac); const list = await listChats(mac);
setChats(list); setChats(list);
} catch (e: any) { } catch (e: any) {
toast.error(formatText(aiText.loadHistoryFailed, { message: e?.message ?? e })); toast.error(formatNetworkAwareMessage(aiText.loadHistoryFailed, e, aiText.aiUnreachable));
} }
}, [aiText, mac]); }, [aiText, mac]);
@@ -357,7 +358,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
setChatId(id); setChatId(id);
if (opts?.closePanel !== false) setHistoryOpen(false); if (opts?.closePanel !== false) setHistoryOpen(false);
} catch (e: any) { } catch (e: any) {
toast.error(formatText(aiText.loadChatFailed, { message: e?.message ?? e })); toast.error(formatNetworkAwareMessage(aiText.loadChatFailed, e, aiText.aiUnreachable));
} }
}, },
[aiText, mapRowsToUI, settleRiskyConfirm], [aiText, mapRowsToUI, settleRiskyConfirm],
@@ -429,7 +430,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
newChat(); newChat();
toast.success(aiText.cleared); toast.success(aiText.cleared);
} catch (e: any) { } catch (e: any) {
toast.error(formatText(aiText.clearFailed, { message: e?.message ?? e })); toast.error(formatNetworkAwareMessage(aiText.clearFailed, e, aiText.aiUnreachable));
} }
}, [aiText, chatId, newChat]); }, [aiText, chatId, newChat]);
@@ -445,7 +446,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
toast.success(aiText.history.deleted); toast.success(aiText.history.deleted);
} catch (e: any) { } catch (e: any) {
toast.error( toast.error(
formatText(aiText.history.deleteFailed, { message: e?.message ?? e }), formatNetworkAwareMessage(aiText.history.deleteFailed, e, aiText.aiUnreachable),
); );
} }
}, [aiText, chatId]); }, [aiText, chatId]);
@@ -514,14 +515,15 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
msgCount: deviceState.msgCount ?? 0, msgCount: deviceState.msgCount ?? 0,
}; };
} catch (e: any) { } catch (e: any) {
const text = isLikelyNetworkFetchError(e) ? aiText.deviceUnreachable : aiText.requestFailed;
updateAssistant((m) => ({ updateAssistant((m) => ({
...m, ...m,
pending: false, pending: false,
error: true, error: true,
text: aiText.requestFailed, text,
})); }));
setSending(false); setSending(false);
toast.error(formatText(aiText.requestFailed, { message: e?.message ?? e })); toast.error(text);
return; return;
} }
@@ -560,15 +562,16 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
try { try {
await sendToolResult(block.id, result); await sendToolResult(block.id, result);
} catch (e: any) { } catch (e: any) {
toast.error(formatText(aiText.toolResultFailed, { message: e?.message ?? e })); toast.error(formatNetworkAwareMessage(aiText.toolResultFailed, e, aiText.aiUnreachable));
} }
}, },
onError: (msg) => { onError: (msg) => {
const text = isLikelyNetworkFetchError(msg) ? aiText.aiUnreachable : (msg || aiText.requestFailed);
updateAssistant((m) => ({ updateAssistant((m) => ({
...m, ...m,
pending: false, pending: false,
error: true, error: true,
text: m.text || msg || aiText.requestFailed, text: m.text || text,
})); }));
setSending(false); setSending(false);
}, },
@@ -779,7 +782,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
nextApplied ? aiText.optimize.applySuccess : aiText.optimize.rollbackSuccess, nextApplied ? aiText.optimize.applySuccess : aiText.optimize.rollbackSuccess,
); );
} catch (e: any) { } catch (e: any) {
toast.error(formatText(aiText.optimize.applyFailed, { message: e?.message ?? e })); toast.error(formatNetworkAwareMessage(aiText.optimize.applyFailed, e, aiText.deviceUnreachable));
} finally { } finally {
setApplyingId(null); 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 updateSetting(bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 });
await executeApplyToggle(msg); await executeApplyToggle(msg);
} catch (e: any) { } 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], [aiText, deviceState?.dsp_enable, executeApplyToggle, updateSetting],