From 0a00cd5b3a6ca034037c9b90b19e7bf97068b6af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=91=9E=E5=93=B2?= Date: Tue, 4 Aug 2026 17:22:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(travel):=20=E6=8E=A5=E5=85=A5=E8=BF=9C?= =?UTF-8?q?=E7=AB=AF=20AI=20=E6=8E=A8=E8=8D=90=E6=9C=8D=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20GLM=20real=20=E6=A8=A1=E5=BC=8F=E4=B8=8E?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E9=9D=99=E9=BB=98=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.development | 7 ++ server/.env.example | 3 + server/README.md | 15 +++- src/pages/assistant/index.vue | 5 +- src/pages/planner/index.vue | 9 ++- src/services/travel-assistant/index.ts | 2 + src/services/travel-assistant/service.ts | 56 ++++++++++++++ src/services/travel-assistant/storage.ts | 5 +- test/travel-assistant.test.ts | 93 +++++++++++++++++++++++- 9 files changed, 183 insertions(+), 12 deletions(-) diff --git a/.env.development b/.env.development index e440437..e9db3d1 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/server/.env.example b/server/.env.example index 0675cea..6636ff8 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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= diff --git a/server/README.md b/server/README.md index c7ae546..4206fd5 100644 --- a/server/README.md +++ b/server/README.md @@ -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= +``` + +`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. diff --git a/src/pages/assistant/index.vue b/src/pages/assistant/index.vue index 893837c..a038500 100644 --- a/src/pages/assistant/index.vue +++ b/src/pages/assistant/index.vue @@ -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([]) const hasSavedPlan = ref(false) +const remoteAssistantEnabled = isRemoteTravelAssistantEnabled() const prompts: AssistantPrompt[] = [ { @@ -166,7 +167,7 @@ onShow(loadAssistantHome) POC 能力边界 - 本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。 + {{ remoteAssistantEnabled ? '已接入远端 AI:仅把无坐标的点位摘要发给模型选点,路线、交通与时长仍由本机核算;当前路线为推荐游览顺序,不提供实时路况或逐段导航。' : '本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。' }} diff --git a/src/pages/planner/index.vue b/src/pages/planner/index.vue index d139391..8e459b2 100644 --- a/src/pages/planner/index.vue +++ b/src/pages/planner/index.vue @@ -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) 从全部 POI 中选 2-8 个编排 - 本期由本机完成选点、顺序、交通估算与时长核算。 + {{ remoteAssistantEnabled ? 'AI 从审核点位白名单中选点;顺序、交通估算与时长核算由本机完成。' : '本期由本机完成选点、顺序、交通估算与时长核算。' }} @@ -695,7 +696,7 @@ onUnload(deactivatePlannerPage) - 匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机 + {{ remoteAssistantEnabled ? '匿名使用 · AI 仅收到无坐标的点位摘要 · 路线与偏好仅存本机' : '匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机' }} diff --git a/src/services/travel-assistant/index.ts b/src/services/travel-assistant/index.ts index 4f6010f..ee0d493 100644 --- a/src/services/travel-assistant/index.ts +++ b/src/services/travel-assistant/index.ts @@ -1,3 +1,5 @@ +export { isRemoteTravelAssistantEnabled } from './remote' +export type { RemotePoiCandidateInput, RemoteRecommendationResponse } from './remote' export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service' export { loadPlan, diff --git a/src/services/travel-assistant/service.ts b/src/services/travel-assistant/service.ts index a506e4a..86302fc 100644 --- a/src/services/travel-assistant/service.ts +++ b/src/services/travel-assistant/service.ts @@ -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 { + 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 export function createPlan(input: TravelPreferences, planningOrigin?: PlanningOrigin): Promise 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) diff --git a/src/services/travel-assistant/storage.ts b/src/services/travel-assistant/storage.ts index 3162f4b..73e9f19 100644 --- a/src/services/travel-assistant/storage.ts +++ b/src/services/travel-assistant/storage.ts @@ -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 diff --git a/test/travel-assistant.test.ts b/test/travel-assistant.test.ts index 3de8437..19a3e16 100644 --- a/test/travel-assistant.test.ts +++ b/test/travel-assistant.test.ts @@ -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>, 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('已改用本机') + }) +}) -- 2.54.0