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

Merged
zhouruizhe merged 2 commits from zhouruizhe/gmTouringMiniApp:zhouruizhe into main 2026-08-04 17:29:12 +08:00
9 changed files with 183 additions and 12 deletions
Showing only changes of commit 0a00cd5b3a - Show all commits
+7
View File
@@ -1 +1,8 @@
# 在开发时进行加载
# Deployed travel recommender (FastAPI, real GLM) on the gml box, exposed via
# Tailscale Funnel. The /travel/ prefix is stripped by Tailscale before reaching
# the app, so the client calls .../travel/api/v1/plans. Set to empty (or stop the
# service) to use the deterministic local planner; on any remote error the client
# silently falls back to local so the demo never breaks.
VITE_TRAVEL_ASSISTANT_API_BASE_URL=https://desktop-gml.tailc2083d.ts.net/travel
+3
View File
@@ -2,6 +2,9 @@
LLM_MODE=mock
# Configure these on the server only when LLM_MODE=real.
# Any OpenAI-compatible endpoint works, e.g. Zhipu GLM:
# OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# OPENAI_MODEL=glm-4-flash # free tier; use glm-4-plus etc. if you have credit
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=
+14 -1
View File
@@ -2,7 +2,7 @@
This FastAPI service is the server-only AI boundary for the Guangming travel POC. It defaults to deterministic `mock` mode. Model credentials belong only in `server/.env` or the deployment environment and must never be added to the mini-program build.
This service is reserved for a later AI integration phase. The current mini-program `createPlan` entry point is hard-wired to the local planner and does not call this service.
The mini-program calls this service when `VITE_TRAVEL_ASSISTANT_API_BASE_URL` is set (`.env.development` points it at `http://localhost:8000`). If that variable is empty, or the service is unreachable or returns an error, the client silently falls back to its deterministic local planner — so the demo always works. The model only ever recommends POIs from a coordinate-free client whitelist; route geometry, transfer estimates, and final itinerary time remain client responsibilities. The itinerary page labels the result `真实 AI 推荐` (real), `远端演示规划` (mock), or `本地 POC 规划` (local fallback).
## Contract
@@ -60,3 +60,16 @@ Run tests from the repository root:
```bash
corepack pnpm server:test
```
## Real model mode
Mock mode is deterministic and needs no credentials. To use a real model, set `LLM_MODE=real` in `server/.env` (gitignored) with any OpenAI-compatible endpoint — for example Zhipu GLM:
```
LLM_MODE=real
OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
OPENAI_MODEL=glm-4-flash
OPENAI_API_KEY=<your key>
```
`GET /api/v1/health` then reports `mode=real, configured=true, ready=true`. With `pnpm server:dev` running, generate a plan from the mini-program (H5 dev server on `:5173` is already in `CORS_ORIGINS`; for `mp-weixin` enable “不校验合法域名” in the devtools). To force the local planner again, unset `VITE_TRAVEL_ASSISTANT_API_BASE_URL` or stop the server.
+3 -2
View File
@@ -2,7 +2,7 @@
import type { PoiResolved } from '@/domain/poi'
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
import { getPoiRepository } from '@/data/poi'
import { loadPlan } from '@/services/travel-assistant'
import { isRemoteTravelAssistantEnabled, loadPlan } from '@/services/travel-assistant'
import { useMapStore } from '@/stores'
interface AssistantPrompt {
@@ -16,6 +16,7 @@ interface AssistantPrompt {
const mapStore = useMapStore()
const featuredPois = ref<PoiResolved[]>([])
const hasSavedPlan = ref(false)
const remoteAssistantEnabled = isRemoteTravelAssistantEnabled()
const prompts: AssistantPrompt[] = [
{
@@ -166,7 +167,7 @@ onShow(loadAssistantHome)
<view class="assistant-notice">
<text class="assistant-notice__title">POC 能力边界</text>
<text>本期不调用 AI助手使用本地 POI 和确定性规则生成路线当前路线为推荐游览顺序不提供实时路况或逐段导航</text>
<text>{{ remoteAssistantEnabled ? '已接入远端 AI:仅把无坐标的点位摘要发给模型选点,路线、交通与时长仍由本机核算;当前路线为推荐游览顺序,不提供实时路况或逐段导航。' : '本期不调用 AI助手使用本地 POI 和确定性规则生成路线当前路线为推荐游览顺序不提供实时路况或逐段导航。' }}</text>
</view>
</view>
</view>
+5 -4
View File
@@ -4,7 +4,7 @@ import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, Tr
import { getPoiRepository } from '@/data/poi'
import { TravelValidationError } from '@/domain/travel'
import { isLocationSnapshotFresh } from '@/services/location'
import { createPlan, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
import { useLocationStore } from '@/stores'
interface PlannerPreset {
@@ -88,7 +88,8 @@ const durationText = computed(() => {
})
const usesCurrentLocation = computed(() => locationAvailable.value
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
const planningEngineLabel = '本地 POC 规划'
const remoteAssistantEnabled = isRemoteTravelAssistantEnabled()
const planningEngineLabel = remoteAssistantEnabled ? 'AI 辅助选点(远端)' : '本地 POC 规划'
const locationActionText = computed(() => {
if (locationStore.status === 'locating')
return '定位中…'
@@ -466,7 +467,7 @@ onUnload(deactivatePlannerPage)
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
</button>
</view>
<text class="planning-mode-tip">本期由本机完成选点顺序交通估算与时长核算</text>
<text class="planning-mode-tip">{{ remoteAssistantEnabled ? 'AI 从审核点位白名单中选点;顺序、交通估算与时长核算由本机完成。' : '本期由本机完成选点、顺序、交通估算与时长核算。' }}</text>
</view>
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
@@ -695,7 +696,7 @@ onUnload(deactivatePlannerPage)
</view>
<text class="planner-footer">
匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机
{{ remoteAssistantEnabled ? '匿名使用 · AI 仅收到无坐标的点位摘要 · 路线与偏好仅存本机' : '匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机' }}
</text>
</view>
</template>
+2
View File
@@ -1,3 +1,5 @@
export { isRemoteTravelAssistantEnabled } from './remote'
export type { RemotePoiCandidateInput, RemoteRecommendationResponse } from './remote'
export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service'
export {
loadPlan,
+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)
+3 -2
View File
@@ -78,8 +78,9 @@ export function loadPlan(): PlanResponse | null {
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
return null
try {
const plan = normalizePlanResponse(envelope.payload, repository)
return plan.source === 'local_poc' ? plan : null
// All three sources (local_poc / remote_mock / remote_ai) are safe to restore:
// plans never carry coordinates, and normalizePlanResponse re-validates every field.
return normalizePlanResponse(envelope.payload, repository)
}
catch {
return null
+90 -3
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getPoiRepository } from '../src/data/poi'
import {
adjustLocalPlan,
@@ -550,12 +550,14 @@ describe('travel storage', () => {
expect(loadPlan()).toBeNull()
})
it('does not restore remote plans while AI planning is frozen', () => {
it('restores remote plans so the itinerary survives navigation', () => {
const plan = createLocalPlan(preferences(), getPoiRepository())
const remotePlan = { ...plan, source: 'remote_ai' as const }
values.set(PLAN_STORAGE_KEY, storageEnvelope(remotePlan))
expect(loadPlan()).toBeNull()
const loaded = loadPlan()
expect(loaded).not.toBeNull()
expect(loaded?.source).toBe('remote_ai')
})
it('contains storage read failures and reports write failures', () => {
@@ -571,3 +573,88 @@ describe('travel storage', () => {
expect(() => savePreferences(preferences())).toThrow('无法保存本机行程数据:quota exceeded')
})
})
describe('remote travel assistant integration', () => {
const repository = getPoiRepository()
const publishedIds = repository.getPublishedPois().map(poi => poi.id)
beforeEach(() => {
// createPlan persists the planning request; keep storage harmless and isolated
// from the "quota exceeded" implementation left behind by the storage suite.
vi.mocked(uni.setStorageSync).mockImplementation(() => {})
vi.mocked(uni.getStorageSync).mockImplementation(() => undefined)
})
afterEach(() => {
vi.unstubAllEnvs()
vi.mocked(uni.request).mockReset()
vi.mocked(uni.setStorageSync).mockReset()
vi.mocked(uni.getStorageSync).mockReset()
})
function quickRequest(origin = true): PlanningRequest {
return {
mode: 'quick',
preferences: preferences(),
durationMinutes: 240,
selectedPoiIds: [],
...(origin ? { origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' } } : {}),
}
}
it('routes createPlan through the remote service, maps generation mode, and leaks no coordinates', async () => {
vi.stubEnv('VITE_TRAVEL_ASSISTANT_API_BASE_URL', 'http://localhost:8000')
let postedBody: { candidates?: Array<Record<string, unknown>>, origin?: unknown }
vi.mocked(uni.request).mockImplementation((options: { data?: unknown, success: (res: unknown) => void }) => {
postedBody = options.data as typeof postedBody
options.success({
statusCode: 200,
data: {
requestId: 'req-remote-1',
mode: 'quick',
assistantMessage: '远端演示推荐完成',
generationMode: 'mock',
recommendations: publishedIds.slice(0, 3).map((poiId, index) => ({
poiId,
reason: `远端推荐理由 ${index + 1}`,
order: index + 1,
})),
},
})
return {} as never
})
const plan = await createPlan(quickRequest())
expect(plan.source).toBe('remote_mock')
expect(plan.assistantMessage).toBe('远端演示推荐完成')
expect(plan.itinerary.items.every(item => item.reason.includes('远端推荐理由'))).toBe(true)
// The current location never leaves the client…
expect(postedBody!.origin).toBeUndefined()
expect(JSON.stringify(postedBody!)).not.toContain('113.94')
expect(JSON.stringify(postedBody!)).not.toContain('22.76')
// …candidates are coordinate-free and cover every published POI…
expect(postedBody!.candidates).toHaveLength(publishedIds.length)
const candidateKeys = new Set(postedBody!.candidates!.flatMap(candidate => Object.keys(candidate)))
expect(candidateKeys.has('longitude')).toBe(false)
expect(candidateKeys.has('latitude')).toBe(false)
expect(candidateKeys.has('summary')).toBe(true)
// …and the resulting plan carries no coordinates either.
expect(JSON.stringify(plan)).not.toContain('113.94')
expect(JSON.stringify(plan)).not.toContain('22.76')
})
it('falls back to the local planner when the remote service is unreachable', async () => {
vi.stubEnv('VITE_TRAVEL_ASSISTANT_API_BASE_URL', 'http://localhost:8000')
vi.mocked(uni.request).mockImplementation((options: { fail: () => void }) => {
options.fail()
return {} as never
})
const plan = await createPlan(quickRequest(false))
expect(vi.mocked(uni.request)).toHaveBeenCalled()
expect(plan.source).toBe('local_poc')
expect(plan.assistantMessage).toContain('已改用本机')
})
})