'use strict'; // 系统设置:模型列表、配置读写(脱敏)、生图/对话 API 连通性测试 const fs = require('fs'); const path = require('path'); const https = require('https'); const { URL } = require('url'); const { sendJson, readBody } = require('../util.js'); const { DEFAULT_CHAT_MODEL } = require('../context.js'); function register(app, ctx) { app.get('/api/models', async ({ res }) => { sendJson(res, 200, { models: ctx.models.MODELS, default: ctx.config.model }); }); app.get('/api/settings', async ({ res }) => { sendJson(res, 200, { apiKey: ctx.config.apiKey ? ctx.config.apiKey.slice(0, 8) + '••••••••' : '', apiBase: ctx.config.apiBase, model: ctx.config.model, rootDir: ctx.workspace.getWorkspaceRoot(), pageSize: ctx.config.pageSize, delayMs: ctx.config.delayMs, crawlChannel: ctx.config.crawlChannel || 'auto', cookieConfigured: !!ctx.session.cookie && !!ctx.session.getKsid(), port: ctx.config.port, chatModel: ctx.config.chatModel || '', chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase, chatApiKey: ctx.config.chatApiKey ? ctx.config.chatApiKey.slice(0, 8) + '••••••••' : '', chatKeyFallback: !ctx.config.chatApiKey && !!ctx.config.apiKey, chatModelAgent: ctx.config.chatModelAgent || '', chatJsonMode: !!ctx.config.chatJsonMode, autoSyncEnabled: !!(ctx.config.autoSync && ctx.config.autoSync.enabled), autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00', accessTokenSet: !!ctx.config.accessToken, aitableEnabled: !!(ctx.config.aitable && ctx.config.aitable.enabled), aitableReadOnly: !!(ctx.config.aitable && ctx.config.aitable.readOnly), aitableAppKey: (ctx.config.aitable && ctx.config.aitable.appKey) || '', aitableBaseId: (ctx.config.aitable && ctx.config.aitable.baseId) || '', aitableSheetDetail: (ctx.config.aitable && ctx.config.aitable.sheetDetail) || '门店明细', aitableSheetChain: (ctx.config.aitable && ctx.config.aitable.sheetChain) || '连锁汇总', aitableSheetCompany: (ctx.config.aitable && ctx.config.aitable.sheetCompany) || '公司汇总', aitableSecretSet: !!(ctx.config.aitable && ctx.config.aitable.appSecret), aitableOperatorSet: !!(ctx.config.aitable && ctx.config.aitable.operatorId), }); }); app.post('/api/settings', async ({ req, res }) => { const body = await readBody(req); const config = ctx.config; const previousApiKey = config.apiKey; const previousCookie = ctx.session.cookie; if (body.apiKey && !body.apiKey.includes('••••')) config.apiKey = body.apiKey; if (body.model && ctx.models.MODEL_IDS.has(body.model)) config.model = body.model; if (body.rootDir) { const rootDir = path.resolve(String(body.rootDir).trim()); if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) { return sendJson(res, 400, { error: '输出根目录不存在: ' + rootDir }); } config.rootDir = rootDir; } if (body.pageSize !== undefined) { const pageSize = Number(body.pageSize); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) { return sendJson(res, 400, { error: '每页条数必须为 1-200 的整数' }); } config.pageSize = pageSize; } if (body.delayMs !== undefined) { const delayMs = Number(body.delayMs); if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60000) { return sendJson(res, 400, { error: '间隔必须为 0-60000 毫秒' }); } config.delayMs = delayMs; } if (body.crawlChannel !== undefined) { const ch = String(body.crawlChannel); if (!['auto', 'client', 'cookie'].includes(ch)) { return sendJson(res, 400, { error: '采集通道取值应为 auto / client / cookie' }); } config.crawlChannel = ch; } if (body.chatModel !== undefined) config.chatModel = String(body.chatModel).trim(); if (body.chatApiBase !== undefined) config.chatApiBase = String(body.chatApiBase).trim(); if (body.chatApiKey !== undefined && !String(body.chatApiKey).includes('••••')) config.chatApiKey = String(body.chatApiKey).trim(); if (body.chatModelAgent !== undefined) config.chatModelAgent = String(body.chatModelAgent).trim(); if (body.chatJsonMode !== undefined) config.chatJsonMode = !!body.chatJsonMode; if (body.autoSyncEnabled !== undefined || body.autoSyncTime !== undefined) { if (!config.autoSync || typeof config.autoSync !== 'object') config.autoSync = { enabled: false, time: '06:00' }; if (body.autoSyncEnabled !== undefined) config.autoSync.enabled = !!body.autoSyncEnabled; if (body.autoSyncTime !== undefined) { const t = String(body.autoSyncTime).trim(); const hm = /^\d{1,2}:\d{2}$/.test(t) ? t.split(':').map(Number) : null; if (!hm || hm[0] > 23 || hm[1] > 59) { return sendJson(res, 400, { error: '自动同步时间格式应为 HH:mm' }); } config.autoSync.time = t; } } if (body.accessToken !== undefined) { const t = String(body.accessToken).trim(); if (t === '-') config.accessToken = ''; // 约定:输入 - 清除口令 else if (t) config.accessToken = t; } 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) { if (!config.aitable || typeof config.aitable !== 'object') config.aitable = { enabled: false, appKey: '', appSecret: '', baseId: '', operatorId: '' }; if (body.aitableEnabled !== undefined) config.aitable.enabled = !!body.aitableEnabled; if (body.aitableReadOnly !== undefined) config.aitable.readOnly = !!body.aitableReadOnly; if (body.aitableAppKey !== undefined) config.aitable.appKey = String(body.aitableAppKey).trim(); if (body.aitableBaseId !== undefined) { // 支持直接粘贴 AI 表格分享链接:自动提取 /i/nodes/ 里的节点 ID 作为 Base ID const raw = String(body.aitableBaseId).trim(); const m = /i\/nodes\/([A-Za-z0-9]+)/.exec(raw) || /[?&]baseId=([A-Za-z0-9]+)/.exec(raw); config.aitable.baseId = m ? m[1] : raw; } // 三张表按维度拆分:门店明细/连锁汇总/公司汇总(空值回退默认名) if (body.aitableSheetDetail !== undefined) config.aitable.sheetDetail = String(body.aitableSheetDetail).trim() || '门店明细'; if (body.aitableSheetChain !== undefined) config.aitable.sheetChain = String(body.aitableSheetChain).trim() || '连锁汇总'; if (body.aitableSheetCompany !== undefined) config.aitable.sheetCompany = String(body.aitableSheetCompany).trim() || '公司汇总'; if (body.aitableAppSecret !== undefined) { const sec = String(body.aitableAppSecret).trim(); if (sec === '-') config.aitable.appSecret = ''; // 约定:输入 - 清除 else if (sec) config.aitable.appSecret = sec; } if (body.aitableOperatorId !== undefined) { const op = String(body.aitableOperatorId).trim(); if (op === '-') config.aitable.operatorId = ''; else if (op) config.aitable.operatorId = op; } } ctx.saveConfig(); if (body.cookie !== undefined) { ctx.session.cookie = body.cookie; ctx.session.saveSession(); } // 变更摘要:只列本次实际提交过的面板域(设置页按面板分域保存) const changed = []; if (previousApiKey !== config.apiKey) changed.push('API Key 已更新'); if (body.cookie !== undefined && previousCookie !== ctx.session.cookie) changed.push('采集 Cookie 已更新'); if (body.chatModel !== undefined || body.chatApiBase !== undefined || body.chatApiKey !== undefined) changed.push('对话模型已更新'); if (body.aitableEnabled !== undefined || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined || body.aitableSheetDetail !== undefined || body.aitableSheetChain !== undefined || body.aitableSheetCompany !== undefined || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) changed.push('钉钉 AI 表格配置已更新'); if (body.autoSyncEnabled !== undefined || body.autoSyncTime !== undefined) changed.push('自动同步已更新'); if (body.crawlChannel !== undefined) changed.push(`采集通道已切换为 ${config.crawlChannel}`); if (body.accessToken !== undefined) changed.push('访问口令已更新'); ctx.logs.pushSystemLog( `系统设置已保存:模型=${config.model},根目录=${config.rootDir},每页=${config.pageSize},间隔=${config.delayMs}ms` + (changed.length ? `,${changed.join(',')}` : ''), 'success' ); sendJson(res, 200, { ok: true }); }); // 生图 API 连通性测试:GET /v1/models(轻量、不产生生图费用) app.post('/api/image/test', async ({ res }) => { return new Promise((resolve) => { let base; try { base = new URL(ctx.config.apiBase); } catch { return sendJson(res, 400, { error: 'API 地址不合法: ' + ctx.config.apiBase }); } const options = { hostname: base.hostname, port: base.port || 443, path: '/v1/models', method: 'GET', headers: { Authorization: `Bearer ${ctx.config.apiKey}` }, timeout: 15000, }; const up = https.request(options, (upRes) => { const chunks = []; upRes.on('data', (c) => chunks.push(c)); upRes.on('end', () => { let json = null; try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* 保留空 */ } const models = json && Array.isArray(json.data) ? json.data.map((m) => (m && m.id) || '').filter(Boolean) : null; sendJson(res, 200, { statusCode: upRes.statusCode, models, modelFound: !!(models && models.includes(ctx.config.model)), model: ctx.config.model }); }); }); up.on('error', (e) => sendJson(res, 200, { statusCode: 0, error: e.message || String(e) })); up.on('timeout', () => up.destroy(new Error('请求超时'))); up.end(); }); }); app.post('/api/chat/test', async ({ req, res }) => { const body = await readBody(req); const nlQuery = require('../../nl-query.js'); const cfg = { chatApiBase: body.chatApiBase || ctx.config.chatApiBase || ctx.config.apiBase, chatApiKey: (body.chatApiKey && !String(body.chatApiKey).includes('••••')) ? body.chatApiKey : (ctx.config.chatApiKey || ctx.config.apiKey), chatModel: body.chatModel || ctx.config.chatModel || DEFAULT_CHAT_MODEL, }; try { const reply = await nlQuery.ping(cfg); sendJson(res, 200, { ok: true, reply }); } catch (e) { sendJson(res, 502, { error: e.message }); } }); // ---------- 云端数据库(PostgreSQL)配置 ---------- function pgInstalled() { try { require.resolve('pg'); return true; } catch { return false; } } function storageFormFromBody(body) { const current = (ctx.config.storage && ctx.config.storage.postgres) || {}; const driver = body.driver === 'postgres' ? 'postgres' : 'sqlite'; const pw = body.pgPassword; return { driver, sqlite: { file: String(body.sqliteFile || (ctx.config.storage && ctx.config.storage.sqlite && ctx.config.storage.sqlite.file) || 'data/ops.db'), }, postgres: { // 未提交的字段回落到已保存配置(测试连接时可直接复用) host: String(body.pgHost !== undefined ? body.pgHost : (current.host || '')).trim(), port: Number(body.pgPort) || current.port || 5432, database: String(body.pgDatabase !== undefined ? body.pgDatabase : (current.database || '')).trim(), user: String(body.pgUser !== undefined ? body.pgUser : (current.user || '')).trim(), password: (pw !== undefined && pw !== null && !String(pw).includes('••')) ? String(pw) : (current.password || ''), ssl: body.pgSsl !== undefined ? !!body.pgSsl : !!current.ssl, schema: String(body.pgSchema || current.schema || 'public'), max: current.max || 5, connectTimeoutMs: 10000, }, }; } app.get('/api/settings/storage', async ({ res }) => { const st = ctx.config.storage || {}; const pg = st.postgres || {}; sendJson(res, 200, { driver: st.driver || 'sqlite', sqliteFile: (st.sqlite && st.sqlite.file) || 'data/ops.db', pgInstalled: pgInstalled(), postgres: { host: pg.host || '', port: pg.port || 5432, database: pg.database || '', user: pg.user || '', passwordSet: !!pg.password, ssl: !!pg.ssl, schema: pg.schema || 'public', }, }); }); app.post('/api/settings/storage/test', async ({ req, res }) => { const body = await readBody(req); if (!pgInstalled()) return sendJson(res, 200, { ok: false, error: '未安装 pg 驱动(npm install pg)' }); const form = storageFormFromBody(body).postgres; if (!form.host || !form.database || !form.user) { return sendJson(res, 200, { ok: false, error: '请填写主机、数据库名和用户名' }); } const { Client } = require('pg'); const client = new Client({ host: form.host, port: form.port, database: form.database, user: form.user, password: form.password, ssl: form.ssl ? { rejectUnauthorized: false } : false, connectionTimeoutMillis: 8000, }); try { await client.connect(); const r = await client.query('select version() as v, current_database() as db'); sendJson(res, 200, { ok: true, version: String(r.rows[0].v).split(' ').slice(0, 2).join(' '), database: r.rows[0].db, }); } catch (e) { sendJson(res, 200, { ok: false, error: e.message || String(e) }); } finally { await client.end().catch(() => {}); } }); app.post('/api/settings/storage', async ({ req, res }) => { const body = await readBody(req); const next = storageFormFromBody(body); if (next.driver === 'postgres' && (!next.postgres.host || !next.postgres.database || !next.postgres.user)) { return sendJson(res, 400, { error: '启用云端数据库需要填写主机、数据库名和用户名' }); } const previous = (ctx.config.storage && ctx.config.storage.driver) || 'sqlite'; ctx.config.storage = { ...(ctx.config.storage || {}), ...next }; ctx.saveConfig(); ctx.logs.pushSystemLog( `存储驱动已配置为 ${next.driver}${next.driver === 'postgres' ? `(${next.postgres.host}:${next.postgres.port}/${next.postgres.database})` : ''}`, 'success', ); sendJson(res, 200, { ok: true, restartRequired: previous !== next.driver }); }); } module.exports = { register };