tamakyi 1f902bc49b 修复 AI 追加轮后路线示意只剩一个点
根因: runPlan 追加轮把 state.nearby 置 null, payload.nearby 随之为空, 模型第二版不再点名具体地点; extractPlanPlaces 的 nearby 来源(带坐标)空了, 加粗词又无坐标, 无 Key 时全被判 missing -> 只剩 1 个已选目的地。

- 追加轮沿用 state.nearby 并继续放进上下文; 提示词要求保留加粗点名
- extractPlanPlaces(text, inherit) 继承上一版坐标 + 否定语境护栏
- planVersions 存 routePoints, 版本切换/回到初版/载入保存方案同步重绘
- previous 改用 currentPlanText(), drawRouteMap 用 state.routeMissing
- 新增 test-route-map-followup.cjs / verify-route-map-followup.cjs 与 README 说明
2026-11-06 09:58:06 +08:00

行知 · 周边出行顾问(TripSense)

按「时间 + 所在地」分析周边适合出行的目的地,叠加逐日天气、空气质量、公路与铁路可达性,给出可解释的备选清单;再由用户补充具体需求,交给任意 OpenAI 兼容接口生成可执行的逐日行程规划。

零 npm 依赖,仅使用 Node.js 内置模块(http / fs / path + 全局 fetch)。


快速开始

要求 Node.js >= 18。

cd tripsense
node server.js          # 或 npm start

服务默认绑定 0.0.0.0,启动后会打印本机与局域网的全部访问地址,例如:

  ➜  本机访问:  http://127.0.0.1:8788
  ➜  局域网访问:http://192.168.1.100:8788   (WLAN 2)
  ➜  局域网访问:http://100.121.122.41:8788   (Tailscale)

手机、平板或同网段电脑直接用上面局域网访问的地址即可打开。启动日志同时会打印当前的 AI 模式、公路与铁路数据来源。

端口被占用时会自动往后找(最多 10 个),所以实际端口以日志为准。若只想自己用, 用 HOST=127.0.0.1 node server.js 启动,就只监听本机。


三步流程

步骤 用户操作 系统行为
① 出行条件 输入地名联想 / 浏览器定位、选日期区间、单程时长上限、搜索半径、是否排除同城、交通与兴趣偏好 地理编码 → 检索周边候选 → 批量取天气与 AQI → 公路矩阵 + 铁路可达性 → 评分排序
② 备选清单 查看方位罗盘、评分环、逐日天气、推荐理由、数据来源说明;按地名分组浏览、搜索目的地;勾选目的地 默认预选 Top 3 并给出提示;支持按评分/天气/距离/单程时长排序;按「地名 → 景点」两级分组;可搜索、可「只看小众」、可「对比已选」并排看关键指标
③ 行程规划 填写具体需求(同行人、预算、体力、禁忌等),点击生成;生成后可继续提要求迭代 清单数据 + 需求 + 本站预设提示词 → OpenAI 兼容接口流式输出 Markdown 方案

页面右上角「设置」分两块:AI 接口(地址、密钥、模型,含常用供应商预设、预设提示词查看/复制、强制演示模式)与 数据源增强(高德 Web 服务 Key)。两者都只存在浏览器 localStorage,随请求传给本地服务代理。

界面

没有引入任何第三方 UI 框架(React / Vue / Tailwind / Bootstrap 均无),全部是手写 CSS 与原生 DOM。视觉为简约主题:纯白底、中性灰分隔、单一蓝色强调(#2f6fed)、细边框 + 轻阴影 + 圆角,标题与正文统一无衬线、数据用等宽。所有颜色集中在 styles.css 顶部 :root,想换主色改一个 --accent 即可。

表单记忆

出发地、起止日期、单程时长、搜索半径、是否排除同城、交通方式、兴趣偏好都会存进 localStorage(键 tripsense.form),刷新页面或从手机切回来不必重填。

  • 日期会校验:存过的日期若已早于今天,自动回落到「今天 + 3 天」。
  • 只输入地名、还没从下拉里选中时,输入框文字会保留,但不会被当成已选出发地(分析时会提示先选)。
  • 完全重置:清站点数据,或控制台执行 localStorage.removeItem('tripsense.form')。

地名联想

输入出发地时按 340ms 防抖调用 /api/geocode。配了高德 Key 时高德优先,否则走 Open-Meteo。

为什么优先高德:Open-Meteo 基于 GeoNames,中文中小城市 / 区县覆盖很差——实测连「河池」都返回空,「金城江」更是查不到;高德能直接定位到区县中心。

几个兜底:

  • 结构化长地址:「杭州市西湖区」会按行政后缀截成「杭州」再查一次,命中后下拉顶部注明「按「杭州」为你找到这些」。只认「市 / 地区 / 州 / 区 / 县 / 镇 / 乡 / 街道 / 路 / 街 / 号」等后缀,不做无脑截字——否则「金城江」会被截成「金城」,命中台湾金门、美国加州的同名地点,比查不到更糟。
  • 完全查不到:不静默隐藏,而是显示「没找到「xxx」」,并按原因给建议(没配 Key 时提示去配、Key 无效时说明、网络超时时提示重试)。

设置里的「测试这个 Key」会实际请求一次高德,当场告诉你能不能用、为什么不能用。

移动端下拉紧贴输入框(间距 4px)、限高 46vh 可滚动,避免被软键盘顶出视口;窄屏下定位按钮收成 📍 图标,与输入框保持同一行——否则纵向排列会把下拉推到按钮下方,用户会以为没有联想。

排除同城

配了高德 Key 之后,周边检索会顺带返回大量本市城区的 POI(美术馆、寺庙、商场),清单很容易变成「本地一日游」。所以第一步提供 「排除出发地同城地点」 开关,默认开启:

语义要说清楚:排除的是「出发地所在的城市/城区」。 目的地本身所在的城不该被排除——比如出发地在河池市金城江区, 同市下辖的凤山、罗城、南丹(50–110km)恰恰是最该推荐的周边目的地。

判定分三层:

  1. 距离硬排除:< 8km 一律排除。「周边出行」不该包含步行 10 分钟就能到的地方——这一条同时挡掉了「窑埠古镇」(离市中心 1km)这类城郊点位。
  2. 区县级判定:出发地与候选点都有区县信息时,同区县才排除,跨县保留。高德 POI 的 cityname 是地级市(如「柳州市」),adname 才是区县(如「融水苗族自治县」「鱼峰区」),所以必须用 adname 判同城区。
  3. 距离兜底:没有区县信息时(内置库 / Photon),退回按 localKm(默认 30km)判定。
  • 过滤发生在轮转取样之前,不是对结果做后处理——否则同城点会先占满 maxCandidates 名额,把真正周边的地点挤掉。
  • localKm 会被压到不超过搜索半径的 50%,所以选 50km 半径时实际排除约 25km 内,不至于把清单清空。
  • 被排除了多少个会写进 stats.localExcluded 与 dataNotes,副标题也会显示「已排除出发地所在城区」。
  • 想连本地一日游一起看,取消勾选即可(excludeLocal: false)。

实测(河池市金城江区出发,250km 半径)

候选 28 个,覆盖河池下辖县(东兰红水河第一湾 72km、大化八里九弯 82km、巴马百魔洞 109km、凤山三门海 111km、环江月亮山 16km)、柳州方向(柳州古城 141km、窑埠古镇 142km、融水双龙沟 125km、柳州博物馆 141km)以及邻省(荔波小七孔 77km、西江千户苗寨 199km)。localExcluded 13 个。

候选池检索策略

候选来自四路,按「覆盖度从高到低、可信度从高到低」合并去重:

来源 覆盖 说明
内置知名景区库 + 城市库 全国 无密钥、必定有坐标,兜底主干
内置小众地点库 全国 206 条 带 niche 标记,约占候选配额 30%
高德 place/text 跨城 主力:一组目的地类型词(风景区 / 古镇 / 森林公园 / 地质公园 / 湿地公园 / 瀑布 / 溶洞 / 峡谷 / 梯田 / 温泉 / 博物馆 / 古村落 / 山水景区 / 草原 / 湖)在省级范围内检索
高德 place/around 50km 内 只补近处遗漏
Photon(OSM) 有限 公共实例限流严格,仅按分类 + bbox 取少量,失败即降级

关键约束:

  • 不能只查出发地一圈。高德 place/around 的 radius 上限 50km、返回按距离排序、offset 只有 25 条——以出发地为圆心连翻页,拿到的 150 条几乎全是市区的游园、亭子、码头,50km 外一个都搜不到。这是接口覆盖边界,不是参数问题。因此主检索改走 place/text 类型词跨城检索,再用直线距离过滤。
  • place/text 用 city=<省级或目的地市> + citylimit=false,才能拿到跨城目的地;结果每条都带 city(地级市)与 adname(区县),正好支撑「排除同城」的区县级判定。
  • Photon 分类检索用 bbox + 分类 作为稳定缓存键(公共实例限流,重复分析必须复用缓存)。
  • 候选上限 28 个,按「类型轮转取样」抽取,保证小众点位不会被热门点位挤掉。

归属地名与分组

第二步把候选按「地名 → 景点」两级展示,所以每个候选都要先解析出它属于哪个地名。

来源 地名来源 层级
内置城市库 名字本身就是地名(柳州市) 地级
高德 POI cityname(地级市)+ adname(区县) 地级 + 区县
内置知名 / 小众库 离线归属表 lib/places-admin.js 地级 + 区县
Photon / Overpass (OSM) 按 OSM 自带的县名字段查官方表 lib/places-admin-dv.js 地级 + 区县
以上都拿不到 按坐标找最近的县 → 推出地级(官方县表,≤60km) 地级 + 区县

铁律:

  • 组名必须落在地级(市 / 州 / 盟 / 地区)。用街道名或村名当组名等于没分组——同一个县下面的几十个点会被拆成几十组。
  • Photon 的 osm.place.city 类目里混着景点 POI(牌坊、寺庙、老街),它们的名字也带「市 / 州」后缀。不能因为 kind === '城市' 就把名字当地名——会出现「社更穿洞北门」自己成一组这种荒唐结果。必须同时满足「名字是行政区名」且「能在官方地级表里查到」才当地名节点,否则走正常归属流程。
  • OSM 中国的字段层级与直觉相反:county 是地级市/州,city 是县/区,district 是街道,locality 是村。
  • 优先按名字精确查官方表,别靠「按坐标猜最近的县」。窄长县按中心点距离会判到隔壁省去:「环江喀斯特世界自然遗产」实际在环江县(河池市),按中心点却会判到 45km 外的荔波县(黔南州)——差一级省。而 OSM 的 addr:county 在多数条目上直接写了标准县名(环江毛南族自治县),按名字查是准的。
    • 查表必须带距离校验:同名县离候选点太远(>80km)就判为不可信,退回按坐标近邻。
    • 查表顺序:先在 county / city / district 三个字段里找县级(拿到下层地名),都找不到再退地级。OSM 常常把地级填进 county、县名填进 city(「黔南州」+「荔波县」),只看第一个字段就定案会丢掉下层地名。
  • 不要信 OSM 自己填的归属字段的层级。Photon 的 city 字段会给出「乐依村」「陈家甫」这种村名当区县——所以查到的东西必须经 isDistrictName()(县/区/旗/盟/州/市)过滤,村名一律丢弃。
  • OSM 的 admin_level 在中国是 L4=省 / L5=地级市 / L6=县,不是 L4/L6/L8。查 admin_level~"^[468]$" 会正好跳过地级市那层。
  • 分组键会剥掉行政后缀(「柳州」与「柳州市」归一组),民族自治州要连民族名一起去掉(「黔南布依族苗族自治州」→「黔南州」)。
  • 高德 regeo 的批量查询不可用(batch=true + locations,无论分隔符与参数顺序都返回 INVALID_PARAMS),必须逐点反查;并发 3 会触发限流,失败的候选会退回街道名当组名,因此用串行 + 60ms 间隔。

离线行政区划表(阿里 DataV GeoAtlas)

表 条数 作用
lib/places-admin.js 323 内置知名 / 小众地点的归属(地级 + 区县)
lib/places-admin-dv.js 2832 县 / 343 地级 官方行政区划表:按名字查县/地级,运行时归属判定的主力

数据来自阿里 DataV GeoAtlas(免密钥、免鉴权),用的是国家民政部 adcode 体系:省级 2 位 / 地级 4 位 / 县级 6 位,县级前 4 位即所属地级码(451226 环江县 → 4512 河池市)——归属关系是编码里写死的,不需要推断。

# 1) 拉原始数据(三级下钻:省 → 地级市 → 县),输出到 C:/tmp/datav-admin.json
node tools/fetch-datav-admin.cjs
# 2) 生成运行时表
node tools/build-admin-dv.cjs C:/tmp/datav-admin.json

早期版本用的是 Overpass 拉的 OSM 县表(lib/places-county.js / lib/places-prefecture.js),已废弃:OSM 中国的 admin_level 层级不可靠、编码只有约 50% 覆盖(1182/2362 个县查不到所属地级),且公共 Overpass 实例限流极狠(大查询后连 1KB 的小查询都 502/504)。DataV 版 2832 个县全部能定位到所属地级。

几个必须记住的坑:

  • 同名县要保留全部候选。全国有 30 组同名县(「城中区」柳州/西宁都有、「鼓楼区」南京/徐州/福州/开封都有、「铁西区」沈阳/鞍山/四平都有、「市中区」济南/枣庄/内江/乐山都有)。用县名做单一字典键会互相覆盖——柳州的「城中区」会被归到西宁市。所以表里同名县的值是数组,运行时按坐标距离选最近的那个。
  • 省直辖县级单位不是地级市。DataV 把海南的东方市/昌江黎族自治县、新疆兵团的石河子市、湖北的仙桃市等标成 level=city,靠 adcode 是否以 00 结尾区分(真地级市是 xx00,省直辖县级是 9xxx 段)。不按这个分,归属会整整差一级。
  • 剥民族名要用显式清单,不能用 [一-龥]{1,3}族 这种贪婪正则——「环江毛南族自治县」会被吃成「环自治县」。而且顺序很重要:先剥「自治县/州」尾巴,民族名块才会露在末尾。
  • 别名表要县/地分开(COUNTY_ALIAS → 县全称,PREF_ALIAS → 地级简称)。混在一张表里会因为「同名县/市」互相覆盖而查错。

兜底路径:Photon 全挂时走 Overpass

步骤 实现 说明
1. 取候选 overpassQuery() 按 OVERPASS_TAGS 逐类查 OSM 原始标签
2. 取行政区 overpassAdminAreas() out center tags 取 bbox 内 L5/L6 行政区的中心点
3. 判归属 areaByPoint() 按中心点做近邻判定
4. 精确反查 overpassAdminAtPoint() 近邻判定拿不到时逐点用 is_in 反查
5. 归一 lookupAdminByName() OSM 的简称/全称统一过官方表,同名县按距离消歧

关键事实:

  • relation 级 geometry 在 Overpass 里拿不到。out tags geom 和 out geom 都不返回 relation 级的 geometry 数组(几何在 members 里)。所以不要走「拉多边形 + 本地点面判定」这条路。
  • is_in 反查一次就能拿到完整层级:[out:json];is_in(lat,lon)->.a;.a out tags; 返回 L4 省 / L5 地级 / L6 县(实测 25.1,107.98 → 环江县 / 河池市 / 广西)。注意 rel(pivot.a) 拿不到点→区映射,要直接输出 area 集合。
  • 公共实例必须多镜像轮换,且要判定响应体是否真是 JSON:实例可能返回 200 + HTML 错误页(如 duplicate_query),只看 res.status 会把错误页当成功。判据是响应体是否以 { 开头。
  • 必须有硬超时和总时限预算。公共实例经常「连着不返回」(不报错也不结束),没有 AbortSignal.timeout 的话一次兜底能把分析流程拖成十几分钟(实测踩过)。三处都要限:
    • 单次请求超时(SEARCH.overpassTimeoutMs,默认 20s);
    • 单个阶段的总时限(镜像轮询不超过 2 倍超时);
    • 逐点 is_in 反查的次数上限(SEARCH.overpassIsInMax,默认 8)——这是最慢的一环,超限后改用 bbox 行政区中心点近邻判定。
    • 超时后候选照常入池(归属还有离线官方表兜底),不能因为反查超时就丢掉整个候选。
  • Overpass 的兴趣点大多没有 addr:* 标签,直接拿归属必然得到空组名——必须靠 is_in 反查。

离线回归测试

归属逻辑不能只靠真实网络验证——Photon / Overpass 随时会挂,挂了就没法回归。仓库里有不发外部请求的离线测试:

node tools/test-admin-lookup.cjs            # 归属判定单测:同名县消歧、已知边界点、狭长县远端点
node tools/test-overpass-fallback.cjs        # 起本地假 Overpass:验兜底路径、简称归一、200+HTML 错误页
node tools/test-grouping-regression.cjs      # 起本地假 Photon:20 个候选的完整分组回归
node tools/test-route-map-followup.cjs       # vm 里跑真实 extractPlanPlaces:初版/追加轮两版路线点对比 + 源码断言

另有端到端 UI 验收(真实无头 Chrome,读真实 DOM):

node tools/verify-ui-full.cjs                # 起假 Photon:分组 / undefined / 空组名 / 搜索 / 折叠 + 截图
node tools/verify-route-map-followup.cjs     # 追加轮路线图点数(1 → 9)+ 版本切换重绘 + 截图

写这类测试时注意:cfg.SEARCH.maxCandidates 缺省会静默返回 0 候选(selected.length < undefined 恒假);第二轮跑要清 require.cache 或换 origin,否则命中第一轮缓存。

拉取时的坑:一次拉全国必然 502(5 个镜像全挂),必须按 10°×15° 分块;用 out center 而不是 out geom(几何版体积大十几倍,更容易超时)。境外行政区会混进同一层级数据,靠有无 division_code 或 name 本身是中文来剔除。

县级表第三项是所属地级名,由 GB/T 2260 编码前缀推出(县级码前 4 位即地级码:荔波县 522722 → 5223 黔南州)。这比「按距离找最近地级」精确得多——桂黔边境荔波县与河池市相距不到 40km,按中心点距离会分错。

评分口径

总分 0~100,由六项加权相加:天气舒适度 0~40、时间适配 0~26、兴趣匹配 0~14、距离 0~7、目的地辨识度 0~8、空气质量 0~6。整段行程都是坏天气时会触发硬门槛,不允许给出「强烈推荐」。每张卡片都带 breakdown、reasons、warnings,可解释。

耗时统一按人类可读格式输出(1小时38分 / 4分钟),不出现 1.07h 这类裸小数。AQI 按六档标注:优 / 良 / 轻度污染 / 中度污染 / 重度污染 / 严重污染。

天气数据的四种模式

模式 触发条件 数据来源
forecast 未来 16 天内 Open-Meteo 实时预报
forecast-past 区间含刚过去的日期 past_days 预报
archive 更早的历史日期 Open-Meteo 历史再分析
climatology 超出 16 天预报窗口 取去年同期气候,映射回所选日期,结果明确标注「历史气候参考」

/api/best-window 会在未来 16 天预报中寻找连续平稳天气窗口,前端选择出发地后自动提示并可一键应用。


数据源

数据源 用途 密钥 不可用时
Open-Meteo geocoding / forecast / archive / air-quality 地名检索、天气预报、历史气候、美标 AQI 免 重试 2 次后报错并提示
Photon (OpenStreetMap) 周边景点/公园/博物馆/山峰/古迹/城市检索 免 4 秒快速失败,切内置地名库
OSRM table/v1/driving 1→N 公路里程与耗时矩阵 免 降级为里程-速度估算并标注
BigDataCloud / Photon Reverse 浏览器定位后的反向地理编码 免 提示手动输入地名
内置 lib/places-cn.js 全国知名景区 + 主要城市坐标库 — 始终参与,保证任何城市都有可用清单
内置 lib/places-niche.js 206 条小众地点:古村、遗址、湿地、地质景观、洞穴、小馆、徒步线 — 始终参与,并保证约 30% 的候选名额
内置 lib/places-admin.js 内置地点的归属地名(323 条,离线生成) — 始终参与;缺失时按坐标回退到最近地级市
内置 lib/places-admin-dv.js 官方行政区划表:2832 县 / 343 地级(DataV GeoAtlas,离线生成) — 始终参与;同名县按坐标消歧,查不到时退到按坐标近邻
Overpass (OpenStreetMap) Photon 全挂时的候选兜底(免密钥),多镜像轮换 免 跳过并注明「实时检索不可用」;实例返回 200 但内容是 HTML 错误页时同样判为失败
高德 Web 服务 geocode/geo / place/text / place/around(含 types=100000 住宿)/ direction/driving / staticmap(路线图真实底图) 中文地名检索、实时周边 POI、周边住宿参考、国内驾车路径、地图图层 AMAP_KEY 或页面「设置 → 数据源增强」 静默跳过并退回 Open-Meteo / OSRM / 内置库 / 方位示意图;Key 无效会在页面顶部或设置里直接报错
RollingGo rgh CLI(本机安装) 酒店实时参考价、星级、距离、可预订链接 npm i -g @rollinggo/hotel + rgh login 跳过,退回高德 POI;只调用只读子命令
铁路接口 真实高铁/城际耗时与票价 RAIL_API_URL 降级为里程-速度估算并标注
OpenAI 兼容接口 生成行程规划 AI_API_KEY 自动进入本地演示模式

Photon 公共实例常被限流,因此 lib/sources.js 采用「每类单独请求 + 并发限制 + 快速失败 + 内置库兜底」。名称过滤分三层:JUNK_NAME 去掉停车场/出入口等设施,POOR_NAME 去掉「土堆」「观景台」等无辨识度条目,LOW_VALUE_NAME 去掉「名字像景点但不是独立目的地」的点位(燕京八景中的小碑、外国使馆旧址等)——这三类过去会把真正值得去的地方挤出榜单。当实时 POI 完全不可用时,结果会带 stats.fallbackOnly,页面提示会明确写出「天气、空气质量与路况仍是实时数据,不受影响」,避免用户误以为整条链路都挂了。

小众地点库的坐标是近似 WGS-84,只用于天气与路网估算;结果里 source 会写「内置小众地点库」,不会伪装成实时检索。候选带 niche: true 与 region 字段,前端据此显示「小众」徽章并提供「只看小众」筛选。


配置

所有配置在 config.js,环境变量优先。

变量 默认值 说明
PORT / HOST 8787 / 0.0.0.0 监听地址;端口被占用时自动往后顺延,实际端口见启动日志
AMAP_KEY 空 高德 Web 服务 Key,开启国内驾车路径、实时周边 POI、中文地名检索与真实地图底图
HOTEL_PRICE_API_URL 空 可选价格源模板,支持 {name} {lat} {lon},期望返回 [{name, price}];用于补高德缺失的酒店价格
RAIL_API_URL 空 铁路接口模板,支持 {fromLat} {fromLon} {toLat} {toLon} {date} {fromName} {toName};期望返回 {available, durationMinutes, distanceKm, fareCny, note}
RAIL_API_KEY 空 铁路接口鉴权
AI_BASE_URL https://api.openai.com/v1 任意兼容 /chat/completions 的服务
AI_API_KEY 空 留空即演示模式
AI_MODEL gpt-4o-mini 模型名
AI_TEMPERATURE / AI_MAX_TOKENS / AI_TIMEOUT_MS 0.7 / 8000 / 120000 生成参数;推理模型思考很吃 token,额度不够会自动放大重试

SEARCH 段为检索调参(maxRadiusKm / photonConcurrency / photonTimeoutMs / maxCandidates 等),一般不改。

临时覆盖(仅原型用途)

前端通过自定义请求头把设置交给本地服务代理:

请求头 含义
X-AI-Base-Url 覆盖 AI_BASE_URL
X-AI-Model 覆盖 AI_MODEL
X-AI-Api-Key 覆盖密钥;__server__ = 用服务端配置;__demo__ = 强制演示模式
X-AMAP-Key 覆盖 AMAP_KEY,只影响 /api/analyze 的周边 POI 与驾车路径

前端「设置 → 数据源增强」填的高德 Key 就是通过 X-AMAP-Key 生效的,优先级高于服务端环境变量;清除后即回到免密钥数据源。Key 无效(如 INVALID_USER_KEY)时不会静默失败,stats.amapError 会带回原因并在页面顶部提示,同时公路自动退回 OSRM。

示例:

AI_BASE_URL="https://api.deepseek.com/v1" \
AI_API_KEY="sk-..." \
AI_MODEL="deepseek-chat" \
node server.js

OpenAI 兼容接口与预设提示词

服务只要求对方提供 POST {BASE_URL}/chat/completions,请求体为标准格式(model / messages / stream: true / temperature / max_tokens),解析 choices[0].delta.content,同时兼容非流式 JSON 响应与 reasoning_content。前端设置里内置以下预设:

供应商 Base URL 示例模型
OpenAI https://api.openai.com/v1 gpt-4o-mini
DeepSeek https://api.deepseek.com/v1 deepseek-chat
通义千问 https://dashscope.aliyuncs.com/compatible-mode/v1 qwen-plus
Kimi https://api.moonshot.cn/v1 moonshot-v1-8k
智谱 GLM https://open.bigmodel.cn/api/paas/v4 glm-4-flash
本地 Ollama http://localhost:11434/v1 qwen2.5:7b

预设提示词写死在 config.js 的 SYSTEM_PROMPT,页面设置弹窗可查看/复制。它向模型说明产品背景与输入 JSON 结构,并规定硬性规则:

  1. 只用输入给出的候选地与数据,禁止编造景点、票价、班次、营业时间;
  2. 估算值与历史气候必须显式标注「估算」/「历史气候参考」;
  3. userNeeds 与数据冲突时必须直接指出并给替代方案,不许假装满足;
  4. 先扣掉往返交通再安排游玩,节奏与同行人体力匹配;
  5. 安全优先,预算用 ¥ 并注明粗估;
  6. 输出固定 Markdown 结构:方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选。

dataNotes 会一并送入模型上下文,避免它把估算数据当作事实。未配置密钥时走本地 demoPlan() 规则引擎,输出同结构的示例方案并标注「演示模式」,因此整条链路无需任何密钥即可完整体验。

版本对比与保存

输出头部有一个版本下拉,把本次会话的每一版和本地保存的方案放在一起:

  • 选任一会话版本即可回看,对比上一版 会把当前版与它前一版左右并排展示(各自独立滚动),再点一次退出。
  • 保存 把当前版写进 localStorage(tripsense.saved,最多 20 条),下拉里显示成 11/5 15:02 · 北京市,选中即可载入并继续调整。
  • 下拉末尾的「清空已保存」可以一次性清掉本地记录。
  • 复制 / 下载都跟随当前查看的那一版,不会永远只导出最新版。

让 AI 能点名具体地点(nearby POI)

只给住宿 POI 时,模型只能说「附近有很多小吃」——因为它手里确实没有餐厅、景点数据,而提示词又禁止编造。所以生成方案前会多查一次目的地周边 POI:

  • GET /api/nearby-pois?lat=&lon=&radius=8 → 高德 place/around,types=050000|110000|140100(餐饮 / 风景名胜 / 博物馆),一次请求拿三类
  • 结果整理成 nearby = { area, radiusKm, food[], sights[] },每项含 name / type / distanceKm / rating / cost
  • 随 /api/plan 一起交给模型,SYSTEM_PROMPT 第 10、11 条要求:必须从 nearby 点名,写成「名称(类型,距目的地区域 X km,评分 Y,人均 ¥Z)」;只有某一类确实缺失时才允许退回「片区 + 筛选方法」,并且必须说明缺的是哪类数据

所以现在方案里会出现具体店名/馆名,而不是「某某古镇附近」。查不到时(没配高德 Key)行为与之前一致,只是没有点名依据。

上下文体积(省 token)

每次生成都要把 SYSTEM_PROMPT + 上下文 JSON 发给模型,所以两者都做了裁剪:

项 之前 现在
SYSTEM_PROMPT 2799 字 1028 字(规则合并,输出结构只留标题与要点)
每个候选的逐日天气 31 天 × 8 字段 只带行程覆盖的天数 × 7 字段
候选里附带的字段 road / rail / options / reasons / warnings 全带 只要 travel.bestHours/bestMode + 结论性字段
dataNotes 12 条全文 4 条 × 80 字
实测单次请求 约 2 万字符 约 4000 字符(system 1028 + 上下文 2956)

weather.summary 保留为对象而不是压成字符串——演示模式的规则引擎要用它的 avgHigh/avgLow 等统计值。

住宿建议与路线示意

两者都在方案定稿之后才出现——先有行程,再谈住哪、怎么走。

住宿建议

  • 落脚点取「已选里评分最高的目的地」,围绕它查 10km 内住宿;用户选的价位既用于筛选,也随需求传给模型。
  • 价位支持 五档预设 + 自定义区间(自定义可填「最低–最高」,如 ¥200–800)。自定义值存 localStorage,切换后会自动按新区间重查住宿,不用重新生成方案。
  • 数据源是高德 POI「住宿服务」(types=100000)。价格取 biz_ext.cost,缺失时明确显示「无标注价」,绝不编造;整片区域都没价格时会直接说明"无法按价位筛选,需自行核对"。每行带「查房价」外链(高德 URI API),方便直接核对。
  • POI 里没有房态和实时房价,预设提示词第 7–9 条要求模型不得编造房型、空房、精确价格,且住宿要靠近当天主要活动地、跨片区要说明是否换住处。
  • 未配置高德 Key 时只提示配置,方案里的住宿预算仍按所选价位给出。
  • 数据源降级要如实说明:实时报价源(RollingGo)没命中而退回到只有位置的高德 POI 时,界面会多写一句「实时报价源这次没命中,已退回到只有位置的酒店数据。」,不会让人以为这就是完整报价。

关于「酒店都没价格」

这是数据源的硬限制,不是解析 bug:高德 POI 的 biz_ext.cost 对住宿类目覆盖很差,多数酒店干脆不返回该字段(extensions=all 也已经带上)。处理方式:

  1. UI 坦白标注「无标注价」,并说明原因,不猜价;
  2. 每行给「查房价」外链,一键到地图/预订平台核对;
  3. 预留可插拔价格源:配置 HOTEL_PRICE_API_URL(模板支持 {name} / {lat} / {lon},返回 [{name, price}])后,后端会按酒店名匹配合并价格,命中时列表会显示 ¥380 起 且脚注标注「价格来自第三方价格源」。未配置时保持 null,不影响主流程。

住宿数据源:RollingGo(可选,能拿到真实报价)

rollinggo-hotel-skill-CN 提供的是 Claude Skill + 本机 CLI,不是 MCP Server:底层是 npm 包 @rollinggo/hotel,可执行文件 rgh,认证走 OAuth 2.1(rgh login),没有 API Key。它能给出酒店的真实参考价、星级、距离与可预订链接,正好补上高德缺失的价格。

接入方式(默认开启 auto,装了才生效):

npm i -g @rollinggo/hotel      # 装 CLI
rgh login                      # 浏览器 OAuth 授权(只有你自己能做这一步)
rgh status                     # 确认已登录

然后在网页「设置 → 数据源增强 → 住宿数据源」点「检测」,会显示 rgh 是否安装、是否已登录。

  • 只读白名单:lib/hotels-rollinggo.js 里 ALLOWED_SUBCOMMANDS 只允许 status / search-hotels / hotel-detail。price-confirm / book / pre-cancel / confirm-cancel 会触发真实资金操作,本适配器永不调用,也没有对应接口对外暴露。
  • 降级链:RollingGo(已登录)→ 高德 POI → 明确提示不可用。任一环节失败都不影响方案生成。
  • 价位直接下推:自定义/预设价位的上限会作为 --max-price-per-night 传给 CLI,由数据源侧筛选,而不是拿回来再过滤。
  • Windows 编码坑:全局安装得到的是 rgh.cmd,经 cmd.exe 转发时中文参数与本地化报错会乱码(实测「不是内部或外部命令」被解成 GBK 乱码,导致"是否安装"判断失效)。适配器会从 bin 脚本位置反查 @rollinggo/hotel/dist/index.js,直接用 node 执行,绕开 shell;同时对自由文本做白名单校验,杜绝命令注入。
变量 默认 说明
HOTEL_PROVIDER auto auto = 装了 rgh 且已登录就用 RollingGo;amap = 只用高德;rollinggo = 只试 RollingGo
RHG_BIN rgh rgh 可执行文件路径,也可指向 .../dist/index.js
ROLLINGGO_TIMEOUT_MS 25000 单次 CLI 调用超时

⚠️ 使用边界(重要):rgh login 把 OAuth 凭据写在本机配置文件(~/.hotel-cli/.env),一台机器只有一份登录态,CLI 也没有「传 token」这类多租户参数。所以:

  • 它天生是单账号工具,不存在「每个访客填自己的」这种用法。给多人用只有两种模式:① 站长统一配置一个账号(所有人共用);② 每个人在自己机器上跑一份服务、各自 rgh login。
  • 站长统一配置时,所有访客的查询都算在站长账号上:额度、计费、风控都在站长身上;把账号提供给第三方查询也可能触及服务条款。只适合自己 / 家人朋友这种可信小范围使用。
  • 要做公开站点,别用 CLI 兜:应走官方 API 或商务合作,或者干脆只保留「跳转到预订平台」的链接。
  • 想彻底关掉它:HOTEL_PROVIDER=amap,服务端就完全不会去碰 rgh。

实测数据(北京 · 故宫周边 5km · 2026-11-09)

查询 结果
1 晚,不限价 8 家,最低 ¥521/晚(北京福庭酒店 3.6km)、¥547/晚(国际艺苑大酒店 1.3km)
3 晚 6 家,最低 ¥456/晚(秋果酒店前门店,3 晚总价 1368)
1 晚 + --max-price-per-night 600 3 家,全部 ≤ ¥600(521 / 524 / 456)
  • price.lowestPrice 是每晚均价(CLI 原文:「3晚总价:1,563CNY(约521CNY/晚)」),所以界面显示 ¥521/晚,不是总价。
  • 每条结果带 bookingUrl(https://rollinggo.cn/pages/hotel/detail/...),界面直接做成「去预订」。

踩到的解析坑:rgh 用 dotenvx 加载配置,启动时会往 stdout 打一行 ◇ injected env (1) from ... // tip: ... { path: '/custom/path/.env' } ——这行自带花括号。原先「取第一个 { 到最后一个 }」会把日志和 JSON 粘在一起,JSON.parse 必然失败,表现为「明明有结果却 0 家」。现在 extractJSON() 先从 "success" / "hotelInformationList" 等业务字段回推对象起点,再按括号配对(跳过字符串内的括号)精确切分,已用 6 个用例覆盖(含字符串里带 } 的情况)。

路线图

路线图画的是方案里实际安排的每个地点,不是用户勾选的候选清单。

旧实现只取「已选候选」,而勾选一个城市目的地时清单往往只有 1 项, 于是图上就一个点——这不是数据问题,是取错了源。

现在的流程:

  1. 提取地点(public/app.js 的 extractPlanPlaces):从方案正文里收集三类地点——① nearby 里模型被强制点名的餐饮/景点(名字必定出现在正文);② 已选目的地;③ 正文里加粗的词。加粗词必须以场所类后缀结尾(景区/公园/博物馆/古镇/寺/山/岛/酒店…)才算地点,否则「演示模式」「自驾」「合计」「若遇降雨或大风」这类说明性词会被误当地点(黑名单永远补不全,所以用白名单)。
  2. 按行程顺序排列:按地点名在正文中首次出现的位置排序,得到先后次序,而不是清单顺序。
  3. 定位坐标:已有坐标的直接用;缺的走 POST /api/route-points,服务端用高德 place/text 按名字检索(city 传目的地所在市、citylimit=false、不限 types,否则会漏掉餐饮/酒店/场馆)。
  4. 画线路:真实高德底图 + 自绘编号圆点 + 虚线动线;下方附完整行程点清单(含类型),图上只画 9 个代表点。

定位的两道护栏(都很重要):

  • 距离护栏:名字里带外地地名会让高德匹配到同名 POI——实测「马鞍山马尾坡野生红花林场」被定位到 1211km 外的安徽马鞍山市。参考点超过 150km 一律不认,宁可返回 null 让界面标注「未能定位」,也不能在地图上画一个完全错误的位置。
  • 城市加分:查询「柳州马鞍山马尾坡」这类名字时,命中 cityname 与目的地一致的那条才加分,避免默认命中外地同名 POI。

其它约束:

  • 地图只画目的地,不画出发地:出发地往往在 100km 外,塞进取景范围会把目的地压成一个点。起点改用文字标注(方位 + 直线距离,如「出发地河池市金城江区在东方 142km」),降级示意图里才画出起点。
  • 静态地图最多承载 9 个点(服务端 staticMapMeta 同样按 9 点截取)。超出的按行程顺序均匀抽样,并保证首尾都在;否则图上只画得出前 9 个、图例却写着 18 个,对不上。
  • 相距 80m 内的点会被合并(同一片街区常见),避免叠在一起连不出线。
  • 标注做了避让:按 y 排序依次分配 6 个标签槽位(上/下/左/右),并记录已占用的矩形,冲突就换槽;全部冲突时只画编号不画名字——宁可少标也不能糊成一团。
  • 取景的 metersPerPx 下限为 6:行程点往往集中在城市几公里内,按真实跨度算会 zoom 到 16 级、几十个点全叠成一坨,反而看不出线路。
  • 有高德 Key 时显示真实地图底图:服务端代理高德静态地图(/v3/staticmap),Key 只在服务端使用,不出现在前端 URL 或 DOM 里。取图失败时自动降级为方位示意,并在图下说明原因。
  • 无 Key 时降级为按真实经纬度等距投影(经度按 cos(lat) 修正)的示意图。
  • 地图服务商只使用合规白名单(高德 / 腾讯 / 百度 / 天地图),不使用 Google / Apple / Mapbox / OSM 直连瓦片。

追加轮不能塌成一个点(用户反馈后修复)

现象:与 AI 优化改变行程后,第二次「行程路线示意」只剩一个点。根因不在地图,在多轮数据流:

  • runPlan() 里只有 !isFollowUp 才查 /api/nearby-pois,末尾又无条件 state.nearby = nearby——追加轮 nearby 为 null,payload.nearby 也是 null。模型上下文里没有「可点名的地点」,第二版退化成「某片区 + 自行核实」,甚至不再重复加粗点名。
  • extractPlanPlaces() 的三类来源里,nearby(带真实坐标)空了;加粗词常只剩说明性词,且不带坐标;没配高德 Key 时 resolveRoutePoints() 把它们全部判为 missing 丢掉 → 只剩「已选目的地」,而用户常常只勾了 1 个 → 图上孤零零一个点。

修复是组合拳,缺一不可:

  1. 追加轮沿用 state.nearby:else if (state.nearby && shortlist.some(c => c.name === state.nearby.area)) nearby = state.nearby;,payload.nearby 继续带上;已选目的地变了才失效。
  2. 提示词约束:追加轮末条 user 消息要求「上一版加粗点名的具体地点继续写出名称,不要退化成某片区/自行核实;新增地点从 nearby / shortlist 取真实名并同样加粗」。
  3. 继承上一版坐标:extractPlanPlaces(text, inherit) 多一个继承池,只把「本版正文里仍出现」且不在否定语境里的点补回坐标;push() 支持「带坐标的同名点覆盖只有名字的加粗词」。
  4. 路线点属于版本:planVersions[i] 存 routePoints / routeMissing,#versionSelect 切换、「回到初版」、载入保存方案都同步重绘;drawRouteMap() 改用 state.routeMissing 真正显示「有 N 个地点未能定位」。
  5. 底稿取当前版本:previous 从 state.planText 改为 currentPlanText(),查看历史版时不会基于最后一版调整。

否定语境护栏(删掉|取消|去掉|不再|… + 名字前 12 字)同时作用于 nearby 沿用与继承两条路径:模型按要求在「风险与备选」里写「删掉 XX」时,该地点不会被画回图上。

实测(tools/verify-route-map-followup.cjs,真实 Chrome):初版 11 点 / 图上 9(抽稀上限);修复前追加轮 1 点 / 图上 1;修复后 9 点 / 图上 9;切回第 1 版重绘为 11 点 / 图上 9。

多轮调整

方案不是一次性的。生成完成后,输出框下方会出现输入条,写一句要求(「第二天改成室内为主」「总预算压到 1200」)点「继续优化」即可迭代。

  • 后端把上一版方案作为 assistant 消息、新要求作为 user 消息一起发给模型,并要求输出完整的新版(不是 diff 片段),免得用户还得自己拼。
  • 输出区顶部显示「第 N 版」,右侧「回到初版」可退回第一版做对比。
  • 演示模式下规则引擎无法真正理解增量需求,会在开头注明并重新生成通用方案,不会假装已经改好。
  • 上一版超过 12000 字会被截断,避免把上下文撑爆;previous 只用于多轮消息,不会重复塞进上下文 JSON。
  • 追加轮沿用第一轮查到的 nearby 周边地点(已选目的地没变时),并把它继续放进上下文 JSON;清空会让第二版失去可点名的地点,路线图塌成一个点。详见上文「路线图 → 追加轮不能塌成一个点」。
  • 每一版都存自己的 routePoints,切换版本 / 回到初版 / 载入保存方案时路线图同步重绘;previous 用 currentPlanText(),保证「正在看哪一版就基于哪一版改」。

AI 接口自检

设置里的「测试 AI 接口」会真的发一次最小请求(非流式、max_tokens: 32),把失败定位到具体环节,而不是只丢一句"没有输出":

显示 含义
可用 · 320ms · gpt-4o-mini 地址、密钥、模型都通
HTTP 401:Invalid API key 密钥无效或已过期
HTTP 404:model not found 模型名写错
接口通了但没返回内容(finish_reason=length) max_tokens 太小,或命中内容策略
无法连接:fetch failed 地址不可达 / 少了 /v1 / 需要代理
没有可用的 API Key 当前等同演示模式,不会发网络请求

为什么会出现「未获得任何输出」

这条提示只在流正常结束、但一个字的正文都没拿到时出现,常见原因:

  1. 模型只返回了思考内容(reasoning_content),正文为空。推理模型(DeepSeek 系、o1/o3/gpt-5 等)思考很耗 token,max_tokens 不够时正文一个字都剩不下。默认额度已提到 8000,并且检测到「只有思考没有正文」会自动放大到 16384 重试一次(顶部会显示「已把上限 8000 → 16384 自动重试」),不需要手动改配置。两次都不够才会报错并建议换非推理模型。
  2. 参数不被支持:新版模型会拒绝 temperature / max_tokens,返回 400 Unsupported parameter。代码会自动去掉这两个参数重试。
  3. 额度超过模型上限:重试到 16384 时若模型只支持 8192,会先降到 8192,再退到服务端默认值,避免"重试反而把请求撑爆"。
  4. 地址少了 /v1:请求打到 404 或返回 HTML,流里没有 data: 事件。
  5. 网关返回 200 但 body 为空(部分代理在鉴权失败时这样)。

这几种情况都会在输出区给出具体原因(含 finish_reason、收到的事件数、原始片段),推理模型思考时会显示「模型思考中…已 N 字」。


HTTP API

接口 方法 说明
/api/health GET 健康检查
/api/config GET 当前 AI 状态、数据源、限制、systemPrompt
/api/geocode?name=杭州 GET 地名联想
/api/reverse?lat=..&lon=.. GET 反向地理编码
/api/best-window?lat=..&lon=..&days=2 GET 未来 16 天内连续好天气窗口
/api/analyze POST SSE:progress → result → done
/api/plan POST SSE:meta → delta → done
/api/hotels?lat=..&lon=..&radius=10 GET 主目的地周边住宿参考(高德 POI,不含房态与实时房价;配了 HOTEL_PRICE_API_URL 会补价格)
/api/nearby-pois?lat=..&lon=..&radius=8 GET 目的地周边可点名地点(餐饮 / 景点 / 博物馆),供 AI 规划使用
/api/route-points POST 按地点名列表定位真实坐标(串行 + 120ms 间隔调高德),返回 points 与 missing,供路线图绘制
/api/hotel-providers GET 住宿数据源可用性:高德 Key 是否配置、rgh 是否安装/已登录(只读)
/api/staticmap?pts=lat,lon;lat,lon&w=680&h=400 GET 代理高德静态地图,返回 image/png;失败时返回 {error}。Key 走 X-AMAP-Key 头,不进 URL
/api/ai-test POST 用最小的非流式请求探测 AI 接口,返回各环节诊断(stage / status / ms / message)
/api/cache GET / DELETE 查看缓存条目数与上限;DELETE 清空全部缓存

缓存

服务端有一层内存缓存(lib/cache.js),带 TTL 与 LRU(上限 300 条)。命中时不再打外部数据源,这是高德 QPS 限流与接口抖动的主要缓冲。

数据 TTL 理由
分析结果(/api/analyze) 30 分钟 天气/路况按小时更新,短时间内重复分析没必要重算
地理编码 / 反向地理编码 7 天 行政区划与地名几乎不变
候选发现(Photon + 高德 POI) 6 小时 商家与景点会变,但不至于几分钟一变
周边 POI / 住宿 POI / 静态地图底图 6 小时 / 30 分钟 / 7 天 房价波动快,底图几乎不变
天气 / 空气质量 / 路网 / 铁路 3 小时 对应上游数据的刷新节奏

缓存键 = 规范化请求指纹。高德 Key 的后 8 位参与指纹——换 Key 会改变 POI 与路网结果,不能串味。地理编码缓存键把坐标取整到 3 位(约 100 米),避免浮点尾数导致永远不命中。

前端行为:命中缓存时页面顶部出现提示条「这份清单来自 N 分钟前的缓存」,并提供「重新获取最新数据」按钮(请求体带 forceRefresh: true 跳过缓存)。若改了条件但结果看着像旧的,直接点它。

# 查看缓存状态 / 清空
curl http://127.0.0.1:8787/api/cache
curl -X DELETE http://127.0.0.1:8787/api/cache

缓存只在内存里,重启服务即清空。多实例部署时各实例独立,不共享。

请求体示例:

# 分析(SSE 流)
curl -N -X POST http://127.0.0.1:8787/api/analyze \
  -H 'Content-Type: application/json' \
  -d '{"origin":{"label":"北京市","lat":39.9042,"lon":116.4074},
       "startDate":"2026-11-09","endDate":"2026-11-10",
       "maxOneWayHours":2.5,"radiusKm":150,
       "excludeLocal":true,"localKm":30,
       "transportPrefs":["自驾","高铁/铁路"],"interests":["自然风光","拍照"]}'

# 规划(SSE 流)
curl -N -X POST http://127.0.0.1:8787/api/plan \
  -H 'Content-Type: application/json' \
  -H 'X-AI-Api-Key: __demo__' \
  -d '{"origin":{...},"timeRange":{...},"constraints":{...},
       "shortlist":[...],"userNeeds":"带老人和小孩,两天一晚","dataNotes":[...]}'

/api/analyze 返回体含 origin / timeRange / constraints(含 excludeLocal、localKm)/ summary / candidates[] / dataNotes[] / stats(含 localExcluded、amapError);每个候选带 score / verdict / weather / road / rail / aqi / breakdown / reasons。


已知限制与数据口径

  • Photon 公共实例不稳定,实时 POI 可能整体降级到内置地名库(坐标为近似值),页面会明确提示。
  • 高德 Key 必须是「Web服务」类型(不是「Web端(JS API)」),且需在控制台启用;填错或配额用尽会在页面顶部报出原因,不会静默失败。未配置时周边 POI 与驾车路径自动使用免密钥数据源。
  • 未配置 RAIL_API_URL 时,铁路耗时与票价均为「里程-速度」估算;road.source 也可能为估算。
  • 超出 16 天预报窗口的日期使用去年同期气候,仅作参考,不可当作实际天气。
  • 所有估算、降级、历史气候替代都会写进 dataNotes 并展示在页面;票价、班次、门票、开放时间请临近出发前在官方渠道核对。
  • 本工具是决策辅助原型,不构成出行安全承诺。

开放访问(局域网)

默认绑定 0.0.0.0,同网段设备可直接访问。要让它真正可达,注意三件事:

  1. 防火墙:Windows 首次让 Node 监听端口时会弹窗询问,选「允许访问」。若当时点了取消,需要到「Windows Defender 防火墙 → 允许应用通过防火墙」里给 node.exe 勾上专用/公用网络。可用 Get-NetFirewallRule -DisplayName 'node.exe' 检查是否已有 Inbound Allow 规则。
  2. 网络类型:家用/公司 Wi-Fi 通常是「专用」或「公用」,两种都默认放行;若被识别为「域」网络而规则未覆盖,要单独放行。
  3. 同一网段:手机不能用 127.0.0.1(那是手机自己),要用启动日志里打印的局域网 IP。

安全边界(重要)

这个原型没有任何登录鉴权,绑定 0.0.0.0 意味着同网段任何人都能打开页面并调用 /api/*:

  • 若服务端配置了 AI_API_KEY,同一网络的人可以通过本服务消耗你的额度(X-AI-Api-Key: __server__ 就是走服务端密钥);启动日志在这种情况下会额外警告。
  • CORS 当前是 *,任意网页都能在浏览器里调用你本机的接口。
  • 没有限流,/api/analyze 每次都会打到 Open-Meteo / Photon / OSRM,反复调用可能让这些公共实例限流。

只在可信网络(家里、自己的热点)开放。要暴露到公网,先做:登录或 token、把 CORS 收紧到具体来源、加限流与超时、上 HTTPS 反向代理。

只给自己用:HOST=127.0.0.1 node server.js。

定位与 HTTPS

浏览器的 navigator.geolocation 只在安全上下文里可用:HTTPS,或 localhost / 127.0.0.1。

访问方式 定位
http://127.0.0.1:8788 ✅ 可用(localhost 属安全上下文)
http://localhost:8788 ✅ 可用
http://192.168.1.100:8788(手机 / 其他电脑) ❌ 浏览器拒绝,控制台报 Only secure origins are allowed

这是浏览器规范,不是代码问题。页面会提前把定位按钮置灰并给出中文说明,不会把英文底层报错弹给用户。手机上想用定位有三条路:

  1. 直接输入地名(最省事):地名联想走的是服务端地理编码,与定位无关。
  2. 套一层 HTTPS:用 tailscale serve、cloudflared tunnel 或 caddy 反代到本地端口,拿到可信 HTTPS 域名后用手机访问。
  3. 自签证书:需要在手机上安装并信任根证书,日常用比较麻烦。

局域网 http 下只是定位不可用,分析、天气、清单、AI 规划全部正常。


Linux / 服务器部署

可以直接跑,不需要改代码。 项目是纯 Node、零 npm 依赖,没有任何 Windows 专有调用;平台差异只集中在住宿适配器里,并且都有 Linux 分支。

已在 WSL Debian(内核 6.6,Node v22.11.0)实测通过:

项目 结果
node server.js 启动 正常,banner / 端口回退 / 网卡枚举均正常
/、/styles.css、/app.js 200
/api/config、/api/hotel-providers 正常(无 rgh 时正确报「未安装」)
/api/geocode?name=柳州 命中「柳州 · 广西」,中文无乱码
/api/staticmap 无 Key 时正确返回 {error:"未配置高德 Key"}
/api/analyze(柳州市 / 150km) 24 个候选、roadProvider=OSRM、6.3 秒完成
# 服务器上
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
git clone <your-repo> /opt/tripsense && cd /opt/tripsense
HOST=0.0.0.0 PORT=8787 node server.js

systemd 示例(/etc/systemd/system/tripsense.service):

[Unit]
Description=TripSense
After=network.target

[Service]
WorkingDirectory=/opt/tripsense
ExecStart=/usr/bin/node server.js
Environment=HOST=0.0.0.0
Environment=PORT=8787
Restart=always
User=www-data

[Install]
WantedBy=multi-user.target

注意事项:

  • 静态资源用 path.join(__dirname, 'public'),所以与启动目录无关,systemd 里 WorkingDirectory 写对即可。
  • 不要把 PORT 改成 80/443:用 Nginx / Caddy 反代,顺带解决 HTTPS(定位功能需要安全上下文)。
  • HOST=0.0.0.0 后记得开放防火墙(ufw allow 8787),生产环境还应先补上鉴权与限流(见下节)。
  • 平台相关代码只有 lib/hotels-rollinggo.js 里的 rgh 调用:Linux 用 which、不需要 shell、全局安装在 /usr/local/bin 或 ~/.local/bin。WSL 特有:which rgh 可能命中 /mnt/c/... 下的 Windows 版(那是 shell 脚本,Linux 跑不起来),适配器已主动排除 /mnt/ 路径。
  • 服务器上装 RollingGo:npm i -g @rollinggo/hotel 后执行 rgh login。该命令是 OAuth 授权、没有「手动粘贴 code」的选项,无图形界面的服务器可能完成不了;兜底是在自己电脑上登录一次,然后把 ~/.hotel-cli/ 整个目录复制到服务器的同一路径。

生产环境建议

  • 密钥只放在服务端环境变量;前端 X-AI-* 覆盖仅为原型调试用途,上线前应移除或改为服务端会话鉴权。
  • 为 /api/* 增加认证、限流与超时保护,不要直接把 8787 暴露到公网。
  • 换用有 SLA 的地理编码 / POI / 路网服务,或自建 Photon / OSRM 实例;内置地名库只应作为兜底。
  • 对天气与路网结果加缓存(按坐标 + 日期),避免重复请求第三方。
  • SYSTEM_PROMPT 若需调整,改 config.js 即可,前后端无需同步(弹窗从 /api/config 读取)。

项目结构

tripsense/
├── server.js              零依赖 HTTP 服务、路由、SSE、静态托管
├── config.js              端点 / 环境变量 / 检索参数 / SYSTEM_PROMPT
├── package.json
├── lib/
│   ├── util.js            fetch 重试、并发限制、haversine、坐标转换、日期工具
│   ├── data.js            WMO 天气码、兴趣标签、三层名称过滤、各地点库汇总
│   ├── places-cn.js       内置全国景区 + 主要城市坐标库
│   ├── places-niche.js    内置小众地点库(206 条,古村/遗址/湿地/地质/洞穴等)
│   ├── sources.js         地理编码 / POI 发现 / 天气 / AQI / 路网 / 铁路 / 天气窗口 / 静态地图
│   ├── hotels-rollinggo.js RollingGo 住宿适配器(本机 rgh CLI,只读白名单)
│   ├── analyze.js         校验 → 编排 → 评分 → 汇总 dataNotes
│   └── planner.js         OpenAI 兼容流式代理 + 演示模式规划
├── public/
│   ├── index.html         三步流程 + 设置弹窗
│   ├── styles.css         FIELD NOTES 设计语言、响应式、罗盘、Markdown 输出
│   └── app.js             表单 / 定位 / SSE / 渲染 / 筛选 / 对比 / 复制下载
└── preview/               三步页面的实际渲染截图

验证记录(本次实测)

  • 全部 JS 文件 node --check 通过;静态资源 /、styles.css、app.js 均 200。
  • /api/health / /api/config / /api/geocode / /api/reverse / /api/best-window 正常。
  • /api/analyze SSE:北京市 2026-11-09 ~ 2026-11-10,total=28、avgScore≈69、rainyCount=0、weatherMode=forecast、roadProvider=OSRM;Top 5 为坡峰岭 78 / 故宫博物院 76 / 天坛公园 76 / 798艺术区 76 / 香山公园 76。
  • 小众地点链路:stats.nicheCount=67(150km 半径内命中数)、候选里 niche=true 占 10/28、dataNotes 写明「内置小众地点 67 个」、region 正常透传到卡片。
  • 逐日天气展开:展开后首卡 455px → 548px(+93px),同一行邻卡 486px → 486px(变化 0),布局不再被连带拉伸;勾选触发重渲染后 details[open] 数量保持不变。
  • 筛选与对比:「只看小众」28 → 10 且全部带徽章;「对比已选」面板 7 行 × 3 列正常渲染。
  • /api/plan 演示模式 SSE:约 2.7k 字符,7 个 Markdown 章节、3 张表格;另用本地 mock OpenAI 兼容服务验证过真实流式代理(stream=true、sysLen=1950)。
  • 无头 Chrome 完整走通三步流程并截图:Step 2 渲染 28 张卡片 / 28 个罗盘点 / 6 条数据说明,Step 3 渲染 7 个章节 / 3 张表格。
  • 排除同城(北京 / 150km):默认开启时 28 个候选、最小距离 30.8km、stats.localExcluded=27、副标题显示「已排除 30km 内同城」,Top 5 全是 60km 以上的异地;关闭后最小距离 0.5km、12 个候选落在 30km 内。50km 半径时 localKm 自动压到 25km,候选从 11 个回升到 16 个。
  • 开放访问:HOST=0.0.0.0 启动后实际监听 0.0.0.0:8788(8787 被占用,自动顺延),http://127.0.0.1:8788 与 http://192.168.1.100:8788 均返回 200,用局域网地址在无头 Chrome 打开页面渲染正常(title / eyebrow / Step 1 / 排除同城开关全部就位)。防火墙已有 node.exe 的 Inbound Allow 规则,覆盖 Private 与 Public。
  • 非安全上下文定位:http://192.168.1.100:8788 下 isSecureContext=false,定位按钮自动置灰(.geo-off)、#originHint 转为 .hint-warn 并给中文说明,点击 toast 为「局域网 http 页面不能调用定位:请直接输入地名,或改用 HTTPS 访问」;http://127.0.0.1:8788 下 isSecureContext=true、按钮与提示保持原样(回归通过)。
  • 地名联想(iPhone 视口 390×844 仿真,走局域网地址):杭州 2 条、西湖 4 条、杭州市西湖区 宽松命中并显示「按「杭州」为你找到这些」、zzzqqqxxx 显示「没找到…」空态;下拉与输入框间距恒为 4px、宽度 328px 与容器右边缘对齐(溢出 0)。
  • 中文地名覆盖:Open-Meteo 实测对「金城江」「金城江区」「河池」「河池市金城江区」全部返回空(GeoNames 中文小城市覆盖不足),「金城」只返回台湾金门 / 美国加州等同名地点。已接入高德 geocode/geo + place/text 作为有 Key 时的首选源;无 Key 或有 Key 但取不到时退回 Open-Meteo。
  • 宽松重试不再乱猜:只对含「市/区/县/镇/路」等后缀的结构化长地址截断(杭州市西湖区 → 杭州),「金城江」这种纯区县名不再被截成「金城」,避免返回同名异地。
  • 设置里「测试这个 Key」:空 Key → 「先填 Key 再测」;无效 Key → 「不可用:高德地理编码:INVALID_USER_KEY」。
  • AI 接口自检(本地 mock 服务,端口 9998):正常 → ok:true, 74ms, sample="可用";401 → HTTP 401:Invalid API key provided;空正文 → 接口通了但没返回内容(finish_reason=length);地址不可达 → 无法连接:fetch failed;拒绝 temperature 的模型 → 自动去掉参数重试后 ok:true 并附 note。
  • /api/plan 失败诊断(同一 mock):空正文 → 「接口返回了流但没有正文(收到 2 个事件,finish_reason=length)」并附原始片段;只有推理内容 → 「模型只输出了 N 字思考过程,没有正文」;401 → 带状态码与上游错误体。设置弹窗实测显示 可用 · 14ms · mock-gpt(绿)/ 没有可用的 API Key(红)。
  • 表单记忆:改单程时长 4h、半径 250km、取消「排除同城」、去掉「自然风光」、设出发地「河池 · 广西」→ 刷新后全部原样恢复(hoursLabel 也是「4 小时」,出发地提示「已定位:河池 · 广西(24.693, 108.085)」)。
  • 多轮调整:第一轮 messages 为 system,user;追加轮变成 system,user,assistant,user,assistant 携带上一版正文,末条 user 含新要求且要求「输出完整方案」。UI 实测:初次生成后出现输入条,追加后 meta 变「第 2 版 · 生成完成」、提示「当前是第 2 版,可以继续改」,点「回到初版」恢复第 1 版且 planText === basePlan。
  • 简约主题:实测 body 背景 rgb(255,255,255)、--accent=#2f6fed、顶栏白底、标题字体不含 Songti(已统一无衬线)、正文 rgb(23,26,31)。
  • 需求输入框:原来不在 .field 里所以没吃到样式,实测高度只有几十像素;修复后 225px、1px rgb(213,217,222) 边框、聚焦有蓝色描边。
  • 版本与保存:生成两版后下拉出现「第 1 版 / 第 2 版 · 第二天改成室内为主」,选「对比上一版」左右并排渲染(左「第 1 版(初版)」右「第 2 版 …」),点「保存」写入 tripsense.saved 并在下拉里出现 11/5 15:02 · 北京市。
  • 住宿接口:无 Key → {hotels:[],available:false,reason:"未配置高德 Key"};无效 Key → {available:true,error:"高德住宿检索:INVALID_USER_KEY"};进入第三步时左侧显示对应提示。
  • 住宿时机:进入第三步时 #planExtra 仍为隐藏(不再提前列酒店),生成方案后同时出现「行程路线示意」与「住宿建议」;#stayNote 显示「围绕「坡峰岭」· ¥200–800」(自定义区间),切换价位后同步更新。
  • 路线图只含目的地:无 Key 时渲染 3 个编号节点(坡峰岭 / 石林峡 / 野鸭湖湿地)、circle 共 6 个(每点两个环),不含出发地圆心与「出发地」图例,图例为「已选目的地(编号为推荐顺序,虚线为行程动线)」;#routeNote 为「3 个目的地 · 方位示意」。
  • 真实地图图层:/api/staticmap 无 Key → {error:"未配置高德 Key"};非法 pts → {error:"pts 不是合法坐标"};无效 Key → {error:"高德静态地图:{\"status\":\"0\",\"info\":\"INVALID_USER_KEY\"...}"},前端自动降级为方位示意,并在图下写明原因(标题保持简短,不换行)。
  • 静态地图 URL 结构核对:三点 74km 跨度 → zoom=9、size=680*400&scale=2、逐点 markers=medium,0x2f6fed,<n>:lng,lat、paths=4,0x2f6fed,0.35,,:...;单点 → zoom=16;5km → 13;200km → 8。坐标均为 GCJ-02(wgs84ToGcj02)。
  • 自定义价位:点「自定义」展开两个输入框 → 填 200 / 800 → currentLodging() 返回 {min:200,max:800,label:"¥200–800"}、chip 高亮「自定义」、提示「当前:每晚 ¥200–800」、localStorage.tripsense.form.lodgingCustom 正确落库;/api/plan 请求体里 lodging 为 {key:"custom",min:200,max:800}。
  • 住宿价格渲染(CDP 拦截 /api/hotels 造数据):三家全部无价格 → 顶部写明「无法按 ¥200–800 筛选」,出现 .price-warn 说明 biz_ext.cost 覆盖问题,每行「查房价」外链指向 uri.amap.com/search?keyword=...;改为 380/168/760/无价 四条 → 「命中 2 家 · 另有 1 家无价格标注」,168 被过滤、760 与 380 保留、无价照常列出并标「无标注价」,脚注变「价格来自第三方价格源」。
  • 演示模式修复:演示方案开头是「> ⚠️ 演示模式…」,原先用 startsWith('> ⚠️') 判断生成失败,导致演示模式下永远看不到路线图与住宿;改为按 errMsg 与「未获得任何输出」判定后恢复正常。
  • RollingGo 适配器(实测 @rollinggo/[email protected],装在临时目录、未登录):
    • rgh --help / search-hotels --help 均可执行,确认参数含 --max-price-per-night / --required-tag / --star-ratings / --distance-in-meter,没有 --json 开关(成功时直接输出 JSON)。
    • 未安装 → {installed:false, reason:"未安装 rgh(npm i -g @rollinggo/hotel)"};已安装未登录 → {installed:true, ready:false, reason:"rgh 已安装但未登录(执行 rgh login)"}。
    • 未登录时调用 search-hotels 不崩,返回可读错误「RollingGo 返回无法解析:搜索失败: 未登录,请先执行 rgh login」。
    • 白名单校验:book / price-confirm 均不在 ALLOWED_SUBCOMMANDS 内。
    • JSON 提取:在输出前混入「📦 有新版本可用!」时仍能正确捞出 hotelInformationList。
    • 注入防护:地点名含 & del /f /q C:\ 时直接拒绝,不进入子进程。
    • 服务端链路:/api/hotels 在无 Key 时返回「未配置高德 Key,且 rgh 已安装但未登录(执行 rgh login)」;设置里「检测」按钮实测显示「rgh 已安装但未登录(执行 rgh login)· 未配高德 Key」。
  • RollingGo 登录后真实数据(rgh status → ready:true):
    • /api/hotels?name=故宫&nights=1 → provider=rollinggo、8 家、带 cost / distanceKm / bookingUrl;nights=3 → 6 家;maxPrice=700&nights=3 → 4 家且全部 ≤ ¥700/晚(证明 --max-price-per-night 下推生效)。
    • 完整三步流程(demo 方案 + 真实住宿):住宿区显示「价位 ¥300–600 · 命中 3 家」,列表为 北京福庭酒店 ★4 · ¥521/晚 · 3.5km 去预订 / 国际艺苑大酒店 ¥547/晚 1.2km / 北京新侨饭店 ¥524/晚 2.3km,脚注「价格为 RollingGo 返回的每晚参考价,实际金额以下单页锁价为准」,无「无标注价」警告。
    • 设置里先填 Key 再点「检测」→「RollingGo 可用(实时报价)· 高德 Key 已配置(页面填写)」,修掉了「只读环境变量、误报未配 Key」的问题(前端用输入框当前值 + X-AMAP-Key 头)。
  • 两个环境相关修复:① 服务进程的 PATH 可能不含 npm 全局目录,resolveBin 补查 %APPDATA%\npm 等位置;② Windows 下 where rgh 首个结果是无扩展名的 sh 脚本(spawn 不起来),改为按 .exe > .cmd > .bat 排序后再取。
  • 静态地图 20003 修复:markers 原先写成多个同名参数(&markers=..&markers=..),高德判为非法并返回 {"status":"0","info":"UNKNOWN_ERROR","infocode":"20003"}(这不是 Key 失效)。改为单个 markers= 参数、组间用 | 分隔;同时 size 从 680×400 降到 640×360,因为 scale=2 会返回双倍像素,680×400×4 会超过高德 1024×1024 的上限。
  • 路线图单点场景:只有 1 个目的地时补出发点(origin 参数 → markers=small,0x6b7280,S),SVG 降级图同样画出发地并连线;多目的地时不含出发地。实测 1 个目的地 → 1 个编号节点 + 出发点 + polyline;3 个目的地 → 3 个节点、无出发点。
  • 住宿地点类型:柳州市 这类行政名原先按「景点」查 → RollingGo 返回 0 家 → 误降级到高德(于是又看到「价格未标注」)。新增 guessPlaceType()(市/地区/自治州→城市,区/县→区-县,其余→景点),并支持查空后换一种类型重试一次。实测 柳州市 → 城市类型 6 家;柳州 → 景点类型也能查到 4 家;坡峰岭 换城市类型虽返回重庆酒店,但被距离过滤全部丢弃,最终 0 家(正确)。
  • 住宿距离修正:数据源返回的 distanceInMeters 是错的(柳州城中区的酒店被标 64527 米,按坐标算实际 2.8km),改为优先用坐标自己算 haversine,并按查询半径过滤(城市类放宽到至少 20km),避免「去柳州却推荐重庆酒店」。
  • 住宿城市兜底:景区名在酒店库里常查不到(酒店按城市建库),查空后用反向地理编码取所属城市再查一次,再按坐标过滤。
  • 地图 20003 的两轮排查:① markers 写成多个同名参数 → 改为单个 + | 分组;② 手工拼 query 会留下裸 |,高德判为非法 → 改用 URL + URLSearchParams 构造,| 编码为 %7C。同时服务端做了渐进降级(full → markers → base),并通过 X-Map-Mode 告诉前端用了哪一档;/api/staticmap?probe=1 会依次试 编码版 / 未编码版 / 只标记 / 只底图 四种组合,设置页「测试这个 Key」直接调用它,一次就能定位是哪个参数被拒(返回里带脱敏后的完整 URL)。
  • 地图 20003 的第三个原因(已改):对照高德官方文档,markers 的 size 只接受 small / mid / large——原来写的是 medium,属于非法值;同时官方所有示例都用裸分隔符(, ; |),所以默认改回裸分隔符拼接,编码版仅作对照。今 URL 形如 markers=mid,0x2f6fed,1:109.41,24.30|mid,0x2f6fed,2:...,paths=4,0x2f6fed,0.35,,:...(weight 4 在 [2,15] 内)。
  • nearby POI:/api/nearby-pois 无 Key 时返回 {available:false, reason:"未配置高德 Key"};有 Key 时按 biz_ext.rating/cost 补齐评分与人均,按距离排序、按名称去重,餐饮与景点分开各取前 12 条。
  • 推理模型 token 不足的自动重试(mock 四个用例):正常模型 1 次请求即成功;「只有思考没有正文」→ 请求序列 [8000, 16384],顶部出现「已把上限 8000 → 16384 自动重试」并最终拿到正文;两次都只有思考 → 报「已把 max_tokens 提到 16384 仍不够,建议换非推理模型」;额度超限 → 序列 [8000, <无 max_tokens>],降级后成功。
  • 高德 Key 链路(用故意无效的 Key 验证):设置弹窗能填写并存入 localStorage → 请求带 X-AMAP-Key → 服务端覆盖 cfg.AMAP_KEY → 高德真实返回 INVALID_USER_KEY → stats.amapError 透传 → 页面顶部红边提示「高德接口拒绝请求:INVALID_USER_KEY」并给出「修改 Key」入口;同时公路正确回退为 roadProvider=OSRM(不再因"配了 Key"就谎称走高德)。清除 Key 后提示切换为「设置已更新,请重新分析」。
  • 路线图消失的根因:routeMapBlob 是从未声明过的模块级变量,'use strict' 下抛 ReferenceError;drawRouteMap() 是 async 且调用处没有 catch,异常变成未处理的 Promise 拒绝 → DOM 保持为空、尺寸 0×0,页面看起来"路线图不显示"。改为 state.routeMapBlob(在 state 里声明)并在调用处补 .catch(),失败时降级为方位示意并写明原因,杜绝静默失败。修复后实测 #routeMap 尺寸 563×326、img 与 svg 各 1 个、3 个编号标记 + 折线。
  • 缓存失效的根因(隐蔽):$('#analyzeBtn').addEventListener('click', runAnalyze) 会把 Event 对象传进 runAnalyze(force),而 Boolean(event) 恒为 true —— 于是每一次点击都是「强制刷新」,缓存永远不命中。改为只认严格的 force === true,并把监听器包成 () => runAnalyze()。实测:冷启动 12.8s → 第二次 518ms(24 倍),提示条正确显示;点「重新获取最新数据」后提示条正确隐藏。
  • 反向地理编码的假地名:reverseGeocode 彻底失败时兜底返回「当前位置」这个假地名,拿它去查酒店库必然落空,还会被误判成"该数据源没数据"。新增 resolveCityName() 把假地名视为失败并重试一次;数据源层缓存也不缓存假地名。
  • 缓存分层实测:/api/analyze 层缓存 30 分钟;数据源层(lib/sources.js)另有 Photon 分类检索 / 高德 POI / 天气 / AQI / 路网 / 铁路各自的缓存。清空后重建:discoverCandidates 首次 8137ms → 第二次 0ms;roadMatrix 首次 127ms → 第二次 0ms。
  • AI 追加轮路线图只剩一个点:根因是 runPlan() 追加轮把 state.nearby 置 null(let nearby = null; if (!isFollowUp) {...查 POI...} state.nearby = nearby;),payload.nearby 随之为空;第二版正文不再点名 nearby 地点,extractPlanPlaces() 里带坐标的来源 1 空,加粗词又没坐标,无 Key 时全被 resolveRoutePoints() 判为 missing 丢掉 → 只剩 1 个已选目的地。修复:追加轮沿用 nearby + 提示词要求保留加粗点名 + extractPlanPlaces(text, inherit) 继承上一版坐标(含否定语境护栏)+ 每个版本存 routePoints 并在切版本时重绘 + previous 改用 currentPlanText()。真实 Chrome 验收:初版 11 点 → 修复前追加轮 1 点 → 修复后 9 点 → 切回初版 11 点。另修两个隐性 bug:drawRouteMap() 的 missing 是永远为空的局部数组,导致「有 N 个地点未能定位」提示从不显示;「回到初版」未同步 state.viewing,正文与版本下拉不跟着变。

预览图见 preview/preview-step1.png、preview/preview-step2.png、preview/preview-step2-detail.png(展开逐日天气 + 对比面板)、preview/preview-step3.png、preview/preview-settings.png(设置弹窗含高德 Key)、preview/preview-amap-error.png(Key 无效时的提示)、preview/preview-map-fallback.png 与 preview/preview-map-price.png(只含目的地的路线图 + 自定义价位 + 住宿价格缺失说明)。

本轮(缓存 + 路线图修复 + 真实浏览器验收)新增:preview/preview-diag-route.png(高德真实底图 + 3 个编号标记 + 折线连线)、preview/preview-diag-hotel.png(RollingGo 真实报价 ¥2416/晚)、preview/preview-cache.png(缓存命中提示条)。

追加轮路线图修复新增:preview/preview-route-followup.png(修复后的多点行程路线示意 + 完整行程点清单)。

说明:上面这组截图恰好是在 Photon 公共实例全部超时(stats.fallbackOnly=true)时拍的,因此 Step 2 顶部能看到降级提示条——这正好是「实时源挂掉时页面怎么表现」的真实记录;天气、AQI、路况仍为实时数据。

S
Description
No description provided
Readme
6.4 MiB
Languages
JavaScript 88.3%
CSS 8.5%
HTML 3.2%