Merge pull request 'Update AI planner integration, docs, and config' (#1) from zhouruizhe/gmTouringMiniApp:main into main
Reviewed-on: team/gmTouringMiniApp#1 Reviewed-by: admin <zhouruihe@gml.ac.cn>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集和行程规划。
|
基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集和行程规划。
|
||||||
|
|
||||||
当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。POC 默认使用随包数据与本地确定性规则生成游览顺序;有定位快照时会优先安排较近首站,但不是道路级实时导航,也不包含打卡、登录或个人中心。
|
当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;本地规划器按时间预算动态排程,配置服务端后可由受控 AI 在审核白名单内推荐点位与顺序。当前不是道路级实时导航,也不包含打卡、登录或个人中心。
|
||||||
|
|
||||||
## 微信开发者工具
|
## 微信开发者工具
|
||||||
|
|
||||||
@@ -58,9 +58,9 @@ src/
|
|||||||
└── static/ # 随包静态资源
|
└── static/ # 随包静态资源
|
||||||
```
|
```
|
||||||
|
|
||||||
不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序始终使用本地确定性 POC 规划器。若后续接入远程 AI,只允许在 `.env.local` 配置受控后端地址,并在微信公众平台配置合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。
|
不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序使用本地确定性 POC 规划器。配置受控后端地址后,服务端 AI 只接收偏好和不含坐标的 POI 白名单,返回推荐 ID、理由和顺序;当前位置、交通估算、时长适配及最终路线仍由小程序本机计算。服务端默认 `LLM_MODE=mock`,只有部署时设置 `LLM_MODE=real` 及模型凭据才会标记为真实 AI。微信公众平台还需配置 HTTPS 合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。
|
||||||
|
|
||||||
实时位置仅在用户点击地图中的“定位到我”后启用,并只在地图页处于前台时更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给可选远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。
|
实时位置仅由用户主动触发,并只在地图页处于前台时持续更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。
|
||||||
|
|
||||||
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。
|
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
1. 复制 `.env.example` 为 `.env.local`。
|
1. 复制 `.env.example` 为 `.env.local`。
|
||||||
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。
|
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。
|
||||||
3. 真机预览或上传前,将根目录 `project.config.json` 的 `appid` 从 `touristappid` 同步为相同的真实 AppID。
|
3. 真机预览或上传前,将根目录 `project.config.json` 的 `appid` 从 `touristappid` 同步为相同的真实 AppID。
|
||||||
4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。
|
4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。服务端必须设置 `LLM_MODE=real` 才会作为真实 AI 展示;mock 仅用于联调演示。
|
||||||
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
|
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
|
||||||
|
|
||||||
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
|
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
|
||||||
@@ -78,7 +78,7 @@ macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装
|
|||||||
5. 返回地图后恢复分类、中心、缩放和选中点位。
|
5. 返回地图后恢复分类、中心、缩放和选中点位。
|
||||||
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
|
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
|
||||||
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
|
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
|
||||||
8. 从“AI 助手”和“路线规划”进入偏好表单,生成半日及一日行程,核对点位、顺序、时间、转场提示和来源说明。
|
8. 从“AI 助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
|
||||||
9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。
|
9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。
|
||||||
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
|
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
|
||||||
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
|
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
|
||||||
|
|||||||
+1
-1
@@ -27,4 +27,4 @@
|
|||||||
"appid": "wx946b42048d117dde",
|
"appid": "wx946b42048d117dde",
|
||||||
"editorSetting": {},
|
"editorSetting": {},
|
||||||
"condition": {}
|
"condition": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=64"]
|
requires = [ "setuptools>=64" ]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
@@ -22,9 +22,8 @@ test = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["app"]
|
packages = [ "app" ]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = [ "." ]
|
||||||
testpaths = ["tests"]
|
testpaths = [ "tests" ]
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,9 @@ function selectQuickPois(
|
|||||||
cursor = next
|
cursor = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selected.length === 0 && remaining.length > 0)
|
||||||
|
selected.push(rankedCandidates(remaining, preferences, [], origin)[0])
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +315,9 @@ function selectRecommendedPois(
|
|||||||
: recommendedPois
|
: recommendedPois
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = [...recommendedPois]
|
const remaining = origin
|
||||||
|
? orderPoisFromOrigin(recommendedPois, origin)
|
||||||
|
: [...recommendedPois]
|
||||||
const selected: PoiResolved[] = []
|
const selected: PoiResolved[] = []
|
||||||
let usedMinutes = 0
|
let usedMinutes = 0
|
||||||
let cursor: PlanningOrigin | PoiResolved | null = origin
|
let cursor: PlanningOrigin | PoiResolved | null = origin
|
||||||
@@ -326,6 +331,8 @@ function selectRecommendedPois(
|
|||||||
usedMinutes += transfer + visit
|
usedMinutes += transfer + visit
|
||||||
cursor = next
|
cursor = next
|
||||||
}
|
}
|
||||||
|
if (selected.length === 0 && recommendedPois.length > 0)
|
||||||
|
selected.push(remaining[0] ?? recommendedPois[0])
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@
|
|||||||
"startLocationUpdate",
|
"startLocationUpdate",
|
||||||
"onLocationChange"
|
"onLocationChange"
|
||||||
],
|
],
|
||||||
"projectname": "【dev】光明文旅地图"
|
"projectname": "【prod】光明文旅地图"
|
||||||
},
|
},
|
||||||
"mp-alipay": {
|
"mp-alipay": {
|
||||||
"usingComponents": true
|
"usingComponents": true
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@
|
|||||||
"type": "page",
|
"type": "page",
|
||||||
"layout": "map",
|
"layout": "map",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "AI 路线规划",
|
"navigationBarTitleText": "智能路线规划",
|
||||||
"navigationBarBackgroundColor": "#ffffff",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"backgroundColor": "#f4f8f6"
|
"backgroundColor": "#f4f8f6"
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ const routeTags = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const planningModeLabel = computed(() => plan.value?.itinerary.planningMode === 'custom' ? '自选规划' : '快速规划')
|
const planningModeLabel = computed(() => plan.value?.itinerary.planningMode === 'custom' ? '自选规划' : '快速规划')
|
||||||
|
const planningSourceLabel = computed(() => {
|
||||||
|
if (plan.value?.source === 'remote_ai')
|
||||||
|
return '真实 AI 推荐'
|
||||||
|
if (plan.value?.source === 'remote_mock')
|
||||||
|
return '远端演示规划'
|
||||||
|
return '本地智能规划'
|
||||||
|
})
|
||||||
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
|
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
|
||||||
|
|
||||||
function loadStoredPlan() {
|
function loadStoredPlan() {
|
||||||
@@ -294,7 +301,7 @@ onReady(initializeMap)
|
|||||||
<view class="route-hero__top">
|
<view class="route-hero__top">
|
||||||
<view class="route-hero__label">
|
<view class="route-hero__label">
|
||||||
<text class="route-hero__spark">✦</text>
|
<text class="route-hero__spark">✦</text>
|
||||||
{{ planningModeLabel }}
|
{{ planningModeLabel }} · {{ planningSourceLabel }}
|
||||||
</view>
|
</view>
|
||||||
<button @click="openPlanner">重新规划</button>
|
<button @click="openPlanner">重新规划</button>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
+16
-11
@@ -2,7 +2,7 @@
|
|||||||
import type { PoiSummary } from '@/domain/poi'
|
import type { PoiSummary } from '@/domain/poi'
|
||||||
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
|
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { createPlan, isRemoteTravelAssistantEnabled, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
||||||
import { useLocationStore } from '@/stores'
|
import { useLocationStore } from '@/stores'
|
||||||
|
|
||||||
interface PlannerPreset {
|
interface PlannerPreset {
|
||||||
@@ -74,8 +74,8 @@ const durationText = computed(() => {
|
|||||||
})
|
})
|
||||||
const usesCurrentLocation = computed(() => locationAvailable.value
|
const usesCurrentLocation = computed(() => locationAvailable.value
|
||||||
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
||||||
const planningEngineLabel = computed(() => remoteEnabled.value && planningMode.value === 'quick'
|
const planningEngineLabel = computed(() => remoteEnabled.value
|
||||||
? '在线 AI 服务'
|
? '在线智能服务'
|
||||||
: '本地智能规划')
|
: '本地智能规划')
|
||||||
const locationActionText = computed(() => {
|
const locationActionText = computed(() => {
|
||||||
if (locationStore.status === 'locating')
|
if (locationStore.status === 'locating')
|
||||||
@@ -151,9 +151,6 @@ function toggleInterest(interest: Interest) {
|
|||||||
|
|
||||||
function setPlanningMode(mode: PlanningRequest['mode']) {
|
function setPlanningMode(mode: PlanningRequest['mode']) {
|
||||||
planningMode.value = mode
|
planningMode.value = mode
|
||||||
if (mode === 'custom' && remoteEnabled.value) {
|
|
||||||
uni.showToast({ title: '自选点位将使用本地智能规划', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePoi(poiId: string) {
|
function togglePoi(poiId: string) {
|
||||||
@@ -333,10 +330,18 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
const stored = loadPreferences()
|
const storedRequest = loadPlanningRequest()
|
||||||
if (stored) {
|
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
|
||||||
assignPreferences(stored)
|
if (storedPreferences)
|
||||||
durationMinutes.value = stored.duration === 'half_day' ? 4 * 60 : 8 * 60
|
assignPreferences(storedPreferences)
|
||||||
|
if (storedRequest) {
|
||||||
|
planningMode.value = storedRequest.mode
|
||||||
|
durationMinutes.value = storedRequest.durationMinutes
|
||||||
|
selectedPoiIds.value = [...storedRequest.selectedPoiIds]
|
||||||
|
customStartsFromCurrentLocation.value = false
|
||||||
|
}
|
||||||
|
else if (storedPreferences) {
|
||||||
|
durationMinutes.value = storedPreferences.duration === 'half_day' ? 4 * 60 : 8 * 60
|
||||||
}
|
}
|
||||||
applyPreset()
|
applyPreset()
|
||||||
})
|
})
|
||||||
@@ -390,7 +395,7 @@ onShow(() => {
|
|||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
|
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
|
||||||
自选点位不依赖在线服务,将使用本地智能规划。
|
在线 AI 将在你选择的点位内建议顺序,本机负责定位、交通与时长核算。
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ export { isRemoteTravelAssistantEnabled } from './remote'
|
|||||||
export { adjustPlan, createPlan } from './service'
|
export { adjustPlan, createPlan } from './service'
|
||||||
export {
|
export {
|
||||||
loadPlan,
|
loadPlan,
|
||||||
|
loadPlanningRequest,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
PLAN_STORAGE_KEY,
|
PLAN_STORAGE_KEY,
|
||||||
|
PLANNING_REQUEST_STORAGE_KEY,
|
||||||
PREFERENCES_STORAGE_KEY,
|
PREFERENCES_STORAGE_KEY,
|
||||||
savePlan,
|
savePlan,
|
||||||
|
savePlanningRequest,
|
||||||
savePreferences,
|
savePreferences,
|
||||||
} from './storage'
|
} from './storage'
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
cloneTravelPreferences,
|
cloneTravelPreferences,
|
||||||
createLocalPlan,
|
createLocalPlan,
|
||||||
createRecommendedPlan,
|
createRecommendedPlan,
|
||||||
normalizePlanResponse,
|
|
||||||
normalizePlanningRequest,
|
normalizePlanningRequest,
|
||||||
normalizeTravelPreferences,
|
normalizeTravelPreferences,
|
||||||
type PlanningOrigin,
|
type PlanningOrigin,
|
||||||
@@ -15,7 +14,7 @@ import {
|
|||||||
TravelValidationError,
|
TravelValidationError,
|
||||||
} from '@/domain/travel'
|
} from '@/domain/travel'
|
||||||
import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote'
|
import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote'
|
||||||
import { loadPlan, loadPreferences, savePreferences } from './storage'
|
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
|
||||||
|
|
||||||
interface LocalSession {
|
interface LocalSession {
|
||||||
plan: PlanResponse
|
plan: PlanResponse
|
||||||
@@ -55,7 +54,8 @@ function restoreLocalSession(conversationId: string): LocalSession | null {
|
|||||||
const preferences = loadPreferences()
|
const preferences = loadPreferences()
|
||||||
if (!plan || !preferences || plan.conversationId !== conversationId)
|
if (!plan || !preferences || plan.conversationId !== conversationId)
|
||||||
return null
|
return null
|
||||||
rememberLocalSession(plan, {
|
const storedRequest = loadPlanningRequest()
|
||||||
|
rememberLocalSession(plan, storedRequest ?? {
|
||||||
mode: plan.itinerary.planningMode,
|
mode: plan.itinerary.planningMode,
|
||||||
preferences,
|
preferences,
|
||||||
durationMinutes: plan.itinerary.requestedMinutes,
|
durationMinutes: plan.itinerary.requestedMinutes,
|
||||||
@@ -118,11 +118,13 @@ export async function createPlan(
|
|||||||
remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock',
|
remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock',
|
||||||
)
|
)
|
||||||
rememberLocalSession(plan, request)
|
rememberLocalSession(plan, request)
|
||||||
|
savePlanningRequest(request)
|
||||||
return clonePlanResponse(plan, repository)
|
return clonePlanResponse(plan, repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = createLocalPlan(request, repository)
|
const plan = createLocalPlan(request, repository)
|
||||||
rememberLocalSession(plan, request)
|
rememberLocalSession(plan, request)
|
||||||
|
savePlanningRequest(request)
|
||||||
return clonePlanResponse(plan, repository)
|
return clonePlanResponse(plan, repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ export async function adjustPlan(conversationId: string, message: string): Promi
|
|||||||
adjusted.plan.source = session.plan.source
|
adjusted.plan.source = session.plan.source
|
||||||
}
|
}
|
||||||
rememberLocalSession(adjusted.plan, adjusted.request)
|
rememberLocalSession(adjusted.plan, adjusted.request)
|
||||||
|
savePlanningRequest(adjusted.request)
|
||||||
savePreferences(adjusted.preferences)
|
savePreferences(adjusted.preferences)
|
||||||
return clonePlanResponse(adjusted.plan, repository)
|
return clonePlanResponse(adjusted.plan, repository)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
cloneTravelPreferences,
|
cloneTravelPreferences,
|
||||||
normalizePlanResponse,
|
normalizePlanResponse,
|
||||||
normalizeTravelPreferences,
|
normalizeTravelPreferences,
|
||||||
|
type PlanningRequest,
|
||||||
type PlanResponse,
|
type PlanResponse,
|
||||||
type TravelPreferences,
|
type TravelPreferences,
|
||||||
} from '@/domain/travel'
|
} from '@/domain/travel'
|
||||||
|
|
||||||
export const PLAN_STORAGE_KEY = 'guangming:last-plan'
|
export const PLAN_STORAGE_KEY = 'guangming:last-plan'
|
||||||
export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences'
|
export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences'
|
||||||
|
export const PLANNING_REQUEST_STORAGE_KEY = 'guangming:last-planning-request'
|
||||||
|
|
||||||
const STORAGE_SCHEMA_VERSION = 1
|
const STORAGE_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
@@ -99,3 +101,68 @@ export function loadPreferences(): TravelPreferences | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function savePlanningRequest(request: PlanningRequest): void {
|
||||||
|
const repository = getPoiRepository()
|
||||||
|
const sanitized: PlanningRequest = {
|
||||||
|
mode: request.mode,
|
||||||
|
preferences: normalizeTravelPreferences(request.preferences),
|
||||||
|
durationMinutes: request.durationMinutes,
|
||||||
|
selectedPoiIds: [...request.selectedPoiIds],
|
||||||
|
}
|
||||||
|
if (sanitized.mode !== 'quick' && sanitized.mode !== 'custom')
|
||||||
|
throw new Error('无法保存规划条件:规划模式无效')
|
||||||
|
if (!Number.isInteger(sanitized.durationMinutes)
|
||||||
|
|| sanitized.durationMinutes < 120
|
||||||
|
|| sanitized.durationMinutes > 600
|
||||||
|
|| sanitized.durationMinutes % 30 !== 0) {
|
||||||
|
throw new Error('无法保存规划条件:规划时长无效')
|
||||||
|
}
|
||||||
|
if (sanitized.mode === 'quick' && sanitized.selectedPoiIds.length > 0)
|
||||||
|
throw new Error('无法保存规划条件:快速规划不能预选点位')
|
||||||
|
if (sanitized.mode === 'custom'
|
||||||
|
&& (sanitized.selectedPoiIds.length < 2 || sanitized.selectedPoiIds.length > 8)) {
|
||||||
|
throw new Error('无法保存规划条件:自选点位数量无效')
|
||||||
|
}
|
||||||
|
if (new Set(sanitized.selectedPoiIds).size !== sanitized.selectedPoiIds.length
|
||||||
|
|| sanitized.selectedPoiIds.some(poiId => !repository.getPoiById(poiId))) {
|
||||||
|
throw new Error('无法保存规划条件:自选点位无效')
|
||||||
|
}
|
||||||
|
writeStorage(PLANNING_REQUEST_STORAGE_KEY, createEnvelope(sanitized))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPlanningRequest(): PlanningRequest | null {
|
||||||
|
const repository = getPoiRepository()
|
||||||
|
const envelope = readEnvelope(readStorage(PLANNING_REQUEST_STORAGE_KEY))
|
||||||
|
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion || !isRecord(envelope.payload))
|
||||||
|
return null
|
||||||
|
try {
|
||||||
|
const payload = envelope.payload
|
||||||
|
const request: PlanningRequest = {
|
||||||
|
mode: payload.mode as PlanningRequest['mode'],
|
||||||
|
preferences: normalizeTravelPreferences(payload.preferences),
|
||||||
|
durationMinutes: Number(payload.durationMinutes),
|
||||||
|
selectedPoiIds: Array.isArray(payload.selectedPoiIds) ? payload.selectedPoiIds.map(String) : [],
|
||||||
|
}
|
||||||
|
if (request.mode !== 'quick' && request.mode !== 'custom')
|
||||||
|
return null
|
||||||
|
if (!Number.isInteger(request.durationMinutes)
|
||||||
|
|| request.durationMinutes < 120
|
||||||
|
|| request.durationMinutes > 600
|
||||||
|
|| request.durationMinutes % 30 !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (request.mode === 'quick' && request.selectedPoiIds.length > 0)
|
||||||
|
return null
|
||||||
|
if (request.mode === 'custom' && (request.selectedPoiIds.length < 2 || request.selectedPoiIds.length > 8))
|
||||||
|
return null
|
||||||
|
if (new Set(request.selectedPoiIds).size !== request.selectedPoiIds.length
|
||||||
|
|| request.selectedPoiIds.some(poiId => !repository.getPoiById(poiId))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
adjustLocalPlan,
|
adjustLocalPlan,
|
||||||
classifyDurationFit,
|
classifyDurationFit,
|
||||||
createLocalPlan,
|
createLocalPlan,
|
||||||
|
createRecommendedPlan,
|
||||||
hasSignificantDurationGap,
|
hasSignificantDurationGap,
|
||||||
LEGACY_PLACE_ID_TO_POI_ID,
|
LEGACY_PLACE_ID_TO_POI_ID,
|
||||||
normalizePlanningRequest,
|
normalizePlanningRequest,
|
||||||
@@ -17,10 +18,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
createPlan,
|
createPlan,
|
||||||
loadPlan,
|
loadPlan,
|
||||||
|
loadPlanningRequest,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
PLAN_STORAGE_KEY,
|
PLAN_STORAGE_KEY,
|
||||||
|
PLANNING_REQUEST_STORAGE_KEY,
|
||||||
PREFERENCES_STORAGE_KEY,
|
PREFERENCES_STORAGE_KEY,
|
||||||
savePlan,
|
savePlan,
|
||||||
|
savePlanningRequest,
|
||||||
savePreferences,
|
savePreferences,
|
||||||
} from '../src/services/travel-assistant'
|
} from '../src/services/travel-assistant'
|
||||||
|
|
||||||
@@ -354,6 +358,56 @@ describe('deterministic local POC planner', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reviewed AI recommendations', () => {
|
||||||
|
const repository = getPoiRepository()
|
||||||
|
const selectedPoiIds = repository.getPublishedPois().slice(0, 3).map(poi => poi.id)
|
||||||
|
const request: PlanningRequest = {
|
||||||
|
mode: 'custom',
|
||||||
|
preferences: preferences(),
|
||||||
|
durationMinutes: 120,
|
||||||
|
selectedPoiIds,
|
||||||
|
origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
|
||||||
|
}
|
||||||
|
const recommendations = selectedPoiIds.map((poiId, index) => ({
|
||||||
|
poiId,
|
||||||
|
reason: `AI 白名单推荐理由 ${index + 1}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('keeps all custom POIs, AI reasons and local-only location data', () => {
|
||||||
|
const plan = createRecommendedPlan(request, recommendations, repository, '真实模型推荐完成', 'remote_ai')
|
||||||
|
|
||||||
|
expect(plan.source).toBe('remote_ai')
|
||||||
|
expect(new Set(plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
|
||||||
|
expect(plan.itinerary.items.every(item => item.reason.includes('AI 白名单推荐理由'))).toBe(true)
|
||||||
|
expect(plan.itinerary.durationFitStatus).toBe('overflow')
|
||||||
|
expect(JSON.stringify(plan)).not.toContain('113.94')
|
||||||
|
expect(JSON.stringify(plan)).not.toContain('22.76')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses current location to optimize a quick AI recommendation and still returns an overflow route', () => {
|
||||||
|
const quickRequest: PlanningRequest = {
|
||||||
|
...request,
|
||||||
|
mode: 'quick',
|
||||||
|
selectedPoiIds: [],
|
||||||
|
durationMinutes: 120,
|
||||||
|
origin: { longitude: 0, latitude: 0, coordinateSystem: 'GCJ02' },
|
||||||
|
}
|
||||||
|
const plan = createRecommendedPlan(quickRequest, recommendations, repository, '推荐完成', 'remote_ai')
|
||||||
|
|
||||||
|
expect(plan.itinerary.items).toHaveLength(1)
|
||||||
|
expect(plan.itinerary.durationFitStatus).toBe('overflow')
|
||||||
|
expect(plan.itinerary.startsFromCurrentLocation).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels mock recommendations honestly and rejects an incomplete custom set', () => {
|
||||||
|
const mockPlan = createRecommendedPlan(request, recommendations, repository, '演示推荐完成', 'remote_mock')
|
||||||
|
expect(mockPlan.source).toBe('remote_mock')
|
||||||
|
expect(mockPlan.itinerary.title).not.toContain('AI')
|
||||||
|
expect(() => createRecommendedPlan(request, recommendations.slice(0, 2), repository, '', 'remote_ai'))
|
||||||
|
.toThrow('未完整保留')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('travel storage', () => {
|
describe('travel storage', () => {
|
||||||
const values = new Map<string, unknown>()
|
const values = new Map<string, unknown>()
|
||||||
|
|
||||||
@@ -382,6 +436,24 @@ describe('travel storage', () => {
|
|||||||
expect(values.has(PLAN_STORAGE_KEY)).toBe(true)
|
expect(values.has(PLAN_STORAGE_KEY)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('round-trips custom planning inputs without persisting an exact origin', () => {
|
||||||
|
const selectedPoiIds = getPoiRepository().getPublishedPois().slice(0, 4).map(poi => poi.id)
|
||||||
|
const request: PlanningRequest = {
|
||||||
|
mode: 'custom',
|
||||||
|
preferences: preferences(),
|
||||||
|
durationMinutes: 330,
|
||||||
|
selectedPoiIds,
|
||||||
|
origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
|
||||||
|
}
|
||||||
|
savePlanningRequest(request)
|
||||||
|
|
||||||
|
const loaded = loadPlanningRequest()!
|
||||||
|
expect(loaded).toEqual({ ...request, origin: undefined })
|
||||||
|
expect(loaded).not.toHaveProperty('origin')
|
||||||
|
expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('113.94')
|
||||||
|
expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('22.76')
|
||||||
|
})
|
||||||
|
|
||||||
it('never persists planning-origin coordinates with a plan', () => {
|
it('never persists planning-origin coordinates with a plan', () => {
|
||||||
const sourcePlan = createLocalPlan(
|
const sourcePlan = createLocalPlan(
|
||||||
preferences(),
|
preferences(),
|
||||||
|
|||||||
Reference in New Issue
Block a user