2026-07-30 16:04:34 +08:00
|
|
|
|
import type { PlanningRequest } from '@/domain/travel'
|
|
|
|
|
|
|
|
|
|
|
|
interface RemoteAPIError {
|
|
|
|
|
|
detail?: unknown
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RemotePoiCandidate {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
categoryCode: string
|
|
|
|
|
|
tagCodes: string[]
|
|
|
|
|
|
summary: string
|
|
|
|
|
|
recommendationIndex: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RemotePoiCandidateInput {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
categoryCode: string
|
|
|
|
|
|
tagCodes: readonly string[]
|
|
|
|
|
|
summary: string
|
|
|
|
|
|
recommendationIndex: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RemoteRecommendationItem {
|
|
|
|
|
|
poiId: string
|
|
|
|
|
|
reason: string
|
|
|
|
|
|
order: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RemoteRecommendationResponse {
|
|
|
|
|
|
requestId: string
|
|
|
|
|
|
mode: PlanningRequest['mode']
|
|
|
|
|
|
assistantMessage: string
|
|
|
|
|
|
recommendations: RemoteRecommendationItem[]
|
|
|
|
|
|
generationMode: 'mock' | 'real'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function apiBaseUrl(): string {
|
|
|
|
|
|
const value = import.meta.env.VITE_TRAVEL_ASSISTANT_API_BASE_URL
|
|
|
|
|
|
return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isRemoteTravelAssistantEnabled(): boolean {
|
|
|
|
|
|
return apiBaseUrl().length > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function remoteUrl(path: string): string {
|
|
|
|
|
|
const baseUrl = apiBaseUrl()
|
|
|
|
|
|
if (!/^https?:\/\//i.test(baseUrl))
|
|
|
|
|
|
throw new Error('VITE_TRAVEL_ASSISTANT_API_BASE_URL 必须是有效的 HTTP(S) 地址')
|
|
|
|
|
|
return `${baseUrl}${path}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function errorDetail(data: unknown, statusCode: number): string {
|
|
|
|
|
|
if (typeof data === 'object' && data !== null) {
|
|
|
|
|
|
const detail = (data as RemoteAPIError).detail
|
|
|
|
|
|
if (typeof detail === 'string' && detail.trim())
|
|
|
|
|
|
return detail.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
return `行程服务请求失败(${statusCode})`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function request<T>(options: UniApp.RequestOptions): Promise<T> {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
uni.request({
|
|
|
|
|
|
...options,
|
|
|
|
|
|
success(response) {
|
|
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
|
|
|
|
resolve(response.data as T)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
reject(new Error(errorDetail(response.data, response.statusCode)))
|
|
|
|
|
|
},
|
|
|
|
|
|
fail() {
|
|
|
|
|
|
reject(new Error('无法连接行程服务,请检查网络、服务地址和微信合法域名配置'))
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readNonEmptyString(value: unknown, fieldName: string, maxLength: number): string {
|
|
|
|
|
|
if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength)
|
|
|
|
|
|
throw new Error(`AI 推荐响应中的${fieldName}格式无效`)
|
|
|
|
|
|
return value.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function safeCandidates(candidates: readonly RemotePoiCandidateInput[]): RemotePoiCandidate[] {
|
|
|
|
|
|
if (candidates.length < 1 || candidates.length > 100)
|
|
|
|
|
|
throw new Error('发送给 AI 的 POI 候选数量必须为 1 至 100 个')
|
|
|
|
|
|
|
|
|
|
|
|
const ids = new Set<string>()
|
|
|
|
|
|
return candidates.map((candidate) => {
|
|
|
|
|
|
const id = candidate.id.trim()
|
|
|
|
|
|
if (!id || ids.has(id))
|
|
|
|
|
|
throw new Error(`发送给 AI 的 POI ID 无效或重复:${id || '空值'}`)
|
|
|
|
|
|
const name = candidate.name.trim()
|
|
|
|
|
|
const categoryCode = candidate.categoryCode.trim()
|
|
|
|
|
|
const summary = candidate.summary.trim()
|
|
|
|
|
|
if (!name || name.length > 100)
|
|
|
|
|
|
throw new Error(`发送给 AI 的 POI 名称无效:${id}`)
|
|
|
|
|
|
if (!categoryCode || categoryCode.length > 64)
|
|
|
|
|
|
throw new Error(`发送给 AI 的 POI 分类无效:${id}`)
|
|
|
|
|
|
if (summary.length > 300)
|
|
|
|
|
|
throw new Error(`发送给 AI 的 POI 摘要过长:${id}`)
|
|
|
|
|
|
if (!Number.isInteger(candidate.recommendationIndex)
|
|
|
|
|
|
|| candidate.recommendationIndex < 1
|
|
|
|
|
|
|| candidate.recommendationIndex > 5) {
|
|
|
|
|
|
throw new Error(`发送给 AI 的 POI 推荐指数无效:${id}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
const tagCodes = [...new Set(candidate.tagCodes.map(tagCode => tagCode.trim()).filter(Boolean))]
|
|
|
|
|
|
if (tagCodes.length > 20 || tagCodes.some(tagCode => tagCode.length > 64))
|
|
|
|
|
|
throw new Error(`发送给 AI 的 POI 标签无效:${id}`)
|
|
|
|
|
|
ids.add(id)
|
|
|
|
|
|
return {
|
|
|
|
|
|
id,
|
|
|
|
|
|
name,
|
|
|
|
|
|
categoryCode,
|
|
|
|
|
|
tagCodes,
|
|
|
|
|
|
summary,
|
|
|
|
|
|
recommendationIndex: candidate.recommendationIndex,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeRecommendations(
|
|
|
|
|
|
value: unknown,
|
|
|
|
|
|
planningRequest: PlanningRequest,
|
|
|
|
|
|
candidates: readonly RemotePoiCandidate[],
|
|
|
|
|
|
): RemoteRecommendationResponse {
|
|
|
|
|
|
if (!isRecord(value))
|
|
|
|
|
|
throw new Error('AI 推荐响应格式无效')
|
|
|
|
|
|
if (value.mode !== planningRequest.mode)
|
|
|
|
|
|
throw new Error('AI 推荐响应的规划模式不匹配')
|
|
|
|
|
|
if (value.generationMode !== 'mock' && value.generationMode !== 'real')
|
|
|
|
|
|
throw new Error('AI 推荐响应的生成模式无效')
|
|
|
|
|
|
if (!Array.isArray(value.recommendations) || value.recommendations.length < 1 || value.recommendations.length > 8)
|
|
|
|
|
|
throw new Error('AI 推荐响应必须包含 1 至 8 个 POI')
|
|
|
|
|
|
|
|
|
|
|
|
const allowedIds = new Set(candidates.map(candidate => candidate.id))
|
|
|
|
|
|
const seenIds = new Set<string>()
|
|
|
|
|
|
const seenOrders = new Set<number>()
|
|
|
|
|
|
const recommendations = value.recommendations.map((item): RemoteRecommendationItem => {
|
|
|
|
|
|
if (!isRecord(item))
|
|
|
|
|
|
throw new Error('AI 推荐项格式无效')
|
|
|
|
|
|
const poiId = readNonEmptyString(item.poiId, 'POI ID', 100)
|
|
|
|
|
|
if (!allowedIds.has(poiId))
|
|
|
|
|
|
throw new Error(`AI 返回了候选白名单外的 POI:${poiId}`)
|
|
|
|
|
|
if (seenIds.has(poiId))
|
|
|
|
|
|
throw new Error(`AI 返回了重复 POI:${poiId}`)
|
|
|
|
|
|
if (!Number.isInteger(item.order) || (item.order as number) < 1 || (item.order as number) > 8)
|
|
|
|
|
|
throw new Error('AI 推荐响应中的顺序格式无效')
|
|
|
|
|
|
if (seenOrders.has(item.order as number))
|
|
|
|
|
|
throw new Error('AI 推荐响应中存在重复顺序')
|
|
|
|
|
|
seenIds.add(poiId)
|
|
|
|
|
|
seenOrders.add(item.order as number)
|
|
|
|
|
|
return {
|
|
|
|
|
|
poiId,
|
|
|
|
|
|
reason: readNonEmptyString(item.reason, '推荐理由', 300),
|
|
|
|
|
|
order: item.order as number,
|
|
|
|
|
|
}
|
|
|
|
|
|
}).sort((left, right) => left.order - right.order)
|
|
|
|
|
|
|
|
|
|
|
|
if (recommendations.some((item, index) => item.order !== index + 1))
|
|
|
|
|
|
throw new Error('AI 推荐响应中的顺序必须从 1 连续递增')
|
|
|
|
|
|
if (planningRequest.mode === 'custom') {
|
|
|
|
|
|
const selectedIds = new Set(planningRequest.selectedPoiIds)
|
|
|
|
|
|
if (recommendations.length !== selectedIds.size
|
|
|
|
|
|
|| recommendations.some(item => !selectedIds.has(item.poiId))) {
|
|
|
|
|
|
throw new Error('AI 自选规划未完整保留用户选择的 POI')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
requestId: readNonEmptyString(value.requestId, '请求 ID', 200),
|
|
|
|
|
|
mode: planningRequest.mode,
|
|
|
|
|
|
assistantMessage: readNonEmptyString(value.assistantMessage, '助手消息', 1000),
|
|
|
|
|
|
recommendations,
|
|
|
|
|
|
generationMode: value.generationMode,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Requests coordinate-free POI recommendations. The caller must calculate the
|
|
|
|
|
|
* final route and duration locally; planningRequest.origin is never uploaded.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function createRemoteRecommendations(
|
|
|
|
|
|
planningRequest: PlanningRequest,
|
|
|
|
|
|
candidateInputs: readonly RemotePoiCandidateInput[],
|
|
|
|
|
|
): Promise<RemoteRecommendationResponse> {
|
|
|
|
|
|
const candidates = safeCandidates(candidateInputs)
|
|
|
|
|
|
const response = await request<unknown>({
|
|
|
|
|
|
url: remoteUrl('/api/v1/plans'),
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
mode: planningRequest.mode,
|
2026-07-31 12:50:14 +08:00
|
|
|
|
preferences: {
|
|
|
|
|
|
destination: planningRequest.preferences.destination,
|
|
|
|
|
|
themes: [...planningRequest.preferences.themes],
|
|
|
|
|
|
pace: planningRequest.preferences.pace,
|
|
|
|
|
|
interests: [...planningRequest.preferences.interests],
|
|
|
|
|
|
transport: planningRequest.preferences.transport,
|
|
|
|
|
|
},
|
2026-07-30 16:04:34 +08:00
|
|
|
|
durationMinutes: planningRequest.durationMinutes,
|
|
|
|
|
|
selectedPoiIds: [...planningRequest.selectedPoiIds],
|
|
|
|
|
|
candidates,
|
|
|
|
|
|
},
|
|
|
|
|
|
header: { 'content-type': 'application/json' },
|
|
|
|
|
|
timeout: 50000,
|
|
|
|
|
|
})
|
|
|
|
|
|
return normalizeRecommendations(response, planningRequest, candidates)
|
|
|
|
|
|
}
|