TripSense 行知 · 周边出行顾问:首版

零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
This commit is contained in:
2026-11-05 20:25:03 +08:00
commit c5c2ffae68
53 changed files with 8586 additions and 0 deletions
+587
View File
@@ -0,0 +1,587 @@
'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 };