#!/usr/bin/env node /** * Copy local deepseek-cp provider config to production and rebind Goose executors. * * Usage (from dev machine): * eval "$(ssh john@58.38.22.103 'grep -E "^(DATABASE_URL|TKMIND_API_TARGET|TKMIND_API_TARGETS|TKMIND_SERVER__SECRET_KEY)=" /Users/john/Project/Memind/.env | sed "s/^/TARGET_/"')" * node scripts/sync-deepseek-cp-prod.mjs */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import mysql from 'mysql2/promise'; import { Agent, fetch } from 'undici'; import { createDbPool } from '../db.mjs'; import { createLlmProviderService, decryptSecret, CUSTOM_PROVIDER_ID, } from '../llm-providers.mjs'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const CONFIG_NAME = 'deepseek-cp'; const OLD_CONFIG_NAME = 'DeepSeek 直连'; const MODEL = 'deepseek-v4-pro'; const MODELS = ['deepseek-v4-pro', 'deepseek-v4-flash']; const API_URL = 'https://card.nassaapi.xyz/v1'; const EXECUTORS = ['goose', 'aider', 'openhands']; function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return; for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eq = trimmed.indexOf('='); if (eq < 0) continue; const key = trimmed.slice(0, eq).trim(); const value = trimmed.slice(eq + 1).trim(); if (!process.env[key]) process.env[key] = value; } } function parseApiTargets(raw, fallback) { const values = String(raw ?? '') .split(',') .map((item) => item.trim()) .filter(Boolean); return [...new Set([...(values.length ? values : []), fallback].filter(Boolean))]; } async function readApiKeyFromLocal(pool) { const [rows] = await pool.query( 'SELECT * FROM h5_llm_provider_keys WHERE name = ? LIMIT 1', [CONFIG_NAME], ); const row = rows[0]; if (!row) throw new Error(`本地未找到 ${CONFIG_NAME} 配置`); const apiKey = decryptSecret( { ciphertext: row.api_key_ciphertext, iv: row.api_key_iv, tag: row.api_key_tag, }, process.env.TKMIND_SERVER__SECRET_KEY, ); if (!apiKey) throw new Error(`无法解密本地 ${CONFIG_NAME} API Key`); return apiKey; } async function upsertDeepseekCp(svc, apiKey) { const keys = await svc.listKeys(); let row = keys.find((item) => item.name === CONFIG_NAME); if (row) { const updated = await svc.updateKey(row.id, { name: CONFIG_NAME, apiKey, apiUrl: API_URL, defaultModel: MODEL, models: MODELS, status: 'active', }); if (!updated.ok) throw new Error(`更新 ${CONFIG_NAME} 失败: ${updated.message}`); row = updated.key; console.log(`已更新生产 ${CONFIG_NAME}: ${row.id}`); } else { const created = await svc.createKey({ providerId: CUSTOM_PROVIDER_ID, providerKind: 'custom', name: CONFIG_NAME, apiKey, apiUrl: API_URL, defaultModel: MODEL, models: MODELS, }); if (!created.ok) throw new Error(`创建 ${CONFIG_NAME} 失败: ${created.message}`); row = created.key; console.log(`已创建生产 ${CONFIG_NAME}: ${row.id}`); } return row.id; } async function replaceProviderKeyReferences(pool, oldKeyId, newKeyId) { if (!oldKeyId || oldKeyId === newKeyId) return; const [pluginRows] = await pool.query( 'SELECT plugin_id, llm_provider_key_id FROM h5_asset_plugin_configs WHERE llm_provider_key_id = ?', [oldKeyId], ); if (pluginRows.length) { await pool.query( 'UPDATE h5_asset_plugin_configs SET llm_provider_key_id = ?, updated_at = ? WHERE llm_provider_key_id = ?', [newKeyId, Date.now(), oldKeyId], ); console.log(`已更新 asset plugin 引用: ${pluginRows.length} 条`); } const [memRows] = await pool.query( 'SELECT config_scope, config_json FROM h5_memory_v2_admin_config WHERE config_json LIKE ?', [`%${oldKeyId}%`], ); for (const row of memRows) { const config = typeof row.config_json === 'string' ? JSON.parse(row.config_json) : row.config_json; let changed = false; for (const section of Object.values(config ?? {})) { if (section && typeof section === 'object' && section.modelProviderKeyId === oldKeyId) { section.modelProviderKeyId = newKeyId; if (section.model === 'deepseek-chat') section.model = 'deepseek-v4-flash'; changed = true; } } if (changed) { await pool.query( 'UPDATE h5_memory_v2_admin_config SET config_json = ?, updated_at = ? WHERE config_scope = ?', [JSON.stringify(config), Date.now(), row.config_scope], ); console.log(`已更新 memory-v2 配置: ${row.config_scope}`); } } const [wechatRows] = await pool.query( 'SELECT config_key, config_json FROM h5_wechat_admin_config WHERE config_json LIKE ?', [`%${oldKeyId}%`], ); for (const row of wechatRows) { const config = typeof row.config_json === 'string' ? JSON.parse(row.config_json) : row.config_json; if (config?.modelProviderKeyId === oldKeyId) { config.modelProviderKeyId = newKeyId; await pool.query( 'UPDATE h5_wechat_admin_config SET config_json = ?, updated_at = ? WHERE config_key = ?', [JSON.stringify(config), Date.now(), row.config_key], ); console.log(`已更新 wechat admin 配置: ${row.config_key}`); } } } async function testChatCompletion(svc, keyId) { const result = await svc.createChatCompletion({ providerKeyId: keyId, model: MODEL, temperature: 0, messages: [{ role: 'user', content: 'Reply with OK only.' }], }); return result; } loadEnvFile(path.join(root, '.env')); loadEnvFile(path.join(root, '.env.local')); const targetDatabaseUrl = String( process.env.TARGET_DATABASE_URL ?? process.env.PROD_DATABASE_URL ?? '', ).trim(); const targetApiTarget = String( process.env.TARGET_TKMIND_API_TARGET ?? process.env.TKMIND_API_TARGET ?? '', ).trim(); const targetApiTargets = parseApiTargets( process.env.TARGET_TKMIND_API_TARGETS ?? process.env.TKMIND_API_TARGETS, targetApiTarget, ); const targetApiSecret = String( process.env.TARGET_TKMIND_SERVER__SECRET_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? '', ).trim(); if (!targetDatabaseUrl) { console.error('缺少 TARGET_DATABASE_URL(或 PROD_DATABASE_URL)'); process.exit(1); } if (!targetApiTarget || !targetApiSecret) { console.error('缺少 TARGET_TKMIND_API_TARGET / TARGET_TKMIND_SERVER__SECRET_KEY'); process.exit(1); } const localPool = createDbPool(); const prodPool = mysql.createPool({ uri: targetDatabaseUrl, connectionLimit: 4 }); try { const apiKey = await readApiKeyFromLocal(localPool); console.log(`已读取本地 ${CONFIG_NAME} API Key`); const prodSvc = createLlmProviderService(prodPool, { apiTarget: targetApiTarget, apiTargets: targetApiTargets, apiSecret: targetApiSecret, }); const prodKeysBefore = await prodSvc.listKeys(); const oldKey = prodKeysBefore.find((item) => item.name === OLD_CONFIG_NAME); const oldKeyId = oldKey?.id ?? null; const newKeyId = await upsertDeepseekCp(prodSvc, apiKey); const selected = await prodSvc.selectKey(newKeyId); if (!selected.ok) throw new Error(`启用 ${CONFIG_NAME} 失败: ${selected.message}`); console.log(`已将 ${CONFIG_NAME} 设为全局默认`); for (const executor of EXECUTORS) { const bound = await prodSvc.setExecutorBinding(executor, { providerKeyId: newKeyId, model: MODEL, enabled: true, }); if (!bound.ok) throw new Error(`绑定 ${executor} 失败: ${bound.message}`); console.log(`已绑定 ${executor} -> ${CONFIG_NAME} / ${MODEL}`); } await replaceProviderKeyReferences(prodPool, oldKeyId, newKeyId); const synced = await prodSvc.syncSelectedToGoosed(); if (!synced.ok) throw new Error(`同步 goosed 失败: ${synced.message}`); console.log( 'goosed 已同步:', synced.providerId, synced.model, `targets=${(synced.targets ?? []).length}`, ); const test = await testChatCompletion(prodSvc, newKeyId); console.log( `${CONFIG_NAME} 联通测试:`, test.ok ? `OK model=${test.model}` : `失败 ${test.status ?? ''} ${test.message ?? ''}`, ); const gooseRuntime = await prodSvc.getExecutorRuntimeConfig('goose', { includeSecret: false }); console.log('Goose runtime:', JSON.stringify({ providerId: gooseRuntime.providerId, providerName: gooseRuntime.providerName, model: gooseRuntime.model, apiUrl: gooseRuntime.apiUrl, })); if (!test.ok) process.exit(1); } finally { await localPool.end().catch(() => {}); await prodPool.end().catch(() => {}); }