| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- '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> 里的节点 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 };
|