'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 };