'use strict'; /** * 智能问数智能体 · 对话模型通道 * ---------------------------------------------------------------------------- * OpenAI 兼容 /v1/chat/completions 手写 HTTP(零依赖纪律,与 nl-query 同款)。 * chatCompletion / extractJson 自 nl-query.js 抽出共用(nl-query 改为 require 本模块)。 * * parseAgentAction 是 harness 的输入闸门:模型每回合只允许输出一个 JSON 对象, * 二选一 —— 调工具 {"thought","action":{tool,args}} 或收敛 {"thought","final":{answer,…}}。 * 解析失败抛带可读 reason 的错误,供主循环回注模型自修复。 */ const https = require('https'); const http = require('http'); function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) { return chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs, temperature }).then((out) => out.content); } /** * 同 chatCompletion,但返回 { content, usage }(usage 来自 OpenAI 兼容响应的 * usage 字段:prompt_tokens / completion_tokens;网关不给时为 null)。 * 智能体主循环用它记录每回合的 token 用量(编排时间线展示)。 */ function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) { return new Promise((resolve, reject) => { let base; try { base = new URL(apiBase); } catch (e) { return reject(new Error('对话模型地址不合法: ' + apiBase)); } const body = JSON.stringify({ model, messages, temperature, stream: false }); const options = { hostname: base.hostname, port: base.port || 443, path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions', method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), }, timeout: timeoutMs, }; const req = (base.protocol === 'http:' ? http : https).request(options, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); let json = null; try { json = JSON.parse(raw); } catch { /* 保留 raw */ } if (res.statusCode !== 200) { const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`; return reject(new Error(`对话模型请求失败: ${msg}`)); } const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content; if (!content) return reject(new Error('对话模型返回为空')); const u = (json && json.usage) || null; resolve({ content, usage: u ? { promptTokens: Number(u.prompt_tokens) || null, completionTokens: Number(u.completion_tokens) || null, } : null, }); }); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('对话模型请求超时'))); req.write(body); req.end(); }); } /** 从模型输出中稳健地提取第一个 JSON 对象(支持 ```json 围栏与前后杂讯) */ function extractJson(text) { const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); const candidate = fenced ? fenced[1] : text; const start = candidate.indexOf('{'); const end = candidate.lastIndexOf('}'); if (start === -1 || end <= start) throw new Error('模型未返回 JSON'); return JSON.parse(candidate.slice(start, end + 1)); } /** * 解析一回合模型输出为结构化动作。 * 返回 { type:'tool', thought, tool, args } 或 { type:'final', thought, final }。 * chart / applied 只收编形状,指标白名单等业务钳制在 guard / 工具层做。 */ function parseAgentAction(text) { let obj; try { obj = extractJson(text); } catch (e) { throw new Error('输出不是可解析的 JSON 对象:' + (e.message || e)); } if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('输出不是 JSON 对象'); const thought = String(obj.thought || '').slice(0, 500); if (obj.final && typeof obj.final === 'object' && !Array.isArray(obj.final)) { const answer = String(obj.final.answer || '').trim(); if (!answer) throw new Error('final.answer 不能为空'); const final = { answer: answer.slice(0, 4000) }; if (obj.final.chart && typeof obj.final.chart === 'object') { final.chart = { type: obj.final.chart.type, x: obj.final.chart.x, y: Array.isArray(obj.final.chart.y) ? obj.final.chart.y : [], title: obj.final.chart.title, }; } return { type: 'final', thought, final }; } if (obj.action && typeof obj.action === 'object' && !Array.isArray(obj.action)) { const tool = String(obj.action.tool || '').trim(); if (!tool) throw new Error('action.tool 不能为空'); const rawArgs = obj.action.args; const args = (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) ? rawArgs : {}; return { type: 'tool', thought, tool: tool.slice(0, 64), args }; } throw new Error('输出缺少 action 或 final:每回合必须二选一'); } module.exports = { chatCompletion, chatCompletionDetailed, extractJson, parseAgentAction };