v4.1.2: 기상도(Windy) 캡처 화질 개선 + 지도 읽는 법·좌표·태풍 트리거 추가
- weather_map_screenshot 캡처 해상도 900x620→1280x880, PNG→JPEG(품질90)로 변경.
300KB 넘으면 vision 파이프라인이 600x600으로 강제 축소하던 걸(server-v2.ts
resolveToolImageContent) 파일 크기를 낮춰서 회피 — 지명·기온 숫자가 선명해짐
- 기상전문가 프롬프트에 lat/lon을 서울로 고정 복사하지 않고 실제 언급된 지역
기준으로 치환하도록 명시 + 주요 도시 12곳 좌표표 추가
- 오버레이 9종(바람/강수/기온/구름/기압/대기질/레이더/적설/파고)별 색상 범례
해석 가이드("지도 읽는 법") 추가 — 이미지는 받아도 읽는 법이 없어 해석이
부실했던 문제
- "태풍" 계열 단어를 Windy 지도 트리거에 추가(기존엔 없어서 뜨는 게 랜덤했음)
+ 태풍 요청 시 좌표를 먼저 검색해 그 위치로 지도를 센터링하도록 지시
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+115
-1
@@ -1,9 +1,11 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { getVault } from '../security/vault.js';
|
||||
import { getPaths } from '../config/paths.js';
|
||||
import { getPaths, getWorkspacePath } from '../config/paths.js';
|
||||
import { buildImageMarkdown } from './image.js';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
|
||||
// IPv4-forced HTTPS GET — used for hosts whose IPv6 is unreachable from this server
|
||||
@@ -1361,3 +1363,115 @@ export const weatherCmip6Tool = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── weather_map_screenshot (headless capture of Windy embed map) ─────────────
|
||||
// The Windy iframe embedded in the weather app UI is rendered client-side for
|
||||
// the human user only — the model never sees it. This tool actually opens the
|
||||
// same embed URL in a headless browser and screenshots it, so the model can
|
||||
// look at the rendered map instead of guessing what its own coordinates show.
|
||||
|
||||
let _mapPlaywrightModule: any = null;
|
||||
let _mapBrowser: any = null;
|
||||
let _mapChromiumPath: string | null | undefined = undefined; // undefined = not probed yet
|
||||
|
||||
async function findMapChromiumExecutable(): Promise<string | null> {
|
||||
const candidates = [
|
||||
process.env.CHROME_PATH,
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/snap/bin/chromium',
|
||||
].filter(Boolean) as string[];
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getMapBrowser(): Promise<any> {
|
||||
if (_mapBrowser) {
|
||||
try {
|
||||
await _mapBrowser.version();
|
||||
return _mapBrowser;
|
||||
} catch {
|
||||
_mapBrowser = null;
|
||||
}
|
||||
}
|
||||
if (!_mapPlaywrightModule) {
|
||||
if (!process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
||||
const os = await import('os');
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(os.homedir(), '.playwright-browsers');
|
||||
}
|
||||
_mapPlaywrightModule = await (Function('return import("playwright")')() as Promise<any>);
|
||||
}
|
||||
if (_mapChromiumPath === undefined) _mapChromiumPath = await findMapChromiumExecutable();
|
||||
_mapBrowser = await _mapPlaywrightModule.chromium.launch({
|
||||
headless: true,
|
||||
...(_mapChromiumPath ? { executablePath: _mapChromiumPath } : {}),
|
||||
});
|
||||
return _mapBrowser;
|
||||
}
|
||||
|
||||
export const weatherMapScreenshotTool = {
|
||||
name: 'weather_map_screenshot',
|
||||
description: 'Capture a real screenshot of a rendered Windy weather map (radar, satellite, wind, precipitation, etc.) so you can actually SEE it before describing it. Call this with the exact embed.windy.com URL you just generated — do not guess what the map shows from the URL parameters alone.',
|
||||
schema: {
|
||||
url: 'The exact https://embed.windy.com/embed2.html?... URL to capture (must match the one shown to the user)',
|
||||
wait_ms: 'Milliseconds to wait for map tiles to finish loading before capturing (default 4000, max 8000)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The exact embed.windy.com URL to capture' },
|
||||
wait_ms: { type: 'number', description: 'Wait time in ms for tiles to load (default 4000, max 8000)' },
|
||||
},
|
||||
required: ['url'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const urlStr = String(args?.url || '').trim();
|
||||
if (!urlStr) return { success: false, error: 'url is required' };
|
||||
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(urlStr); } catch { return { success: false, error: 'Invalid URL' }; }
|
||||
if (parsed.hostname !== 'embed.windy.com') {
|
||||
return { success: false, error: 'Only embed.windy.com URLs can be captured' };
|
||||
}
|
||||
|
||||
const waitMs = Math.min(Math.max(Number(args?.wait_ms) || 4000, 1000), 8000);
|
||||
|
||||
let context: any = null;
|
||||
try {
|
||||
const browser = await getMapBrowser();
|
||||
// Bigger viewport than before (was 900x620) for more legible city labels/numbers —
|
||||
// safe because JPEG capture below keeps the file small despite the larger canvas.
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 880 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(parsed.toString(), { waitUntil: 'load', timeout: 20_000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
// Capture as JPEG (not PNG) so the file lands under the 300KB threshold in
|
||||
// resolveToolImageContent (server-v2.ts) that otherwise re-downscales anything
|
||||
// bigger to a blurry 600x600 thumbnail — losing exactly the city-name/temperature
|
||||
// text and color-band precision the model needs to read the map accurately.
|
||||
const buf: Buffer = await page.screenshot({ type: 'jpeg', quality: 90 });
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const outDir = path.join(workspacePath, 'weather-maps');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
const filePath = path.join(outDir, `windy-${Date.now()}.jpg`);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
|
||||
const overlay = parsed.searchParams.get('overlay') || 'map';
|
||||
const zoom = parsed.searchParams.get('zoom') || '?';
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Windy 지도 캡처 완료 (overlay=${overlay}, zoom=${zoom}):\n\n${buildImageMarkdown(filePath, workspacePath)}\n\n위 이미지에 실제로 보이는 내용만 근거로 설명하세요. 보이지 않는 세부사항을 추측하지 마세요.`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `지도 캡처 실패: ${err.message}` };
|
||||
} finally {
|
||||
try { if (context) await context.close(); } catch {}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user