UNPKG

@mfukushim/map-traveler-mcp

Version:
218 lines (216 loc) 11.5 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, Schedule } from "effect"; import dayjs from "dayjs"; const timezone = __require("dayjs/plugin/timezone"); import { MapService } from "./MapService.js"; import { __pwd, DbService, env } from "./DbService.js"; import { McpLogService } from "./McpLogService.js"; import * as path from "node:path"; import * as fs from "node:fs"; import { bs_handle, bs_id, bs_pass } from "./EnvUtils.js"; dayjs.extend(timezone); export class StoryService extends Effect.Service()("traveler/StoryService", { accessors: true, effect: Effect.gen(function* () { const getNearlyPoliticalParse = (places, id) => { const n = (places).filter(value => value.id === id).flatMap(d => { if (Option.isSome(d.addressComponents)) { const addressComponents = d.addressComponents.value; const country = addressComponents.find(f => f.types[0] === 'country'); const townName = addressComponents.find(f => f.types[0] === 'sublocality_level_2') || addressComponents.find(f => f.types[0] === 'locality'); return [ { address: Option.fromNullable(d.formattedAddress), country: Option.fromNullable(country?.shortText), townName: Option.fromNullable(townName?.longText) } ]; } return []; }).shift(); return n ? n : { country: Option.none(), townName: Option.none(), address: Option.none() }; }; const getNearlyParse = (places) => { return places.filter(value => Option.isSome(value.photos)).map(d => { return { name: d.displayName.text, types: Option.isSome(d.primaryType) ? [d.primaryType.value] : [], id: d.id }; }); }; const getPhotoReferences = (places, id) => { return (places).filter(value => value.id === id).map(result => { const filter = Option.getOrElse(result.photos, () => []).map(a => { return { photoRef: a.name || '', author: a?.authorAttributions ? a?.authorAttributions[0]?.displayName || '' : '' }; }); const t = Option.getOrElse(result.primaryType, () => ''); return { name: result.displayName.text, types: t ? [t] : Option.isSome(result.types) ? result.types.value : [result.displayName.text], photoReference: filter }; }); }; function placesToFacilities(a) { const buildings = getNearlyParse(a); if (buildings.length === 0) { return Effect.succeed({ townName: Option.none(), address: Option.none(), country: Option.none(), facilities: [], photoReferences: [], }); } const selBuilding = buildings[Math.floor(Math.random() * buildings.length)]; const political = getNearlyPoliticalParse(a, selBuilding.id); const photoReferences = getPhotoReferences(a, selBuilding.id); const maxLocationNum = 4; const outBuildings = buildings.slice(0, maxLocationNum); const buildingProperties = outBuildings.flatMap(value => value.types); buildingProperties.push('|'); return Effect.succeed({ townName: political.townName, address: political.address, country: political.country, facilities: outBuildings, photoReferences: photoReferences, }); } function getNearbyFacilities(currentLoc) { return Effect.gen(function* () { let retry = 3; return yield* Effect.async((resume) => resume(Effect.succeed(--retry))).pipe(Effect.tap(a => McpLogService.logTrace(`getNearbyFacilities retry:`, a)), Effect.andThen(a => MapService.getNearly(currentLoc.lat, currentLoc.lng, a === 2 ? 200 : a === 1 ? 1000 : 3000)), Effect.andThen(a => a.kind === 'places' ? placesToFacilities(a.places) : Effect.fail(new Error('no nearly'))), Effect.tap(a => McpLogService.logTrace(`getNearbyFacilities:`, a)), Effect.tapError(e => McpLogService.logTrace(`getNearbyFacilities error:${e}`)), Effect.retry(Schedule.recurs(2).pipe(Schedule.intersect(Schedule.spaced("3 seconds")))), Effect.orElse(() => placesToFacilities([]))); }); } const tips = () => { const textList = []; const imagePathList = []; if (env.isPractice) { textList.push('Currently in practice mode. You can only go to fixed locations.' + ' To switch to normal mode, you need to obtain and set a Google Map API key.' + ' key for detail: https://developers.google.com/maps/documentation/streetview/get-api-key ' + ' Need Credentials: [Street View Static API],[Places API (New)],[Time Zone API],[Directions API]' + ' Please specify the API key in the configuration file(claude_desktop_config.json).' + ' And restart app. Claude Desktop App. Claude App may shrink into the taskbar, so please quit it completely.\n' + `claude_desktop_config.json\n \`\`\` "env":{"GoogleMapApi_key":"xxxxxxx"} \`\`\` `); } else { if (!env.dbFileExist) { textList.push('Since the database is not currently set, the configuration information will be lost when you exit.' + ' Please specify the path of the saved database file in the configuration file(claude_desktop_config.json).' + `claude_desktop_config.json\n \`\`\` "env":{"sqlite_path":"%USERPROFILE%/Desktop/traveler.sqlite"} \`\`\` `); } else { if (!env.anyImageAiExist) { textList.push('If you want to synthesize an avatar image, you will need a key for the image generation AI.' + ' Currently, PixAi and Stability AI\'s SDXL 1.0 API are supported.' + ' Please refer to the website of each company to obtain an API key.' + ' https://platform.stability.ai/docs/getting-started https://platform.stability.ai/account/keys ' + ' https://pixai.art/ https://platform.pixai.art/docs/getting-started/00---quickstart/ ' + ' Please specify the API key in the configuration file(claude_desktop_config.json).' + `claude_desktop_config.json\n \`\`\` "env":{"pixAi_key":"xyzxyz"} or "env":{"sd_key":"xyzxyz"} \`\`\` `); } if (!env.rembgPath && !env.remBgUrl) { textList.push('In order to synthesize avatar images, your PC must be running Python and install rembg.' + ` Please install Python and rembg on your PC using information from the Internet.\n \`\`\` "env":{"rembgPath":"(absolute path to rembg cli)"} \`\`\`\n or \`\`\` "env":{"rembgUrl":"(rembg service API url)"} \`\`\`\n To keep your pc environment clean, I recommend using a Python virtual environment such as venv. `); } const bsEnable = bs_id && bs_pass && bs_handle; if (!bsEnable) { textList.push('Optional: Set up a Bluesky SNS account\n' + 'By setting your registered address, password, and handle for Bluesky SNS, you can post travel information on the SNS and obtain and interact with other people\'s travel information.\n' + 'Since articles may be posted automatically, we strongly recommend using a dedicated account.\n' + `claude_desktop_config.json\n \`\`\` "env":{ "bs_id":"xxxx", "bs_pass":"yyyyy", "bs_handle":"zzzz" } \`\`\` `); } if (!env.promptChanged && !env.fixedModelPrompt) { textList.push('You can change the appearance of your avatar by directly telling the AI what you want it to look like, or by specifying a prompt to show its appearance with set_avatar_prompt.'); } textList.push('You can play a tiny role play game using the scenario in carBattle.txt. Have fun!'); } } return Effect.succeed({ textList, imagePathList }); }; function getSettingResource(pathname) { return Effect.gen(function* () { yield* McpLogService.logTrace(`getSettingResource:`, pathname); const files = yield* Effect.tryPromise(() => fs.promises.readdir(path.join(__pwd, `assets/scenario`))); if (files.some(value => pathname === `/${value}`)) { return yield* Effect.async((resume) => { fs.readFile(path.join(__pwd, `assets/scenario${pathname}`), { encoding: "utf8" }, (err, data) => { if (err) resume(Effect.fail(err)); else resume(Effect.succeed(data)); }); }); } else if (pathname.includes("/setting.txt")) { const langText = yield* DbService.getEnv('language').pipe(Effect.andThen(a => `Please speak to me in ${a}`), Effect.orElseSucceed(() => 'The language of the conversation should be the language the user speaks.')); const destText = yield* Effect.gen(function* () { const runStatus = yield* DbService.getRecentRunStatus(); if (runStatus.status !== 'stop' && runStatus.destination) { return `Current destination is ${runStatus.destination}`; } return yield* DbService.getEnv('destination').pipe(Effect.andThen(a => `Current destination is ${a}`), Effect.orElseSucceed(() => 'The destination is not decided.')); }); return [langText, destText].join('\n'); } else if (pathname.includes("/credit.txt")) { return yield* DbService.getVersion().pipe(Effect.andThen(a => `map-traveler.mcp version:${a} https://akibakokoubou.jp/ `)); } else { return yield* Effect.fail(new Error(`resource not found`)); } }); } return { tips, placesToFacilities, getNearbyFacilities, getSettingResource }; }), }) { } export const StoryServiceLive = StoryService.Default;