fix(ai): 网络失败时显示友好提示,不再直接展示 Failed to fetch
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "新增會話",
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user