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 { 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() {
<>
<p className="text-[15px] text-white/80 mb-2">{text.failedTitle}</p>
<p className="text-[13px] text-white/45 text-center max-w-xs">
{error ?? text.failedDesc}
{error && !isLikelyNetworkFetchError(error) ? error : text.failedDesc}
</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,
} 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]);
+15 -2
View File
@@ -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<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()}`);
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) };
}
}
+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:",
"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",
+3 -1
View File
@@ -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",
+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:",
"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",
+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 :",
"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",
+3 -1
View File
@@ -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": "新增會話",
+3 -1
View File
@@ -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": "新建会话",
+13 -10
View File
@@ -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],