feat(travel): 接入远端 AI 推荐服务,支持 GLM real 模式与本地静默回退

- createPlan 在配置 VITE_TRAVEL_ASSISTANT_API_BASE_URL 时调用 FastAPI 推荐服务
  做无坐标选点,路线/交通/时长仍由本机核算;远端不可用即静默回退本地规划
- 放开 loadPlan 对远端行程的回读限制(原 AI 冻结期禁用),行程页跳转不再丢行程
- planner/assistant 文案按是否启用远端如实切换,不再谎称「本期不调用 AI」
- 新增远端接通/回退/候选载荷单测;server/README 更新联调与 real 模式说明
- .env.development 默认指向已部署推荐服务;server/.env.example 补 GLM 示例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
周瑞哲
2026-08-04 17:22:38 +08:00
co-authored by Claude Fable 5
parent 377efbffa8
commit 0a00cd5b3a
9 changed files with 183 additions and 12 deletions
+56
View File
@@ -1,17 +1,25 @@
import type { PoiRepository } from '@/domain/poi'
import { getPoiRepository } from '@/data/poi'
import {
adjustLocalPlan,
clonePlanResponse,
cloneTravelPreferences,
createLocalPlan,
createRecommendedPlan,
normalizePlanningRequest,
normalizeTravelPreferences,
type PlanningOrigin,
type PlanningRecommendation,
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'
interface LocalSession {
@@ -82,6 +90,38 @@ function isPlanningRequest(input: TravelPreferences | PlanningRequest): input is
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)
}
export function createPlan(input: PlanningRequest): Promise<PlanResponse>
export function createPlan(input: TravelPreferences, planningOrigin?: PlanningOrigin): Promise<PlanResponse>
export async function createPlan(
@@ -98,7 +138,23 @@ export async function createPlan(
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)
}
}
const plan = createLocalPlan(request, repository)
if (isRemoteTravelAssistantEnabled())
plan.assistantMessage = '远程行程服务暂不可用,已改用本机确定性规划生成路线。'
rememberLocalSession(plan, request)
savePlanningRequest(request)
return clonePlanResponse(plan, repository)