feat(travel): 接入远端 AI 推荐服务,支持 GLM real 模式与本地静默回退
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:
周瑞哲
2026-08-04 17:22:38 +08:00
co-authored by Claude Fable 5
parent 377efbffa8
commit 0a00cd5b3a
9 changed files with 183 additions and 12 deletions
+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('已改用本机')
})
})