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

217 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
preferences: {
destination: planningRequest.preferences.destination,
themes: [...planningRequest.preferences.themes],
pace: planningRequest.preferences.pace,
interests: [...planningRequest.preferences.interests],
transport: planningRequest.preferences.transport,
},
durationMinutes: planningRequest.durationMinutes,
selectedPoiIds: [...planningRequest.selectedPoiIds],
candidates,
},
header: { 'content-type': 'application/json' },
timeout: 50000,
})
return normalizeRecommendations(response, planningRequest, candidates)
}