settings.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. 'use strict';
  2. // 系统设置:模型列表、配置读写(脱敏)、生图/对话 API 连通性测试
  3. const fs = require('fs');
  4. const path = require('path');
  5. const https = require('https');
  6. const { URL } = require('url');
  7. const { sendJson, readBody } = require('../util.js');
  8. const { DEFAULT_CHAT_MODEL } = require('../context.js');
  9. function register(app, ctx) {
  10. app.get('/api/models', async ({ res }) => {
  11. sendJson(res, 200, { models: ctx.models.MODELS, default: ctx.config.model });
  12. });
  13. app.get('/api/settings', async ({ res }) => {
  14. sendJson(res, 200, {
  15. apiKey: ctx.config.apiKey ? ctx.config.apiKey.slice(0, 8) + '••••••••' : '',
  16. apiBase: ctx.config.apiBase,
  17. model: ctx.config.model,
  18. rootDir: ctx.workspace.getWorkspaceRoot(),
  19. pageSize: ctx.config.pageSize,
  20. delayMs: ctx.config.delayMs,
  21. cookieConfigured: !!ctx.session.cookie && !!ctx.session.getKsid(),
  22. port: ctx.config.port,
  23. chatModel: ctx.config.chatModel || '',
  24. chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase,
  25. chatApiKey: ctx.config.chatApiKey ? ctx.config.chatApiKey.slice(0, 8) + '••••••••' : '',
  26. chatKeyFallback: !ctx.config.chatApiKey && !!ctx.config.apiKey,
  27. chatModelAgent: ctx.config.chatModelAgent || '',
  28. chatJsonMode: !!ctx.config.chatJsonMode,
  29. autoSyncEnabled: !!(ctx.config.autoSync && ctx.config.autoSync.enabled),
  30. autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00',
  31. accessTokenSet: !!ctx.config.accessToken,
  32. aitableEnabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
  33. aitableReadOnly: !!(ctx.config.aitable && ctx.config.aitable.readOnly),
  34. aitableAppKey: (ctx.config.aitable && ctx.config.aitable.appKey) || '',
  35. aitableBaseId: (ctx.config.aitable && ctx.config.aitable.baseId) || '',
  36. aitableSheetDetail: (ctx.config.aitable && ctx.config.aitable.sheetDetail) || '门店明细',
  37. aitableSheetChain: (ctx.config.aitable && ctx.config.aitable.sheetChain) || '连锁汇总',
  38. aitableSheetCompany: (ctx.config.aitable && ctx.config.aitable.sheetCompany) || '公司汇总',
  39. aitableSecretSet: !!(ctx.config.aitable && ctx.config.aitable.appSecret),
  40. aitableOperatorSet: !!(ctx.config.aitable && ctx.config.aitable.operatorId),
  41. });
  42. });
  43. app.post('/api/settings', async ({ req, res }) => {
  44. const body = await readBody(req);
  45. const config = ctx.config;
  46. const previousApiKey = config.apiKey;
  47. const previousCookie = ctx.session.cookie;
  48. if (body.apiKey && !body.apiKey.includes('••••')) config.apiKey = body.apiKey;
  49. if (body.model && ctx.models.MODEL_IDS.has(body.model)) config.model = body.model;
  50. if (body.rootDir) {
  51. const rootDir = path.resolve(String(body.rootDir).trim());
  52. if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
  53. return sendJson(res, 400, { error: '输出根目录不存在: ' + rootDir });
  54. }
  55. config.rootDir = rootDir;
  56. }
  57. if (body.pageSize !== undefined) {
  58. const pageSize = Number(body.pageSize);
  59. if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) {
  60. return sendJson(res, 400, { error: '每页条数必须为 1-200 的整数' });
  61. }
  62. config.pageSize = pageSize;
  63. }
  64. if (body.delayMs !== undefined) {
  65. const delayMs = Number(body.delayMs);
  66. if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60000) {
  67. return sendJson(res, 400, { error: '间隔必须为 0-60000 毫秒' });
  68. }
  69. config.delayMs = delayMs;
  70. }
  71. if (body.chatModel !== undefined) config.chatModel = String(body.chatModel).trim();
  72. if (body.chatApiBase !== undefined) config.chatApiBase = String(body.chatApiBase).trim();
  73. if (body.chatApiKey !== undefined && !String(body.chatApiKey).includes('••••')) config.chatApiKey = String(body.chatApiKey).trim();
  74. if (body.chatModelAgent !== undefined) config.chatModelAgent = String(body.chatModelAgent).trim();
  75. if (body.chatJsonMode !== undefined) config.chatJsonMode = !!body.chatJsonMode;
  76. if (body.autoSyncEnabled !== undefined || body.autoSyncTime !== undefined) {
  77. if (!config.autoSync || typeof config.autoSync !== 'object') config.autoSync = { enabled: false, time: '06:00' };
  78. if (body.autoSyncEnabled !== undefined) config.autoSync.enabled = !!body.autoSyncEnabled;
  79. if (body.autoSyncTime !== undefined) {
  80. const t = String(body.autoSyncTime).trim();
  81. const hm = /^\d{1,2}:\d{2}$/.test(t) ? t.split(':').map(Number) : null;
  82. if (!hm || hm[0] > 23 || hm[1] > 59) {
  83. return sendJson(res, 400, { error: '自动同步时间格式应为 HH:mm' });
  84. }
  85. config.autoSync.time = t;
  86. }
  87. }
  88. if (body.accessToken !== undefined) {
  89. const t = String(body.accessToken).trim();
  90. if (t === '-') config.accessToken = ''; // 约定:输入 - 清除口令
  91. else if (t) config.accessToken = t;
  92. }
  93. if (body.aitableEnabled !== undefined || body.aitableReadOnly !== undefined || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined || body.aitableSheetDetail !== undefined || body.aitableSheetChain !== undefined || body.aitableSheetCompany !== undefined || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) {
  94. if (!config.aitable || typeof config.aitable !== 'object') config.aitable = { enabled: false, appKey: '', appSecret: '', baseId: '', operatorId: '' };
  95. if (body.aitableEnabled !== undefined) config.aitable.enabled = !!body.aitableEnabled;
  96. if (body.aitableReadOnly !== undefined) config.aitable.readOnly = !!body.aitableReadOnly;
  97. if (body.aitableAppKey !== undefined) config.aitable.appKey = String(body.aitableAppKey).trim();
  98. if (body.aitableBaseId !== undefined) {
  99. // 支持直接粘贴 AI 表格分享链接:自动提取 /i/nodes/<ID> 里的节点 ID 作为 Base ID
  100. const raw = String(body.aitableBaseId).trim();
  101. const m = /i\/nodes\/([A-Za-z0-9]+)/.exec(raw) || /[?&]baseId=([A-Za-z0-9]+)/.exec(raw);
  102. config.aitable.baseId = m ? m[1] : raw;
  103. }
  104. // 三张表按维度拆分:门店明细/连锁汇总/公司汇总(空值回退默认名)
  105. if (body.aitableSheetDetail !== undefined) config.aitable.sheetDetail = String(body.aitableSheetDetail).trim() || '门店明细';
  106. if (body.aitableSheetChain !== undefined) config.aitable.sheetChain = String(body.aitableSheetChain).trim() || '连锁汇总';
  107. if (body.aitableSheetCompany !== undefined) config.aitable.sheetCompany = String(body.aitableSheetCompany).trim() || '公司汇总';
  108. if (body.aitableAppSecret !== undefined) {
  109. const sec = String(body.aitableAppSecret).trim();
  110. if (sec === '-') config.aitable.appSecret = ''; // 约定:输入 - 清除
  111. else if (sec) config.aitable.appSecret = sec;
  112. }
  113. if (body.aitableOperatorId !== undefined) {
  114. const op = String(body.aitableOperatorId).trim();
  115. if (op === '-') config.aitable.operatorId = '';
  116. else if (op) config.aitable.operatorId = op;
  117. }
  118. }
  119. ctx.saveConfig();
  120. if (body.cookie !== undefined) {
  121. ctx.session.cookie = body.cookie;
  122. ctx.session.saveSession();
  123. }
  124. // 变更摘要:只列本次实际提交过的面板域(设置页按面板分域保存)
  125. const changed = [];
  126. if (previousApiKey !== config.apiKey) changed.push('API Key 已更新');
  127. if (body.cookie !== undefined && previousCookie !== ctx.session.cookie) changed.push('采集 Cookie 已更新');
  128. if (body.chatModel !== undefined || body.chatApiBase !== undefined || body.chatApiKey !== undefined) changed.push('对话模型已更新');
  129. if (body.aitableEnabled !== undefined || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined
  130. || body.aitableSheetDetail !== undefined || body.aitableSheetChain !== undefined || body.aitableSheetCompany !== undefined
  131. || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) changed.push('钉钉 AI 表格配置已更新');
  132. if (body.autoSyncEnabled !== undefined || body.autoSyncTime !== undefined) changed.push('自动同步已更新');
  133. if (body.accessToken !== undefined) changed.push('访问口令已更新');
  134. ctx.logs.pushSystemLog(
  135. `系统设置已保存:模型=${config.model},根目录=${config.rootDir},每页=${config.pageSize},间隔=${config.delayMs}ms` +
  136. (changed.length ? `,${changed.join(',')}` : ''),
  137. 'success'
  138. );
  139. sendJson(res, 200, { ok: true });
  140. });
  141. // 生图 API 连通性测试:GET /v1/models(轻量、不产生生图费用)
  142. app.post('/api/image/test', async ({ res }) => {
  143. return new Promise((resolve) => {
  144. let base;
  145. try { base = new URL(ctx.config.apiBase); } catch { return sendJson(res, 400, { error: 'API 地址不合法: ' + ctx.config.apiBase }); }
  146. const options = {
  147. hostname: base.hostname, port: base.port || 443, path: '/v1/models', method: 'GET',
  148. headers: { Authorization: `Bearer ${ctx.config.apiKey}` }, timeout: 15000,
  149. };
  150. const up = https.request(options, (upRes) => {
  151. const chunks = [];
  152. upRes.on('data', (c) => chunks.push(c));
  153. upRes.on('end', () => {
  154. let json = null;
  155. try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* 保留空 */ }
  156. const models = json && Array.isArray(json.data) ? json.data.map((m) => (m && m.id) || '').filter(Boolean) : null;
  157. sendJson(res, 200, { statusCode: upRes.statusCode, models, modelFound: !!(models && models.includes(ctx.config.model)), model: ctx.config.model });
  158. });
  159. });
  160. up.on('error', (e) => sendJson(res, 200, { statusCode: 0, error: e.message || String(e) }));
  161. up.on('timeout', () => up.destroy(new Error('请求超时')));
  162. up.end();
  163. });
  164. });
  165. app.post('/api/chat/test', async ({ req, res }) => {
  166. const body = await readBody(req);
  167. const nlQuery = require('../../nl-query.js');
  168. const cfg = {
  169. chatApiBase: body.chatApiBase || ctx.config.chatApiBase || ctx.config.apiBase,
  170. chatApiKey: (body.chatApiKey && !String(body.chatApiKey).includes('••••')) ? body.chatApiKey : (ctx.config.chatApiKey || ctx.config.apiKey),
  171. chatModel: body.chatModel || ctx.config.chatModel || DEFAULT_CHAT_MODEL,
  172. };
  173. try {
  174. const reply = await nlQuery.ping(cfg);
  175. sendJson(res, 200, { ok: true, reply });
  176. } catch (e) {
  177. sendJson(res, 502, { error: e.message });
  178. }
  179. });
  180. }
  181. module.exports = { register };