Files
tamakyi c5c2ffae68 TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

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

588 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };