forked from zhouruizhe/gmTouringMiniApp
feat(travel): 接入远端 AI 推荐服务,支持 GLM real 模式与本地静默回退
AI Code Review / review (pull_request) Successful in 1s
AI Code Review / review (pull_request) Successful in 1s
- 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:
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
LLM_MODE=mock
|
LLM_MODE=mock
|
||||||
|
|
||||||
# Configure these on the server only when LLM_MODE=real.
|
# 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_API_KEY=
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
OPENAI_MODEL=
|
OPENAI_MODEL=
|
||||||
|
|||||||
+14
-1
@@ -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 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
|
## Contract
|
||||||
|
|
||||||
@@ -60,3 +60,16 @@ Run tests from the repository root:
|
|||||||
```bash
|
```bash
|
||||||
corepack pnpm server:test
|
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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { PoiResolved } from '@/domain/poi'
|
import type { PoiResolved } from '@/domain/poi'
|
||||||
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
|
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { loadPlan } from '@/services/travel-assistant'
|
import { isRemoteTravelAssistantEnabled, loadPlan } from '@/services/travel-assistant'
|
||||||
import { useMapStore } from '@/stores'
|
import { useMapStore } from '@/stores'
|
||||||
|
|
||||||
interface AssistantPrompt {
|
interface AssistantPrompt {
|
||||||
@@ -16,6 +16,7 @@ interface AssistantPrompt {
|
|||||||
const mapStore = useMapStore()
|
const mapStore = useMapStore()
|
||||||
const featuredPois = ref<PoiResolved[]>([])
|
const featuredPois = ref<PoiResolved[]>([])
|
||||||
const hasSavedPlan = ref(false)
|
const hasSavedPlan = ref(false)
|
||||||
|
const remoteAssistantEnabled = isRemoteTravelAssistantEnabled()
|
||||||
|
|
||||||
const prompts: AssistantPrompt[] = [
|
const prompts: AssistantPrompt[] = [
|
||||||
{
|
{
|
||||||
@@ -166,7 +167,7 @@ onShow(loadAssistantHome)
|
|||||||
|
|
||||||
<view class="assistant-notice">
|
<view class="assistant-notice">
|
||||||
<text class="assistant-notice__title">POC 能力边界</text>
|
<text class="assistant-notice__title">POC 能力边界</text>
|
||||||
<text>本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。</text>
|
<text>{{ remoteAssistantEnabled ? '已接入远端 AI:仅把无坐标的点位摘要发给模型选点,路线、交通与时长仍由本机核算;当前路线为推荐游览顺序,不提供实时路况或逐段导航。' : '本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, Tr
|
|||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { TravelValidationError } from '@/domain/travel'
|
import { TravelValidationError } from '@/domain/travel'
|
||||||
import { isLocationSnapshotFresh } from '@/services/location'
|
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'
|
import { useLocationStore } from '@/stores'
|
||||||
|
|
||||||
interface PlannerPreset {
|
interface PlannerPreset {
|
||||||
@@ -88,7 +88,8 @@ const durationText = computed(() => {
|
|||||||
})
|
})
|
||||||
const usesCurrentLocation = computed(() => locationAvailable.value
|
const usesCurrentLocation = computed(() => locationAvailable.value
|
||||||
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
||||||
const planningEngineLabel = '本地 POC 规划'
|
const remoteAssistantEnabled = isRemoteTravelAssistantEnabled()
|
||||||
|
const planningEngineLabel = remoteAssistantEnabled ? 'AI 辅助选点(远端)' : '本地 POC 规划'
|
||||||
const locationActionText = computed(() => {
|
const locationActionText = computed(() => {
|
||||||
if (locationStore.status === 'locating')
|
if (locationStore.status === 'locating')
|
||||||
return '定位中…'
|
return '定位中…'
|
||||||
@@ -466,7 +467,7 @@ onUnload(deactivatePlannerPage)
|
|||||||
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
|
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<text class="planning-mode-tip">本期由本机完成选点、顺序、交通估算与时长核算。</text>
|
<text class="planning-mode-tip">{{ remoteAssistantEnabled ? 'AI 从审核点位白名单中选点;顺序、交通估算与时长核算由本机完成。' : '本期由本机完成选点、顺序、交通估算与时长核算。' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
|
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
|
||||||
@@ -695,7 +696,7 @@ onUnload(deactivatePlannerPage)
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<text class="planner-footer">
|
<text class="planner-footer">
|
||||||
匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机
|
{{ remoteAssistantEnabled ? '匿名使用 · AI 仅收到无坐标的点位摘要 · 路线与偏好仅存本机' : '匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export { isRemoteTravelAssistantEnabled } from './remote'
|
||||||
|
export type { RemotePoiCandidateInput, RemoteRecommendationResponse } from './remote'
|
||||||
export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service'
|
export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service'
|
||||||
export {
|
export {
|
||||||
loadPlan,
|
loadPlan,
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
|
import type { PoiRepository } from '@/domain/poi'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import {
|
import {
|
||||||
adjustLocalPlan,
|
adjustLocalPlan,
|
||||||
clonePlanResponse,
|
clonePlanResponse,
|
||||||
cloneTravelPreferences,
|
cloneTravelPreferences,
|
||||||
createLocalPlan,
|
createLocalPlan,
|
||||||
|
createRecommendedPlan,
|
||||||
normalizePlanningRequest,
|
normalizePlanningRequest,
|
||||||
normalizeTravelPreferences,
|
normalizeTravelPreferences,
|
||||||
type PlanningOrigin,
|
type PlanningOrigin,
|
||||||
|
type PlanningRecommendation,
|
||||||
type PlanningRequest,
|
type PlanningRequest,
|
||||||
type PlanResponse,
|
type PlanResponse,
|
||||||
type TravelPreferences,
|
type TravelPreferences,
|
||||||
TravelValidationError,
|
TravelValidationError,
|
||||||
} from '@/domain/travel'
|
} from '@/domain/travel'
|
||||||
|
import {
|
||||||
|
createRemoteRecommendations,
|
||||||
|
isRemoteTravelAssistantEnabled,
|
||||||
|
type RemotePoiCandidateInput,
|
||||||
|
} from './remote'
|
||||||
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
|
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
|
||||||
|
|
||||||
interface LocalSession {
|
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
|
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: PlanningRequest): Promise<PlanResponse>
|
||||||
export function createPlan(input: TravelPreferences, planningOrigin?: PlanningOrigin): Promise<PlanResponse>
|
export function createPlan(input: TravelPreferences, planningOrigin?: PlanningOrigin): Promise<PlanResponse>
|
||||||
export async function createPlan(
|
export async function createPlan(
|
||||||
@@ -98,7 +138,23 @@ export async function createPlan(
|
|||||||
selectedPoiIds: [],
|
selectedPoiIds: [],
|
||||||
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
|
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
|
||||||
}, repository)
|
}, 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)
|
const plan = createLocalPlan(request, repository)
|
||||||
|
if (isRemoteTravelAssistantEnabled())
|
||||||
|
plan.assistantMessage = '远程行程服务暂不可用,已改用本机确定性规划生成路线。'
|
||||||
rememberLocalSession(plan, request)
|
rememberLocalSession(plan, request)
|
||||||
savePlanningRequest(request)
|
savePlanningRequest(request)
|
||||||
return clonePlanResponse(plan, repository)
|
return clonePlanResponse(plan, repository)
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ export function loadPlan(): PlanResponse | null {
|
|||||||
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
|
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
|
||||||
return null
|
return null
|
||||||
try {
|
try {
|
||||||
const plan = normalizePlanResponse(envelope.payload, repository)
|
// All three sources (local_poc / remote_mock / remote_ai) are safe to restore:
|
||||||
return plan.source === 'local_poc' ? plan : null
|
// plans never carry coordinates, and normalizePlanResponse re-validates every field.
|
||||||
|
return normalizePlanResponse(envelope.payload, repository)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -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 { getPoiRepository } from '../src/data/poi'
|
||||||
import {
|
import {
|
||||||
adjustLocalPlan,
|
adjustLocalPlan,
|
||||||
@@ -550,12 +550,14 @@ describe('travel storage', () => {
|
|||||||
expect(loadPlan()).toBeNull()
|
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 plan = createLocalPlan(preferences(), getPoiRepository())
|
||||||
const remotePlan = { ...plan, source: 'remote_ai' as const }
|
const remotePlan = { ...plan, source: 'remote_ai' as const }
|
||||||
values.set(PLAN_STORAGE_KEY, storageEnvelope(remotePlan))
|
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', () => {
|
it('contains storage read failures and reports write failures', () => {
|
||||||
@@ -571,3 +573,88 @@ describe('travel storage', () => {
|
|||||||
expect(() => savePreferences(preferences())).toThrow('无法保存本机行程数据:quota exceeded')
|
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('已改用本机')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user