llm.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 对话模型通道
  4. * ----------------------------------------------------------------------------
  5. * OpenAI 兼容 /v1/chat/completions 手写 HTTP(零依赖纪律,与 nl-query 同款)。
  6. * chatCompletion / extractJson 自 nl-query.js 抽出共用(nl-query 改为 require 本模块)。
  7. *
  8. * parseAgentAction 是 harness 的输入闸门:模型每回合只允许输出一个 JSON 对象,
  9. * 二选一 —— 调工具 {"thought","action":{tool,args}} 或收敛 {"thought","final":{answer,…}}。
  10. * 解析失败抛带可读 reason 的错误,供主循环回注模型自修复。
  11. */
  12. const https = require('https');
  13. const http = require('http');
  14. function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) {
  15. return chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs, temperature }).then((out) => out.content);
  16. }
  17. /**
  18. * 同 chatCompletion,但返回 { content, usage }(usage 来自 OpenAI 兼容响应的
  19. * usage 字段:prompt_tokens / completion_tokens;网关不给时为 null)。
  20. * 智能体主循环用它记录每回合的 token 用量(编排时间线展示)。
  21. */
  22. function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0, jsonMode = false }) {
  23. return new Promise((resolve, reject) => {
  24. let base;
  25. try { base = new URL(apiBase); } catch (e) { return reject(new Error('对话模型地址不合法: ' + apiBase)); }
  26. const body = { model, messages, temperature, stream: false };
  27. // 结构化解码:网关支持 JSON mode 时动作输出更稳(配置开关 chatJsonMode,默认关)
  28. if (jsonMode) body.response_format = { type: 'json_object' };
  29. const payload = JSON.stringify(body);
  30. const options = {
  31. hostname: base.hostname,
  32. port: base.port || 443,
  33. path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions',
  34. method: 'POST',
  35. headers: {
  36. Authorization: `Bearer ${apiKey}`,
  37. 'Content-Type': 'application/json; charset=utf-8',
  38. 'Content-Length': Buffer.byteLength(payload),
  39. },
  40. timeout: timeoutMs,
  41. };
  42. const req = (base.protocol === 'http:' ? http : https).request(options, (res) => {
  43. const chunks = [];
  44. res.on('data', (c) => chunks.push(c));
  45. res.on('end', () => {
  46. const raw = Buffer.concat(chunks).toString('utf8');
  47. let json = null;
  48. try { json = JSON.parse(raw); } catch { /* 保留 raw */ }
  49. if (res.statusCode !== 200) {
  50. const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`;
  51. return reject(new Error(`对话模型请求失败: ${msg}`));
  52. }
  53. const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content;
  54. if (!content) return reject(new Error('对话模型返回为空'));
  55. const u = (json && json.usage) || null;
  56. resolve({
  57. content,
  58. usage: u ? {
  59. promptTokens: Number(u.prompt_tokens) || null,
  60. completionTokens: Number(u.completion_tokens) || null,
  61. } : null,
  62. });
  63. });
  64. });
  65. req.on('error', reject);
  66. req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
  67. req.write(payload);
  68. req.end();
  69. });
  70. }
  71. /** 从模型输出中稳健地提取第一个 JSON 对象(支持 ```json 围栏与前后杂讯) */
  72. function extractJson(text) {
  73. const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  74. const candidate = fenced ? fenced[1] : text;
  75. const start = candidate.indexOf('{');
  76. const end = candidate.lastIndexOf('}');
  77. if (start === -1 || end <= start) throw new Error('模型未返回 JSON');
  78. return JSON.parse(candidate.slice(start, end + 1));
  79. }
  80. /**
  81. * 解析一回合模型输出为结构化动作。
  82. * 返回 { type:'tool', thought, tool, args } 或 { type:'final', thought, final }。
  83. * chart / applied 只收编形状,指标白名单等业务钳制在 guard / 工具层做。
  84. */
  85. function parseAgentAction(text) {
  86. let obj;
  87. try {
  88. obj = extractJson(text);
  89. } catch (e) {
  90. throw new Error('输出不是可解析的 JSON 对象:' + (e.message || e));
  91. }
  92. if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('输出不是 JSON 对象');
  93. const thought = String(obj.thought || '').slice(0, 500);
  94. if (obj.final && typeof obj.final === 'object' && !Array.isArray(obj.final)) {
  95. const answer = String(obj.final.answer || '').trim();
  96. if (!answer) throw new Error('final.answer 不能为空');
  97. const final = { answer: answer.slice(0, 4000) };
  98. if (obj.final.chart && typeof obj.final.chart === 'object') {
  99. final.chart = {
  100. type: obj.final.chart.type,
  101. x: obj.final.chart.x,
  102. y: Array.isArray(obj.final.chart.y) ? obj.final.chart.y : [],
  103. title: obj.final.chart.title,
  104. };
  105. }
  106. return { type: 'final', thought, final };
  107. }
  108. if (obj.action && typeof obj.action === 'object' && !Array.isArray(obj.action)) {
  109. const tool = String(obj.action.tool || '').trim();
  110. if (!tool) throw new Error('action.tool 不能为空');
  111. const rawArgs = obj.action.args;
  112. const args = (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) ? rawArgs : {};
  113. return { type: 'tool', thought, tool: tool.slice(0, 64), args };
  114. }
  115. throw new Error('输出缺少 action 或 final:每回合必须二选一');
  116. }
  117. module.exports = { chatCompletion, chatCompletionDetailed, extractJson, parseAgentAction };