UNPKG

@mfukushim/map-traveler-mcp

Version:
550 lines (549 loc) 31 kB
/*! map-traveler-mcp | MIT License | https://github.com/mfukushim/map-traveler-mcp */ import { createRequire as _createRequire } from "module"; const __require = _createRequire(import.meta.url); import { Effect, Option, Schema } from "effect"; import { MapService, MapDef } from "./MapService.js"; import { __pwd, DbService, DbServiceLive, env, } from "./DbService.js"; import * as geolib from "geolib"; const dayjs = __require("dayjs"); const utc = __require("dayjs/plugin/utc"); const duration = __require("dayjs/plugin/duration"); const relativeTime = __require("dayjs/plugin/relativeTime"); import { ImageService, widthOut, heightOut } from "./ImageService.js"; import { StoryService } from "./StoryService.js"; import 'dotenv/config'; import { McpLogService } from "./McpLogService.js"; import { AnswerError } from "./mapTraveler.js"; import * as path from "path"; const sharp = __require("sharp"); import * as fs from "node:fs"; import { bodyAreaRatio, bodyHWRatio, bodyWindowRatioH, bodyWindowRatioW, comfy_url, noImageOut, pixAi_key, sd_key, time_scale } from "./EnvUtils.js"; dayjs.extend(utc); dayjs.extend(duration); dayjs.extend(relativeTime); export const useAiImageGen = (pixAi_key ? 'pixAi' : sd_key ? 'sd' : comfy_url ? 'comfyUi' : ''); export const defaultAvatarId = 1; export const practiceData = [ { address: "Hakata Station,〒812-0012 Fukuoka, Hakata Ward, 博多駅中央街1−1", placesPath: "assets/places1.json", sampleImagePath: "assets/place1.png", durationSec: 20 * 60, }, { address: "Nishitetsu Fukuoka Tenjin Station,〒810-0001 福岡県福岡市中央区天神2−22", placesPath: "assets/places2.json", sampleImagePath: "assets/place2.png", durationSec: 20 * 60, }, { address: "Ohori Park,〒810-0051 Fukuoka, Chuo Ward, Ohorikoen, 公園管理事務所", placesPath: "assets/places3.json", sampleImagePath: "assets/place3.png", durationSec: 20 * 60, }, { address: "Fukuoka Tower,2 Chome-3-26 Momochihama, Sawara Ward, Fukuoka, 814-0001", placesPath: "assets/places4.json", sampleImagePath: "assets/place4.png", durationSec: 20 * 60, } ]; export class RunnerService extends Effect.Service()("traveler/RunnerService", { accessors: true, effect: Effect.gen(function* () { const durationScale2 = (time_scale && Number.parseFloat(time_scale)) || 4; const isShips = (maneuver) => ['ferry', 'airplane'].includes(maneuver || ''); const maneuverIsShip = (step) => isShips(step.maneuver); const getFacilitiesPractice = (toAddress, includePhoto) => { return Effect.gen(function* () { const practiceInfo = practiceData.find(value => value.address === toAddress) || practiceData[0]; const nearFacilities = yield* Effect.async((resume) => fs.readFile(path.join(__pwd, practiceInfo.placesPath), (err, data) => { if (err) { resume(Effect.fail(err)); } resume(Effect.succeed(data)); })).pipe(Effect.andThen(a => Schema.decode(Schema.parseJson(MapDef.GmPlacesSchema))((Buffer.from(a).toString('utf-8')))), Effect.andThen(a => StoryService.placesToFacilities(a))); const image = includePhoto ? (yield* Effect.async((resume) => fs.readFile(path.join(__pwd, practiceInfo.sampleImagePath), (err, data) => { if (err) { resume(Effect.fail(err)); } resume(Effect.succeed(data)); })).pipe(Effect.andThen(a => ImageService.shrinkImage(a)), Effect.andThen(a => Buffer.from(a)), Effect.orElseSucceed(() => undefined))) : undefined; return { nearFacilities, image, locText: '' }; }); }; const getFacilities = (loc, includePhoto, abort = false, localDebug = false) => { return Effect.gen(function* () { const nearFacilities = yield* StoryService.getNearbyFacilities({ lat: loc.lat, lng: loc.lng, bearing: 0 }); const image = includePhoto && env.anyImageAiExist && !noImageOut ? (yield* getStreetImage(loc, abort, localDebug).pipe(Effect.andThen(a => a.buf), Effect.orElseSucceed(() => undefined))) : includePhoto && !noImageOut ? (yield* getStreetImageOnly(loc).pipe(Effect.orElseSucceed(() => undefined))) : undefined; return { nearFacilities, image, locText: `current location is below\nlatitude:${loc.lat}\nlongitude:${loc.lng}\nhttps://www.google.com/maps?q=${loc.lat},${loc.lng}\n` }; }); }; const runningReport = (locText, nearFacilities, image, abort = false, justArrive = false) => { return Effect.gen(function* () { const facilityText = nearFacilities && nearFacilities.facilities.length !== 0 ? `The following facilities are nearby:\n` + nearFacilities.facilities.map(value => value.name + (value.types.length > 0 ? ' (kinds:' + value.types.join(',') + ')' : '')).join('\n') + '\n' : "There don't appear to be any buildings nearby."; const infoText = abort ? `I have received a message to discontinue my trip. This time, I will discontinue my trip.\n` : justArrive ? `We have arrived at our destination.` : ''; const posText = nearFacilities && Option.isSome(nearFacilities.townName) ? `Town name is ${nearFacilities.townName.value}\n` : 'Town name is unknown.\n'; const content = [{ type: "text", text: infoText + locText + posText + (nearFacilities ? facilityText : '') }]; if (image) { content.push({ type: "image", data: image.toString('base64'), mimeType: 'image/png' }); } return { out: content, address: nearFacilities ? nearFacilities.address : Option.none() }; }); }; const vehicleView = (loc, includePhoto) => { const maneuver = loc.maneuver; const vehiclePrompt = maneuver?.includes('ferry') ? '(on ship deck:1.3),(ferry:1.2),sea,handrails' : maneuver?.includes('airplane') ? '(airplane cabin:1.3),reclining seat,sitting' : ''; const out = [ { type: "text", text: `I'm on the ${maneuver} now. Longitude and Latitude is almost ${loc.lat},${loc.lng}` }, ]; if (includePhoto && env.anyImageAiExist && !noImageOut) { return ImageService.makeEtcTripImage(useAiImageGen, vehiclePrompt, loc.timeZoneId).pipe(Effect.andThen(image => { out.push({ type: "image", data: image.toString("base64"), mimeType: 'image/png' }); return Effect.succeed(out); }), Effect.orElse(() => Effect.succeed(out))); } return Effect.succeed(out); }; const hotelView = (timeZoneId, includePhoto, toAddress) => { const hour = dayjs().tz(timeZoneId).hour(); const out = [ { type: "text", text: `I am in a hotel in ${toAddress}.` } ]; if (includePhoto) { return ImageService.makeHotelPict(useAiImageGen, hour, undefined).pipe(Effect.andThen(image1 => { out.push({ type: "image", data: image1.toString("base64"), mimeType: 'image/png' }); return Effect.succeed(out); }), Effect.orElse(() => Effect.succeed(out))); } return Effect.succeed(out); }; const getRunStatusAndUpdateEnd = (now) => { return Effect.gen(function* () { const status = yield* DbService.getRecentRunStatus().pipe(Effect.orElseFail(() => new AnswerError(`current location not set. Please set the current location address`))); if (status.status === "stop" && !status.to) { return yield* Effect.fail(new AnswerError(`current location not set. Please set the current location address`)); } const endTime = dayjs.unix(status.tilEndEpoch); const start = dayjs(status.startTime); const elapseRatio = Math.min((now.diff(start, "seconds")) / (endTime.diff(start, "seconds")), 1); let justArrive = false; if (elapseRatio >= 1) { if (status.status !== "running") { justArrive = true; } resetRunStatus(status, status.to, endTime.toDate(), status.endLat, status.endLng, status.endCountry, status.endTz); yield* DbService.saveRunStatus(status); } return { runStatus: status, justArrive, elapseRatio }; }); }; function getElapsedView(proceedPercent, debugRouteStr) { const proceed = Math.max(0, Math.min(100, proceedPercent)) / 100; return Effect.gen(function* () { const runStatus = yield* DbService.getRecentRunStatus().pipe(Effect.orElseFail(() => new AnswerError(`current location not set. Please set the current location address`))); let p = 1; let rs; if (runStatus.status === 'stop') { if (!runStatus.to) { return yield* Effect.fail(new AnswerError(`current location not set. Please set the current location address`)); } const dest = yield* DbService.getEnvOption('destination'); if (Option.isNone(dest)) { return yield* Effect.fail(new AnswerError(`destination not set`)); } if (env.isPractice) { rs = runStatus; } else { rs = yield* setStart(runStatus, dayjs()); } p = proceed; } else { if (!runStatus.to || !runStatus.from) { return yield* Effect.fail(new Error(`getElapsedView running but no from or to`)); } const endTime = dayjs.unix(runStatus.tilEndEpoch); const start = dayjs(runStatus.startTime); const elapseRatio = Math.min((dayjs().diff(start, "seconds")) / (endTime.diff(start, "seconds")), 1); const remainRatio = (1 - elapseRatio) * (1 - proceed); p = 1 - remainRatio; rs = runStatus; } const loc = yield* calcCurrentLoc(rs, p, debugRouteStr); yield* McpLogService.logTrace(`getCurrentView:recalcRatio:${p},start:${rs.startTime},end:${dayjs.unix(rs.tilEndEpoch)},status:${loc.status}`); const { nearFacilities, image, locText } = yield* getFacilities(loc, true, false); resetRunStatus(rs, Option.getOrElse(nearFacilities.address, () => rs.to), dayjs().toDate(), loc.lat, loc.lng, Option.getOrElse(nearFacilities.country, () => rs.endCountry), loc.timeZoneId); yield* DbService.saveRunStatus(rs); return yield* runningReport(locText, nearFacilities, image, false, true).pipe(Effect.andThen(a => a.out)); }); } function getCurrentView(now, includePhoto, includeNearbyFacilities, practice = false) { return Effect.gen(function* () { const { runStatus, justArrive, elapseRatio } = yield* getRunStatusAndUpdateEnd(now); return yield* makeView(runStatus, elapseRatio, justArrive && dayjs().isBefore(dayjs.unix(runStatus.tilEndEpoch).add(1, "hour")), includePhoto, includeNearbyFacilities, practice); }); } function makeView(runStatus, elapseRatio, showRunning, includePhoto, includeNearbyFacilities, practice = false, debugRoute) { return Effect.gen(function* () { let loc; let viewStatus; if (practice) { loc = { status: runStatus.status, lat: runStatus.endLat, lng: runStatus.endLng, bearing: MapService.getBearing(runStatus.startLat, runStatus.startLng, runStatus.endLat, runStatus.endLng), timeZoneId: 'Asia/Tokyo', remainSecInPath: 0, maneuver: undefined, isEnd: true, landPathNo: -1, }; viewStatus = runStatus.status; } else { loc = yield* calcCurrentLoc(runStatus, elapseRatio, debugRoute); viewStatus = loc.status; yield* McpLogService.logTrace(`getCurrentView:elapseRatio:${elapseRatio},start:${runStatus.startTime},end:${dayjs.unix(runStatus.tilEndEpoch)},status:${loc.status}`); if (showRunning) { viewStatus = 'running'; } if (env.moveMode === "skip" && viewStatus === "stop") { viewStatus = 'running'; } } switch (viewStatus) { case 'vehicle': return yield* vehicleView(loc, includePhoto); case 'stop': return yield* hotelView(practice ? 'Asia/Tokyo' : loc.timeZoneId, includePhoto, runStatus.to); case "running": const { nearFacilities, image, locText } = yield* (practice ? getFacilitiesPractice(runStatus.to, includePhoto) : getFacilities(loc, includePhoto, false)); return yield* runningReport(locText, includeNearbyFacilities ? nearFacilities : undefined, image, false, showRunning).pipe(Effect.andThen(a => a.out)); } }).pipe(Effect.catchAll(e => { if (e instanceof AnswerError) { return Effect.fail(e); } return McpLogService.logError(`getCurrentView catch:${e},${JSON.stringify(e)}`).pipe(Effect.andThen(() => Effect.fail(new AnswerError("Sorry,I don't know where you are right now. Please wait a moment and ask again.")))); })); } function saveCurrentRunnerRoute(avatarId, data) { return DbService.updateRoute(avatarId, JSON.stringify(data)); } function loadCurrentRunnerRoute(avatarId) { return DbService.getAvatar(avatarId).pipe(Effect.andThen(a => a.currentRoute ? Effect.succeed(a.currentRoute) : Effect.fail(new AnswerError('The route to the destination has not yet been determined.'))), Effect.andThen(a => Schema.decodeUnknownSync(Schema.parseJson(MapDef.RouteArraySchema))(a)), Effect.tapError(cause => McpLogService.logError(`loadCurrentRunnerRoute ${cause}`))); } const reNumberSteps = (r) => { const directionSteps = (r.leg.steps).map((r, idx) => ({ ...r, stepNo: idx, isRelayPoint: false, pathNo: -1, start: -1, end: -1 })); directionSteps[directionSteps.length - 1].isRelayPoint = true; return directionSteps; }; const calcStepTime = (step) => { return maneuverIsShip(step) ? step.duration.value : step.duration.value * durationScale2; }; const routesToDirectionStep = (routeInfo) => { const all = routeInfo.flatMap((r, idx) => { return reNumberSteps(r).map(value => { value.pathNo = idx; return value; }); }); all.reduce((p, c) => { c.start = p; c.end = p + calcStepTime(c); p = c.end; return p; }, 0); return all; }; const calcCurrentStep = (steps, currentRunSec = Number.MAX_SAFE_INTEGER) => { return Option.fromNullable(steps.find(value => value.start <= currentRunSec && value.end > currentRunSec)); }; function calcCurrentLoc(runStatus, elapseRatio, debugRouteStr) { return Effect.gen(function* () { if (runStatus.status === "stop") { yield* McpLogService.logTrace(`calcCurrentLoc: stopped`); return { status: "stop", lat: runStatus.endLat, lng: runStatus.endLng, bearing: MapService.getBearing(runStatus.startLat, runStatus.startLng, runStatus.endLat, runStatus.endLng), timeZoneId: runStatus.endTz, remainSecInPath: 0, maneuver: undefined, isEnd: true, landPathNo: -1, }; } const runAllSec = dayjs.unix(runStatus.tilEndEpoch).diff(runStatus.startTime, "seconds") * elapseRatio; yield* McpLogService.logTrace(`calcCurrentLoc: elapseRatio=${elapseRatio},runAllSec=${runAllSec}`); const fullRoute = debugRouteStr ? yield* Schema.decode(Schema.parseJson(MapDef.RouteArraySchema))(debugRouteStr) : yield* loadCurrentRunnerRoute(runStatus.avatarId); const allSteps = routesToDirectionStep(fullRoute); const currentStepOption = calcCurrentStep(allSteps, runAllSec); if (Option.isNone(currentStepOption)) { yield* McpLogService.logTrace(`calcCurrentLoc: end`); return { status: "stop", lat: runStatus.endLat, lng: runStatus.endLng, bearing: MapService.getBearing(runStatus.startLat, runStatus.startLng, runStatus.endLat, runStatus.endLng), timeZoneId: runStatus.endTz, remainSecInPath: 0, maneuver: undefined, isEnd: true, landPathNo: -1, }; } const currentStep = currentStepOption.value; const rat = Math.min((runAllSec - currentStep.start) / (currentStep.end - currentStep.start), 1); const lat = (currentStep.end_location.lat - currentStep.start_location.lat) * rat + currentStep.start_location.lat; const lng = (currentStep.end_location.lng - currentStep.start_location.lng) * rat + currentStep.start_location.lng; yield* McpLogService.logTrace(`calcCurrentLoc: step=${currentStep.pathNo},${currentStep.stepNo},${currentStep.start},${currentStep.end},${runAllSec},${rat},${lat},${lng},${currentStep.maneuver}`); return { status: (isShips(currentStep.maneuver) ? "vehicle" : "running"), lat: lat, lng: lng, bearing: geolib.getRhumbLineBearing({ lat: currentStep.start_location.lat, lng: currentStep.start_location.lng }, { lat: currentStep.end_location.lat, lng: currentStep.end_location.lng }), maneuver: currentStep.maneuver, timeZoneId: yield* MapService.getTimezoneByLatLng(lat, lng).pipe(Effect.orElseSucceed(() => 'Asia/Tokyo')), isEnd: false, remainSecInPath: Math.min(currentStep.end - runAllSec, currentStep.end - currentStep.start), landPathNo: currentStep.pathNo, }; }); } function getDestinationAddress() { return Effect.gen(function* () { const dest = yield* DbService.getEnvOption('destination'); if (Option.isSome(dest)) { return dest.value; } const runStatus = yield* DbService.getRecentRunStatus(); if (runStatus.status === 'running' && runStatus.to) { return runStatus.to; } return yield* Effect.fail(new AnswerError("The destination has not yet been decided")); }); } const sumDurationSec = (destList) => destList.flatMap(v => v.leg).flatMap(a => a.steps) .map(a => calcStepTime(a)).reduce((p, c) => p + c, 0); function setDestinationAddress(address) { return Effect.gen(function* () { const location = yield* MapService.getMapLocation(address); if (Option.isNone(location)) { return yield* Effect.fail(new AnswerError("I don't know where you're talking about. destination location not found")); } const { runStatus } = yield* getRunStatusAndUpdateEnd(dayjs()); const destList = yield* MapService.calcMultiPathRoute({ lat: runStatus.endLat, lng: runStatus.endLng, country: runStatus.endCountry || location.value.country }, [{ lat: location.value.lat, lng: location.value.lng, country: location.value.country }]); if (destList.length === 0) { return yield* Effect.fail(new AnswerError("I can't find a route to my destination.")); } const durationSec = sumDurationSec(destList); if (durationSec > 3 * 24 * 60 * 60) { return yield* Effect.fail(new AnswerError("It will take 3 days to reach your destination. That's too long.")); } yield* saveCurrentRunnerRoute(defaultAvatarId, destList); const timeZoneId = yield* MapService.getTimezoneByLatLng(location.value.lat, location.value.lng); yield* DbService.saveEnv('destination', address); yield* DbService.saveEnv('destTimezoneId', timeZoneId); const mesList = [ `The traveler's destination was set as follows: ${address}`, `The journey takes approximately ${dayjs.duration(durationSec, "seconds").humanize()}.` ]; const listElement = destList[destList.length - 1]; return { message: mesList.join('\n'), tilEndSec: durationSec, destination: listElement.leg.end_location || { lat: location.value.lat, lng: location.value.lng } }; }); } const resetRunStatus = (recent, to, endTime, lat, lng, country, timeZone) => { recent.status = "stop"; recent.startTime = new Date(0); recent.endTime = endTime; recent.to = to; recent.endLat = lat; recent.endLng = lng; recent.destination = null; recent.tilEndEpoch = 0; recent.durationSec = 0; recent.distanceM = 0; recent.startCountry = country; recent.startTz = timeZone; recent.currentPathNo = -1; recent.currentStepNo = -1; }; function setStart(runStatus, now) { return Effect.gen(function* () { const dest = yield* DbService.getEnv('destination'); const destInfo = yield* setDestinationAddress(dest); runStatus.status = "running"; runStatus.startTime = now.toDate(); runStatus.endTime = dayjs.unix(destInfo.tilEndSec).toDate(); runStatus.destination = ""; runStatus.from = runStatus.to; runStatus.to = dest; runStatus.startTz = runStatus.endTz; runStatus.startLat = runStatus.endLat; runStatus.startLng = runStatus.endLng; runStatus.endLat = destInfo.destination.lat; runStatus.endLng = destInfo.destination.lng; runStatus.tilEndEpoch = destInfo.tilEndSec + now.unix(); runStatus.endTz = yield* DbService.getEnv("destTimezoneId").pipe(Effect.orElseSucceed(() => runStatus.startTz)); return runStatus; }); } function startJourney(practice = false) { return Effect.gen(function* () { const now = dayjs(); let rs; if (practice) { rs = yield* DbService.practiceRunStatus(true); } else { const { runStatus } = yield* getRunStatusAndUpdateEnd(now).pipe(Effect.tap(a => { if (["running", "vehicle"].includes(a.runStatus.status)) { return Effect.fail(new AnswerError(`already start journey.You may stop or continue the journey`)); } })); rs = yield* setStart(runStatus, now); } const hour = now.tz(rs.startTz).hour(); const image1 = yield* ImageService.makeHotelPict(useAiImageGen, hour).pipe(Effect.andThen(a => Effect.succeed(Option.some(a))), Effect.orElseSucceed(() => Option.none())); yield* DbService.saveEnv("destination", ""); yield* DbService.saveEnv("destTimezoneId", ""); yield* DbService.saveRunStatus(rs); return { text: `We set out on a journey. The departure point is "${rs.from}". I'm heading to "${rs.to}".`, image: image1 }; }); } function stopJourney(practice) { return Effect.gen(function* () { const now = dayjs(); const { runStatus } = yield* getRunStatusAndUpdateEnd(now); if (runStatus.status === "stop") { return yield* Effect.fail(new AnswerError(`The journey has already arrived in "${runStatus.to}".`)); } let res; if (practice) { res = yield* getFacilitiesPractice(runStatus.to, true).pipe(Effect.andThen(a => runningReport(a.locText, a.nearFacilities, a.image, true))); } else { const elapse = Math.min(now.diff(runStatus.startTime, "seconds") / dayjs.unix(runStatus.tilEndEpoch).diff(runStatus.startTime, "seconds"), 1); const currentInfo = yield* calcCurrentLoc(runStatus, elapse); const nears = yield* StoryService.getNearbyFacilities({ lat: currentInfo.lat, lng: currentInfo.lng, bearing: currentInfo.bearing }); resetRunStatus(runStatus, Option.getOrElse(nears.address, () => runStatus.to), now.toDate(), currentInfo.lat, currentInfo.lng, Option.getOrElse(nears.country, () => runStatus.endCountry), currentInfo.timeZoneId); res = yield* getFacilities(currentInfo, true, false).pipe(Effect.andThen(a => runningReport(a.locText, a.nearFacilities, a.image, true))); } runStatus.to = Option.getOrElse(res.address, () => runStatus.from); yield* DbService.saveRunStatus(runStatus); return res.out; }).pipe(Effect.provide(DbServiceLive)); } function getStreetImage(loc, abort = false, localDebug = false) { return Effect.gen(function* () { const okLoc = yield* MapService.findStreetViewMeta(loc.lat, loc.lng, loc.bearing, 640, 640); const baseImage = yield* MapService.getStreetViewImage(okLoc.lat, okLoc.lng, loc.bearing, 640, 640); const bodyAreaRatioJ = bodyAreaRatio ? { bodyAreaRatio: Number.parseFloat(bodyAreaRatio) } : {}; const bodyHWRatioJ = bodyHWRatio ? { bodyHWRatio: Number.parseFloat(bodyHWRatio) } : {}; const bodyWindowRatioWJ = bodyWindowRatioW ? { bodyWindowRatioW: Number.parseFloat(bodyWindowRatioW) } : {}; const bodyWindowRatioHJ = bodyWindowRatioH ? { bodyWindowRatioH: Number.parseFloat(bodyWindowRatioH) } : {}; return yield* ImageService.makeRunnerImageV3(baseImage, useAiImageGen, abort, { ...bodyAreaRatioJ, ...bodyHWRatioJ, ...bodyWindowRatioWJ, ...bodyWindowRatioHJ }, localDebug).pipe(Effect.andThen(a => Effect.gen(function* () { const buf = yield* Effect.tryPromise(() => sharp(a.buf).resize({ width: widthOut, height: heightOut }).png().toBuffer()); return { ...a, buf: buf }; })), Effect.orElse(() => { return Effect.tryPromise(() => sharp(baseImage).resize({ width: widthOut, height: heightOut }).png().toBuffer()).pipe(Effect.andThen(a => ({ buf: a, shiftX: 0, shiftY: 0, fit: false, append: '' }))); })); }); } function getStreetImageOnly(loc) { return MapService.findStreetViewMeta(loc.lat, loc.lng, loc.bearing, 640, 640).pipe(Effect.andThen(okLoc => MapService.getStreetViewImage(okLoc.lat, okLoc.lng, loc.bearing, 640, 640)), Effect.andThen(baseImage => Effect.tryPromise(() => sharp(baseImage).resize({ width: widthOut, height: heightOut }).png().toBuffer()))); } return { getCurrentView, resetRunStatus, getDestinationAddress, setDestinationAddress, startJourney, stopJourney, sumDurationSec, routesToDirectionStep, getElapsedView, makeView, }; }), dependencies: [DbServiceLive] }) { } export const RunnerServiceLive = RunnerService.Default;