Files
gmTouringMiniApp/src/services/travel-assistant/service.ts
T

203 lines
7.5 KiB
TypeScript
Raw Normal View History

import type { PoiRepository } from '@/domain/poi'
2026-07-30 16:04:34 +08:00
import { getPoiRepository } from '@/data/poi'
import {
adjustLocalPlan,
clonePlanResponse,
cloneTravelPreferences,
createLocalPlan,
createRecommendedPlan,
2026-07-30 16:04:34 +08:00
normalizePlanningRequest,
normalizeTravelPreferences,
type PlanningOrigin,
type PlanningRecommendation,
2026-07-30 16:04:34 +08:00
type PlanningRequest,
type PlanResponse,
type TravelPreferences,
TravelValidationError,
} from '@/domain/travel'
import {
createRemoteRecommendations,
isRemoteTravelAssistantEnabled,
type RemotePoiCandidateInput,
} from './remote'
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
2026-07-30 16:04:34 +08:00
interface LocalSession {
plan: PlanResponse
preferences: TravelPreferences
request: PlanningRequest
planningOrigin: PlanningOrigin | null
}
const localSessions = new Map<string, LocalSession>()
function clonePlanningRequest(request: PlanningRequest, includeOrigin = true): PlanningRequest {
return {
mode: request.mode,
preferences: cloneTravelPreferences(request.preferences),
durationMinutes: request.durationMinutes,
selectedPoiIds: [...request.selectedPoiIds],
...(includeOrigin && request.origin ? { origin: { ...request.origin } } : {}),
}
}
function rememberLocalSession(plan: PlanResponse, request: PlanningRequest): void {
const repository = getPoiRepository()
localSessions.set(plan.conversationId, {
plan: clonePlanResponse(plan, repository),
preferences: cloneTravelPreferences(request.preferences),
request: clonePlanningRequest(request),
planningOrigin: request.origin ? { ...request.origin } : null,
})
}
function restoreLocalSession(conversationId: string): LocalSession | null {
const remembered = localSessions.get(conversationId)
if (remembered)
return remembered
const plan = loadPlan()
const preferences = loadPreferences()
if (!plan || !preferences || plan.conversationId !== conversationId)
return null
const storedRequest = loadPlanningRequest()
rememberLocalSession(plan, storedRequest ?? {
2026-07-30 16:04:34 +08:00
mode: plan.itinerary.planningMode,
preferences,
durationMinutes: plan.itinerary.requestedMinutes,
selectedPoiIds: plan.itinerary.planningMode === 'custom'
? plan.itinerary.items.map(item => item.placeId)
: [],
})
return localSessions.get(conversationId) ?? null
}
function normalizePlanningOrigin(origin?: PlanningOrigin): PlanningOrigin | null {
if (!origin
|| origin.coordinateSystem !== 'GCJ02'
|| !Number.isFinite(origin.longitude)
|| !Number.isFinite(origin.latitude)
|| origin.longitude < -180
|| origin.longitude > 180
|| origin.latitude < -90
|| origin.latitude > 90) {
return null
}
return { ...origin }
}
function isPlanningRequest(input: TravelPreferences | PlanningRequest): input is PlanningRequest {
return typeof input === 'object' && input !== null && 'mode' in input && 'preferences' in input
}
/**
* Builds the coordinate-free POI candidate whitelist sent to the remote service.
* Quick mode offers every published POI; custom mode offers only the user's picks.
* No coordinates ever leave the client.
*/
function buildRemoteCandidates(repository: PoiRepository, request: PlanningRequest): RemotePoiCandidateInput[] {
const pois = request.mode === 'custom'
? request.selectedPoiIds.flatMap(poiId => repository.getPoiById(poiId) ?? [])
: repository.getPublishedPois()
return pois.map(poi => ({
id: poi.id,
name: poi.name,
categoryCode: poi.categoryCode,
tagCodes: poi.tagCodes,
summary: poi.summary,
recommendationIndex: poi.recommendationIndex,
}))
}
/** Asks the remote service for POI recommendations, then schedules them locally. */
async function createRemotePlan(
request: PlanningRequest,
repository: PoiRepository,
): Promise<PlanResponse> {
const candidates = buildRemoteCandidates(repository, request)
const remote = await createRemoteRecommendations(request, candidates)
// Array order is the suggested visit order; reasons travel with each POI.
const recommendations: PlanningRecommendation[] = remote.recommendations.map(({ poiId, reason }) => ({ poiId, reason }))
const source = remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock'
return createRecommendedPlan(request, recommendations, repository, remote.assistantMessage, source)
}
2026-07-30 16:04:34 +08:00
export function createPlan(input: PlanningRequest): Promise<PlanResponse>
export function createPlan(input: TravelPreferences, planningOrigin?: PlanningOrigin): Promise<PlanResponse>
export async function createPlan(
input: TravelPreferences | PlanningRequest,
planningOrigin?: PlanningOrigin,
): Promise<PlanResponse> {
const repository = getPoiRepository()
const request = normalizePlanningRequest(isPlanningRequest(input)
? input
: {
mode: 'quick',
preferences: normalizeTravelPreferences(input),
durationMinutes: input.duration === 'half_day' ? 240 : 480,
selectedPoiIds: [],
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
}, repository)
if (isRemoteTravelAssistantEnabled()) {
try {
const plan = await createRemotePlan(request, repository)
rememberLocalSession(plan, request)
savePlanningRequest(request)
return clonePlanResponse(plan, repository)
}
catch (error) {
// Resilient POC default: never block the demo on a remote hiccup.
console.warn('Remote travel assistant unavailable, falling back to local planner', error)
}
}
2026-07-30 16:04:34 +08:00
const plan = createLocalPlan(request, repository)
if (isRemoteTravelAssistantEnabled())
plan.assistantMessage = '远程行程服务暂不可用,已改用本机确定性规划生成路线。'
2026-07-30 16:04:34 +08:00
rememberLocalSession(plan, request)
savePlanningRequest(request)
2026-07-30 16:04:34 +08:00
return clonePlanResponse(plan, repository)
}
export async function adjustPlan(conversationId: string, message: string): Promise<PlanResponse> {
const normalizedConversationId = conversationId.trim()
const normalizedMessage = message.trim()
if (!normalizedConversationId)
throw new TravelValidationError('会话 ID 不能为空')
if (!normalizedMessage)
throw new TravelValidationError('请填写需要调整的内容')
if (normalizedMessage.length > 500)
throw new TravelValidationError('调整内容不能超过 500 字')
const repository = getPoiRepository()
const session = restoreLocalSession(normalizedConversationId)
if (!session)
throw new Error('本机行程会话不存在,请重新生成行程')
const adjusted = adjustLocalPlan(
session.plan,
session.preferences,
normalizedMessage,
repository,
session.planningOrigin ?? undefined,
session.request,
)
if (session.plan.source !== 'local_poc') {
adjusted.plan.assistantMessage = '已在本机按你的补充要求重新核算路线;如需 AI 重新选点,请返回规划页重新生成。'
adjusted.plan.source = 'local_poc'
2026-07-30 16:04:34 +08:00
}
rememberLocalSession(adjusted.plan, adjusted.request)
savePlanningRequest(adjusted.request)
2026-07-30 16:04:34 +08:00
savePreferences(adjusted.preferences)
return clonePlanResponse(adjusted.plan, repository)
}
/** Exact planning origins live only in this in-memory session and are never persisted. */
export function getSessionPlanningOrigin(conversationId: string): PlanningOrigin | null {
const normalizedConversationId = conversationId.trim()
if (!normalizedConversationId)
return null
const origin = localSessions.get(normalizedConversationId)?.planningOrigin
return origin ? { ...origin } : null
}