예제 소스
오목 좌표 LoRA: 15×15와 19×19 격자를 정확히 읽히기 · 파일 59개
vlmClient.ts
/** Browser client → Next `/api/vlm/move` → VLM TCP(:9299). 별도 WS 브리지 불필요. */
export type VlmMove =
| { ok: true; row: number; col: number }
| { ok: false; error: string; retryable?: boolean };
const GATEWAY_HINT =
"연결이 잠깐 끊겼습니다(게이트웨이). 새 대국 후 다시 두거나, 잠시 뒤 재시도하세요.";
function looksLikeHtml(raw: string): boolean {
const s = raw.trimStart().slice(0, 32).toLowerCase();
return s.startsWith("<!doctype") || s.startsWith("<html") || s.startsWith("<head");
}
function isTransientGateway(status: number, raw: string): boolean {
if ([502, 503, 504].includes(status)) return true;
if (/bad gateway|gateway time-out|service unavailable/i.test(raw)) return true;
return looksLikeHtml(raw);
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function fetchVlmOnce(body: string, signal: AbortSignal): Promise<VlmMove> {
const res = await fetch("/api/vlm/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal,
});
const raw = await res.text();
if (isTransientGateway(res.status, raw)) {
return { ok: false, error: GATEWAY_HINT, retryable: true };
}
let msg: {
type?: string;
row?: number;
col?: number;
error?: string;
};
try {
msg = JSON.parse(raw) as typeof msg;
} catch {
return {
ok: false,
error: `서버 응답이 JSON이 아닙니다 (HTTP ${res.status}). ${GATEWAY_HINT}`,
retryable: true,
};
}
if (!res.ok || msg.type === "error") {
return { ok: false, error: msg.error || `HTTP ${res.status}` };
}
if (msg.type === "move" && msg.row != null && msg.col != null) {
return { ok: true, row: Number(msg.row), col: Number(msg.col) };
}
return { ok: false, error: "알 수 없는 응답" };
}
export async function requestVlmMove(opts: {
board: number[];
size: number;
aiSide: 1 | 2;
timeoutMs?: number;
}): Promise<VlmMove> {
const timeoutMs = opts.timeoutMs ?? 300_000;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const body = JSON.stringify({
board: opts.board,
size: opts.size,
ai_side: opts.aiSide,
});
try {
// 터널 재시작 직의 HTML 502 는 짧게 재시도
const delays = [0, 1500, 3000];
let last: VlmMove = { ok: false, error: GATEWAY_HINT, retryable: true };
for (let i = 0; i < delays.length; i++) {
if (delays[i]! > 0) await sleep(delays[i]!);
try {
last = await fetchVlmOnce(body, ctrl.signal);
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") {
return { ok: false, error: "VLM 응답 시간 초과" };
}
last = {
ok: false,
error: `VLM API 호출 실패: ${String(e)}. ${GATEWAY_HINT}`,
retryable: true,
};
continue;
}
if (last.ok) return last;
if (!last.retryable) return last;
}
return last;
} finally {
clearTimeout(timer);
}
}