零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。 - 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底 - 区县级「排除同城」判定(adname),过滤发生在轮转取样之前 - 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图 - 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造 - 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存 - 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
588 lines
30 KiB
JavaScript
588 lines
30 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* AI 规划层
|
||
* - 使用标准 OpenAI 兼容 /chat/completions 协议(流式 SSE)
|
||
* - 无 API Key 时自动进入「演示模式」:用本地规则引擎生成同结构 Markdown,
|
||
* 保证整个网站不配密钥也能端到端跑通
|
||
*/
|
||
|
||
const U = require('./util');
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
function chunkString(str, size = 28) {
|
||
const out = [];
|
||
for (let i = 0; i < str.length; i += size) out.push(str.slice(i, i + size));
|
||
return out;
|
||
}
|
||
|
||
/** 构造发给模型的结构化上下文。字段刻意精简:多传一个字段就多一次 token。 */
|
||
function buildPayload(body) {
|
||
const days = Math.min(7, Math.max(1, Number(body.timeRange?.days) || 2));
|
||
const shortlist = (Array.isArray(body.shortlist) ? body.shortlist : []).slice(0, 5).map((c, i) => {
|
||
// 逐日天气是最大的 token 头:每个候选都带一串逐日数据。
|
||
// 只给第 1 名(主目的地)完整逐日天气——模型排日程主要围绕它,
|
||
// 其余候选只留区间摘要,需要时用 summary 判断"要不要换目的地"。
|
||
const withDaily = i === 0;
|
||
return {
|
||
rank: i + 1,
|
||
name: String(c.name || '').slice(0, 40),
|
||
kind: c.kind || '',
|
||
tags: (c.tags || []).slice(0, 3),
|
||
distanceKm: c.distanceKm,
|
||
direction: c.direction || '',
|
||
score: c.score,
|
||
verdict: String(c.verdict || '').slice(0, 30),
|
||
// 只要最优一档,别把 3 种交通方式全塞进去。
|
||
// road/rail 只给第 1 名(模型写交通与预算只针对主目的地),其余候选留空。
|
||
travel: c.travel ? {
|
||
bestHours: c.travel.bestHours,
|
||
bestMode: c.travel.bestMode,
|
||
road: (i === 0 && c.road) ? { distanceKm: c.road.distanceKm, durationMinutes: c.road.durationMinutes, source: c.road.source } : null,
|
||
rail: (i === 0 && c.rail) ? { available: c.rail.available, durationMinutes: c.rail.durationMinutes, fareCny: c.rail.fareCny, source: c.rail.source, note: c.rail.note } : null,
|
||
} : null,
|
||
weather: c.weather ? {
|
||
// 第 1 名给完整摘要;其余只给"够判断要不要换目的地"的极简摘要
|
||
summary: withDaily ? (c.weather.summary || null) : (c.weather.summary ? {
|
||
avgLow: c.weather.summary.avgLow,
|
||
avgHigh: c.weather.summary.avgHigh,
|
||
maxPrecipProb: c.weather.summary.maxPrecipProb,
|
||
badDays: c.weather.summary.badDays,
|
||
} : null),
|
||
// 只带行程覆盖的那几天:原来一次带 31 天,纯浪费
|
||
daily: withDaily ? (c.weather.daily || []).slice(0, days).map((d) => ({
|
||
date: d.date, weekday: d.weekday, label: d.label, tmax: d.tmax, tmin: d.tmin, precip: d.precip, precipProb: d.precipProb,
|
||
})) : [],
|
||
} : null,
|
||
aqi: c.aqi ?? null,
|
||
};
|
||
});
|
||
|
||
return {
|
||
origin: body.origin ? { name: body.origin.name || body.origin.label || '', label: body.origin.label || body.origin.name || '' } : {},
|
||
timeRange: body.timeRange || {},
|
||
constraints: body.constraints ? {
|
||
maxOneWayHours: body.constraints.maxOneWayHours,
|
||
interests: (body.constraints.interests || []).slice(0, 6),
|
||
} : {},
|
||
shortlist,
|
||
userNeeds: String(body.userNeeds || '').slice(0, 500) || '(未填,按清单与约束做通用安排)',
|
||
// 数据口径只留最关键的几条,全文在页面上有,不必喂给模型
|
||
dataNotes: (Array.isArray(body.dataNotes) ? body.dataNotes : []).slice(0, 3)
|
||
.map((n) => String(n).slice(0, 60)),
|
||
// 周边住宿:只作为"住哪一片"的参考,POI 里没有房态和实时房价
|
||
hotels: Array.isArray(body.hotels) ? body.hotels.slice(0, 5).map((h) => ({
|
||
name: String(h.name || '').slice(0, 30),
|
||
distanceKm: h.distanceKm,
|
||
rating: h.rating,
|
||
cost: h.cost,
|
||
})) : [],
|
||
// 目的地周边的可点名地点:没有这部分,模型只能写「某某片区」再让你自行核实
|
||
nearby: (() => {
|
||
const n = body.nearby;
|
||
if (!n || typeof n !== 'object') return null;
|
||
const pick = (arr) => (Array.isArray(arr) ? arr.slice(0, 5) : []).map((x) => ({
|
||
name: String(x.name || '').slice(0, 20),
|
||
type: String(x.type || '').slice(0, 10),
|
||
distanceKm: x.distanceKm,
|
||
rating: x.rating,
|
||
cost: x.cost,
|
||
})).filter((x) => x.name);
|
||
const food = pick(n.food);
|
||
const sights = pick(n.sights);
|
||
if (!food.length && !sights.length) return null;
|
||
return {
|
||
area: String(n.area || '').slice(0, 40),
|
||
radiusKm: Number(n.radiusKm) || 8,
|
||
food,
|
||
sights,
|
||
};
|
||
})(),
|
||
// 用户选的住宿价位:既约束推荐,也用于预算表
|
||
lodging: body.lodging && typeof body.lodging === 'object' ? {
|
||
label: String(body.lodging.label || '').slice(0, 24),
|
||
min: Number.isFinite(body.lodging.min) ? body.lodging.min : null,
|
||
max: Number.isFinite(body.lodging.max) ? body.lodging.max : null,
|
||
} : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 构造对话。
|
||
* 第一轮:system + 完整上下文 JSON + 用户需求。
|
||
* 追加轮:再塞入「上一版方案(assistant)」+「这次要怎么改(user)」,
|
||
* 让模型在原方案上迭代,而不是从零重来——否则用户会觉得"又给我一份新的"。
|
||
*/
|
||
function buildMessages(payload, systemPrompt, extra = {}) {
|
||
// 上下文用紧凑 JSON:缩进和换行本身也要算 token,白白多花几百
|
||
const json = JSON.stringify(payload);
|
||
const msgs = [
|
||
{ role: 'system', content: systemPrompt },
|
||
{
|
||
role: 'user',
|
||
content:
|
||
'以下是本站分析得到的上下文(JSON)。请据此与我的具体需求生成行程规划,严格按系统提示要求的 Markdown 结构输出。\n\n' +
|
||
'```json\n' + json + '\n```\n\n' +
|
||
`我的具体需求:${payload.userNeeds}`,
|
||
},
|
||
];
|
||
if (extra.previous) {
|
||
msgs.push({ role: 'assistant', content: extra.previous });
|
||
msgs.push({
|
||
role: 'user',
|
||
content:
|
||
`在上一版方案的基础上做如下调整:${extra.followUp || '请自行提升可执行性'}\n\n` +
|
||
'要求:输出调整后的**完整**方案,保持原有 Markdown 结构(方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选)。' +
|
||
'不要只输出改动片段,也不要写「已按要求修改」之类的交代;与上一版冲突的地方直接改掉,并在「风险与备选」里说明取舍。',
|
||
});
|
||
}
|
||
return msgs;
|
||
}
|
||
|
||
// ------------------------------------------------------------ 演示模式规划
|
||
|
||
function fmtHours(min) {
|
||
if (min == null) return '—';
|
||
const h = Math.floor(min / 60);
|
||
const m = Math.round(min % 60);
|
||
return h ? `${h}小时${m ? m + '分' : ''}` : `${m}分钟`;
|
||
}
|
||
|
||
function demoPlan(p) {
|
||
const list = p.shortlist || [];
|
||
const days = p.timeRange?.days || 1;
|
||
const dates = U.eachDate(p.timeRange?.start || U.todayISO(), p.timeRange?.end || U.todayISO(), 31);
|
||
const main = list[0];
|
||
const backup = list[1];
|
||
const maxH = p.constraints?.maxOneWayHours || 2.5;
|
||
const prefs = (p.constraints?.transportPrefs || []).join('、') || '不限';
|
||
const interests = (p.constraints?.interests || []).join('、') || '未指定';
|
||
const road = main?.travel?.road || main?.road || null;
|
||
const rail = main?.travel?.rail || main?.rail || null;
|
||
const w = main?.weather?.summary;
|
||
const daily = main?.weather?.daily || [];
|
||
const allNotes = (p.dataNotes || []).map((n) => `- ${n}`).join('\n');
|
||
|
||
const L = [];
|
||
L.push('> ⚠️ **演示模式**:当前未配置 AI 接口密钥,以下为本地规则引擎依据同一份数据生成的示例方案。配置任意 OpenAI 兼容接口后,将改由大模型结合你的需求生成更贴合的规划。');
|
||
L.push('');
|
||
L.push('## 方案总览');
|
||
L.push('');
|
||
if (!main) {
|
||
L.push('尚未选择目的地。请返回上一步,在备选清单中勾选至少一个目的地后再生成规划。');
|
||
return L.join('\n');
|
||
}
|
||
L.push(`以 **${main.name}** 为主目的地(适配分 ${main.score ?? '—'},${main.verdict || ''}),` +
|
||
`出行 ${days} 天(${p.timeRange?.start || ''} ~ ${p.timeRange?.end || ''},${p.timeRange?.weekdayHint || ''})。` +
|
||
`出发地 ${p.origin?.name || '—'},直线距离约 ${U.round(main.distanceKm ?? 0, 1)}km,` +
|
||
`最快单程约 ${fmtHours((main.travel?.bestHours ?? (road?.durationMinutes ?? 0)) * 60)}(${main.travel?.bestMode || '自驾'}),` +
|
||
`往返约 ${fmtHours(((main.travel?.bestHours ?? 0) * 2) * 60)}。`);
|
||
L.push('');
|
||
if (backup) L.push(`备选 **${backup.name}**(${backup.score ?? '—'} 分):${backup.reasons?.[0] || '综合条件次优'};不首选的原因是适配分低于主目的地,适合作为天气突变或主目的地拥挤时的替换。`);
|
||
else L.push('清单中暂无第二备选,建议放宽半径或时间范围后再分析一次。');
|
||
L.push('');
|
||
L.push(`约束:可接受单程 ≤ ${maxH} 小时;交通偏好 ${prefs};兴趣 ${interests}。节奏按「先扣往返、再排游玩」安排${days >= 2 ? ',每天留出午餐与 1 小时机动时间' : ',当日往返不安排过夜项目'}。`);
|
||
L.push('');
|
||
L.push(`> **需求备忘**:${p.userNeeds}。演示模式只做通用排布;配置 AI 接口后,模型会据此细化同行人、预算、体力与偏好,并指出与数据的冲突。`);
|
||
L.push('');
|
||
|
||
L.push('## 逐日安排');
|
||
L.push('');
|
||
dates.forEach((date, i) => {
|
||
const d = daily[i] || daily[Math.min(i, daily.length - 1)] || null;
|
||
const wd = U.weekdayCN(date);
|
||
L.push(`### D${i + 1}(${date} ${wd})`);
|
||
L.push('');
|
||
L.push('| 时段 | 安排 | 交通与耗时 | 天气应对 |');
|
||
L.push('| --- | --- | --- | --- |');
|
||
const weatherText = d ? `${d.label} ${d.tmin}~${d.tmax}℃,降水概率 ${d.precipProb}%` : '天气数据缺失';
|
||
const rainPlan = d && (d.precipProb >= 60 || d.precip >= 5)
|
||
? '改为室内:博物馆 / 展馆 / 商圈,备雨具'
|
||
: '按户外安排,备防晒与饮水';
|
||
if (i === 0) {
|
||
const arriveH = 8 + Math.ceil(main.travel?.bestHours ?? 1);
|
||
L.push(`| 08:00–${String(Math.min(arriveH, 23)).padStart(2, '0')}:00 | 从${p.origin?.name || '出发地'}出发前往${main.name} | ${main.travel?.bestMode || '自驾'} 约 ${fmtHours((main.travel?.bestHours ?? 0) * 60)}${main.travel?.bestMode === '铁路/高铁' ? '(估算)' : ''} | 出发前再看一次当日预报 |`);
|
||
L.push(`| 上午 | 抵达后先游览核心区域,轻量开场 | 步行 / 景区接驳 | ${weatherText};${rainPlan} |`);
|
||
L.push(`| 12:00–13:30 | 午餐与休息(不安排赶场) | — | ${d && d.tmax >= 30 ? '避开正午高温,选室内就餐' : '按体感调整'} |`);
|
||
L.push(`| 下午 | ${main.kind || '目的地'}主线游览${backup && days === 1 ? `,视体力可选顺路短停 ${backup.name}` : ''} | 景区内交通 | ${weatherText};${rainPlan} |`);
|
||
if (days === 1) {
|
||
L.push(`| 16:30 前 | 返程,预留 1 小时缓冲 | ${fmtHours((main.travel?.bestHours ?? 0) * 60)} | 天黑前结束山路/水域活动 |`);
|
||
} else {
|
||
L.push('| 傍晚 | 入住 / 晚餐,整理次日装备 | — | 复核次日天气与车次 |');
|
||
}
|
||
} else if (i === dates.length - 1 && days > 1) {
|
||
L.push('| 上午 | 补充昨日未尽项目或周边轻量点位 | 步行 / 短途接驳 | ' + weatherText + ';' + rainPlan + ' |');
|
||
L.push('| 12:00–13:30 | 午餐、退房 | — | — |');
|
||
L.push(`| 14:00 前 | 返程,预留 1 小时缓冲 | ${fmtHours((main.travel?.bestHours ?? 0) * 60)} | 避免夜间长途 |`);
|
||
} else {
|
||
L.push(`| 上午 | ${backup ? backup.name + ' 或 ' : ''}主线深度游 | 当地交通(估算) | ${weatherText};${rainPlan} |`);
|
||
L.push('| 12:00–13:30 | 午餐与休息 | — | 高温/降雨时延长室内休息 |');
|
||
L.push('| 下午 | 次级点位,留 1 小时机动 | 当地交通(估算) | ' + rainPlan + ' |');
|
||
}
|
||
L.push('');
|
||
});
|
||
|
||
L.push('## 天气与装备');
|
||
L.push('');
|
||
if (w) {
|
||
L.push(`区间平均 **${w.avgLow ?? '—'}~${w.avgHigh ?? '—'}℃**,累计降水 ${w.totalPrecip ?? 0}mm,最大降水概率 ${w.maxPrecipProb ?? 0}%,最大风速 ${w.maxWind ?? '—'}km/h,需留意天数 ${w.badDays ?? 0} 天,最差天气为${w.dominant?.label || '—'}。`);
|
||
} else {
|
||
L.push('未取到逐日天气,以下为通用建议。');
|
||
}
|
||
L.push('');
|
||
L.push('- 穿衣:按日温差分层,外层防风;' + (w && w.avgHigh >= 28 ? '高温天备速干衣物与遮阳帽。' : '低温天备保暖层。'));
|
||
L.push('- 雨具/防晒:' + (w && w.maxPrecipProb >= 50 ? '折叠伞或轻便雨衣必带,鞋子防滑。' : '备一把折叠伞与防晒霜即可。'));
|
||
L.push(`- 空气质量:${main.aqi != null ? `AQI ${main.aqi}(${main.aqi <= 50 ? '优' : main.aqi <= 100 ? '良' : '一般'}),敏感人群酌情减少户外停留。` : '本次未取到 AQI 数据,敏感人群请临近出发前查询。'}`);
|
||
L.push('- **若遇降雨或大风**:把户外主线替换为清单中标注「室内避雨」的候选地,或缩短停留、提前返程;山区/水域项目一律取消。');
|
||
L.push('');
|
||
|
||
L.push('## 交通方案');
|
||
L.push('');
|
||
const noDrive = (p.constraints?.transportPrefs || []).some((x) => /不限|公共交通|铁路|高铁/.test(x)) && !(p.constraints?.transportPrefs || []).includes('自驾');
|
||
if (!noDrive && road) {
|
||
L.push(`**自驾**:${p.origin?.name || '出发地'} → ${main.name},约 ${road.distanceKm}km / ${fmtHours(road.durationMinutes)}(${road.source}${road.source === '估算' ? ',非实时路网' : ''})。建议 08:00 前出发避开拥堵;确认停车场位置与收费、景区是否限行/需预约,长途每 2 小时休息一次。`);
|
||
L.push('');
|
||
}
|
||
if (rail) {
|
||
L.push(rail.available
|
||
? `**铁路 / 公共交通**:估算约 ${fmtHours(rail.durationMinutes)},参考票价 ¥${rail.fareCny ?? '—'}(${rail.source})。${rail.note || ''} 建议提前 30–45 分钟到站,回程留意末班车。`
|
||
: `**铁路**:${rail.note || '距离过近或暂无直达城际铁路'},建议改用自驾或市内公共交通。`);
|
||
L.push('');
|
||
}
|
||
L.push('> 车次、余票、实时路况与限行政策请在 12306 / 地图 App / 景区官方渠道临近出发前核对;本方案中的估算值不作为购票依据。');
|
||
L.push('');
|
||
|
||
L.push('## 预算粗估');
|
||
L.push('');
|
||
L.push('| 项目 | 区间(¥) | 说明 |');
|
||
L.push('| --- | --- | --- |');
|
||
L.push(`| 往返交通 | ${Math.round((road?.distanceKm || 60) * 1.2)} – ${Math.round((road?.distanceKm || 60) * 2.4)} | 油费/过路费或车票,按 ${road?.distanceKm || 60}km 里程粗估 |`);
|
||
L.push('| 门票 / 预约 | 0 – 200 | 是否收费需查景区官方公告,本站未提供门票数据 |');
|
||
L.push(`| 餐饮 | ${60 * days} – ${120 * days} | 按每人每天 60–120 元 × ${days} 天 |`);
|
||
L.push(days > 1 ? `| 住宿 | ${200 * (days - 1)} – ${500 * (days - 1)} | 按 ${days - 1} 晚、每间 200–500 元粗估 |` : '| 住宿 | 0 | 当日往返,不安排过夜 |');
|
||
L.push('| 其他(停车/接驳/保险) | 30 – 150 | 机动 |');
|
||
L.push('| **合计** | **粗估,不含购物** | 以实际发生为准,所有金额均为估算 |');
|
||
L.push('');
|
||
|
||
L.push('## 出发前清单');
|
||
L.push('');
|
||
L.push('- 复核:出行日前 1–2 天再看一次天气预报(本方案天气可能来自预报或历史气候参考)、景区开放与预约状态、车次余票。');
|
||
L.push('- 证件与支付:身份证、优惠证件、手机电量与充电宝、少量现金。');
|
||
L.push('- 装备:按「天气与装备」一节准备;山区加防滑鞋与登山杖,带儿童/老人加常用药与保暖备用。');
|
||
L.push('- 把返程时间与末班交通设成手机提醒,预留至少 1 小时缓冲。');
|
||
L.push('');
|
||
|
||
L.push('## 风险与备选');
|
||
L.push('');
|
||
L.push('1. **降雨/大风** → 触发条件:当日降水概率 ≥60% 或风速 ≥40km/h;替换动作:启用「室内避雨」候选地,取消山区、水域与高处观景。');
|
||
L.push(`2. **交通延误** → 触发条件:单程实际耗时超过 ${maxH} 小时;替换动作:压缩下午项目${backup ? `,或改去更近的 ${backup.name}` : ''},不硬赶返程。`);
|
||
L.push('3. **体力不支 / 同行人不适** → 触发条件:老人、儿童或同行者提出休息;替换动作:只保留上午核心项目,午后直接返程。');
|
||
L.push('4. **拥挤排队** → 触发条件:节假日或现场排队超预期;替换动作:改走次级点位,把热门项目放到次日早场。');
|
||
L.push('');
|
||
L.push('---');
|
||
L.push('');
|
||
L.push('**数据来源说明**');
|
||
L.push('');
|
||
L.push(allNotes || '- 见页面底部数据源声明');
|
||
L.push('');
|
||
L.push(`> 本方案由${p.site || 'TripSense'}基于候选清单数据生成,未提供的票价、班次、营业时间均未编造;凡标注「估算」处请自行核实。`);
|
||
return L.join('\n');
|
||
}
|
||
|
||
// ------------------------------------------------- OpenAI 兼容流式调用
|
||
|
||
async function* planEvents(body, ai, cfg) {
|
||
const payload = buildPayload(body);
|
||
// 追加轮:带上上一版方案与这次的新要求,让模型在原文上改
|
||
const rawPrev = String(body.previous || '');
|
||
const previous = rawPrev.length > 3500
|
||
? `${rawPrev.slice(0, 3500)}\n\n(上一版过长,已截断,请以本次要求为准重构)`
|
||
: rawPrev;
|
||
const followUp = String(body.followUp || '').slice(0, 500);
|
||
const useDemo = !ai.apiKey;
|
||
// 粗估输入 token:中文按 ~1.6 字/token,JSON 里的英文/数字约 3 字/token。
|
||
// 只用于给用户一个"上下文有多大"的直观数字,不做计费依据。
|
||
const ctxChars = JSON.stringify(payload).length;
|
||
const approxTokens = Math.round(ctxChars / 2.2);
|
||
yield {
|
||
type: 'meta',
|
||
mode: useDemo ? 'demo' : 'live',
|
||
model: useDemo ? '本地规则引擎' : ai.model,
|
||
baseUrl: ai.baseUrl,
|
||
count: payload.shortlist.length,
|
||
isFollowUp: Boolean(previous),
|
||
ctxChars,
|
||
approxTokens,
|
||
};
|
||
|
||
if (useDemo) {
|
||
let md = demoPlan(payload);
|
||
if (previous) {
|
||
md = '> ℹ️ 当前是**演示模式**:本地规则引擎无法真正理解增量需求,下面是重新生成的通用方案。到「设置」里填好 AI 接口后,就能基于上一版做针对性调整。\n\n' + md;
|
||
}
|
||
for (const piece of chunkString(md, 26)) {
|
||
yield { type: 'delta', text: piece };
|
||
await sleep(10);
|
||
}
|
||
yield { type: 'done' };
|
||
return;
|
||
}
|
||
|
||
const url = `${String(ai.baseUrl).replace(/\/+$/, '')}/chat/completions`;
|
||
const messages = buildMessages(payload, cfg.SYSTEM_PROMPT, { previous, followUp });
|
||
const timeoutMs = ai.timeoutMs || cfg.AI.TIMEOUT_MS;
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${ai.apiKey}`,
|
||
Accept: 'text/event-stream, application/json',
|
||
};
|
||
// 只带必需参数的版本:新版推理模型会拒绝 temperature / max_tokens
|
||
const minimalPayload = { model: ai.model, messages, stream: true };
|
||
const baseMaxTokens = ai.maxTokens ?? cfg.AI.MAX_TOKENS;
|
||
|
||
// 推理模型会把 max_tokens 大量花在思考上,正文可能一个字都不剩
|
||
// (实测 deepseek 系模型 3000 的额度全被思考吃光)。第一次只拿到思考时,
|
||
// 自动放大额度重跑一次,省得用户自己去改配置。
|
||
let maxTokens = baseMaxTokens;
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
const out = yield* runOnce({ url, headers, payload: minimalPayload, timeoutMs, maxTokens, allowParamFallback: true });
|
||
if (out.emitted > 0) { yield { type: 'done' }; return; }
|
||
if (out.fatal) { yield { type: 'error', message: out.errorMsg }; yield { type: 'done' }; return; }
|
||
|
||
if (out.reasoningLen > 0 && attempt === 1) {
|
||
const next = Math.min(Math.max(baseMaxTokens * 3, 8192), 16384);
|
||
if (next > maxTokens) {
|
||
yield { type: 'notice', message: `模型把 token 预算花在思考上了(${out.reasoningLen} 字),已把上限 ${maxTokens} → ${next} 自动重试` };
|
||
maxTokens = next;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
let msg;
|
||
if (out.reasoningLen) {
|
||
msg = `模型只输出了 ${out.reasoningLen} 字思考过程,没有正文;已把 max_tokens 提到 ${maxTokens} 仍不够。建议换非推理模型,或手动调大 AI_MAX_TOKENS。`;
|
||
} else if (out.finishReason === 'content_filter') {
|
||
msg = '模型触发了内容安全策略,输出被拦截(finish_reason=content_filter)。';
|
||
} else if (out.eventCount) {
|
||
msg = `接口返回了流但没有正文(收到 ${out.eventCount} 个事件,finish_reason=${out.finishReason || '空'})。片段:${(out.sample || []).join(' | ') || '(空)'}`;
|
||
} else {
|
||
msg = out.errorMsg || '接口返回 200,但流里没有任何数据事件。请确认地址是 OpenAI 兼容的 /chat/completions(常见坑:少了 /v1 后缀)。';
|
||
}
|
||
yield { type: 'error', message: msg };
|
||
yield { type: 'done' };
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 单次请求:发出去、读流,只负责 yield delta / reasoning。
|
||
* 结果(有没有正文、思考了多少字、是否已致命)通过 return 值交回,
|
||
* 外层才能判断"要不要换更大的 max_tokens 再试一次"。
|
||
*/
|
||
async function* runOnce({ url, headers, payload, timeoutMs, maxTokens, allowParamFallback }) {
|
||
const body = { ...payload, max_tokens: maxTokens };
|
||
let ctrl = new AbortController();
|
||
let timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||
let res;
|
||
try {
|
||
res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal });
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => '');
|
||
const paramIssue = res.status === 400
|
||
&& /unsupported|not support|unknown parameter|too large|temperature|max_tokens|max_completion_tokens/i.test(txt);
|
||
if (paramIssue && allowParamFallback) {
|
||
// 额度超限时先降到 8192(DeepSeek 系等模型的上限)再试,
|
||
// 仍不行才退回最小参数,避免"重试反而把请求撑爆"
|
||
clearTimeout(timer);
|
||
const tooLarge = /too large|too_large|exceed|out of range/i.test(txt);
|
||
const tries = [];
|
||
if (tooLarge && maxTokens > 8192) tries.push({ ...payload, max_tokens: 8192 });
|
||
tries.push(payload);
|
||
let lastTxt = txt;
|
||
for (const b of tries) {
|
||
ctrl = new AbortController();
|
||
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||
res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(b), signal: ctrl.signal });
|
||
if (res.ok) break;
|
||
lastTxt = await res.text().catch(() => '');
|
||
}
|
||
if (!res.ok) {
|
||
clearTimeout(timer);
|
||
return { fatal: true, errorMsg: `AI 接口返回 HTTP ${res.status}:${lastTxt.slice(0, 300)}`, emitted: 0, reasoningLen: 0 };
|
||
}
|
||
} else {
|
||
clearTimeout(timer);
|
||
return { fatal: true, errorMsg: `AI 接口返回 HTTP ${res.status}:${txt.slice(0, 300)}`, emitted: 0, reasoningLen: 0 };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
clearTimeout(timer);
|
||
return { fatal: true, errorMsg: `连接 AI 接口失败:${err.message}`, emitted: 0, reasoningLen: 0 };
|
||
}
|
||
|
||
const ct = res.headers.get('content-type') || '';
|
||
if (!ct.includes('text/event-stream')) {
|
||
// 部分兼容服务忽略 stream 参数,直接返回完整 JSON
|
||
clearTimeout(timer);
|
||
try {
|
||
const d = await res.json();
|
||
const text = d.choices?.[0]?.message?.content || d.choices?.[0]?.text || '';
|
||
const reasoning = d.choices?.[0]?.message?.reasoning_content || '';
|
||
if (text) {
|
||
yield { type: 'delta', text };
|
||
return { emitted: String(text).length, reasoningLen: 0, finishReason: '' };
|
||
}
|
||
return {
|
||
emitted: 0,
|
||
reasoningLen: String(reasoning).length,
|
||
finishReason: d.choices?.[0]?.finish_reason || '',
|
||
};
|
||
} catch (err) {
|
||
return { fatal: true, errorMsg: `AI 接口返回的不是 JSON:${err.message}`, emitted: 0, reasoningLen: 0 };
|
||
}
|
||
}
|
||
|
||
let emitted = 0;
|
||
let reasoningLen = 0;
|
||
let finishReason = '';
|
||
let eventCount = 0;
|
||
const sample = [];
|
||
try {
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buf = '';
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += decoder.decode(value, { stream: true });
|
||
const lines = buf.split('\n');
|
||
buf = lines.pop() || '';
|
||
for (const raw of lines) {
|
||
const line = raw.trim();
|
||
if (!line.startsWith('data:')) continue;
|
||
const data = line.slice(5).trim();
|
||
if (!data || data === '[DONE]') continue;
|
||
eventCount += 1;
|
||
if (sample.length < 2) sample.push(data.slice(0, 150));
|
||
try {
|
||
const j = JSON.parse(data);
|
||
const choice = j.choices?.[0];
|
||
if (choice?.finish_reason) finishReason = choice.finish_reason;
|
||
const delta = choice?.delta?.content ?? choice?.message?.content ?? '';
|
||
const reasoning = choice?.delta?.reasoning_content || choice?.message?.reasoning_content;
|
||
if (delta) { emitted += String(delta).length; yield { type: 'delta', text: delta }; }
|
||
if (reasoning) { reasoningLen += String(reasoning).length; yield { type: 'reasoning', text: reasoning }; }
|
||
// 个别兼容服务把正文放在 delta.text
|
||
if (!delta && choice?.delta?.text) {
|
||
emitted += String(choice.delta.text).length;
|
||
yield { type: 'delta', text: choice.delta.text };
|
||
}
|
||
} catch { /* 忽略心跳/非法片段 */ }
|
||
}
|
||
}
|
||
} catch (err) {
|
||
clearTimeout(timer);
|
||
return { fatal: false, errorMsg: `读取 AI 流失败:${err.message}`, emitted, reasoningLen, finishReason };
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
|
||
return { emitted, reasoningLen, finishReason, eventCount, sample };
|
||
}
|
||
|
||
/**
|
||
* 连通性自检:发一个最小的非流式请求,把失败定位到具体环节
|
||
* (没 Key / 连不上 / HTTP 报错 / 返回不是 JSON / 参数不被支持 / 通了但没内容)。
|
||
* 正因为它用非流式,才能直接读到上游的错误 JSON,比在流里猜要准。
|
||
*/
|
||
async function testAI(ai) {
|
||
if (!ai.apiKey) return { ok: false, stage: 'config', message: '没有可用的 API Key' };
|
||
if (!ai.model) return { ok: false, stage: 'config', message: '没有填写模型名称' };
|
||
const url = `${String(ai.baseUrl).replace(/\/+$/, '')}/chat/completions`;
|
||
const t0 = Date.now();
|
||
const messages = [{ role: 'user', content: '回复两个字:可用' }];
|
||
const timeout = Math.min(ai.timeoutMs || 30000, 30000);
|
||
|
||
const once = async (payload) => {
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), timeout);
|
||
try {
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ai.apiKey}` },
|
||
body: JSON.stringify(payload),
|
||
signal: ctrl.signal,
|
||
});
|
||
const txt = await res.text();
|
||
return { res, txt };
|
||
} finally { clearTimeout(timer); }
|
||
};
|
||
|
||
try {
|
||
let note = '';
|
||
let { res, txt } = await once({ model: ai.model, messages, stream: false, max_tokens: 32, temperature: 0.2 });
|
||
if (!res.ok && res.status === 400
|
||
&& /unsupported|not support|unknown parameter|temperature|max_tokens|max_completion_tokens/i.test(txt)) {
|
||
note = '该模型不接受 temperature / max_tokens,已按最小参数重试';
|
||
({ res, txt } = await once({ model: ai.model, messages, stream: false }));
|
||
}
|
||
const ms = Date.now() - t0;
|
||
if (!res.ok) {
|
||
return { ok: false, stage: 'http', status: res.status, ms, note, message: `HTTP ${res.status}:${txt.slice(0, 400)}` };
|
||
}
|
||
let data;
|
||
try { data = JSON.parse(txt); } catch {
|
||
return { ok: false, stage: 'parse', status: res.status, ms, note, message: `返回的不是 JSON(前 200 字):${txt.slice(0, 200)}` };
|
||
}
|
||
if (data.error) {
|
||
return { ok: false, stage: 'api', status: res.status, ms, note, message: `接口报错:${JSON.stringify(data.error).slice(0, 300)}` };
|
||
}
|
||
const choice = data.choices?.[0];
|
||
const content = choice?.message?.content ?? choice?.text ?? '';
|
||
const reasoning = choice?.message?.reasoning_content || '';
|
||
const finish = choice?.finish_reason || '';
|
||
if (!content && !reasoning) {
|
||
return { ok: false, stage: 'empty', status: res.status, ms, note, finish, message: `接口通了但没返回内容(finish_reason=${finish || '空'})。可能是 max_tokens 太小或命中内容策略。` };
|
||
}
|
||
return {
|
||
ok: true, status: res.status, ms, note,
|
||
model: data.model || ai.model,
|
||
sample: String(content || reasoning).replace(/\s+/g, ' ').slice(0, 60),
|
||
reasoningOnly: !content && Boolean(reasoning),
|
||
finish,
|
||
};
|
||
} catch (err) {
|
||
const ms = Date.now() - t0;
|
||
const isTimeout = err.name === 'AbortError';
|
||
return {
|
||
ok: false, stage: 'network', ms,
|
||
message: isTimeout
|
||
? `请求超时(${Math.round(timeout / 1000)}s 内无响应)`
|
||
: `无法连接:${err.message}。检查地址能否访问、是否需要代理,或是否少了 /v1。`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/** 合并服务端配置与前端覆盖头 */
|
||
function resolveAI(headers, cfg) {
|
||
const h = (k) => {
|
||
const v = headers[k];
|
||
return Array.isArray(v) ? v[0] : v;
|
||
};
|
||
const overrideKey = h('x-ai-api-key');
|
||
let apiKey = cfg.AI.API_KEY;
|
||
if (overrideKey === '__demo__') apiKey = '';
|
||
else if (overrideKey && overrideKey !== '__server__') apiKey = overrideKey;
|
||
return {
|
||
baseUrl: (h('x-ai-base-url') || cfg.AI.BASE_URL).replace(/\/+$/, ''),
|
||
apiKey,
|
||
model: h('x-ai-model') || cfg.AI.MODEL,
|
||
temperature: cfg.AI.TEMPERATURE,
|
||
maxTokens: cfg.AI.MAX_TOKENS,
|
||
timeoutMs: cfg.AI.TIMEOUT_MS,
|
||
};
|
||
}
|
||
|
||
module.exports = { planEvents, resolveAI, buildPayload, demoPlan, testAI };
|