diff --git a/.env b/.env index cd56da8..9662778 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ PY_HOSTNAME=localhost PY_PORT=9999 VAR=1234 -BKEXE_PATH=./backend/__main__.exe \ No newline at end of file +BKEXE_PATH=./backend/__main__.exe +PKG_VERSION=0.0.1 \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 93e4674..0f60c89 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,10 @@ module.exports = { 'max-classes-per-file': 'off', 'no-continue': 'off', 'no-plusplus': 'off', + 'no-underscore-dangle': 'off', + camelcase: 'off', + 'no-use-before-define': 'off', + 'no-dupe-class-members': 'off', }, parserOptions: { ecmaVersion: 2022, diff --git a/src/main/backend/backend.ts b/src/main/backend/backend.ts index 9c5d0fc..7d4e478 100644 --- a/src/main/backend/backend.ts +++ b/src/main/backend/backend.ts @@ -17,7 +17,7 @@ import { BroadcastService } from './services/broadcastService'; import { SystemService } from './services/systemService'; import { ConfigService } from './services/configService'; import { PlatformConfigController } from './controllers/platformConfigController'; -import { AutoReplyController } from './controllers/autoReplyController'; +import { AutoReplyController } from './controllers/keywordReplyController'; const sessionController = new SessionController(); const configController = new ConfigController(); @@ -93,7 +93,6 @@ class BKServer { console.log('Client connected registerHandlers'); this.messageService.registerHandlers(socket); this.sessionService.registerHandlers(socket); - this.configService.registerHandlers(socket); this.broadcastService.registerHandlers(socket); socket.on('disconnect', () => { diff --git a/src/main/backend/constants/index.ts b/src/main/backend/constants/index.ts index e69de29..087f125 100644 --- a/src/main/backend/constants/index.ts +++ b/src/main/backend/constants/index.ts @@ -0,0 +1,71 @@ +export const ALL_PLATFORMS = [ + { + id: 'bilibili', + name: 'bilibili', + }, + { + id: 'douyin', + name: '抖音', + }, + { + id: 'douyin_mp', + name: '抖音企业号', + }, + { + id: 'win_jinmai', + name: '京卖', + }, + { + id: 'jinritemai', + name: '抖店', + }, + { + id: 'win_qianniu', + name: '千牛', + }, + { + id: 'win_wechat', + name: '微信', + }, + { + id: 'win_wecom', + name: '企微(Bate版)', + }, + { + id: 'weibo', + name: '微博私信', + }, + { + id: 'xiaohongshu', + name: '小红书评论', + }, + { + id: 'xiaohongshu_pro', + name: '小红书私信', + }, + { + id: 'zhihu', + name: '知乎', + }, +]; + +// # 固定会传递的上下文参数 +// CTX_APP_NAME = "app_name" +// CTX_APP_ID = "app_id" +// CTX_INSTANCE_ID = "instance_id" + +// CTX_USERNAME = "username" +// CTX_PLATFORM = "platform" +// CTX_HAS_NEW_MESSAGE = "has_new_message" +// CTX_HAS_GROUP_MESSAGE = "has_group_message" + +// # 千牛平台特有的上下文参数 +// CTX_CURRENT_GOODS = "CTX_CURRENT_GOODS" # 当前商品 +// CTX_CURRENT_GOODS_ID = "CTX_CURRENT_GOODS_ID" # 当前商品 ID +// CTX_MEMBER_TAG = "CTX_MEMBER_TAG" # 会员标签 +// CTX_FAN_TAG = "CTX_FAN_TAG" # 粉丝标签 +// CTX_NEW_CUSTOMER_TAG = "CTX_NEW_CUSTOMER_TAG" # 新客标签 + +export const CTX_APP_NAME = 'app_name'; +export const CTX_APP_ID = 'app_id'; +export const CTX_INSTANCE_ID = 'instance_id'; diff --git a/src/main/backend/controllers/configController.ts b/src/main/backend/controllers/configController.ts index 71f2e1d..875c4e2 100644 --- a/src/main/backend/controllers/configController.ts +++ b/src/main/backend/controllers/configController.ts @@ -1,44 +1,37 @@ import { Config } from '../entities/config'; +import { Plugin } from '../entities/plugin'; export class ConfigController { - async getConfig(): Promise { - try { - const config = await Config.findByPk(1); - if (!config) { - throw new Error('Config not found'); - } - - return config; - } catch (error) { - const newConfig = await Config.create({ - extract_phone: true, - extract_product: true, - save_path: '', - reply_speed: 0, - reply_random_speed: 0, - default_reply: '', - wait_humans_time: 60, - context_count: 1, - gpt_base_url: 'https://api.openai.com/v1', - gpt_key: 'your-key', - gpt_model: 'gpt-3.5-turbo', - gpt_temperature: 0.7, - gpt_top_p: 0.9, - stream: true, - use_lazy: false, - lazy_key: 'your-key', - }); - return newConfig; - } + async getByAppId(appId: string): Promise { + const config = await Config.findOne({ + where: { platform_id: appId }, + }); + return config; } - async updateConfig(id: number, configData: Partial): Promise { - const config = await Config.findByPk(id); + async getByInstanceId(instanceId: string): Promise { + const config = await Config.findOne({ + where: { instance_id: instanceId }, + }); + return config; + } + + async getGlobalConfig(): Promise { + let config = await Config.findOne({ + where: { global: true }, + }); + if (!config) { - throw new Error('Config not found'); + config = await Config.create({ + global: true, + }); } - const data = await config.update(configData); - return data; + return config; + } + + async getPluginConfig(pluginId: number): Promise { + const plugin = await Plugin.findByPk(pluginId); + return plugin; } } diff --git a/src/main/backend/controllers/globalParamController.ts b/src/main/backend/controllers/globalParamController.ts deleted file mode 100644 index 83ac6f2..0000000 --- a/src/main/backend/controllers/globalParamController.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { GlobalParam } from '../entities/globalParam'; - -export class GlobalParamController { - async create(globalParamData: any) { - return GlobalParam.create(globalParamData); - } - - async update(id: number, globalParamData: any) { - const globalParam = await GlobalParam.findByPk(id); - if (!globalParam) { - throw new Error('GlobalParam not found'); - } - return globalParam.update(globalParamData); - } - - async delete(id: number) { - const globalParam = await GlobalParam.findByPk(id); - if (!globalParam) { - throw new Error('GlobalParam not found'); - } - return globalParam.destroy(); - } - - async list() { - return GlobalParam.findAll(); - } -} diff --git a/src/main/backend/controllers/autoReplyController.ts b/src/main/backend/controllers/keywordReplyController.ts similarity index 90% rename from src/main/backend/controllers/autoReplyController.ts rename to src/main/backend/controllers/keywordReplyController.ts index 462a98e..845d05e 100644 --- a/src/main/backend/controllers/autoReplyController.ts +++ b/src/main/backend/controllers/keywordReplyController.ts @@ -1,17 +1,17 @@ import ExcelJS from 'exceljs'; import fs from 'fs'; import { Op } from 'sequelize'; -import { AutoReply } from '../entities/autoReply'; +import { Keyword } from '../entities/keyword'; import { ALL_PLATFORMS } from '../constants'; import { getTempPath } from '../../utils'; export class AutoReplyController { async create(autoReplyData: any) { - return AutoReply.create(autoReplyData); + return Keyword.create(autoReplyData); } async update(id: number, autoReplyData: any) { - const autoReply = await AutoReply.findByPk(id); + const autoReply = await Keyword.findByPk(id); if (!autoReply) { throw new Error('AutoReply not found'); } @@ -19,7 +19,7 @@ export class AutoReplyController { } async delete(id: number) { - const autoReply = await AutoReply.findByPk(id); + const autoReply = await Keyword.findByPk(id); if (!autoReply) { throw new Error('AutoReply not found'); } @@ -68,22 +68,22 @@ export class AutoReplyController { } }); - const originalAutoReplies = await AutoReply.findAll(); + const originalAutoReplies = await Keyword.findAll(); try { // 先删除所有数据 - await AutoReply.destroy({ where: {} }); - await AutoReply.bulkCreate(autoReplies); + await Keyword.destroy({ where: {} }); + await Keyword.bulkCreate(autoReplies); } catch (error) { // 如果插入失败,回滚数据 // @ts-ignore - await AutoReply.bulkCreate(originalAutoReplies); + await Keyword.bulkCreate(originalAutoReplies); throw error; } } async exportExcel() { - const autoReplies = await AutoReply.findAll(); + const autoReplies = await Keyword.findAll(); const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet('自动回复'); @@ -144,13 +144,13 @@ export class AutoReplyController { } async getKeywords(platformId: string) { - const autoReplies = await AutoReply.findAll({ + const autoReplies = await Keyword.findAll({ where: { platform_id: platformId, }, }); - const globalKeywords = await AutoReply.findAll({ + const globalKeywords = await Keyword.findAll({ where: { platform_id: { [Op.or]: [null, ''], @@ -172,7 +172,7 @@ export class AutoReplyController { }) { try { const { rows: autoReplies, count: total } = - await AutoReply.findAndCountAll({ + await Keyword.findAndCountAll({ where: platformId ? { platform_id: platformId, diff --git a/src/main/backend/controllers/platformConfigController.ts b/src/main/backend/controllers/platformConfigController.ts deleted file mode 100644 index 59b29a1..0000000 --- a/src/main/backend/controllers/platformConfigController.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { PlatformConfig } from '../entities/platformConfig'; - -export class PlatformConfigController { - async updateByPlatformId( - platformId: string, - settings: any, - ): Promise { - const platformConfig = await PlatformConfig.findOne({ - where: { platform_id: platformId }, - }); - - const active = settings && settings.active ? settings.active : false; - - if (platformConfig) { - // @ts-ignore - return this.update(platformConfig.id, { settings, active }); - } - - return this.create({ - platform_id: platformId, - settings, - active, - }); - } - - async getByPlatformId(platformId: string) { - let data = await PlatformConfig.findOne({ - where: { platform_id: platformId }, - }); - - if (!data) { - // 创建一个对象 - data = await PlatformConfig.create({ - platform_id: platformId, - active: false, - settings: {}, - }); - } - - return data; - } - - async create(platformConfigData: any): Promise { - const platformConfig = await PlatformConfig.create(platformConfigData); - return platformConfig; - } - - async update(id: number, platformConfigData: Partial) { - const platformConfig = await PlatformConfig.findOne({ - where: { id }, - }); - if (!platformConfig) { - return; - } - - await platformConfig.update(platformConfigData); - } - - async delete(id: number): Promise { - const platformConfig = await PlatformConfig.findOne({ - where: { id }, - }); - if (!platformConfig) { - return; - } - await platformConfig.destroy(); - } -} diff --git a/src/main/backend/controllers/sessionController.ts b/src/main/backend/controllers/sessionController.ts index 9fb8012..11e0cef 100644 --- a/src/main/backend/controllers/sessionController.ts +++ b/src/main/backend/controllers/sessionController.ts @@ -17,9 +17,22 @@ export class SessionCreate { } export class SessionController { - async create(sessionData: SessionCreate) { - // @ts-ignore - return Session.create(sessionData); + async createSession( + platformId: string, + platformName: string, + username?: string, + goodsName?: string, + goodsAvatar?: string, + ): Promise { + return Session.create({ + platform_id: platformId, + platform: platformName, + username: username || '', + last_active: new Date(), + goods_name: goodsName || '', + goods_avatar: goodsAvatar || '', + created_at: new Date(), + }); } async update(id: number, sessionData: Session) { diff --git a/src/main/backend/entities/config.ts b/src/main/backend/entities/config.ts index 83552ab..0e8a506 100644 --- a/src/main/backend/entities/config.ts +++ b/src/main/backend/entities/config.ts @@ -4,6 +4,20 @@ import { DataTypes, Model, Sequelize } from 'sequelize'; export class Config extends Model { declare id: number; + declare global: boolean; + + declare active: boolean; + + declare platform: string; + + declare platform_id: string; + + declare instance_id: string; + + declare use_plugin: boolean; + + declare plugin_id: number; + declare extract_phone: boolean; declare extract_product: boolean; @@ -24,48 +38,7 @@ export class Config extends Model { declare gpt_key: string; - declare use_dify: boolean; - - declare gpt_model: string; - - declare gpt_temperature: number; - - declare gpt_top_p: number; - - declare stream: boolean; -} - -export async function checkAndAddFields(sequelize: Sequelize) { - const tableDescription = await Config.describe(); - - // @ts-ignore - if (!tableDescription.reply_random_speed) { - await sequelize - .getQueryInterface() - .addColumn('Config', 'reply_random_speed', { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - }); - } - - // @ts-ignore - if (!tableDescription.context_count) { - await sequelize.getQueryInterface().addColumn('Config', 'context_count', { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - }); - } - - // @ts-ignore - if (!tableDescription.default_reply) { - await sequelize.getQueryInterface().addColumn('Config', 'default_reply', { - type: DataTypes.STRING, - allowNull: true, - defaultValue: '', - }); - } + declare llm_type: string; } export function initConfig(sequelize: Sequelize) { @@ -76,6 +49,37 @@ export function initConfig(sequelize: Sequelize) { autoIncrement: true, primaryKey: true, }, + global: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: true, + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: true, + }, + platform: { + type: DataTypes.STRING(255), + allowNull: true, + }, + platform_id: { + type: DataTypes.STRING(255), + allowNull: true, + }, + instance_id: { + type: DataTypes.STRING(255), + allowNull: true, + }, + use_plugin: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: true, + }, + plugin_id: { + type: DataTypes.INTEGER, + allowNull: true, + }, extract_phone: { type: DataTypes.BOOLEAN, defaultValue: false, @@ -110,43 +114,25 @@ export function initConfig(sequelize: Sequelize) { type: DataTypes.FLOAT, allowNull: true, }, - gpt_base_url: { + base_url: { type: DataTypes.STRING, allowNull: true, }, - gpt_key: { + key: { type: DataTypes.STRING, allowNull: true, }, - use_dify: { - type: DataTypes.BOOLEAN, - defaultValue: false, - allowNull: true, - }, - gpt_model: { + llm_type: { type: DataTypes.STRING, allowNull: true, - }, - gpt_temperature: { - type: DataTypes.FLOAT, - allowNull: true, - }, - gpt_top_p: { - type: DataTypes.FLOAT, - allowNull: true, - }, - stream: { - type: DataTypes.BOOLEAN, - allowNull: true, + defaultValue: 'chatgpt', }, }, { sequelize, modelName: 'Config', - tableName: 'config', + tableName: 'n_config', timestamps: false, }, ); - - checkAndAddFields(sequelize); } diff --git a/src/main/backend/entities/globalParam.ts b/src/main/backend/entities/globalParam.ts deleted file mode 100644 index 220b93c..0000000 --- a/src/main/backend/entities/globalParam.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DataTypes, Model, Sequelize } from 'sequelize'; - -// Extend the Model class with the attributes interface -export class GlobalParam extends Model { - declare id: number; - - declare key: string; - - declare value: string; -} - -export function initGlobalParam(sequelize: Sequelize) { - GlobalParam.init( - { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true, - }, - key: { - type: DataTypes.STRING(255), - allowNull: false, - }, - value: { - type: DataTypes.TEXT, - allowNull: false, - }, - }, - { - sequelize, - modelName: 'GlobalParam', - tableName: 'global_params', - timestamps: false, - }, - ); -} diff --git a/src/main/backend/entities/autoReply.ts b/src/main/backend/entities/keyword.ts similarity index 83% rename from src/main/backend/entities/autoReply.ts rename to src/main/backend/entities/keyword.ts index 45e4dfc..a4b800c 100644 --- a/src/main/backend/entities/autoReply.ts +++ b/src/main/backend/entities/keyword.ts @@ -1,7 +1,7 @@ import { DataTypes, Model, Sequelize } from 'sequelize'; // Extend the Model class with the attributes interface -export class AutoReply extends Model { +export class Keyword extends Model { declare id: number; // Note that the `null assertion` `!` is required in strict mode. declare keyword: string; @@ -13,8 +13,8 @@ export class AutoReply extends Model { declare platform_id: string; } -export function initAutoReply(sequelize: Sequelize) { - AutoReply.init( +export function initKeyword(sequelize: Sequelize) { + Keyword.init( { id: { type: DataTypes.INTEGER, @@ -40,8 +40,8 @@ export function initAutoReply(sequelize: Sequelize) { }, { sequelize, - modelName: 'AutoReply', - tableName: 'auto_reply', + modelName: 'Keyword', + tableName: 'keyword', timestamps: false, }, ); diff --git a/src/main/backend/entities/message.ts b/src/main/backend/entities/message.ts index ca17ad3..54eb989 100644 --- a/src/main/backend/entities/message.ts +++ b/src/main/backend/entities/message.ts @@ -10,7 +10,7 @@ export class Message extends Model { declare content: string; - declare unique: string; + declare sender: string; declare msg_type: string; @@ -31,19 +31,19 @@ export function initMessage(sequelize: Sequelize) { }, role: { type: DataTypes.STRING(100), - allowNull: false, + allowNull: true, + }, + sender: { + type: DataTypes.STRING(100), + allowNull: true, }, content: { type: DataTypes.TEXT, - allowNull: false, - }, - unique: { - type: DataTypes.STRING(255), - allowNull: false, + allowNull: true, }, msg_type: { type: DataTypes.STRING(55), - allowNull: false, + allowNull: true, }, created_at: { type: DataTypes.DATE, @@ -53,7 +53,7 @@ export function initMessage(sequelize: Sequelize) { { sequelize, modelName: 'Message', - tableName: 'messages', + tableName: 'n_messages', timestamps: false, }, ); diff --git a/src/main/backend/entities/platformConfig.ts b/src/main/backend/entities/platformConfig.ts deleted file mode 100644 index 30fa1bb..0000000 --- a/src/main/backend/entities/platformConfig.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { DataTypes, Model, Sequelize } from 'sequelize'; - -// Extend the Model class with the attributes interface -export class PlatformConfig extends Model { - declare id: number; - - declare platform_id: string; - - declare settings: any; - - declare active: boolean; -} - -export function initPlatformConfig(sequelize: Sequelize) { - PlatformConfig.init( - { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true, - }, - platform_id: { - type: DataTypes.STRING(255), - allowNull: false, - }, - settings: { - type: DataTypes.JSON, - allowNull: false, - }, - active: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - }, - { - sequelize, - modelName: 'PlatformConfig', - tableName: 'platform_cfg', // 创建一个新的表,用于存储 JSON 格式的配置 - timestamps: false, - }, - ); -} diff --git a/src/main/backend/entities/plugin.ts b/src/main/backend/entities/plugin.ts new file mode 100644 index 0000000..3608599 --- /dev/null +++ b/src/main/backend/entities/plugin.ts @@ -0,0 +1,65 @@ +import { DataTypes, Model, Sequelize } from 'sequelize'; + +export class Plugin extends Model { + declare id: number; + + declare name: string; + + declare enabled: boolean; + + declare code: string; + + declare platform: string; + + declare platform_id: string; + + declare instance_id: string; // 可能是作用于单个实例的插件 + + declare created_at: Date; +} + +export function initPlugin(sequelize: Sequelize) { + Plugin.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING(255), + allowNull: true, + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: true, + }, + code: { + type: DataTypes.TEXT, + allowNull: true, + }, + platform: { + type: DataTypes.STRING(255), + allowNull: true, + }, + platform_id: { + type: DataTypes.STRING(255), + allowNull: true, + }, + instance_id: { + type: DataTypes.STRING(255), + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'Plugin', + tableName: 'plugins', + timestamps: false, + }, + ); +} diff --git a/src/main/backend/entities/session.ts b/src/main/backend/entities/session.ts index fc3b05c..4cfbb41 100644 --- a/src/main/backend/entities/session.ts +++ b/src/main/backend/entities/session.ts @@ -3,17 +3,13 @@ import { DataTypes, Model, Sequelize } from 'sequelize'; export class Session extends Model { declare id: number; - declare username: string; - - declare last_active: Date; - declare platform: string; declare platform_id: string; - declare goods_name: string; + declare instance_id: string; // 可能是作用于单个实例的插件 - declare goods_avatar: string; + declare context: string; declare created_at: Date; } @@ -26,14 +22,6 @@ export function initSession(sequelize: Sequelize) { autoIncrement: true, primaryKey: true, }, - username: { - type: DataTypes.STRING(255), - allowNull: false, - }, - last_active: { - type: DataTypes.DATE, - allowNull: true, - }, platform: { type: DataTypes.STRING(255), allowNull: true, @@ -42,12 +30,8 @@ export function initSession(sequelize: Sequelize) { type: DataTypes.STRING(255), allowNull: true, }, - goods_name: { - type: DataTypes.STRING(255), - allowNull: true, - }, - goods_avatar: { - type: DataTypes.STRING(255), + context: { + type: DataTypes.JSON, allowNull: true, }, created_at: { @@ -58,7 +42,7 @@ export function initSession(sequelize: Sequelize) { { sequelize, modelName: 'Session', - tableName: 'sessions', + tableName: 'n_sessions', timestamps: false, }, ); diff --git a/src/main/backend/ormconfig.ts b/src/main/backend/ormconfig.ts index b65e764..2f94dfc 100644 --- a/src/main/backend/ormconfig.ts +++ b/src/main/backend/ormconfig.ts @@ -6,11 +6,10 @@ import * as path from 'path'; import sqlite from 'sqlite3'; import { Sequelize } from 'sequelize'; import { initConfig } from './entities/config'; -import { initPlatformConfig } from './entities/platformConfig'; import { initSession } from './entities/session'; import { initMessage } from './entities/message'; -import { AutoReply, initAutoReply } from './entities/autoReply'; -import { initGlobalParam } from './entities/globalParam'; +import { initPlugin } from './entities/plugin'; +import { Keyword, initKeyword } from './entities/keyword'; // Get user's documents directory path const DOCUMENTS_DIR = path.join(os.homedir(), 'Documents'); @@ -35,15 +34,14 @@ const sequelize = new Sequelize({ // 初始化模型 initConfig(sequelize); -initPlatformConfig(sequelize); initSession(sequelize); initMessage(sequelize); -initAutoReply(sequelize); -initGlobalParam(sequelize); +initKeyword(sequelize); +initPlugin(sequelize); // 异步初始化和数据填充函数 async function initDb(): Promise { - const count = await AutoReply.count(); + const count = await Keyword.count(); if (count === 0) { const replies = [ { @@ -153,7 +151,7 @@ async function initDb(): Promise { }, ]; - await AutoReply.bulkCreate(replies); + await Keyword.bulkCreate(replies); } } diff --git a/src/main/backend/services/broadcastService.ts b/src/main/backend/services/broadcastService.ts deleted file mode 100644 index 10caf40..0000000 --- a/src/main/backend/services/broadcastService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import socketIo from 'socket.io'; -import { BrowserWindow } from 'electron'; - -export class BroadcastService { - private mainWindow: BrowserWindow; - - constructor(mainWindow: BrowserWindow) { - this.mainWindow = mainWindow; - } - - public registerHandlers(socket: socketIo.Socket): void { - socket.on('broadcastService-sendBroadcast', (msg: any, callback) => { - const { event_id: eventId, message } = msg; - this.receiveBroadcast(msg); - callback({ - event_id: eventId, - event_type: message, - }); - }); - } - - public receiveBroadcast(msg: any): void { - this.mainWindow.webContents.send('broadcast', msg); - } -} diff --git a/src/main/backend/services/configService.ts b/src/main/backend/services/configService.ts index 998f812..7012559 100644 --- a/src/main/backend/services/configService.ts +++ b/src/main/backend/services/configService.ts @@ -1,67 +1,29 @@ -import socketIo from 'socket.io'; import { ConfigController } from '../controllers/configController'; -import { PlatformConfigController } from '../controllers/platformConfigController'; import { Config } from '../entities/config'; +import { CTX_APP_ID, CTX_INSTANCE_ID } from '../constants'; export class ConfigService { private configController: ConfigController; - private platformConfigController: PlatformConfigController; - - constructor( - configController: ConfigController, - platformConfigController: PlatformConfigController, - ) { + constructor(configController: ConfigController) { this.configController = configController; - this.platformConfigController = platformConfigController; } - public registerHandlers(socket: socketIo.Socket): void { - socket.on('configService-getConfig', async (arg, callback) => { - try { - const config = await this.getConfig(); - callback(config); - } catch (error) { - console.error('Failed to get config', error); - callback(null); - } - }); - } + public async get(ctx: any): Promise { + const appId = ctx.get(CTX_APP_ID); + const instanceId = ctx.get(CTX_INSTANCE_ID); - public async getConfigByPlatformId(platformId: string): Promise { - const platformConfig = - await this.platformConfigController.getByPlatformId(platformId); - - const { settings } = platformConfig; - const settingsVal = - typeof settings === 'string' ? JSON.parse(settings) : settings; - if (!settingsVal || !settingsVal.active) { - return this.configController.getConfig(); + const instanceConfig = + await this.configController.getByInstanceId(instanceId); + if (instanceConfig) { + return instanceConfig; } - const config = await this.configController.getConfig(); + const config = await this.configController.getByAppId(appId); + if (config) { + return config; + } - return { - id: config.id, - extract_phone: config.extract_phone, - extract_product: config.extract_product, - save_path: config.save_path, - reply_speed: config.reply_speed, - reply_random_speed: config.reply_random_speed, - wait_humans_time: config.wait_humans_time, - context_count: settingsVal.contextCount ?? config.context_count, - gpt_base_url: settingsVal.proxyAddress ?? config.gpt_base_url, - gpt_key: settingsVal.apiKey ?? config.gpt_key, - use_dify: settingsVal.useDify ?? config.use_dify, - gpt_model: settingsVal.model ?? config.gpt_model, - default_reply: settingsVal.defaultReply ?? config.default_reply, - gpt_temperature: config.gpt_temperature, - gpt_top_p: config.gpt_top_p, - stream: config.stream, - } as any; - } - - public async getConfig(): Promise { - return this.configController.getConfig(); + return this.configController.getGlobalConfig(); } } diff --git a/src/main/backend/services/dispatchService.ts b/src/main/backend/services/dispatchService.ts new file mode 100644 index 0000000..efe76eb --- /dev/null +++ b/src/main/backend/services/dispatchService.ts @@ -0,0 +1,80 @@ +import socketIo from 'socket.io'; +import { BrowserWindow } from 'electron'; +import { Platform, StrategyServiceStatusEnum } from '../types'; +import { emitAndWait } from '../../utils'; + +export class DispatchService { + private mainWindow: BrowserWindow; + + private io: socketIo.Server; + + constructor(mainWindow: BrowserWindow, io: socketIo.Server) { + this.mainWindow = mainWindow; + this.io = io; + } + + public registerHandlers(socket: socketIo.Socket): void { + socket.on('messageService-broadcast', (msg: any, callback) => { + const { event_id: eventId, message } = msg; + this.receiveBroadcast(msg); + callback({ + event_id: eventId, + event_type: message, + }); + }); + + socket.on('messageService-getMessages', async (data, callback) => { + const { ctx, messages } = data; + }); + } + + public receiveBroadcast(msg: any): void { + this.mainWindow.webContents.send('broadcast', msg); + } + + public async checkHealth(): Promise { + try { + return await this.io.timeout(5000).emitWithAck('systemService-health'); + } catch (error) { + console.error('Failed to check health', error); + return false; + } + } + + public async updateStatus(status: StrategyServiceStatusEnum): Promise { + try { + return await emitAndWait(this.io, 'strategyService-updateStatus', { + status, + }); + } catch (error) { + console.error('Failed to update status', error); + return null; + } + } + + public async getAllPlatforms(): Promise { + const maxRetries = 10; + let attempt = 0; + + while (attempt < maxRetries) { + try { + // eslint-disable-next-line no-await-in-loop + const data = await emitAndWait( + this.io, + 'strategyService-getAppsInfo', + ); + return data; + } catch (error) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 1000)); + attempt++; + console.error(`Attempt ${attempt} failed to update strategies`, error); + if (attempt >= maxRetries) { + return []; + } + } + } + + return []; + } +} diff --git a/src/main/backend/services/messageService.ts b/src/main/backend/services/messageService.ts index ed5ee89..c76940c 100644 --- a/src/main/backend/services/messageService.ts +++ b/src/main/backend/services/messageService.ts @@ -1,720 +1,462 @@ import fs from 'fs/promises'; import { DateTime } from 'luxon'; -import OpenAI from 'openai'; import socketIo from 'socket.io'; -import axios from 'axios'; import { ConfigService } from './configService'; import { MessageController } from '../controllers/messageController'; -import { AutoReplyController } from '../controllers/autoReplyController'; +import { AutoReplyController } from '../controllers/keywordReplyController'; import { MessageDTO, ReplyDTO, MessageType } from '../types'; -import { Session } from '../entities/session'; import { Config } from '../entities/config'; -import { AutoReply } from '../entities/autoReply'; +import { Keyword } from '../entities/keyword'; import { TimeoutError, HumanTaskError } from '../errors/errors'; +import PluginSystem from './pluginSystem'; export class MessageService { private isKeywordMatch: boolean; private isUseGptReply: boolean; - private baseUrl: string; - - private apiKey: string; - - private openaiClient: OpenAI; - private configService: ConfigService; private messageController: MessageController; private autoReplyController: AutoReplyController; + private pluginSystem: PluginSystem; + constructor( configService: ConfigService, messageController: MessageController, autoReplyController: AutoReplyController, + pluginSystem: PluginSystem, ) { this.isKeywordMatch = true; this.isUseGptReply = true; - this.baseUrl = ''; - this.apiKey = ''; this.configService = configService; this.messageController = messageController; this.autoReplyController = autoReplyController; + this.pluginSystem = pluginSystem; } - // 提供一个方法用于注册事件处理器 - public registerHandlers(socket: socketIo.Socket): void { - socket.on('messageService-getMessages', async (data, callback) => { - const { sess, msgs } = data; - const session = { - id: sess.id, - username: sess.uname, - platform_id: sess.pid, - platform: sess.plat, - last_active: sess.last, - }; + async getMessages(ctx: any, messages: MessageDTO[]) { + const cfg = await this.configService.get(ctx); + // 检查是否使用插件 + if (cfg.use_plugin && cfg.plugin_id) { + return this.pluginSystem.executePlugin(cfg.plugin_id, ctx, messages); + } - const messages = msgs.map((msg: any) => ({ - session_id: msg.sid || session.id, // 处理默认值 - platform_id: msg.pid || session.platform_id, - unique: msg.uniq, - content: msg.cnt, - role: msg.role, - msg_type: msg.type || 'text', // 处理默认值 - })); - - let reply = { msg_type: 'text', content: '' }; - let config: Config; - - try { - config = await this.configService.getConfigByPlatformId( - session.platform_id, - ); - } catch (error) { - console.error(`Error in getMessages: ${error}`); - - config = await this.configService.getConfig(); - callback({ msg_type: 'text', content: config.default_reply }); - return; - } - - try { - // @ts-ignore - reply = await this.getMessages(config, session, messages); - callback(reply); - } catch (error) { - console.error(`Error in getMessages: ${error}`); - reply = { msg_type: 'text', content: config.default_reply }; - callback(reply); - } finally { - messages.push({ - session_id: session.id, - platform_id: session.platform_id, - unique: DateTime.now().toFormat('yyyy-MM-dd HH:mm:ss'), - content: reply.content, - role: 'assistant', - msg_type: reply.msg_type, - }); - - await this.messageController.batchCreateMsgs(session.id, messages); - } - }); - - socket.on('messageService-checkMessage', async (data, callback) => { - const { unique, sessionId } = data; - try { - const exists = await this.checkMessage(unique, sessionId); - callback(exists); - } catch (error) { - console.error(`Error in checkMessage: ${error}`); - callback(false); - } - }); + } - public async checkApiHealth(data: { - baseUrl: string; - apiKey: string; - model: string; - useDify: boolean; - }): Promise<{ - status: boolean; - message: string; - }> { - if (!data.baseUrl || !data.apiKey) { - return { - status: false, - message: '请输入正确的服务地址和 API Key', - }; - } + // // 提供一个方法用于注册事件处理器 + // public registerHandlers(socket: socketIo.Socket): void { + // socket.on('messageService-getMessages', async (data, callback) => { + // const { sess, msgs } = data; + // const session = { + // id: sess.id, + // username: sess.uname, + // platform_id: sess.pid, + // platform: sess.plat, + // last_active: sess.last, + // }; - if (data.useDify) { - try { - const reply = await this.difyRequest({ - gpt_base_url: data.baseUrl, - gpt_key: data.apiKey, - query: 'Hello', - }); + // const messages = msgs.map((msg: any) => ({ + // session_id: msg.sid || session.id, // 处理默认值 + // platform_id: msg.pid || session.platform_id, + // unique: msg.uniq, + // content: msg.cnt, + // role: msg.role, + // msg_type: msg.type || 'text', // 处理默认值 + // })); - console.warn('Dify API response:', reply); + // let reply = { msg_type: 'text', content: '' }; + // let config: Config; - if (reply === '') { - return { - status: false, - message: '请检查 Dify API Key 和服务地址是否正确', - }; - } + // try { + // config = await this.configService.getConfigByPlatformId( + // session.platform_id, + // ); + // } catch (error) { + // console.error(`Error in getMessages: ${error}`); - return { - status: true, - message: 'Dify API 正常', - }; - } catch (error) { - console.error(`Error in checkApiHealth: ${error}`); - return { - status: false, - message: - error instanceof Error ? error.message : JSON.stringify(error), - }; - } - } + // config = await this.configService.getConfig(); + // callback({ msg_type: 'text', content: config.default_reply }); + // return; + // } - const client = new OpenAI({ - apiKey: data.apiKey, - baseURL: data.baseUrl, - }); + // try { + // // @ts-ignore + // reply = await this.getMessages(config, session, messages); + // callback(reply); + // } catch (error) { + // console.error(`Error in getMessages: ${error}`); + // reply = { msg_type: 'text', content: config.default_reply }; + // callback(reply); + // } finally { + // messages.push({ + // session_id: session.id, + // platform_id: session.platform_id, + // unique: DateTime.now().toFormat('yyyy-MM-dd HH:mm:ss'), + // content: reply.content, + // role: 'assistant', + // msg_type: reply.msg_type, + // }); - try { - const response = await client.chat.completions.create({ - model: data.model, - max_tokens: 100, - messages: [ - { - role: 'user', - content: 'Tell me a message', - }, - ], - stream: false, - temperature: 0.5, - top_p: 1, - }); + // await this.messageController.batchCreateMsgs(session.id, messages); + // } + // }); - console.warn('OpenAI API response:', response); + // socket.on('messageService-checkMessage', async (data, callback) => { + // const { unique, sessionId } = data; + // try { + // const exists = await this.checkMessage(unique, sessionId); + // callback(exists); + // } catch (error) { + // console.error(`Error in checkMessage: ${error}`); + // callback(false); + // } + // }); + // } - if ( - !response.choices || - !response.choices.length || - !response.choices[0].message || - !response.choices[0].message.content - ) { - return { - status: false, - message: '请检查 OpenAI API Key 和服务地址是否正确', - }; - } + // public async checkApiHealth(data: { + // baseUrl: string; + // apiKey: string; + // model: string; + // useDify: boolean; + // }): Promise<{ + // status: boolean; + // message: string; + // }> { + // if (!data.baseUrl || !data.apiKey) { + // return { + // status: false, + // message: '请输入正确的服务地址和 API Key', + // }; + // } - return { - status: true, - message: 'OpenAI API 正常', - }; - } catch (error) { - console.error(`Error in checkApiHealth: ${error}`); - return { - status: false, - message: error instanceof Error ? error.message : JSON.stringify(error), - }; - } - } + // if (data.useDify) { + // try { + // const reply = await this.difyRequest({ + // gpt_base_url: data.baseUrl, + // gpt_key: data.apiKey, + // query: 'Hello', + // }); - async checkMessage(unique: string, sessionId: number) { - try { - const exists = await this.messageController.checkExists( - unique, - sessionId, - ); - return exists; - } catch (e) { - console.error(`Error in checkMessage: ${e}`); - return false; - } - } + // console.warn('Dify API response:', reply); + + // if (reply === '') { + // return { + // status: false, + // message: '请检查 Dify API Key 和服务地址是否正确', + // }; + // } + + // return { + // status: true, + // message: 'Dify API 正常', + // }; + // } catch (error) { + // console.error(`Error in checkApiHealth: ${error}`); + // return { + // status: false, + // message: + // error instanceof Error ? error.message : JSON.stringify(error), + // }; + // } + // } + + // const client = new OpenAI({ + // apiKey: data.apiKey, + // baseURL: data.baseUrl, + // }); + + // try { + // const response = await client.chat.completions.create({ + // model: data.model, + // max_tokens: 100, + // messages: [ + // { + // role: 'user', + // content: 'Tell me a message', + // }, + // ], + // stream: false, + // temperature: 0.5, + // top_p: 1, + // }); + + // console.warn('OpenAI API response:', response); + + // if ( + // !response.choices || + // !response.choices.length || + // !response.choices[0].message || + // !response.choices[0].message.content + // ) { + // return { + // status: false, + // message: '请检查 OpenAI API Key 和服务地址是否正确', + // }; + // } + + // return { + // status: true, + // message: 'OpenAI API 正常', + // }; + // } catch (error) { + // console.error(`Error in checkApiHealth: ${error}`); + // return { + // status: false, + // message: error instanceof Error ? error.message : JSON.stringify(error), + // }; + // } + // } updateKeywordMatch(isKeywordMatch: boolean, isUseGptReply: boolean) { this.isKeywordMatch = isKeywordMatch; this.isUseGptReply = isUseGptReply; } - async getMessages(config: Config, session: Session, msgs: MessageDTO[]) { - let messages = [...msgs]; - if (!messages.length) { - throw new Error('messages cannot be empty'); - } + // async getMessages(config: Config, session: Session, msgs: MessageDTO[]) { + // let messages = [...msgs]; + // if (!messages.length) { + // throw new Error('messages cannot be empty'); + // } - // 再进行一次过滤,根据 config 中的 context_count - if (config.context_count > 0) { - messages = messages.slice(-config.context_count); - } + // // 再进行一次过滤,根据 config 中的 context_count + // if (config.context_count > 0) { + // messages = messages.slice(-config.context_count); + // } - // 检查如果不存在用户的消息,则补上一条 - if (!messages.some((msg) => msg.role === 'user')) { - // 从 msgs 中找到最后一条 user 的消息 - const lastUserMsg = messages - .slice() - .reverse() - .find((msg) => msg.role === 'user'); + // // 检查如果不存在用户的消息,则补上一条 + // if (!messages.some((msg) => msg.role === 'user')) { + // // 从 msgs 中找到最后一条 user 的消息 + // const lastUserMsg = messages + // .slice() + // .reverse() + // .find((msg) => msg.role === 'user'); - if (lastUserMsg) { - messages.push(lastUserMsg); - } else { - return { - msg_type: 'text', - content: config.default_reply, - }; - } - } + // if (lastUserMsg) { + // messages.push(lastUserMsg); + // } else { + // return { + // msg_type: 'text', + // content: config.default_reply, + // }; + // } + // } - console.log('Starting to generate message content...'); - const lastMessage = messages[messages.length - 1]; + // console.log('Starting to generate message content...'); + // const lastMessage = messages[messages.length - 1]; - if (lastMessage.role === 'user') { - await this.extractDataAsync(config, lastMessage); - } + // if (lastMessage.role === 'user') { + // await this.extractDataAsync(config, lastMessage); + // } - try { - const replyTask = this.getReplyTask(config, messages, session); - const reply = await this.waitWithTimeout( - replyTask, - config.wait_humans_time * 1000, - ); + // try { + // const replyTask = this.getReplyTask(config, messages, session); + // const reply = await this.waitWithTimeout( + // replyTask, + // config.wait_humans_time * 1000, + // ); - reply.msg_type = this.getMsgType(reply); - await new Promise((resolve) => { - const min = config.reply_speed; // 5 seconds - const max = config.reply_random_speed + config.reply_speed; // 10 seconds - const randomTime = min + Math.random() * (max - min); - setTimeout(resolve, randomTime * 1000); - }); + // reply.msg_type = this.getMsgType(reply); + // await new Promise((resolve) => { + // const min = config.reply_speed; // 5 seconds + // const max = config.reply_random_speed + config.reply_speed; // 10 seconds + // const randomTime = min + Math.random() * (max - min); + // setTimeout(resolve, randomTime * 1000); + // }); - return reply; - } catch (error) { - if (error instanceof TimeoutError) { - throw new HumanTaskError('Reply timeout, please handle manually.'); - } - console.error(`Error in getMessages: ${error}`); - return { - msg_type: 'text', - content: config.default_reply, - }; - } - } + // return reply; + // } catch (error) { + // if (error instanceof TimeoutError) { + // throw new HumanTaskError('Reply timeout, please handle manually.'); + // } + // console.error(`Error in getMessages: ${error}`); + // return { + // msg_type: 'text', + // content: config.default_reply, + // }; + // } + // } - private async waitWithTimeout( - promise: Promise, - time: number, - ): Promise { - // Create a new promise that rejects after a timeout - let timeoutHandle; - const timeoutPromise = new Promise((resolve, reject) => { - timeoutHandle = setTimeout(() => { - reject(new TimeoutError('Operation timed out')); - }, time); - }); + // private async waitWithTimeout( + // promise: Promise, + // time: number, + // ): Promise { + // // Create a new promise that rejects after a timeout + // let timeoutHandle; + // const timeoutPromise = new Promise((resolve, reject) => { + // timeoutHandle = setTimeout(() => { + // reject(new TimeoutError('Operation timed out')); + // }, time); + // }); - try { - // Race the timeout against the original promise - const result = await Promise.race([promise, timeoutPromise]); - return result; - } finally { - // If the original promise or the timeout completes, we clear the timeout - clearTimeout(timeoutHandle); - } - } + // try { + // // Race the timeout against the original promise + // const result = await Promise.race([promise, timeoutPromise]); + // return result; + // } finally { + // // If the original promise or the timeout completes, we clear the timeout + // clearTimeout(timeoutHandle); + // } + // } - private async getReplyTask( - config: Config, - messages: MessageDTO[], - session: Session, - ) { - console.warn('getReplyTask', messages, session); + // private async getReplyTask(config: Config, messages: MessageDTO[]) { + // console.warn('getReplyTask', messages, session); - // 提前定义好回复内容 - let replyContent = null; + // // 提前定义好回复内容 + // let replyContent = null; - // 使用关键词匹配尝试回复 - if (this.isKeywordMatch) { - console.log('Attempting to use keyword matching...'); - const { content, found } = await this.matchAndReply( - messages, - session.platform_id, - ); + // // 使用关键词匹配尝试回复 + // if (this.isKeywordMatch) { + // console.log('Attempting to use keyword matching...'); + // const { content, found } = await this.matchAndReply( + // messages, + // session.platform_id, + // ); - if (found && content) { - console.log('Keyword match successful, using keyword to reply...'); - replyContent = content; - } - } + // if (found && content) { + // console.log('Keyword match successful, using keyword to reply...'); + // replyContent = content; + // } + // } - if (!replyContent) { - // 如果关键词匹配无效或未启用关键词匹配,调用 OpenAI 生成回复 - if (this.isUseGptReply) { - console.log( - 'Keyword matching failed or not used, using OpenAI to generate reply...', - ); - if (config.use_dify) { - replyContent = await this.getDifyResponse( - config, - messages, - session.platform_id, - ); - } else { - replyContent = await this.getOpenAIResponse( - config, - messages, - session.platform_id, - ); - } - } else { - replyContent = { - content: config.default_reply, - msg_type: 'text' as MessageType, - }; - } - } + // if (!replyContent) { + // // 如果关键词匹配无效或未启用关键词匹配,调用 OpenAI 生成回复 + // if (this.isUseGptReply) { + // console.log( + // 'Keyword matching failed or not used, using OpenAI to generate reply...', + // ); + // if (config.use_dify) { + // replyContent = await this.getDifyResponse( + // config, + // messages, + // session.platform_id, + // ); + // } else { + // replyContent = await this.getOpenAIResponse( + // config, + // messages, + // session.platform_id, + // ); + // } + // } else { + // replyContent = { + // content: config.default_reply, + // msg_type: 'text' as MessageType, + // }; + // } + // } - replyContent.msg_type = this.getMsgType(replyContent); - return replyContent; - } + // replyContent.msg_type = this.getMsgType(replyContent); + // return replyContent; + // } - private async getOpenAIResponse( - cfg: Config, - messages: MessageDTO[], - prompt: string = 'Tell me a message', - maxTokens: number = 100, - ): Promise { - if ( - !this.openaiClient || - this.baseUrl !== cfg.gpt_base_url || - this.apiKey !== cfg.gpt_key - ) { - this.openaiClient = new OpenAI({ - apiKey: cfg.gpt_key, - baseURL: cfg.gpt_base_url, - }); - } + // private async extractDataAsync( + // cfg: Config, + // message: MessageDTO, + // ): Promise { + // if (!cfg.extract_phone && !cfg.extract_product) return; + // if (cfg.save_path === '') return; - const targets = messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })); + // console.log('开始提取用户消息中的数据....'); + // const dataExtracted: { [key: string]: string } = {}; + // const fileName = `${cfg.save_path}/${new Date().toISOString().split('T')[0]}.txt`; - if (prompt !== '') { - targets.push({ role: 'system', content: prompt }); - } + // if (cfg.extract_phone) { + // const phoneNumbers = message.content.match(/\b1[3-9]\d{9}\b/g); + // if (phoneNumbers) dataExtracted.phone_numbers = phoneNumbers.join(', '); + // } else if (cfg.extract_product && message.msg_type === 'goods') { + // dataExtracted.products = message.content; + // } - try { - const response = await this.openaiClient.chat.completions.create({ - model: cfg.gpt_model, - max_tokens: maxTokens, - messages: targets, - stream: false, - temperature: cfg.gpt_temperature, - top_p: cfg.gpt_top_p, - }); + // await fs.appendFile( + // fileName, + // `${Object.entries(dataExtracted) + // .map(([key, value]) => `${key}: ${value}`) + // .join('\n')}\n`, + // ); + // } - if ( - !response.choices || - !response.choices.length || - !response.choices[0].message || - !response.choices[0].message.content - ) { - console.error('Error in getOpenAIResponse: response is empty'); + // private async matchAndReply( + // messages: MessageDTO[], + // platformId: string, + // ): Promise<{ + // content: ReplyDTO; + // found: boolean; + // }> { + // if (!messages.length) { + // return { + // content: { content: '', msg_type: 'text' }, + // found: false, + // }; + // } - return { - msg_type: 'text', - content: cfg.default_reply, - }; - } + // const lastMessage = messages[messages.length - 1].content; + // const keywords = await this.autoReplyController.getKeywords(platformId); - const message = response.choices[0].message.content.trim(); - return { msg_type: 'text', content: message }; - } catch (error) { - console.error(`Error in getOpenAIResponse: ${error}`); - return { - msg_type: 'text', - content: cfg.default_reply, - }; - } - } + // const foundKeywordObj = keywords.find((keywordObj) => { + // return keywordObj.keyword.split('|').some((pattern) => { + // return this.simpleWildcardMatch(pattern, lastMessage); + // }); + // }); - private async extractDataAsync( - cfg: Config, - message: MessageDTO, - ): Promise { - if (!cfg.extract_phone && !cfg.extract_product) return; - if (cfg.save_path === '') return; + // if (foundKeywordObj) { + // return { + // content: this.chooseReply(foundKeywordObj), + // found: true, + // }; + // } - console.log('开始提取用户消息中的数据....'); - const dataExtracted: { [key: string]: string } = {}; - const fileName = `${cfg.save_path}/${new Date().toISOString().split('T')[0]}.txt`; + // return { + // content: { content: '', msg_type: 'text' }, + // found: false, + // }; + // } - if (cfg.extract_phone) { - const phoneNumbers = message.content.match(/\b1[3-9]\d{9}\b/g); - if (phoneNumbers) dataExtracted.phone_numbers = phoneNumbers.join(', '); - } else if (cfg.extract_product && message.msg_type === 'goods') { - dataExtracted.products = message.content; - } + // private chooseReply(keywordObj: AutoReply): ReplyDTO { + // const replies = keywordObj.reply.split('[or]'); + // let chosenReply = this.replaceSpecialTokens( + // replies[Math.floor(Math.random() * replies.length)], + // ); - await fs.appendFile( - fileName, - `${Object.entries(dataExtracted) - .map(([key, value]) => `${key}: ${value}`) - .join('\n')}\n`, - ); - } + // let msgType = 'text'; + // if (chosenReply.includes('[@]') && chosenReply.includes('[/@]')) { + // msgType = 'file'; + // const fileStart = chosenReply.indexOf('[@]') + 3; + // const fileEnd = chosenReply.indexOf('[/@]'); + // const filePath = chosenReply.substring(fileStart, fileEnd); + // chosenReply = filePath; + // } - private async matchAndReply( - messages: MessageDTO[], - platformId: string, - ): Promise<{ - content: ReplyDTO; - found: boolean; - }> { - if (!messages.length) { - return { - content: { content: '', msg_type: 'text' }, - found: false, - }; - } + // // @ts-ignore + // return { content: chosenReply, msg_type: msgType }; + // } - const lastMessage = messages[messages.length - 1].content; - const keywords = await this.autoReplyController.getKeywords(platformId); + // private getMsgType(reply: ReplyDTO) { + // if (reply.msg_type === 'text') { + // return 'text'; + // } - const foundKeywordObj = keywords.find((keywordObj) => { - return keywordObj.keyword.split('|').some((pattern) => { - return this.simpleWildcardMatch(pattern, lastMessage); - }); - }); - - if (foundKeywordObj) { - return { - content: this.chooseReply(foundKeywordObj), - found: true, - }; - } - - return { - content: { content: '', msg_type: 'text' }, - found: false, - }; - } - - private simpleWildcardMatch(pattern: string, msg: string) { - if (pattern.includes('[and]')) { - const keywords = pattern.split('[and]'); - return keywords.every((keyword) => - this.matchKeyword(keyword.trim(), msg), - ); - } - return this.matchKeyword(pattern, msg); - } - - private matchKeyword(ptt: string, msg: string): boolean { - let pattern = ptt.trim(); - - // 如果模式只是一个星号,它应该匹配任何消息。 - if (pattern === '*') { - return true; - } - - // 合并连续的 '*' 字符为一个 '*' - pattern = pattern.replace(/\*+/g, '*'); - - // 如果模式不包含 '*',则直接比较是否相等 - if (!pattern.includes('*')) { - return pattern === msg; - } - - const parts = pattern.split('*'); - let lastIndex = 0; - - // eslint-disable-next-line no-restricted-syntax - for (const part of parts) { - // 跳过空字符串(它们来自模式开始、结束或连续 '*') - if (part === '') continue; - - const index = msg.indexOf(part, lastIndex); - // 如果找不到部分或部分不是按顺序出现,则匹配失败 - if (index === -1 || index < lastIndex) { - return false; - } - lastIndex = index + part.length; - } - - // 确保消息的剩余部分可以被模式尾部的 '*' 匹配 - return parts[parts.length - 1] === '' || lastIndex <= msg.length; - } - - private replaceSpecialTokens(replyMsg: string) { - let reply = replyMsg; - const randomChoices = [ - ...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', - '🌸', - '😊', - '🌷', - '🌹', - '💖', - '🪷', - '💐', - '🌺', - '🌼', - '🌻', - ]; - while (reply.includes('[~]')) { - const randomChoice = - randomChoices[Math.floor(Math.random() * randomChoices.length)]; - reply = reply.replace('[~]', randomChoice); - } - return reply; - } - - private chooseReply(keywordObj: AutoReply): ReplyDTO { - const replies = keywordObj.reply.split('[or]'); - let chosenReply = this.replaceSpecialTokens( - replies[Math.floor(Math.random() * replies.length)], - ); - - let msgType = 'text'; - if (chosenReply.includes('[@]') && chosenReply.includes('[/@]')) { - msgType = 'file'; - const fileStart = chosenReply.indexOf('[@]') + 3; - const fileEnd = chosenReply.indexOf('[/@]'); - const filePath = chosenReply.substring(fileStart, fileEnd); - chosenReply = filePath; - } - - // @ts-ignore - return { content: chosenReply, msg_type: msgType }; - } - - private getMsgType(reply: ReplyDTO) { - if (reply.msg_type === 'text') { - return 'text'; - } - - const { content } = reply; - if ( - content.endsWith('.jpg') || - content.endsWith('.png') || - content.endsWith('.gif') || - content.endsWith('.jpeg') || - content.endsWith('.webp') - ) { - return 'image'; - } - if ( - content.endsWith('.mp4') || - content.endsWith('.mov') || - content.endsWith('.avi') || - content.endsWith('.flv') - ) { - return 'video'; - } - return 'file'; - } - - // https://docs.dify.ai/v/zh-hans/guides/application-publishing/developing-with-apis - // https://github.com/fatwang2/dify2openai - private async getDifyResponse( - cfg: Config, - messages: MessageDTO[], - prompt: string = 'Tell me a message', - ): Promise { - const targets = messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })); - - if (prompt !== '') { - targets.push({ role: 'system', content: prompt }); - } - - const lastMessage = messages[messages.length - 1]; - const queryString = `here is our talk history:\n'''\n${messages - .slice(0, -1) - .map((message) => `${message.role}: ${message.content}`) - .join('\n')}\n'''\n\nhere is my question:\n${lastMessage.content}`; - - const answer = await this.difyRequest({ - query: queryString, - gpt_base_url: cfg.gpt_base_url, - gpt_key: cfg.gpt_key, - }); - - if (answer === '') { - return { - msg_type: 'text', - content: cfg.default_reply, - }; - } - - return { - msg_type: 'text', - // @ts-ignore - content: answer || cfg.default_reply, - }; - } - - private async difyRequest(data: { - query: string; - gpt_base_url: string; - gpt_key: string; - }) { - const resp = await axios.post( - `${data.gpt_base_url}/chat-messages`, - { - inputs: {}, - query: data.query, - response_mode: 'streaming', - user: 'apiuser', - auto_generate_name: false, - }, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${data.gpt_key}`, - }, - responseType: 'stream', - }, - ); - - return new Promise((resolve, reject) => { - try { - let result = ''; - let buffer = ''; - - const stream = resp.data; - stream.on('data', (chunk: any) => { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - - for (let i = 0; i < lines.length - 1; i++) { - const line = lines[i].trim(); - if (line === '') continue; - let chunkObj; - try { - const cleanedLine = line.replace(/^data: /, '').trim(); - if (cleanedLine.startsWith('{') && cleanedLine.endsWith('}')) { - chunkObj = JSON.parse(cleanedLine); - } else { - continue; - } - } catch (error) { - console.error('Error parsing JSON:', error); - continue; - } - - if ( - chunkObj.event === 'message' || - chunkObj.event === 'agent_message' - ) { - result += chunkObj.answer; - } - } - - buffer = lines[lines.length - 1]; - }); - - stream.on('end', () => { - resolve(result); - }); - - stream.on('error', (error: Error) => { - reject(error); - }); - } catch (error) { - reject(error); - } - }); - } + // const { content } = reply; + // if ( + // content.endsWith('.jpg') || + // content.endsWith('.png') || + // content.endsWith('.gif') || + // content.endsWith('.jpeg') || + // content.endsWith('.webp') + // ) { + // return 'image'; + // } + // if ( + // content.endsWith('.mp4') || + // content.endsWith('.mov') || + // content.endsWith('.avi') || + // content.endsWith('.flv') + // ) { + // return 'video'; + // } + // return 'file'; + // } } diff --git a/src/main/backend/services/pluginSystem.ts b/src/main/backend/services/pluginSystem.ts new file mode 100644 index 0000000..f5441c5 --- /dev/null +++ b/src/main/backend/services/pluginSystem.ts @@ -0,0 +1,126 @@ +import * as vm from 'vm'; + +// 静态导入预加载模块 +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; +import { ConfigController } from '../controllers/configController'; +import { MessageDTO, ReplyDTO } from '../types'; + +interface PreloadedModules { + [key: string]: any; +} + +// 创建一个包含所有预加载模块的对象 +const preloadedModules: PreloadedModules = { + fs, + path, + axios, +}; + +class PluginSystem { + private configController: ConfigController; + + constructor(configController: ConfigController) { + this.configController = configController; + } + + /** + * 执行插件函数 + * @param plugin_id 插件的ID + * @param ctx 上下文信息 + * @param messages 消息数组 + * @returns 插件函数的执行结果 + */ + async executePlugin( + plugin_id: number, + ctx: any, + messages: MessageDTO[], + ): Promise { + // 从配置控制器中获取插件配置 + const plugin = await this.configController.getPluginConfig(plugin_id); + if (!plugin) { + throw new Error('Plugin not found'); + } + + try { + // 创建沙盒环境 + const sandbox = { + module: {} as any, + exports: {}, + require: (module: string) => { + // 只允许加载预加载的模块 + if (preloadedModules[module]) { + return preloadedModules[module]; + } + throw new Error(`Module ${module} is not available`); + }, + }; + + // =================== 插件代码示例 =================== + // module.exports = function(ctx, messages) { + // do something... + // return { + // content: 'Hello, world!', + // type: 'TEXT', + // }; + // }; + // =================== HTTP请求示例 =================== + // module.exports = async function(ctx, messages) { + // const response = await axios.get('https://api.example.com'); + // return { + // content: response.data, + // type: 'TEXT', + // }; + // }; + // =================== 文件读取示例 =================== + // module.exports = function(ctx, messages) { + // const content = fs.readFileSync('example.txt', 'utf8'); + // return { + // content, + // type: 'TEXT', + // }; + // }; + // =================================================== + + // 插件代码包装模板,确保插件函数存在并导出 + const pluginCode = ` + const pluginFunction = ${plugin.code}; + if (typeof pluginFunction !== 'function') { + throw new Error('Plugin function is not defined or not a function'); + } + module.exports = pluginFunction; + `; + + // 创建并运行沙盒上下文 + vm.createContext(sandbox); + vm.runInContext(pluginCode, sandbox); + + // 检查导出的是否为函数 + if (typeof sandbox.module.exports !== 'function') { + throw new Error('Plugin does not export a function'); + } + + // 执行插件函数并传递 ctx 和 messages + const data = sandbox.module.exports(ctx, messages); + + // data 的返回类型应该是 ReplyDTO,这里做个数据校验 + if ( + data && + typeof data === 'object' && + 'content' in data && + 'type' in data + ) { + return data as ReplyDTO; + } + + throw new Error('Plugin function did not return a valid response'); + } catch (error) { + // 捕获并返回错误信息 + console.error('Plugin execution error:', error); + throw error; + } + } +} + +export default PluginSystem; diff --git a/src/main/backend/services/sessionService.ts b/src/main/backend/services/sessionService.ts deleted file mode 100644 index 6d873cb..0000000 --- a/src/main/backend/services/sessionService.ts +++ /dev/null @@ -1,101 +0,0 @@ -import socketIo from 'socket.io'; -import { SessionController } from '../controllers/sessionController'; -import { Session } from '../entities/session'; -import { StrategyService } from './strategyService'; - -export class SessionService { - private sessionController: SessionController; - - private strategyService: StrategyService; - - constructor( - sessionController: SessionController, - strategyService: StrategyService, - ) { - this.sessionController = sessionController; - this.strategyService = strategyService; - } - - public registerHandlers(socket: socketIo.Socket): void { - socket.on('sessionService-getSession', async (data: any, callback) => { - const { platformId, username, goodsName, goodsAvatar } = data; - - const ptf = await this.strategyService.getPlatformInfoById(platformId); - const startTime = new Date().getTime(); - try { - if (!username) { - // 直接新建一个 session - const session = await this.createSession( - platformId, - ptf?.name || '', - goodsName, - goodsAvatar, - ); - - callback(session); - return; - } - - const session = await this.getSessionOrCreate( - platformId, - username, - goodsName, - goodsAvatar, - ); - callback(session); - } catch (error) { - console.error('Failed to get session', error); - callback(null); - } finally { - console.log('getSession time:', new Date().getTime() - startTime, 'ms'); - } - }); - } - - public async createSession( - platformId: string, - platformName: string, - goodsName?: string, - goodsAvatar?: string, - ): Promise { - return this.sessionController.create({ - platform_id: platformId, - platform: platformName, - username: '', - last_active: new Date(), - goods_name: goodsName || '', - goods_avatar: goodsAvatar || '', - created_at: new Date(), - }); - } - - public async getSessionOrCreate( - platformId: string, - username: string, - goodsName?: string, - goodsAvatar?: string, - ): Promise { - const sessions = await this.sessionController.search(platformId, username); - if (sessions.length > 0) { - const session = sessions[0]; - session.last_active = new Date(); - if (goodsName && goodsAvatar) { - session.goods_name = goodsName; - session.goods_avatar = goodsAvatar; - } - return this.sessionController.update(session.id, session); - } - - const ptf = await this.strategyService.getPlatformInfoById(platformId); - - return this.sessionController.create({ - platform_id: platformId, - platform: ptf?.name || '', - username, - last_active: new Date(), - goods_name: goodsName || '', - goods_avatar: goodsAvatar || '', - created_at: new Date(), - }); - } -} diff --git a/src/main/backend/services/strategyService.ts b/src/main/backend/services/strategyService.ts deleted file mode 100644 index aba1b5d..0000000 --- a/src/main/backend/services/strategyService.ts +++ /dev/null @@ -1,82 +0,0 @@ -import socketIo from 'socket.io'; -import { Platform, StrategyServiceStatusEnum } from '../types'; - -export class StrategyService { - private io: socketIo.Server; - - constructor(io: socketIo.Server) { - this.io = io; - } - - async emitAndWait( - event: string, - data?: any, - timeout: number = 5000, - ): Promise { - let response; - if (data === undefined) { - response = await this.io.timeout(timeout).emitWithAck(event); - } else { - response = await this.io.timeout(timeout).emitWithAck(event, data); - } - - // 判断是否是 Array - if (Array.isArray(response) && response.length === 1) { - if (typeof response[0] === 'undefined') { - return {} as T; - } - - if (response[0] === 'string') { - return {} as T; - } - - // 如果 response[0] 也是数组,那么直接返回 - if (Array.isArray(response[0])) { - return response[0] as T; - } - - return JSON.parse(response[0]); - } - - // 判断是否是字符串 - if (typeof response === 'string') { - return JSON.parse(response); - } - - return response; - } - - async updateStatus(status: StrategyServiceStatusEnum): Promise { - try { - return await this.emitAndWait('strategyService-updateStatus', { status }); - } catch (error) { - console.error('Failed to update status', error); - return null; - } - } - - public async getAllPlatforms(): Promise { - const maxRetries = 10; - let attempt = 0; - - while (attempt < maxRetries) { - try { - // eslint-disable-next-line no-await-in-loop - const data = await this.emitAndWait( - 'strategyService-getAppsInfo', - ); - return data; - } catch (error) { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => setTimeout(resolve, 1000)); - attempt++; - console.error(`Attempt ${attempt} failed to update strategies`, error); - if (attempt >= maxRetries) { - return []; - } - } - } - - return []; - } -} diff --git a/src/main/backend/services/systemService.ts b/src/main/backend/services/systemService.ts deleted file mode 100644 index 7701061..0000000 --- a/src/main/backend/services/systemService.ts +++ /dev/null @@ -1,18 +0,0 @@ -import socketIo from 'socket.io'; - -export class SystemService { - private io: socketIo.Server; - - constructor(io: socketIo.Server) { - this.io = io; - } - - async checkHealth(): Promise { - try { - return await this.io.timeout(5000).emitWithAck('systemService-health'); - } catch (error) { - console.error('Failed to check health', error); - return false; - } - } -} diff --git a/src/main/backend/types/index.ts b/src/main/backend/types/index.ts index 3985f53..41ad072 100644 --- a/src/main/backend/types/index.ts +++ b/src/main/backend/types/index.ts @@ -12,27 +12,19 @@ export enum PlatformTypeEnum { ME_MEDIA = 'ME_MEDIA', } -export type RoleType = 'assistant' | 'user' | 'system'; -export type MessageType = - | 'text' - | 'image' - | 'video' - | 'file' - | 'mention' - | 'goods'; +export type RoleType = 'SELF' | 'OTHER' | 'SYSTEM'; +export type MessageType = 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE'; export interface MessageDTO { - session_id: number; - platform_id: string; - unique: string; - content: string; // If it's an image, this is the URL of the image + sender: string; + content: string; role: RoleType; // assistant, user - msg_type: MessageType; + type: MessageType; } export interface ReplyDTO { content: string; - msg_type: MessageType; + type: MessageType; } export interface Platform { diff --git a/src/main/gptproxy/README.md b/src/main/gptproxy/README.md new file mode 100644 index 0000000..5df4347 --- /dev/null +++ b/src/main/gptproxy/README.md @@ -0,0 +1,2 @@ + +fork from https://github.com/zhengxs2018/ai \ No newline at end of file diff --git a/src/main/gptproxy/dify/chat/completions.ts b/src/main/gptproxy/dify/chat/completions.ts new file mode 100644 index 0000000..b4953cd --- /dev/null +++ b/src/main/gptproxy/dify/chat/completions.ts @@ -0,0 +1,212 @@ +import axios from 'axios'; +import OpenAI from 'openai'; +import { Stream } from 'openai/streaming'; +import { APIResource } from '../../resource'; + +export class Completions extends APIResource { + create( + body: ChatCompletionCreateParamsNonStreaming, + options?: OpenAI.RequestOptions, + ): Promise; + + create( + body: ChatCompletionCreateParamsStreaming, + options?: OpenAI.RequestOptions, + ): Promise>; + + async create( + params: ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, + ): Promise> { + const { stream } = params; + const body = this.buildCreateParams(params); + const path = '/chat-messages'; + + const response: Response = await this._client.post(path, { + ...options, + body: body as unknown as Record, + stream: false, + __binaryResponse: true, + }); + + if (stream) { + const controller = new AbortController(); + + options?.signal?.addEventListener('abort', () => { + controller.abort(); + }); + + return this.afterSSEResponse(response, controller); + } + + // if ('stream' in params && params.stream) { + // // Streaming request + // const stream = new Stream(); + // try { + // const resp = await axios.post( + // `${gpt_base_url}/chat-messages`, + // { + // inputs: {}, + // query: rest.query, + // response_mode: 'streaming', + // user: 'apiuser', + // auto_generate_name: false, + // }, + // { + // headers: { + // 'Content-Type': 'application/json', + // Authorization: `Bearer ${gpt_key}`, + // }, + // responseType: 'stream', + // }, + // ); + + // let buffer = ''; + + // resp.data.on('data', (chunk: any) => { + // buffer += chunk.toString(); + // const lines = buffer.split('\n'); + + // for (let i = 0; i < lines.length - 1; i++) { + // const line = lines[i].trim(); + // if (line === '') continue; + // let chunkObj; + // try { + // const cleanedLine = line.replace(/^data: /, '').trim(); + // if (cleanedLine.startsWith('{') && cleanedLine.endsWith('}')) { + // chunkObj = JSON.parse(cleanedLine); + // } else { + // continue; + // } + // } catch (error) { + // console.error('Error parsing JSON:', error); + // continue; + // } + + // if ( + // chunkObj.event === 'message' || + // chunkObj.event === 'agent_message' + // ) { + // stream.push(chunkObj); + // } + // } + + // buffer = lines[lines.length - 1]; + // }); + + // resp.data.on('end', () => { + // stream.push(null); + // }); + + // resp.data.on('error', (error: Error) => { + // stream.emit('error', error); + // }); + // } catch (error) { + // stream.emit('error', error); + // } + + // return stream; + // } + + // // Non-streaming request + // const response = await axios.post( + // `${gpt_base_url}/chat-messages`, + // { + // inputs: {}, + // query: rest.query, + // response_mode: 'non-streaming', + // user: 'apiuser', + // auto_generate_name: false, + // }, + // { + // headers: { + // 'Content-Type': 'application/json', + // Authorization: `Bearer ${gpt_key}`, + // }, + // }, + // ); + + return response.data as OpenAI.ChatCompletion; + } + + protected buildCreateParams(params: ChatCompletionCreateParams) { + const { model, top_k, ...rest } = params; + return { + ...rest, + model, + top_k, + response_mode: 'streaming', + }; + } + + protected afterSSEResponse( + response: Response, + controller: AbortController, + ): Stream { + const stream = Stream.fromSSEResponse( + response, + controller, + ); + + // response.body.on('data', (chunk: any) => { + // const chunkObj = JSON.parse(chunk.toString()); + + // if (chunkObj.event === 'message' || chunkObj.event === 'agent_message') { + // stream.push(chunkObj); + // } + // }); + + // response.body.on('end', () => { + // stream.push(null); + // }); + + // response.body.on('error', (error: Error) => { + // stream.emit('error', error); + // }); + + // return stream; + + const toChoices = (chunk: DifyChat.GenerateContentResponse) => {}; + } +} + +export type ChatCompletionCreateParamsNonStreaming = + Chat.ChatCompletionCreateParamsNonStreaming; + +export type ChatCompletionCreateParamsStreaming = + Chat.ChatCompletionCreateParamsStreaming; + +export type ChatCompletionCreateParams = Chat.ChatCompletionCreateParams; + +export namespace Chat { + // eslint-disable-next-line @typescript-eslint/no-shadow + export type ChatModel = (string & NonNullable) | 'gemini-pro'; + // 支持的有点问题 + // | 'gemini-pro-vision'; + + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ChatCompletionCreateParamsNonStreaming + extends OpenAI.ChatCompletionCreateParamsNonStreaming { + model: ChatModel; + top_k?: number; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ChatCompletionCreateParamsStreaming + extends OpenAI.ChatCompletionCreateParamsStreaming { + model: ChatModel; + top_k?: number | null; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + export type ChatCompletionCreateParams = + | ChatCompletionCreateParamsNonStreaming + | ChatCompletionCreateParamsStreaming; +} + +export namespace DifyChat { + export interface GenerateContentResponse { + event: 'message' | 'agent_message'; + answer: string; + } +} diff --git a/src/main/gptproxy/dify/chat/index.ts b/src/main/gptproxy/dify/chat/index.ts new file mode 100644 index 0000000..adc174e --- /dev/null +++ b/src/main/gptproxy/dify/chat/index.ts @@ -0,0 +1,7 @@ +export { + type ChatModel, + type ChatCompletionCreateParams, + type ChatCompletionCreateParamsNonStreaming, + type ChatCompletionCreateParamsStreaming, + Completions, +} from './completions'; diff --git a/src/main/gptproxy/dify/index.ts b/src/main/gptproxy/dify/index.ts new file mode 100644 index 0000000..d41b525 --- /dev/null +++ b/src/main/gptproxy/dify/index.ts @@ -0,0 +1,64 @@ +import type { Agent } from 'node:http'; + +import { + APIClient, + type DefaultQuery, + type Fetch, + type FinalRequestOptions, + type Headers, +} from 'openai/core'; +import * as API from './resources'; + +export interface DifyAIOptions { + baseURL?: string; + apiKey?: string; + timeout?: number | undefined; + httpAgent?: Agent; + fetch?: Fetch | undefined; + defaultHeaders?: Headers; + defaultQuery?: DefaultQuery; +} + +export class DifyAI extends APIClient { + protected apiKey: string; + + private _options: DifyAIOptions; + + constructor(options: DifyAIOptions = {}) { + const { + apiKey = process.env.DIFY_API_KEY || '', + baseURL = 'https://api.dify.ai/v1/', + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.apiKey = apiKey; + } + + chat = new API.Chat(this); + + protected override defaultHeaders(opts: FinalRequestOptions): Headers { + return { + ...super.defaultHeaders(opts), + ...this._options.defaultHeaders, + }; + } + + protected override defaultQuery(): DefaultQuery | undefined { + return { + ...this._options.defaultQuery, + key: this.apiKey, + }; + } +} diff --git a/src/main/gptproxy/dify/resources.ts b/src/main/gptproxy/dify/resources.ts new file mode 100644 index 0000000..23460d8 --- /dev/null +++ b/src/main/gptproxy/dify/resources.ts @@ -0,0 +1 @@ +export * from './chat/index'; \ No newline at end of file diff --git a/src/main/gptproxy/ernie/index.ts b/src/main/gptproxy/ernie/index.ts new file mode 100644 index 0000000..f0881de --- /dev/null +++ b/src/main/gptproxy/ernie/index.ts @@ -0,0 +1,99 @@ +import type { Agent } from 'node:http'; + +import { + APIClient, + type DefaultQuery, + type Fetch, + type FinalRequestOptions, + type Headers, +} from 'openai/core'; + +import * as API from './resources'; + +export interface ErnieAIOptions { + baseURL?: string; + apiKey?: string; + timeout?: number | undefined; + httpAgent?: Agent; + fetch?: Fetch | undefined; + + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `undefined` or `null` in request options. + */ + defaultHeaders?: Headers; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: DefaultQuery; +} + +export class ErnieAI extends APIClient { + protected apiKey: string; + + private _options: ErnieAIOptions; + + constructor(options: ErnieAIOptions = {}) { + const { + apiKey = process.env.EB_API_KEY || '', + baseURL = 'https://aistudio.baidu.com/llm/lmapi/v1', + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.apiKey = apiKey; + } + + chat = new API.Chat(this); + + embeddings = new API.Embeddings(this); + + protected override authHeaders() { + return { + Authorization: `token ${this.apiKey}`, + }; + } + + protected override defaultHeaders(opts: FinalRequestOptions): Headers { + return { + ...super.defaultHeaders(opts), + ...this._options.defaultHeaders, + }; + } + + protected override defaultQuery(): DefaultQuery | undefined { + return this._options.defaultQuery; + } +} + +// eslint-disable-next-line no-redeclare +export namespace ErnieAI { + export type Chat = API.Chat; + export type ChatModel = API.ChatModel; + export type ChatCompletionCreateParams = API.ChatCompletionCreateParams; + export type ChatCompletionCreateParamsNonStreaming = + API.ChatCompletionCreateParamsNonStreaming; + export type ChatCompletionCreateParamsStreaming = + API.ChatCompletionCreateParamsStreaming; + + export type EmbeddingCreateParams = API.EmbeddingCreateParams; +} + +export default ErnieAI; diff --git a/src/main/gptproxy/ernie/resources/chat/chat.ts b/src/main/gptproxy/ernie/resources/chat/chat.ts new file mode 100644 index 0000000..1bd15cc --- /dev/null +++ b/src/main/gptproxy/ernie/resources/chat/chat.ts @@ -0,0 +1,6 @@ +import { APIResource } from '../../../resource'; +import { Completions } from './completions'; + +export class Chat extends APIResource { + completions = new Completions(this._client); +} diff --git a/src/main/gptproxy/ernie/resources/chat/completions.ts b/src/main/gptproxy/ernie/resources/chat/completions.ts new file mode 100644 index 0000000..9bd09a8 --- /dev/null +++ b/src/main/gptproxy/ernie/resources/chat/completions.ts @@ -0,0 +1,417 @@ +import OpenAI, { APIError, OpenAIError } from 'openai'; +import { Stream } from 'openai/streaming'; + +import { APIResource } from '../../../resource'; +import { ensureArray } from '../../../util'; + +export class Completions extends APIResource { + protected endpoints: Record = { + 'ernie-bot': '/chat/completions', + 'ernie-bot-turbo': '/chat/eb-instant', + 'ernie-bot-4': '/chat/completions_pro', + 'ernie-bot-8k': '/chat/ernie_bot_8k', + }; + + /** + * Creates a model response for the given chat conversation. + * + * 文心一言 由于分发在不同的平台,所以有不同的文档 + * 百度云的响应和 OpenAI 的比较类似,但授权没有 AI Studio 方便 + * 之前 AI Studio 的文档是有文档的,但现在不知道去哪了 + * 参考: + * - https://cloud.baidu.com/doc/WENXINWORKSHOP/s/jlil56u11 + * - https://github.com/PaddlePaddle/ERNIE-Bot-SDK/blob/develop/erniebot/backends/aistudio.py + */ + create( + body: ChatCompletionCreateParamsNonStreaming, + options?: OpenAI.RequestOptions, + ): Promise; + + create( + body: ChatCompletionCreateParamsStreaming, + options?: OpenAI.RequestOptions, + ): Promise>; + + async create( + params: ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, + ) { + const { model = 'ernie-bot', ...body } = + Completions.buildCreateParams(params); + + const endpoint = this.endpoints[model]; + + if (!endpoint) { + throw new OpenAIError(`Invalid model: ${model}`); + } + + const { stream } = body; + + const headers = { + ...options?.headers, + // Note: 如果是 stream 的话,需要设置 Accept 为 text/event-stream + Accept: stream ? 'text/event-stream' : 'application/json', + }; + + const response: Response = await this._client.post(endpoint, { + ...options, + body, + headers, + // 文心一言的响应内容被包裹了一层,需要解构并转换为 OpenAI 的格式 + // 设置 __binaryResponse 为 true, 是为了让 client 返回原始的 response + stream: false, + __binaryResponse: true, + }); + + if (stream) { + const controller = new AbortController(); + + options?.signal?.addEventListener('abort', () => { + controller.abort(); + }); + + return Completions.fromOpenAIStream( + model, + Stream.fromSSEResponse(response, controller), + controller, + ); + } + + return Completions.fromResponse(model, await response.json()); + } + + static buildCreateParams( + params: ChatCompletionCreateParams, + ): ChatCompletions.ChatCompletionCreateParams { + const { messages = [], presence_penalty, user, stop, ...rest } = params; + + const head = messages[0]; + + // 文心一言的 system 是独立字段 + // (1)长度限制1024个字符 + // (2)如果使用functions参数,不支持设定人设system + const system = head && head.role === 'system' ? head.content : undefined; + + // 移除 system 角色的消息 + if (system) { + messages.splice(0, 1); + } + + const data: ChatCompletions.ChatCompletionCreateParams = { + ...rest, + messages, + }; + + if (system) { + data.system = system; + } + + if (user) { + data.user_id = user; + } + + if (presence_penalty) { + data.penalty_score = presence_penalty; + } + + if (stop) { + data.stop = ensureArray(stop); + } + + return data; + } + + static fromResponse( + model: string, + data: ChatCompletions.APIResponse, + ): OpenAI.ChatCompletion { + Completions.assert(data); + + const { result } = data; + + const choice: OpenAI.ChatCompletion.Choice = { + index: 0, + message: { + role: 'assistant', + content: result.result, + }, + logprobs: null, + finish_reason: 'stop', + }; + + // TODO 需要确认 is_truncated 是否和 is_end 互斥 + // TODO 需要确认 functions 是否响应式不一样 + if (result.is_end) { + choice.finish_reason = 'stop'; + } else if (result.is_truncated) { + choice.finish_reason = 'length'; + } else if (result.need_clear_history) { + choice.finish_reason = 'content_filter'; + } + + return { + id: result.id, + model, + choices: [choice], + created: parseInt(result.created, 10), + object: 'chat.completion', + usage: result.usage, + }; + } + + static fromOpenAIStream( + model: string, + stream: Stream, + controller: AbortController, + ): Stream { + async function* iterator(): AsyncIterator< + OpenAI.ChatCompletionChunk, + any, + undefined + > { + // eslint-disable-next-line no-restricted-syntax + for await (const chunk of stream) { + Completions.assert(chunk); + + // TODO 某些情况下,文心一言的 result 只有 id,需要排查情况 + const data = chunk.result; + + const choice: OpenAI.ChatCompletionChunk.Choice = { + index: 0, + delta: { + role: 'assistant', + content: data.result || '', + }, + finish_reason: null, + }; + + // TODO 需要确认 is_truncated 是否和 is_end 互斥 + // TODO 需要确认 functions 是否响应式不一样 + if (data.is_end) { + choice.finish_reason = 'stop'; + } else if (data.is_truncated) { + choice.finish_reason = 'length'; + } else if (data.need_clear_history) { + choice.finish_reason = 'content_filter'; + } + + yield { + id: data.id, + model, + choices: [choice], + object: 'chat.completion.chunk', + created: parseInt(data.created, 10), + // openai-node 上 已经有讨论添加 usage 的问题 + // 文心一言是有提供的,这里主要是为了向前兼容 + // @ts-ignore + usage: data.usage, + }; + } + } + + return new Stream(iterator, controller); + } + + /** + * 构建错误 + * + * @param code - + * @param message - + * @returns 错误 + */ + static makeAPIError(code: number, message: string) { + const error = { code, message }; + + switch (code) { + case 2: + return APIError.generate(500, error, message, {}); + case 6: // permission error + case 111: // token expired + return APIError.generate(403, error, message, {}); + case 17: + case 18: + case 19: + case 40407: + return APIError.generate(429, error, message, {}); + case 110: // invalid token + case 40401: // invalid token + return APIError.generate(401, error, message, {}); + case 336003: // invalid parameter + return APIError.generate(400, error, message, {}); + case 336100: // try again + return APIError.generate(500, error, message, {}); + default: + return APIError.generate(undefined, error, message, {}); + } + } + + /** + * 如果 code 不为 0,抛出 APIError + * + * @param code - + * @param message - + */ + static assert(resp: ChatCompletions.APIResponse) { + if (resp.errorCode === 0) return; + + throw Completions.makeAPIError(resp.errorCode, resp.errorMsg); + } +} + +export interface ChatCompletionCreateParamsNonStreaming + extends Pick< + OpenAI.ChatCompletionCreateParamsNonStreaming, + | 'messages' + | 'functions' + | 'temperature' + | 'top_p' + | 'presence_penalty' + | 'stream' + | 'stop' + | 'user' + > { + model: ChatModel; + disable_search?: boolean | null; + enable_citation?: boolean | null; +} + +export interface ChatCompletionCreateParamsStreaming + extends Pick< + OpenAI.ChatCompletionCreateParamsStreaming, + | 'messages' + | 'functions' + | 'temperature' + | 'top_p' + | 'presence_penalty' + | 'stream' + | 'stop' + | 'user' + > { + model: ChatModel; + disable_search?: boolean | null; + enable_citation?: boolean | null; +} + +export type ChatCompletionCreateParams = + | ChatCompletionCreateParamsNonStreaming + | ChatCompletionCreateParamsStreaming; + +export type ChatModel = + | 'ernie-bot' + | 'ernie-bot-turbo' + | 'ernie-bot-4' + | 'ernie-bot-8k'; + +export namespace ChatCompletions { + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ChatCompletionCreateParams { + /** + * 模型名称 + */ + model: ChatModel; + + /** + * 是否强制关闭实时搜索功能,默认 false,表示不关闭 + * + * @defaultValue false + */ + disable_search?: boolean | null; + + /** + * 是否开启上角标返回,说明: + * (1)开启后,有概率触发搜索溯源信息search_info,search_info内容见响应参数介绍 + * (2)默认false,不开启 + * + * @defaultValue false + */ + enable_citation?: boolean | null; + + /** + * 模型人设,主要用于人设设定,例如,你是xxx公司制作的AI助手,说明: + * (1)长度限制1024个字符 + * (2)如果使用 functions 参数,不支持设定人设 system + * + * @remarks OpenAI 是通过 messages 的 role 来区分的 + */ + system?: string | null; + + /** + * 聊天上下文信息 + * + * @remarks 不支持 system 角色 + */ + messages: OpenAI.ChatCompletionCreateParams['messages']; + + /** + * 一个可触发函数的描述列表 + */ + functions?: OpenAI.ChatCompletionCreateParams['functions']; + + /** + * 内容随机性 + * + * 说明: + * (1)较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定 + * (2)默认0.8,范围 (0, 1.0],不能为0 + * (3)建议该参数和 top_p 只设置1个 + * (4)建议 top_p 和 temperature 不要同时更改 + */ + temperature?: number | null; + + /** + * 生成文本的多样性 + * + * 说明: + * (1)影响输出文本的多样性,取值越大,生成文本的多样性越强 + * (2)默认0.8,取值范围 [0, 1.0] + * (3)建议该参数和 temperature 只设置1个 + * (4)建议 top_p 和 temperature 不要同时更改 + */ + top_p?: number | null; + + /** + * + * 通过对已生成的token增加惩罚,减少重复生成的现象。说明: + * (1)值越大表示惩罚越大 + * (2)默认1.0,取值范围:[1.0, 2.0] + * + * @remarks 在 OpenAI 中,参数名为 presence_penalty + */ + penalty_score?: number | null; + + /** + * 是否以流式接口的形式返回数据,默认 false + */ + stream?: boolean | null; + + /** + * 生成停止标识,当模型生成结果以stop中某个元素结尾时,停止文本生成。说明: + * (1)每个元素长度不超过20字符 + * (2)最多4个元素 + */ + stop?: string | string[] | undefined; + + /** + * 表示最终用户的唯一标识符,可以监视和检测滥用行为,防止接口恶意调用 + * + * @remarks OpenAI 中是通过 user 区分 + */ + user_id?: string | undefined; + } + + export type ChatCompletion = { + id: string; + result: string; + created: string; + is_end: boolean; + is_truncated: boolean; + need_clear_history: boolean; + usage: OpenAI.CompletionUsage; + }; + + export type APIResponse = { + errorCode: number; + errorMsg: string; + result: ChatCompletion; + }; +} diff --git a/src/main/gptproxy/ernie/resources/chat/index.ts b/src/main/gptproxy/ernie/resources/chat/index.ts new file mode 100644 index 0000000..4805791 --- /dev/null +++ b/src/main/gptproxy/ernie/resources/chat/index.ts @@ -0,0 +1,8 @@ +export { Chat } from './chat'; +export { + type ChatModel, + type ChatCompletionCreateParams, + type ChatCompletionCreateParamsNonStreaming, + type ChatCompletionCreateParamsStreaming, + Completions, +} from './completions'; diff --git a/src/main/gptproxy/ernie/resources/embeddings.ts b/src/main/gptproxy/ernie/resources/embeddings.ts new file mode 100644 index 0000000..b97f126 --- /dev/null +++ b/src/main/gptproxy/ernie/resources/embeddings.ts @@ -0,0 +1,95 @@ +import OpenAI, { APIError, OpenAIError } from 'openai'; +import { type RequestOptions } from 'openai/core'; + +import { APIResource } from '../../resource'; + +export class Embeddings extends APIResource { + protected endpoints: Record = { + 'ernie-text-embedding': '/embeddings/embedding-v1', + }; + + /** + * Creates an embedding vector representing the input text. + * + * See https://cloud.baidu.com/doc/WENXINWORKSHOP/s/alj562vvu + */ + async create( + params: EmbeddingCreateParams, + options?: RequestOptions, + ): Promise { + const { model, user, input } = params; + const endpoint = this.endpoints[model]; + + if (!endpoint) { + throw new OpenAIError(`Invalid model: ${model}`); + } + + const body = { + input, + user_id: user, + }; + + const response: Response = await this._client.post(endpoint, { + body, + ...options, + __binaryResponse: true, + }); + + return Embeddings.fromResponse(model, await response.json()); + } + + static fromResponse( + model: EmbeddingModel, + data: CreateEmbeddingResponse, + ): OpenAI.CreateEmbeddingResponse { + Embeddings.assert(data); + + const { result } = data; + + return { + data: result.data, + model, + object: 'list', + usage: result.usage, + }; + } + + /** + * 如果 code 不为 0,抛出 APIError + * + * @param code - + * @param message - + */ + static assert(resp: CreateEmbeddingResponse) { + if (resp.errorCode === 0) return; + + const error = { code: resp.errorCode, message: resp.errorMsg }; + + throw APIError.generate(undefined, error, undefined, undefined); + } +} + +export type EmbeddingModel = 'ernie-text-embedding'; + +export interface EmbeddingCreateParams { + /** + * 输入文本 + */ + input: string | Array | Array | Array>; + + /** + * 模型 + */ + model: EmbeddingModel; + + /** + * 用户 ID + */ + user?: string; +} + +type CreateEmbeddingResponse = { + errorCode: number; + errorMsg: string; + result: OpenAI.CreateEmbeddingResponse; +}; diff --git a/src/main/gptproxy/ernie/resources/index.ts b/src/main/gptproxy/ernie/resources/index.ts new file mode 100644 index 0000000..53941ff --- /dev/null +++ b/src/main/gptproxy/ernie/resources/index.ts @@ -0,0 +1,3 @@ +export * from './chat/index'; + +export { Embeddings, type EmbeddingCreateParams } from './embeddings'; diff --git a/src/main/gptproxy/ernie/util.ts b/src/main/gptproxy/ernie/util.ts new file mode 100644 index 0000000..72bba8a --- /dev/null +++ b/src/main/gptproxy/ernie/util.ts @@ -0,0 +1,46 @@ +import { APIError } from 'openai'; + +/** + * 构建错误 + * + * @param code - + * @param message - + * @returns 错误 + */ +export function makeAPIError(code: number, message: string) { + const error = { code, message }; + + switch (code) { + case 2: + return APIError.generate(500, error, message, {}); + case 6: // permission error + case 111: // token expired + return APIError.generate(403, error, message, {}); + case 17: + case 18: + case 19: + case 40407: + return APIError.generate(429, error, message, {}); + case 110: // invalid token + case 40401: // invalid token + return APIError.generate(401, error, message, {}); + case 336003: // invalid parameter + return APIError.generate(400, error, message, {}); + case 336100: // try again + return APIError.generate(500, error, message, {}); + default: + return APIError.generate(undefined, error, message, {}); + } +} + +/** + * 如果 code 不为 0,抛出 APIError + * + * @param code - + * @param message - + */ +export function assertNonZero(code: number, message: string) { + if (code === 0) return; + + throw makeAPIError(code, message); +} diff --git a/src/main/gptproxy/gemini/index.ts b/src/main/gptproxy/gemini/index.ts new file mode 100644 index 0000000..b28e5ae --- /dev/null +++ b/src/main/gptproxy/gemini/index.ts @@ -0,0 +1,81 @@ +import type { Agent } from 'node:http'; + +import { + APIClient, + type DefaultQuery, + type Fetch, + type FinalRequestOptions, + type Headers, +} from 'openai/core'; + +import * as API from './resources'; + +const BASE_URL = 'https://generativelanguage.googleapis.com/v1'; + +export interface GeminiAIOptions { + baseURL?: string; + apiKey?: string; + timeout?: number | undefined; + httpAgent?: Agent; + fetch?: Fetch | undefined; + defaultHeaders?: Headers; + defaultQuery?: DefaultQuery; +} + +export class GeminiAI extends APIClient { + apiKey: string; + + private _options: GeminiAIOptions; + + constructor(options: GeminiAIOptions = {}) { + const { + apiKey = process.env.GEMINI_API_KEY || '', + baseURL = process.env.GEMINI_BASE_URL || BASE_URL, + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.apiKey = apiKey; + } + + chat = new API.Chat(this); + + models = new API.Models(this); + + protected override defaultHeaders(opts: FinalRequestOptions): Headers { + return { + ...super.defaultHeaders(opts), + ...this._options.defaultHeaders, + }; + } + + protected override defaultQuery(): DefaultQuery | undefined { + return { + ...this._options.defaultQuery, + key: this.apiKey, + }; + } +} + +// eslint-disable-next-line no-redeclare +export namespace GeminiAI { + export type ChatModel = API.ChatModel; + export type ChatCompletionCreateParams = API.ChatCompletionCreateParams; + export type ChatCompletionCreateParamsStreaming = + API.ChatCompletionCreateParamsStreaming; + export type ChatCompletionCreateParamsNonStreaming = + API.ChatCompletionCreateParamsNonStreaming; +} + +export default GeminiAI; diff --git a/src/main/gptproxy/gemini/resource.ts b/src/main/gptproxy/gemini/resource.ts new file mode 100644 index 0000000..c3c77ed --- /dev/null +++ b/src/main/gptproxy/gemini/resource.ts @@ -0,0 +1,9 @@ +import type { GeminiAI } from './index'; + +export class APIResource { + protected _client: GeminiAI; + + constructor(client: GeminiAI) { + this._client = client; + } +} diff --git a/src/main/gptproxy/gemini/resources/chat/chat.ts b/src/main/gptproxy/gemini/resources/chat/chat.ts new file mode 100644 index 0000000..c334355 --- /dev/null +++ b/src/main/gptproxy/gemini/resources/chat/chat.ts @@ -0,0 +1,6 @@ +import { Completions } from './completions'; +import { APIResource } from '../../resource'; + +export class Chat extends APIResource { + completions = new Completions(this._client); +} diff --git a/src/main/gptproxy/gemini/resources/chat/completions.ts b/src/main/gptproxy/gemini/resources/chat/completions.ts new file mode 100644 index 0000000..6fc5a0a --- /dev/null +++ b/src/main/gptproxy/gemini/resources/chat/completions.ts @@ -0,0 +1,347 @@ +import { randomUUID } from 'crypto'; +import OpenAI from 'openai'; +import { Stream } from 'openai/streaming'; + +import { ensureArray } from '../../../util'; +import { APIResource } from '../../resource'; + +export class Completions extends APIResource { + /** + * Creates a model response for the given chat conversation. + */ + create( + body: ChatCompletionCreateParamsNonStreaming, + options?: OpenAI.RequestOptions, + ): Promise; + + create( + body: ChatCompletionCreateParamsStreaming, + options?: OpenAI.RequestOptions, + ): Promise>; + + async create( + params: ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, + ): Promise> { + const { stream, model } = params; + const body = this.buildCreateParams(params); + const path = `/models/${model}:generateContent`; + + const response: Response = await this._client.post(path, { + ...options, + query: stream ? { alt: 'sse' } : {}, + body: body as unknown as Record, + stream: false, + __binaryResponse: true, + }); + + if (stream) { + const controller = new AbortController(); + + options?.signal?.addEventListener('abort', () => { + controller.abort(); + }); + + return this.afterSSEResponse(model, response, controller); + } + + return this.afterResponse(model, response); + } + + protected buildCreateParams(params: ChatCompletionCreateParams) { + const { + messages = [], + max_tokens, + top_p, + top_k, + stop, + temperature, + } = params; + + function formatContentParts( + content: string | OpenAI.ChatCompletionContentPart[], + ) { + const parts: GeminiChat.Part[] = []; + + if (typeof content === 'string') { + parts.push({ text: content }); + return parts; + } + + // eslint-disable-next-line no-restricted-syntax + for (const part of content) { + if (part.type === 'text') { + parts.push({ text: part.text }); + } else { + // TODO: Handle images + // parts.push({ + // inline_data: { + // "mime_type": "image/jpeg", + // "data": "'$(base64 -w0 image.jpg)'" + // } + // }); + } + } + + return parts; + } + + function formatRole(role: string): 'user' | 'model' { + return role === 'user' ? 'user' : 'model'; + } + + const generationConfig: GeminiChat.GenerationConfig = {}; + + const data: GeminiChat.GenerateContentRequest = { + contents: messages.map((item) => { + return { + role: formatRole(item.role), + parts: formatContentParts(item.content!), + }; + }), + generationConfig, + }; + + if (temperature != null) { + generationConfig.temperature = temperature; + } + + if (top_k != null) { + generationConfig.topK = top_k; + } + + if (top_p != null) { + generationConfig.topP = top_p; + } + + if (stop != null) { + generationConfig.stopSequences = ensureArray(stop); + } + + if (max_tokens != null) { + generationConfig.maxOutputTokens = max_tokens; + } + + return data; + } + + protected async afterResponse( + model: string, + response: Response, + ): Promise { + const data: GeminiChat.GenerateContentResponse = await response.json(); + const choices: OpenAI.ChatCompletion.Choice[] = data.candidates!.map( + (item) => { + const [part] = item.content.parts; + + const choice: OpenAI.ChatCompletion.Choice = { + index: item.index, + message: { + role: 'assistant', + content: part.text!, + }, + logprobs: null, + finish_reason: 'stop', + }; + + switch (item.finishReason) { + case 'MAX_TOKENS': + choice.finish_reason = 'length'; + break; + case 'SAFETY': + case 'RECITATION': + choice.finish_reason = 'content_filter'; + break; + default: + choice.finish_reason = 'stop'; + } + + return choice; + }, + ); + + return { + id: randomUUID(), + model, + choices, + object: 'chat.completion', + created: Date.now() / 10, + // TODO 需要支持 usage + usage: { + completion_tokens: 0, + prompt_tokens: 0, + total_tokens: 0, + }, + }; + } + + protected afterSSEResponse( + model: string, + response: Response, + controller: AbortController, + ): Stream { + const stream = Stream.fromSSEResponse( + response, + controller, + ); + + const toChoices = (data: GeminiChat.GenerateContentResponse) => { + return data.candidates!.map((item) => { + const [part] = item.content.parts; + + const choice: OpenAI.ChatCompletionChunk.Choice = { + index: item.index, + delta: { + role: 'assistant', + content: part.text || '', + }, + finish_reason: null, + }; + + switch (item.finishReason) { + case 'MAX_TOKENS': + choice.finish_reason = 'length'; + break; + case 'SAFETY': + case 'RECITATION': + choice.finish_reason = 'content_filter'; + break; + default: + choice.finish_reason = 'stop'; + } + + return choice; + }); + }; + + async function* iterator(): AsyncIterator< + OpenAI.ChatCompletionChunk, + any, + undefined + > { + // eslint-disable-next-line no-restricted-syntax + for await (const chunk of stream) { + yield { + id: randomUUID(), + model, + choices: toChoices(chunk), + object: 'chat.completion.chunk', + created: Date.now() / 10, + }; + } + } + + return new Stream(iterator, controller); + } +} + +export type ChatCompletionCreateParamsNonStreaming = + Chat.ChatCompletionCreateParamsNonStreaming; + +export type ChatCompletionCreateParamsStreaming = + Chat.ChatCompletionCreateParamsStreaming; + +export type ChatCompletionCreateParams = Chat.ChatCompletionCreateParams; + +export type ChatModel = Chat.ChatModel; + +export namespace Chat { + // eslint-disable-next-line @typescript-eslint/no-shadow + export type ChatModel = (string & NonNullable) | 'gemini-pro'; + // 支持的有点问题 + // | 'gemini-pro-vision'; + + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ChatCompletionCreateParamsNonStreaming + extends OpenAI.ChatCompletionCreateParamsNonStreaming { + model: ChatModel; + top_k?: number; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ChatCompletionCreateParamsStreaming + extends OpenAI.ChatCompletionCreateParamsStreaming { + model: ChatModel; + top_k?: number | null; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + export type ChatCompletionCreateParams = + | ChatCompletionCreateParamsNonStreaming + | ChatCompletionCreateParamsStreaming; +} + +namespace GeminiChat { + export interface GenerationConfig { + candidateCount?: number; + stopSequences?: string[]; + maxOutputTokens?: number; + temperature?: number; + topP?: number; + topK?: number; + } + + export interface GenerateContentCandidate { + index: number; + content: Content; + finishReason?: + | 'FINISH_REASON_UNSPECIFIED' + | 'STOP' + | 'MAX_TOKENS' + | 'SAFETY' + | 'RECITATION' + | 'OTHER'; + finishMessage?: string; + citationMetadata?: CitationMetadata; + } + + export interface GenerateContentResponse { + candidates?: GenerateContentCandidate[]; + // promptFeedback?: PromptFeedback; + } + + export interface CitationMetadata { + citationSources: CitationSource[]; + } + + export interface CitationSource { + startIndex?: number; + endIndex?: number; + uri?: string; + license?: string; + } + + export interface InputContent { + parts: string | Array; + role: string; + } + + export interface Content extends InputContent { + parts: Part[]; + } + + export type Part = TextPart | InlineDataPart; + + export interface TextPart { + text: string; + inlineData?: never; + } + + export interface InlineDataPart { + text?: never; + inlineData: GeminiContentBlob; + } + + export interface GeminiContentBlob { + mimeType: string; + data: string; + } + + export interface BaseParams { + generationConfig?: GenerationConfig; + } + + export interface GenerateContentRequest extends BaseParams { + contents: Content[]; + } +} diff --git a/src/main/gptproxy/gemini/resources/chat/index.ts b/src/main/gptproxy/gemini/resources/chat/index.ts new file mode 100644 index 0000000..4805791 --- /dev/null +++ b/src/main/gptproxy/gemini/resources/chat/index.ts @@ -0,0 +1,8 @@ +export { Chat } from './chat'; +export { + type ChatModel, + type ChatCompletionCreateParams, + type ChatCompletionCreateParamsNonStreaming, + type ChatCompletionCreateParamsStreaming, + Completions, +} from './completions'; diff --git a/src/main/gptproxy/gemini/resources/index.ts b/src/main/gptproxy/gemini/resources/index.ts new file mode 100644 index 0000000..143b817 --- /dev/null +++ b/src/main/gptproxy/gemini/resources/index.ts @@ -0,0 +1,3 @@ +export * from './chat/index'; + +export { Models, type Model, ModelsPage } from './models'; diff --git a/src/main/gptproxy/gemini/resources/models.ts b/src/main/gptproxy/gemini/resources/models.ts new file mode 100644 index 0000000..af80bce --- /dev/null +++ b/src/main/gptproxy/gemini/resources/models.ts @@ -0,0 +1,85 @@ +import OpenAI from 'openai'; +import { + type FinalRequestOptions, + type PagePromise, + type RequestOptions, +} from 'openai/core'; +import { Page } from 'openai/pagination'; + +import { type GeminiAI } from '../../index'; +import { APIResource } from '../resource'; + +// TODO 输出原始对象 +export class Models extends APIResource { + /** + * Retrieves a model instance, providing basic information about the model such as + * the owner and permissioning. + */ + async retrieve(model: string, options?: RequestOptions): Promise { + const item: GeminiModel = await this._client.get( + `/models/${model}`, + options, + ); + + return { + id: item.name, + created: 0, + object: 'model', + owned_by: 'google', + }; + } + + /** + * Lists the currently available models, and provides basic information about each + * one such as the owner and availability. + */ + list(options?: RequestOptions): PagePromise { + return this._client.getAPIList('/models', ModelsPage, options); + } +} + +export class ModelsPage extends Page { + constructor( + client: GeminiAI, + response: Response, + body: GeminiPageResponse, + options: FinalRequestOptions, + ) { + const data: Model[] = body.models.map((item) => { + return { + id: item.name, + created: 0, + object: 'model', + owned_by: 'google', + }; + }); + + super(client, response, { data, object: 'list' }, options); + } +} + +interface GeminiModel { + name: string; + version: string; + displayName: string; + description: string; + inputTokenLimit: string; + outputTokenLimit: string; + supportedGenerationMethods: string[]; +} + +interface GeminiPageResponse { + models: GeminiModel[]; +} + +/** + * Describes an OpenAI model offering that can be used with the API. + */ +export type Model = OpenAI.Models.Model; + +// eslint-disable-next-line no-redeclare +export namespace Models { + export import Model = OpenAI.Models.Model; + // eslint-disable-next-line @typescript-eslint/no-shadow + export import ModelsPage = OpenAI.Models.ModelsPage; +} diff --git a/src/main/gptproxy/hunyuan/index.ts b/src/main/gptproxy/hunyuan/index.ts new file mode 100644 index 0000000..c2c021f --- /dev/null +++ b/src/main/gptproxy/hunyuan/index.ts @@ -0,0 +1,125 @@ +import { createHmac } from 'node:crypto'; +import type { Agent } from 'node:http'; + +import { + APIClient, + type DefaultQuery, + type Fetch, + type FinalRequestOptions, + type Headers, +} from 'openai/core'; + +import * as API from './resources'; + +export interface HunYuanAIOptions { + baseURL?: string; + appId?: string; + secretId?: string; + secretKey?: string; + timeout?: number | undefined; + httpAgent?: Agent; + fetch?: Fetch | undefined; + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `undefined` or `null` in request options. + */ + defaultHeaders?: Headers; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: DefaultQuery; +} + +export class HunYuanAI extends APIClient { + appId: number; + + secretId: string; + + secretKey: string; + + private _options: HunYuanAIOptions; + + constructor(options: HunYuanAIOptions = {}) { + const { + appId = process.env.HUNYUAN_APP_ID || '', + secretId = process.env.HUNYUAN_SECRET_ID || '', + secretKey = process.env.HUNYUAN_SECRET_KEY || '', + baseURL = 'https://hunyuan.cloud.tencent.com/hyllm/v1', + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.appId = parseInt(appId, 10); + this.secretKey = secretKey; + this.secretId = secretId; + } + + chat = new API.Chat(this); + + protected override defaultHeaders(opts: FinalRequestOptions): Headers { + return { + ...super.defaultHeaders(opts), + ...this._options.defaultHeaders, + }; + } + + protected override defaultQuery(): DefaultQuery | undefined { + return this._options.defaultQuery; + } + + generateAuthorization(path: string, data: Record) { + const rawSessionKey = this.buildURL(path, {}).replace('https://', ''); + + const rawSignature: string[] = []; + + Object.keys(data) + .sort() + .forEach((key) => { + const value = data[key]; + + if (value == null) return; + + if (typeof value === 'object') { + rawSignature.push(`${key}=${JSON.stringify(value)}`); + } else { + rawSignature.push(`${key}=${value}`); + } + }); + + return this.hash(`${rawSessionKey}?${rawSignature.join('&')}`); + } + + protected hash(data: string) { + const hash = createHmac('sha1', this.secretKey); + return hash.update(Buffer.from(data, 'utf8')).digest('base64'); + } +} + +// eslint-disable-next-line no-redeclare +export namespace HunYuanAI { + export type ChatModel = API.ChatModel; + export type ChatCompletionCreateParams = API.ChatCompletionCreateParams; + export type ChatCompletionCreateParamsStreaming = + API.ChatCompletionCreateParamsStreaming; + export type ChatCompletionCreateParamsNonStreaming = + API.ChatCompletionCreateParamsNonStreaming; +} + +export default HunYuanAI; diff --git a/src/main/gptproxy/hunyuan/resource.ts b/src/main/gptproxy/hunyuan/resource.ts new file mode 100644 index 0000000..aaec5d6 --- /dev/null +++ b/src/main/gptproxy/hunyuan/resource.ts @@ -0,0 +1,9 @@ +import type { HunYuanAI } from './index'; + +export class APIResource { + protected _client: HunYuanAI; + + constructor(client: HunYuanAI) { + this._client = client; + } +} diff --git a/src/main/gptproxy/hunyuan/resources/chat/chat.ts b/src/main/gptproxy/hunyuan/resources/chat/chat.ts new file mode 100644 index 0000000..214edf9 --- /dev/null +++ b/src/main/gptproxy/hunyuan/resources/chat/chat.ts @@ -0,0 +1,6 @@ +import { APIResource } from '../../resource'; +import { Completions } from './completions'; + +export class Chat extends APIResource { + completions = new Completions(this._client); +} diff --git a/src/main/gptproxy/hunyuan/resources/chat/completions.ts b/src/main/gptproxy/hunyuan/resources/chat/completions.ts new file mode 100644 index 0000000..f8897e4 --- /dev/null +++ b/src/main/gptproxy/hunyuan/resources/chat/completions.ts @@ -0,0 +1,250 @@ +import { createHmac } from 'node:crypto'; + +import OpenAI, { APIError } from 'openai'; +import { Stream } from 'openai/streaming'; + +import { APIResource } from '../../resource'; + +export class Completions extends APIResource { + /** + * Creates a model response for the given chat conversation. + * + * See https://cloud.tencent.com/document/product/1729/97732 + */ + create( + body: ChatCompletionCreateParamsNonStreaming, + options?: OpenAI.RequestOptions, + ): Promise; + + create( + body: ChatCompletionCreateParamsStreaming, + options?: OpenAI.RequestOptions, + ): Promise>; + + async create( + params: ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, + ): Promise> { + const client = this._client; + const { model, messages, temperature = 0.8, top_p, stream } = params; + + const timestamp = Math.floor(Date.now() / 1000); + + const body: ChatCompletions.ChatCompletionCreateParams = { + app_id: client.appId, + secret_id: client.secretId, + timestamp, + expired: timestamp + 7200, + temperature, + top_p, + stream: stream ? 1 : 0, + messages, + }; + + const path = '/chat/completions'; + + const signature = client.generateAuthorization(path, body); + + const response: Response = await this._client.post(path, { + ...options, + body, + headers: { + ...options?.headers, + Authorization: signature, + }, + stream: false, + __binaryResponse: true, + }); + + if (params.stream) { + const controller = new AbortController(); + + options?.signal?.addEventListener('abort', () => { + controller.abort(); + }); + + return Completions.fromSSEResponse( + model, + Stream.fromSSEResponse(response, controller), + controller, + ); + } + + return Completions.fromResponse(model, await response.json()); + } + + static fromSSEResponse( + model: string, + stream: Stream, + controller: AbortController, + ): Stream { + async function* iterator(): AsyncIterator< + OpenAI.ChatCompletionChunk, + any, + undefined + > { + // eslint-disable-next-line no-restricted-syntax + for await (const chunk of stream) { + if (chunk.error) { + throw new APIError(undefined, chunk.error, undefined, undefined); + } + + const message = chunk.choices[0]; + + const choice: OpenAI.ChatCompletionChunk.Choice = { + index: 0, + delta: { + role: 'assistant', + content: message.delta.content || '', + }, + finish_reason: null, + }; + + yield { + id: chunk.id, + model, + choices: [choice], + object: 'chat.completion.chunk', + created: parseInt(chunk.created, 10), + }; + } + } + + return new Stream(iterator, controller); + } + + static fromResponse( + model: string, + data: ChatCompletions.ChatCompletion, + ): OpenAI.ChatCompletion { + if (data.error) { + throw new APIError(undefined, data.error, undefined, undefined); + } + + const message = data.choices[0]; + + const choice: OpenAI.ChatCompletion.Choice = { + index: 0, + message: { + role: 'assistant', + content: message.messages.content, + }, + logprobs: null, + finish_reason: message.finish_reason, + }; + + return { + id: data.id, + model, + choices: [choice], + created: parseInt(data.created, 10), + object: 'chat.completion', + usage: data.usage, + }; + } + + protected hash(data: string) { + const hash = createHmac('sha1', this._client.secretKey); + return hash.update(Buffer.from(data, 'utf8')).digest('base64'); + } +} + +export interface ChatCompletionCreateParamsNonStreaming + extends OpenAI.ChatCompletionCreateParamsNonStreaming { + model: ChatModel; +} + +export interface ChatCompletionCreateParamsStreaming + extends OpenAI.ChatCompletionCreateParamsStreaming { + model: ChatModel; +} + +export type ChatCompletionCreateParams = + | ChatCompletionCreateParamsNonStreaming + | ChatCompletionCreateParamsStreaming; + +export type ChatModel = 'hunyuan'; + +export namespace ChatCompletions { + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ChatCompletionCreateParams { + /** + * 腾讯云账号的 APPID + */ + app_id: number; + + /** + * API 密钥 + */ + secret_id: string; + + /** + * 当前 UNIX 时间戳,单位为秒,可记录发起 API 请求的时间。 + */ + timestamp: number; + + /** + * 签名的有效期,是一个符合 UNIX Epoch 时间戳规范的数值,单位为秒;Expired 必须与 Timestamp 的差值小于90天 + */ + expired: number; + + /** + * 请求 ID,用于问题排查 + */ + query_id?: string; + + /** + * 内容随机性 + */ + temperature?: number | null; + + /** + * 生成结果的多样性 + */ + top_p?: number | null; + + /** + * 是否返回流式结果 + * + * 0:同步,1:流式 (默认,协议:SSE) + * + * 同步请求超时:60s,如果内容较长建议使用流式 + */ + stream?: number | null; + + /** + * 会话内容, 按对话时间序排列,长度最多为40 + * 最大支持16k tokens上下文 + */ + messages: OpenAI.ChatCompletionMessageParam[]; + } + + export type CompletionChoicesDelta = { + content: string; + }; + + export type CompletionChoice = { + finish_reason: 'stop'; + /** + * 内容,同步模式返回内容,流模式为 null + */ + messages: OpenAI.ChatCompletionMessage; + /** + * 内容,流模式返回内容,同步模式为 null + */ + delta: CompletionChoicesDelta; + }; + + export interface ChatCompletion { + choices: CompletionChoice[]; + created: string; + note: string; + id: string; + usage: OpenAI.CompletionUsage; + + error?: { + message: string; + code: number; + }; + } +} diff --git a/src/main/gptproxy/hunyuan/resources/chat/index.ts b/src/main/gptproxy/hunyuan/resources/chat/index.ts new file mode 100644 index 0000000..4805791 --- /dev/null +++ b/src/main/gptproxy/hunyuan/resources/chat/index.ts @@ -0,0 +1,8 @@ +export { Chat } from './chat'; +export { + type ChatModel, + type ChatCompletionCreateParams, + type ChatCompletionCreateParamsNonStreaming, + type ChatCompletionCreateParamsStreaming, + Completions, +} from './completions'; diff --git a/src/main/gptproxy/hunyuan/resources/index.ts b/src/main/gptproxy/hunyuan/resources/index.ts new file mode 100644 index 0000000..b8f4fd4 --- /dev/null +++ b/src/main/gptproxy/hunyuan/resources/index.ts @@ -0,0 +1 @@ +export * from './chat/index'; diff --git a/src/main/gptproxy/index.ts b/src/main/gptproxy/index.ts new file mode 100644 index 0000000..4e366da --- /dev/null +++ b/src/main/gptproxy/index.ts @@ -0,0 +1,51 @@ +import OpenAI from 'openai'; + +import ErnieAI, { ErnieAIOptions } from './ernie'; +import GeminiAI, { GeminiAIOptions } from './gemini'; +import HunYuanAI, { HunYuanAIOptions } from './hunyuan'; +import MinimaxAI, { MinimaxAIOptions } from './minimax'; +import QWenAI, { QWenAIOptions } from './qwen'; +import SparkAI, { SparkAIOptions } from './spark'; +import VYroAI, { VYroAIOptions } from './vyro'; + +export { + ErnieAI, + type ErnieAIOptions, + GeminiAI, + type GeminiAIOptions, + HunYuanAI, + type HunYuanAIOptions, + MinimaxAI, + type MinimaxAIOptions, + OpenAI, + QWenAI, + type QWenAIOptions, + SparkAI, + type SparkAIOptions, + VYroAI, + type VYroAIOptions, +}; + +export { + OpenAIError, + APIError, + APIConnectionError, + APIConnectionTimeoutError, + APIUserAbortError, + NotFoundError, + ConflictError, + RateLimitError, + BadRequestError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, +} from 'openai'; + +export * from './resource'; +export * from './streaming'; +export * from './util'; + +export default { + version: process.env.PKG_VERSION, +}; diff --git a/src/main/gptproxy/minimax/error.ts b/src/main/gptproxy/minimax/error.ts new file mode 100644 index 0000000..33168e8 --- /dev/null +++ b/src/main/gptproxy/minimax/error.ts @@ -0,0 +1,19 @@ +import { APIError } from 'openai'; + +export type MinimaxAPIResponse = { + base_resp: { + status_code: number; + status_msg: string; + }; +}; + +export function assertStatusCode(data: MinimaxAPIResponse) { + if (data.base_resp.status_code === 0) return; + + const error = { + code: data.base_resp.status_code, + message: data.base_resp.status_msg, + }; + + throw new APIError(undefined, error, undefined, undefined); +} diff --git a/src/main/gptproxy/minimax/index.ts b/src/main/gptproxy/minimax/index.ts new file mode 100644 index 0000000..aec4797 --- /dev/null +++ b/src/main/gptproxy/minimax/index.ts @@ -0,0 +1,98 @@ +import type { Agent } from 'node:http'; + +import { + APIClient, + type DefaultQuery, + type Fetch, + type FinalRequestOptions, + type Headers, +} from 'openai/core'; + +import * as API from './resources'; + +export interface MinimaxAIOptions { + baseURL?: string; + orgId?: string; + apiKey?: string; + timeout?: number | undefined; + httpAgent?: Agent; + fetch?: Fetch | undefined; + defaultHeaders?: Headers; + defaultQuery?: DefaultQuery; +} + +export class MinimaxAI extends APIClient { + protected orgId: string; + + protected apiKey: string; + + private _options: MinimaxAIOptions; + + constructor(options: MinimaxAIOptions = {}) { + const { + orgId = process.env.MINIMAX_API_ORG || '', + apiKey = process.env.MINIMAX_API_KEY || '', + baseURL = 'https://api.minimax.chat/v1', + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.apiKey = apiKey; + this.orgId = orgId; + } + + audio = new API.Audio(this); + + chat = new API.Chat(this); + + embeddings = new API.Embeddings(this); + + protected authHeaders(): Headers { + return { + Authorization: `Bearer ${this.apiKey}`, + }; + } + + protected override defaultHeaders(opts: FinalRequestOptions): Headers { + return { + ...super.defaultHeaders(opts), + ...this._options.defaultHeaders, + }; + } + + protected override defaultQuery(): DefaultQuery | undefined { + return { + GroupId: this.orgId, + ...this._options.defaultQuery, + }; + } +} + +// eslint-disable-next-line no-redeclare +export namespace MinimaxAI { + export type Chat = API.Chat; + export type ChatModel = API.ChatModel; + export type ChatCompletionCreateParams = API.ChatCompletionCreateParams; + export type ChatCompletionCreateParamsNonStreaming = + API.ChatCompletionCreateParamsNonStreaming; + export type ChatCompletionCreateParamsStreaming = + API.ChatCompletionCreateParamsStreaming; + + export type Embeddings = API.Embeddings; + export type EmbeddingCreateParams = API.EmbeddingCreateParams; + + export type Audio = API.Audio; +} + +export default MinimaxAI; diff --git a/src/main/gptproxy/minimax/resources/audio/audio.ts b/src/main/gptproxy/minimax/resources/audio/audio.ts new file mode 100644 index 0000000..da453dd --- /dev/null +++ b/src/main/gptproxy/minimax/resources/audio/audio.ts @@ -0,0 +1,7 @@ +// File generated from our OpenAPI spec by Stainless. +import { APIResource } from '../../../resource'; +import { Speech } from './speech'; + +export class Audio extends APIResource { + speech = new Speech(this._client); +} diff --git a/src/main/gptproxy/minimax/resources/audio/index.ts b/src/main/gptproxy/minimax/resources/audio/index.ts new file mode 100644 index 0000000..d69275e --- /dev/null +++ b/src/main/gptproxy/minimax/resources/audio/index.ts @@ -0,0 +1,2 @@ +export { Audio } from './audio'; +export { type SpeechCreateParams, type SpeechModel, Speech } from './speech'; diff --git a/src/main/gptproxy/minimax/resources/audio/speech.ts b/src/main/gptproxy/minimax/resources/audio/speech.ts new file mode 100644 index 0000000..9183dd8 --- /dev/null +++ b/src/main/gptproxy/minimax/resources/audio/speech.ts @@ -0,0 +1,234 @@ +import { OpenAIError } from 'openai'; +import { type RequestOptions } from 'openai/core'; + +import { APIResource } from '../../../resource'; +import { assertStatusCode, type MinimaxAPIResponse } from '../../error'; + +export class Speech extends APIResource { + protected resources: Record< + SpeechModel, + { + model: string; + endpoint: string; + resposne_type: 'json' | 'binary' | 'stream'; + } + > = { + 'speech-01': { + model: 'speech-01', + endpoint: '/text_to_speech', + resposne_type: 'binary', + }, + 'speech-01-pro': { + model: 'speech-01', + endpoint: '/t2a_pro', + resposne_type: 'json', + }, + // Note: 返回的是 SSE 流数据 + // 'speech-01-stream': { + // model: 'speech-01', + // endpoint: '/tts/stream', + // resposne_type: 'stream', + // }, + }; + + /** + * Generates audio from the input text. + * + * See https://api.minimax.chat/document/guides/T2A-model/tts + */ + create( + params: Speech.SpeechCreateParams, + options?: RequestOptions, + ): Promise; + + create( + params: Speech.SpeechCreateParams, + options: RequestOptions & { + __binaryResponse: false; + }, + ): Promise; + + async create( + params: Speech.SpeechCreateParams, + options?: RequestOptions, + ): Promise { + const { input, voice, ...rest } = params; + + const resource = this.resources[params.model]; + if (!resource) { + throw new OpenAIError(`Invalid model: ${params.model}`); + } + + const body: Record = { + ...rest, + text: input, + model: resource.model, + }; + + if (voice) { + body.voice_id = voice; + } + + const response: Response = await this._client.post(resource.endpoint, { + ...options, + body, + __binaryResponse: true, + }); + + // Note: pro 模型返回 json + if ( + options?.__binaryResponse || + resource.resposne_type === 'binary' || + resource.resposne_type === 'stream' + ) + return response; + + return response.json().then((data: Speech.AudioCreateResponse) => { + assertStatusCode(data); + + return fetch(data.audio_file); + }); + } +} + +export type SpeechModel = Speech.SpeechModel; + +export type SpeechCreateParams = Speech.SpeechCreateParams; + +// eslint-disable-next-line no-redeclare +export namespace Speech { + // eslint-disable-next-line @typescript-eslint/no-shadow + export type SpeechModel = + | (string & NonNullable) + | 'speech-01' + | 'speech-01-pro'; + + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface SpeechCreateParams { + /** + * One of the available [TTS models](https://api.minimax.chat/document/guides/T2A-model/tts) + */ + model: SpeechModel; + + /** + * The text to generate audio for. + */ + input: string; + + /** + * The voice to use when generating the audio. + * + * - 青涩青年音色(male-qn-qingse) + * - 精英青年音色(male-qn-jingying) + * - 霸道青年音色(male-qn-badao) + * - 青年大学生音色(male-qn-daxuesheng) + * - 少女音色(female-shaonv) + * - 御姐音色(female-yujie) + * - 成熟女性音色(female-chengshu) + * - 甜美女性音色(female-tianmei) + * - 男性主持人(presenter_male) + * - 女性主持人(presenter_female) + * - 男性有声书1(audiobook_male_1) + * - 男性有声书2(audiobook_male_2) + * - 女性有声书1(audiobook_female_1) + * - 女性有声书2(audiobook_female_2) + * - 青涩青年音色-beta(male-qn-qingse-jingpin) + * - 精英青年音色-beta(male-qn-jingying-jingpin) + * - 霸道青年音色-beta(male-qn-badao-jingpin) + * - 青年大学生音色-beta(male-qn-daxuesheng-jingpin) + * - 少女音色-beta(female-shaonv-jingpin) + * - 御姐音色-beta(female-yujie-jingpin) + * - 成熟女性音色-beta(female-chengshu-jingpin) + * - 甜美女性音色-beta(female-tianmei-jingpin) + */ + voice: + | (string & NonNullable) + | 'male-qn-qingse' + | 'male-qn-jingying' + | 'male-qn-badao' + | 'male-qn-daxuesheng' + | 'female-shaonv' + | 'female-yujie' + | 'female-chengshu' + | 'female-tianmei' + | 'presenter_male' + | 'presenter_female' + | 'audiobook_male_1' + | 'audiobook_male_2' + | 'audiobook_female_1' + | 'audiobook_female_2' + | 'male-qn-qingse-jingpin' + | 'male-qn-jingying-jingpin' + | 'male-qn-badao-jingpin' + | 'male-qn-daxuesheng-jingpin' + | 'female-shaonv-jingpin' + | 'female-yujie-jingpin' + | 'female-chengshu-jingpin' + | 'female-tianmei-jingpin'; + + /** + * The speed of the generated audio. + * + * Range: 0.5 - 2.0 + * + * @defaultValue 1.0 + */ + speed?: number; + + /** + * The vol of the generated audio. + * + * + * Range: 0~1 + * + * @defaultValue 1.0 + */ + vol?: number; + + /** + * The pitch of the generated audio. + * + * Range: 0~1 + * + * @defaultValue 0 + */ + pitch?: number; + + /** + * 生成声音的采样率。t2a_pro 可用 + * + * Range: [16000, 24000] + * + * @defaultValue 24000 + */ + audio_sample_rate?: number; + + /** + * 生成声音的比特率. t2a_pro 可用 + * + * Range: [32000, 64000,128000] + * + * @defaultValue 128000 + */ + bitrate?: number; + + /** + * The format to audio in. Supported formats are `mp3`, `opus`, `aac`, and `flac`. + */ + response_format?: 'mp3' | 'opus' | 'aac' | 'flac'; + } + + export interface AudioCreateResponse extends MinimaxAPIResponse { + audio_file: string; + subtitle_file: string; + trace_id: string; + extra_info: { + audio_length: number; + audio_sample_rate: number; + audio_size: number; + bitrate: number; + word_count: number; + invisible_character_ratio: number; + }; + } +} diff --git a/src/main/gptproxy/minimax/resources/chat/chat.ts b/src/main/gptproxy/minimax/resources/chat/chat.ts new file mode 100644 index 0000000..1bd15cc --- /dev/null +++ b/src/main/gptproxy/minimax/resources/chat/chat.ts @@ -0,0 +1,6 @@ +import { APIResource } from '../../../resource'; +import { Completions } from './completions'; + +export class Chat extends APIResource { + completions = new Completions(this._client); +} diff --git a/src/main/gptproxy/minimax/resources/chat/completions.ts b/src/main/gptproxy/minimax/resources/chat/completions.ts new file mode 100644 index 0000000..33974f5 --- /dev/null +++ b/src/main/gptproxy/minimax/resources/chat/completions.ts @@ -0,0 +1,505 @@ +import OpenAI, { APIError, OpenAIError } from 'openai'; +import { Stream } from 'openai/streaming'; + +import { APIResource } from '../../../resource'; +import { iterMessages, SSEDecoder } from '../../../streaming'; +import { assertStatusCode } from '../../error'; + +export class Completions extends APIResource { + protected resources: Record< + ChatModel, + { + model: ChatModel; + endpoint: string; + } + > = { + 'abab5-chat': { + model: 'abab5-chat', + endpoint: '/text/chatcompletion', + }, + 'abab5.5-chat': { + model: 'abab5.5-chat', + endpoint: '/text/chatcompletion', + }, + 'abab5.5-chat-pro': { + model: 'abab5.5-chat', + endpoint: '/text/chatcompletion_pro', + }, + }; + + protected system = + 'MM智能助理是一款由MiniMax自研的,没有调用其他产品的接口的大型语言模型。MiniMax是一家中国科技公司,一直致力于进行大模型相关的研究。'; + + /** + * Creates a model response for the given chat conversation. + * + * See https://api.minimax.chat/document/guides/chat-model/chat/api + */ + create( + body: ChatCompletionCreateParamsNonStreaming, + options?: OpenAI.RequestOptions, + ): Promise; + + create( + body: ChatCompletionCreateParamsStreaming, + options?: OpenAI.RequestOptions, + ): Promise>; + + async create( + params: ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, + ) { + const resource = this.resources[params.model]; + + if (!resource) { + throw new OpenAIError(`Invalid model: ${params.model}`); + } + + const body = this.buildCreateParams(params); + + const response: Response = await this._client.post(resource.endpoint, { + ...options, + body: { ...body, model: resource.model }, + stream: false, + __binaryResponse: true, + }); + + if (body.stream) { + const controller = new AbortController(); + + options?.signal?.addEventListener('abort', () => { + controller.abort(); + }); + + return Completions.fromSSEResponse(params.model, response, controller); + } + + return Completions.fromResponse(params.model, await response.json()); + } + + protected buildCreateParams( + params: ChatCompletionCreateParams, + ): ChatCompletions.ChatCompletionCreateParams { + const { model, messages = [], max_tokens, ...rest } = params; + + const data: ChatCompletions.ChatCompletionCreateParams = { + model, + messages: [], + ...rest, + }; + + if (max_tokens) { + data.tokens_to_generate = max_tokens; + } + + const head = messages[0]; + + // minimax 的 system 是独立字段 + const system = head && head.role === 'system' ? head.content : null; + + // 移除 system 角色的消息 + if (system) { + messages.splice(0, 1); + } + + if (model === 'abab5.5-chat-pro') { + data.bot_setting = [ + { + bot_name: 'MM智能助理', + content: system || this.system, + }, + ]; + data.reply_constraints = { + sender_type: 'BOT', + sender_name: 'MM智能助理', + }; + } else { + data.role_meta = { + bot_name: 'MM智能助理', + user_name: '用户', + }; + data.prompt = system || this.system; + } + + data.messages = messages.map((item) => { + switch (item.role) { + case 'assistant': + return { + sender_type: 'BOT', + text: item.content as string, + }; + default: { + const message: ChatCompletions.ChatMessage = { + sender_type: 'USER', + text: item.content as string, + }; + + if (model === 'abab5.5-chat-pro') { + message.sender_name = '用户'; + } + + return message; + } + } + }); + + if (params.stream) { + data.use_standard_sse = true; + } + + return data; + } + + static fromResponse( + model: ChatModel, + data: ChatCompletions.ChatCompletion, + ): OpenAI.ChatCompletion { + assertStatusCode(data); + + return { + id: data.id, + model: data.model, + choices: data.choices.map((choice, index) => { + const { finish_reason } = choice; + + if (model === 'abab5.5-chat-pro') { + return { + index, + message: { + role: 'assistant', + content: choice.messages[0].text, + }, + logprobs: null, + finish_reason, + }; + } + + return { + index, + message: { + role: 'assistant', + content: choice.text, + }, + logprobs: null, + finish_reason, + }; + }), + created: data.created, + object: 'chat.completion', + usage: data.usage, + }; + } + + static fromSSEResponse( + model: ChatModel, + response: Response, + controller: AbortController, + ): Stream { + let consumed = false; + const decoder = new SSEDecoder(); + + function transform( + data: ChatCompletions.ChatCompletionChunk, + ): OpenAI.ChatCompletionChunk { + return { + id: data.request_id, + model, + choices: data.choices.map((choice, index) => { + const { finish_reason = null } = choice; + + if (model === 'abab5.5-chat-pro') { + const content = choice.messages[0].text; + + return { + index, + delta: { + role: 'assistant', + content: finish_reason === 'stop' ? '' : content, + }, + finish_reason, + }; + } + + return { + index, + delta: { + role: 'assistant', + content: choice.delta, + }, + finish_reason, + }; + }), + object: 'chat.completion.chunk', + created: data.created, + }; + } + + async function* iterator(): AsyncIterator< + OpenAI.ChatCompletionChunk, + any, + undefined + > { + if (consumed) { + throw new Error( + 'Cannot iterate over a consumed stream, use `.tee()` to split the stream.', + ); + } + consumed = true; + let done = false; + try { + // eslint-disable-next-line no-restricted-syntax + for await (const sse of iterMessages(response, decoder, controller)) { + if (done) continue; + + if (sse.data.startsWith('[DONE]')) { + done = true; + continue; + } + + if (sse.event === null) { + let data; + + try { + data = JSON.parse(sse.data); + } catch (e) { + console.error(`Could not parse message into JSON:`, sse.data); + console.error(`From chunk:`, sse.raw); + throw e; + } + + if (data && data.code) { + throw new APIError(undefined, data, undefined, undefined); + } + + yield transform(data); + } + } + done = true; + } catch (e) { + // If the user calls `stream.controller.abort()`, we should exit without throwing. + if (e instanceof Error && e.name === 'AbortError') return; + throw e; + } finally { + // If the user `break`s, abort the ongoing request. + if (!done) controller.abort(); + } + } + + return new Stream(iterator, controller); + } +} + +export interface ChatCompletionCreateParamsNonStreaming + extends OpenAI.ChatCompletionCreateParamsNonStreaming { + model: ChatModel; +} + +export interface ChatCompletionCreateParamsStreaming + extends OpenAI.ChatCompletionCreateParamsStreaming { + model: ChatModel; +} + +export type ChatCompletionCreateParams = + | ChatCompletionCreateParamsNonStreaming + | ChatCompletionCreateParamsStreaming; + +export type ChatModel = 'abab5-chat' | 'abab5.5-chat' | 'abab5.5-chat-pro'; + +export namespace ChatCompletions { + export type ChatMessage = { + sender_type: 'USER' | 'BOT' | 'FUNCTION'; + sender_name?: string; + text: string; + }; + + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ChatCompletionCreateParams { + /** + * 模型名称 + */ + model: ChatModel; + + /** + * 对话背景、人物或功能设定 + * + * 和 bot_setting 互斥 + */ + prompt?: string | null; + + /** + * 对话 meta 信息 + * + * 和 bot_setting 互斥 + */ + role_meta?: { + /** + * 用户代称 + */ + user_name: string; + /** + * AI 代称 + */ + bot_name: string; + }; + + /** + * pro 模式下,可以设置 bot 的名称和内容 + * + * 和 prompt 互斥 + */ + bot_setting?: { + bot_name: string; + content: string; + }[]; + + /** + * pro 模式下,设置模型回复要求 + */ + reply_constraints?: { + sender_type: string; + sender_name: string; + }; + + /** + * 对话内容 + */ + messages: ChatMessage[]; + + /** + * 如果为 true,则表明设置当前请求为续写模式,回复内容为传入 messages 的最后一句话的续写; + * + * 此时最后一句发送者不限制 USER,也可以为 BOT。 + */ + continue_last_message?: boolean | null; + + /** + * 内容随机性 + */ + temperature?: number | null; + + /** + * 生成文本的多样性 + */ + top_p?: number | null; + + /** + * 最大生成token数,需要注意的是,这个参数并不会影响模型本身的生成效果, + * + * 而是仅仅通过以截断超出的 token 的方式来实现功能需要保证输入上文的 token 个数和这个值加一起小于 6144 或者 16384,否则请求会失败 + */ + tokens_to_generate?: number | null; + + /** + * 对输出中易涉及隐私问题的文本信息进行脱敏, + * + * 目前包括但不限于邮箱、域名、链接、证件号、家庭住址等,默认 false,即开启脱敏 + */ + skip_info_mask?: boolean | null; + + /** + * 对输出中易涉及隐私问题的文本信息进行打码, + * + * 目前包括但不限于邮箱、域名、链接、证件号、家庭住址等,默认true,即开启打码 + */ + mask_sensitive_info?: boolean | null; + + /** + * 生成多少个结果;不设置默认为1,最大不超过4。 + * + * 由于 beam_width 生成多个结果,会消耗更多 token。 + */ + beam_width?: number | null; + + /** + * 是否以流式接口的形式返回数据,默认 false + */ + stream?: boolean | null; + + /** + * 是否使用标准 SSE 格式,设置为 true 时, + * 流式返回的结果将以两个换行为分隔符。 + * + * 只有在 stream=true 时,此参数才会生效。 + */ + use_standard_sse?: boolean | null; + } + + export type ChatCompletionChoice = { + index?: number; + text: string; + messages: { + sender_type: 'BOT'; + sender_name: string; + text: string; + }[]; + finish_reason: + | 'stop' + | 'length' + | 'tool_calls' + | 'content_filter' + | 'function_call'; + }; + + export interface ChatCompletion { + id: string; + created: number; + model: ChatModel; + reply: string; + choices: ChatCompletionChoice[]; + usage: { + /** + * Number of tokens in the generated completion. + */ + completion_tokens: number; + + /** + * Number of tokens in the prompt. + */ + prompt_tokens: number; + + /** + * Total number of tokens used in the request (prompt + completion). + */ + total_tokens: number; + }; + input_sensitive: boolean; + output_sensitive: boolean; + base_resp: { + status_code: number; + status_msg: string; + }; + } + + export type ChatCompletionChunkChoice = { + index: number; + delta: string; + messages: { + sender_type: 'BOT'; + sender_name: string; + text: string; + }[]; + finish_reason: + | 'stop' + | 'length' + | 'content_filter' + | 'function_call' + | null; + }; + + export interface ChatCompletionChunk { + request_id: string; + created: number; + model: ChatModel; + reply: string; + choices: ChatCompletionChunkChoice[]; + usage: { + total_tokens: number; + }; + input_sensitive: false; + output_sensitive: false; + base_resp: { + status_code: number; + status_msg: string; + }; + } +} diff --git a/src/main/gptproxy/minimax/resources/chat/index.ts b/src/main/gptproxy/minimax/resources/chat/index.ts new file mode 100644 index 0000000..4805791 --- /dev/null +++ b/src/main/gptproxy/minimax/resources/chat/index.ts @@ -0,0 +1,8 @@ +export { Chat } from './chat'; +export { + type ChatModel, + type ChatCompletionCreateParams, + type ChatCompletionCreateParamsNonStreaming, + type ChatCompletionCreateParamsStreaming, + Completions, +} from './completions'; diff --git a/src/main/gptproxy/minimax/resources/embeddings.ts b/src/main/gptproxy/minimax/resources/embeddings.ts new file mode 100644 index 0000000..dd1e6fa --- /dev/null +++ b/src/main/gptproxy/minimax/resources/embeddings.ts @@ -0,0 +1,70 @@ +import OpenAI from 'openai'; +import { type RequestOptions } from 'openai/core'; + +import { APIResource } from '../../resource'; +import { assertStatusCode } from '../error'; + +export class Embeddings extends APIResource { + /** + * Creates an embedding vector representing the input text. + * + * See https://api.minimax.chat/document/guides/Embeddings + */ + async create( + params: EmbeddingCreateParams, + options?: RequestOptions, + ): Promise { + const { model, input, type = 'query' } = params; + + const response: Response = await this._client.post('/embeddings', { + body: { + model, + texts: input, + type, + }, + ...options, + __binaryResponse: true, + }); + + const data: CreateEmbeddingResponse = await response.json(); + + assertStatusCode(data); + + return { + data: data.vectors.map((embedding, index) => { + return { + embedding, + index, + object: 'embedding', + }; + }), + model, + object: 'list', + usage: { + prompt_tokens: data.total_tokens, + total_tokens: data.total_tokens, + }, + }; + } +} + +export interface EmbeddingCreateParams extends OpenAI.EmbeddingCreateParams { + /** + * 模型 + */ + model: 'embo-01'; + + /** + * 首先通过db生成目标内容的向量并存储到向量数据库中,之后通过query生成检索文本的向量。 + */ + type?: 'db' | 'query'; +} + +type CreateEmbeddingResponse = { + vectors: number[][]; + total_tokens: number; + base_resp: { + status_code: number; + status_msg: string; + }; +}; diff --git a/src/main/gptproxy/minimax/resources/index.ts b/src/main/gptproxy/minimax/resources/index.ts new file mode 100644 index 0000000..b89d568 --- /dev/null +++ b/src/main/gptproxy/minimax/resources/index.ts @@ -0,0 +1,4 @@ +export * from './chat/index'; +export * from './audio/audio'; + +export { Embeddings, type EmbeddingCreateParams } from './embeddings'; diff --git a/src/main/gptproxy/qwen/dashscope/index.ts b/src/main/gptproxy/qwen/dashscope/index.ts new file mode 100644 index 0000000..6de5b2d --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/index.ts @@ -0,0 +1,2 @@ +export * from './resolvers'; +export * from './types'; diff --git a/src/main/gptproxy/qwen/dashscope/resolvers/chat.ts b/src/main/gptproxy/qwen/dashscope/resolvers/chat.ts new file mode 100644 index 0000000..488b701 --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/resolvers/chat.ts @@ -0,0 +1,262 @@ +import OpenAI, { APIError } from 'openai'; +import { _iterSSEMessages, Stream } from 'openai/streaming'; + +import { DashscopeChat, OpenAIChatCompatibility } from '../types'; +import { isMultiModal, toCompletionUsage } from './completions'; + +export function fromChatCompletionMessages( + messages: OpenAI.ChatCompletionMessageParam[], +): OpenAI.ChatCompletionMessageParam[] { + return messages.map((message) => { + if (Array.isArray(message.content)) { + message.content.forEach((part) => { + if (part.type === 'image_url') { + // @ts-expect-error + part.image = part.image_url.url; + + // @ts-expect-error + delete part.image_url; + } + + // @ts-expect-error + delete part.type; + }); + } else { + message.content = [ + // @ts-expect-error + { text: message.content! }, + ]; + } + + return message; + }); +} + +export function fromChatCompletionTextMessages( + messages: OpenAI.ChatCompletionMessageParam[], +): OpenAI.ChatCompletionMessageParam[] { + return messages.map((message) => { + if (Array.isArray(message.content)) { + const part = message.content.find( + (c) => c.type === 'text', + ) as OpenAI.ChatCompletionContentPartText; + return { + role: message.role, + content: part.text, + } as OpenAI.ChatCompletionMessageParam; + } + + return message; + }); +} + +export function fromChatCompletionCreateParams( + params: OpenAIChatCompatibility.ChatCompletionCreateParams, +): DashscopeChat.ChatCompletionCreateParams { + const { + model, + messages, + raw, + response_format, + stream_options = {}, + ...parameters + } = params; + + const result: DashscopeChat.ChatCompletionCreateParams = { + model, + input: { + messages: [], + }, + parameters, + }; + + if (raw === true) { + result.input.messages = messages; + } else if (isMultiModal(model)) { + result.input.messages = fromChatCompletionMessages(messages); + } else { + result.input.messages = fromChatCompletionTextMessages(messages); + } + + if (params.tools) { + result.parameters!.result_format = 'message'; + } else { + if (response_format && response_format.type) { + result.parameters!.result_format = response_format.type; + } + + if (params.stream) { + const incremental_output = stream_options?.incremental_output ?? true; + result.parameters!.incremental_output = incremental_output; + } + } + + return result; +} + +export function toChatCompletionFinishReason( + reason?: DashscopeChat.ResponseFinish | null, + stream?: boolean, +) { + if (reason === 'null' || !reason) { + return (stream ? null : 'stop') as 'stop'; + } + + return reason; +} + +export function toChatCompletion( + params: DashscopeChat.ChatCompletionCreateParams, + response: DashscopeChat.ChatCompletion, +): OpenAI.ChatCompletion { + const { model } = params; + const { output, usage } = response; + + const choice: OpenAI.ChatCompletion.Choice = { + index: 0, + message: { + role: 'assistant', + content: '', + }, + logprobs: null, + finish_reason: 'stop', + }; + + // Note: `params.parameters.result_format=message` + if (output.choices) { + const { message, finish_reason } = output.choices[0]; + + choice.message = { + role: message.role, + content: message.content, + }; + + if (finish_reason === 'tool_calls') { + choice.finish_reason = 'tool_calls'; + + choice.message.tool_calls = message.tool_calls; + } else { + choice.finish_reason = toChatCompletionFinishReason(finish_reason, true); + } + } else { + choice.message.content = output.text; + choice.finish_reason = toChatCompletionFinishReason(output.finish_reason); + } + + return { + id: response.request_id, + model, + choices: [choice], + created: Math.floor(Date.now() / 1000), + object: 'chat.completion', + usage: toCompletionUsage(usage), + }; +} + +function toCompletionChunk( + params: DashscopeChat.ChatCompletionCreateParams, + chunk: DashscopeChat.ChatCompletion, +): OpenAI.ChatCompletionChunk { + const { output } = chunk; + + const choice: OpenAI.ChatCompletionChunk.Choice = { + index: 0, + delta: { + role: 'assistant', + content: '', + }, + finish_reason: null, + }; + + // Note: work in `params.parameters.result_format=message` + if (output.choices) { + const { message, finish_reason } = output.choices[0]; + + choice.delta = { + role: message.role, + content: message.content, + }; + + if (finish_reason === 'tool_calls') { + choice.finish_reason = 'tool_calls'; + choice.delta.tool_calls = + message.tool_calls as OpenAI.ChatCompletionChunk.Choice.Delta.ToolCall[]; + } else { + choice.finish_reason = toChatCompletionFinishReason(finish_reason, true); + } + } else { + choice.delta.content = output.text; + choice.finish_reason = toChatCompletionFinishReason( + output.finish_reason, + true, + ); + } + + return { + id: chunk.request_id, + model: params.model, + choices: [choice], + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + }; +} + +export function toChatCompletionStream( + params: DashscopeChat.ChatCompletionCreateParams, + response: Response, + controller: AbortController, +): Stream { + let consumed = false; + async function* iterator(): AsyncIterator< + OpenAI.ChatCompletionChunk, + any, + undefined + > { + if (consumed) { + throw new Error( + 'Cannot iterate over a consumed stream, use `.tee()` to split the stream.', + ); + } + consumed = true; + let done = false; + try { + // eslint-disable-next-line no-restricted-syntax + for await (const sse of _iterSSEMessages(response, controller)) { + if (done) continue; + + if (sse.data.startsWith('[DONE]')) { + done = true; + continue; + } + + if (sse.event === 'result') { + let message; + + try { + message = JSON.parse(sse.data); + } catch (e) { + console.error(`Could not parse message into JSON:`, sse.data); + console.error(`From chunk:`, sse.raw); + throw e; + } + + if (message && message.code) { + throw new APIError(undefined, message, undefined, undefined); + } + + yield toCompletionChunk(params, message); + } + } + done = true; + } catch (e) { + // If the user calls `stream.controller.abort()`, we should exit without throwing. + if (e instanceof Error && e.name === 'AbortError') return; + throw e; + } finally { + // If the user `break`s, abort the ongoing request. + if (!done) controller.abort(); + } + } + + return new Stream(iterator, controller); +} diff --git a/src/main/gptproxy/qwen/dashscope/resolvers/completions.ts b/src/main/gptproxy/qwen/dashscope/resolvers/completions.ts new file mode 100644 index 0000000..67263c3 --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/resolvers/completions.ts @@ -0,0 +1,152 @@ +import OpenAI, { APIError } from 'openai'; +import { _iterSSEMessages, Stream } from 'openai/streaming'; + +import type { + DashscopeCompletions, + OpenAICompletionsCompatibility, +} from '../types'; + +export function isMultiModal(model: string): boolean { + return model.startsWith('qwen-vl'); +} + +export function getCompletionCreateEndpoint(model: string) { + return isMultiModal(model) + ? '/services/aigc/multimodal-generation/generation' + : '/services/aigc/text-generation/generation'; +} + +export function fromCompletionCreateParams( + params: OpenAICompletionsCompatibility.CompletionCreateParams, +): DashscopeCompletions.CompletionCreateParams { + const { model, prompt, response_format, stream_options, ...parameters } = + params; + + const result: DashscopeCompletions.CompletionCreateParams = { + model, + input: { prompt }, + parameters, + }; + + if (response_format && response_format.type) { + result.parameters!.result_format = response_format.type; + } + + if (params.stream) { + const { incremental_output } = stream_options || {}; + result.parameters!.incremental_output = incremental_output ?? true; + } + + return result; +} + +export function toCompletionFinishReason( + reason?: DashscopeCompletions.ResponseFinish | null, + stream?: boolean, +) { + if (reason === 'null' || !reason) { + return (stream ? null : 'stop') as 'stop'; + } + + return reason; +} + +export function toCompletionUsage( + usage: DashscopeCompletions.CompletionUsage, +): OpenAI.CompletionUsage { + // hack: 部分模型不存在 total tokens? + // 如:llama2-7b-chat-v2 + const { + output_tokens, + input_tokens, + total_tokens = output_tokens + input_tokens, + } = usage; + + return { + completion_tokens: output_tokens, + prompt_tokens: input_tokens, + total_tokens, + }; +} + +export function toCompletion( + params: DashscopeCompletions.CompletionCreateParams, + response: DashscopeCompletions.Completion, + stream?: boolean, +): OpenAI.Completion { + const { model } = params; + const { output, usage } = response; + + const choice: OpenAI.CompletionChoice = { + index: 0, + text: output.text, + logprobs: null, + finish_reason: toCompletionFinishReason(output.finish_reason, stream), + }; + + return { + id: response.request_id, + model, + choices: [choice], + created: Math.floor(Date.now() / 1000), + object: 'text_completion', + usage: toCompletionUsage(usage), + }; +} + +export function toCompletionStream( + params: DashscopeCompletions.CompletionCreateParams, + response: Response, + controller: AbortController, +): Stream { + let consumed = false; + async function* iterator(): AsyncIterator { + if (consumed) { + throw new Error( + 'Cannot iterate over a consumed stream, use `.tee()` to split the stream.', + ); + } + + consumed = true; + let done = false; + try { + // eslint-disable-next-line no-restricted-syntax + for await (const sse of _iterSSEMessages(response, controller)) { + if (done) continue; + + if (sse.data.startsWith('[DONE]')) { + done = true; + continue; + } + + if (sse.event === 'result') { + let message; + + try { + message = JSON.parse(sse.data); + } catch (e) { + console.error(`Could not parse message into JSON:`, sse.data); + console.error(`From chunk:`, sse.raw); + throw e; + } + + if (message && message.code) { + throw new APIError(undefined, message, undefined, undefined); + } + + yield toCompletion(params, message, true); + } + } + done = true; + } catch (e) { + // If the user calls `stream.controller.abort()`, we should exit without throwing. + if (e instanceof Error && e.name === 'AbortError') return; + throw e; + } finally { + // If the user `break`s, abort the ongoing request. + if (!done) controller.abort(); + } + } + + return new Stream(iterator, controller); +} diff --git a/src/main/gptproxy/qwen/dashscope/resolvers/embeddings.ts b/src/main/gptproxy/qwen/dashscope/resolvers/embeddings.ts new file mode 100644 index 0000000..9634035 --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/resolvers/embeddings.ts @@ -0,0 +1,38 @@ +import { OpenAI } from 'openai'; + +import { DashscopeEmbeddings, OpenAIEmbeddingsCompatibility } from '../types'; + +export function fromEmbeddingCreatePrams( + params: OpenAIEmbeddingsCompatibility.EmbeddingCreateParams, +): DashscopeEmbeddings.EmbeddingCreateParams { + return { + model: params.model, + input: { + texts: params.input, + }, + parameters: { + text_type: params.type || 'query', + }, + }; +} + +export function toEmbedding( + params: OpenAIEmbeddingsCompatibility.EmbeddingCreateParams, + response: DashscopeEmbeddings.CreateEmbeddingResponse, +): OpenAI.CreateEmbeddingResponse { + const { output, usage } = response; + + return { + object: 'list', + model: params.model, + data: output.embeddings.map(({ text_index, embedding }) => ({ + index: text_index, + embedding, + object: 'embedding', + })), + usage: { + prompt_tokens: usage.total_tokens, + total_tokens: usage.total_tokens, + }, + }; +} diff --git a/src/main/gptproxy/qwen/dashscope/resolvers/index.ts b/src/main/gptproxy/qwen/dashscope/resolvers/index.ts new file mode 100644 index 0000000..6d900aa --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/resolvers/index.ts @@ -0,0 +1,2 @@ +export * from './chat'; +export * from './completions'; diff --git a/src/main/gptproxy/qwen/dashscope/types/chat.ts b/src/main/gptproxy/qwen/dashscope/types/chat.ts new file mode 100644 index 0000000..423d7cf --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/types/chat.ts @@ -0,0 +1,58 @@ +import type OpenAI from 'openai'; + +import type { DashscopeCompletions } from './completions'; + +export namespace DashscopeChat { + /** + * https://help.aliyun.com/zh/dashscope/developer-reference/model-square + */ + export type ChatModel = DashscopeCompletions.CompletionModel; + + export type ResponseFinish = + | 'stop' + | 'length' + | 'tool_calls' + | 'content_filter' + | 'function_call' + | 'null'; + + export interface ChatCompletionParametersParam + extends DashscopeCompletions.CompletionParametersParam { + /** + * 指定可供模型调用的工具列表 + * + * 当输入多个工具时,模型会选择其中一个生成结果。 + * + * 警告: + * + * - tools 暂时无法和 incremental_output 参数同时使用 + * - 使用 tools 时需要同时指定 result_format 为 message + */ + tools?: OpenAI.ChatCompletionTool[]; + } + + export interface ChatCompletionCreateParams { + model: ({} & string) | ChatModel; + input: { + messages: OpenAI.ChatCompletionMessageParam[]; + }; + parameters?: ChatCompletionParametersParam; + } + + export namespace ChatCompletion { + export interface Output { + text: string; + finish_reason?: ResponseFinish; + choices: OpenAI.ChatCompletion.Choice[]; + } + } + + /** + * 详见 [输入参数配置](https://help.aliyun.com/zh/dashscope/developer-reference/api-details) + */ + export interface ChatCompletion { + request_id: string; + usage: DashscopeCompletions.CompletionUsage; + output: ChatCompletion.Output; + } +} diff --git a/src/main/gptproxy/qwen/dashscope/types/completions.ts b/src/main/gptproxy/qwen/dashscope/types/completions.ts new file mode 100644 index 0000000..e18529e --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/types/completions.ts @@ -0,0 +1,170 @@ +export namespace DashscopeCompletions { + /** + * https://help.aliyun.com/zh/dashscope/developer-reference/model-square + */ + export type CompletionModel = + // 通义千问 + | 'qwen-long' + | 'qwen-turbo' + | 'qwen-plus' + | 'qwen-max' + | 'qwen-max-0428' + | 'qwen-max-0403' + | 'qwen-max-0107' + | 'qwen-max-1201' + | 'qwen-max-longcontext' + // 通义千问开源系列 + | 'qwen-7b-chat' + | 'qwen-14b-chat' + | 'qwen-72b-chat' + // 多模型 + | 'qwen-vl-v1' + | 'qwen-vl-chat-v1' + | 'qwen-vl-plus' + // LLAMA2; + | 'llama2-7b-chat-v2' + | 'llama2-13b-chat-v2' + // 百川 + | 'baichuan-7b-v1' + | 'baichuan2-13b-chat-v1' + | 'baichuan2-7b-chat-v1' + // ChatGLM + | 'chatglm3-6b' + | 'chatglm-6b-v2'; + + export type ResponseFinish = 'stop' | 'length' | 'null'; + + /** + * - text 旧版本的 text + * - message 兼容 openai 的 message + * + * @defaultValue "text" + */ + export type ResponseFormat = 'text' | 'message'; + + export type CompletionParametersParam = { + /** + * 启用流式输出 + * + * 默认每次输出为当前生成的整个序列,最后一次输出为最终全部生成结果 + * + * 通过 {@link CompletionParametersParam.incremental_output incremental_output} 参数关闭。 + */ + stream?: boolean | null; + + /** + * 启用增量输出 + * + * 启用 {@link ChatCompletionCreateParamsBase.stream stream} 参数时,每次输出是否每次都包含前面输出的内容。 + * + * Warning: Function call 信息暂时不支持增量输出,开启时需要注意。 + * + * @defaultValue false + */ + incremental_output?: boolean | null; + + /** + * 生成结果的格式 + * + * @defaultValue "text" + */ + result_format?: ResponseFormat; + + /** + * 生成时,随机数的种子,用于控制模型生成的随机性。 + * + * 如果使用相同的种子,每次运行生成的结果都将相同; + * 当需要复现模型的生成结果时,可以使用相同的种子。 + * seed参数支持无符号64位整数类型。 + * + * @defaultValue 1234 + */ + seed?: number | null; + + /** + * 用于限制模型生成token的数量,max_tokens设置的是生成上限,并不表示一定会生成这么多的token数量。最大值和默认值均为1500 + * + * @defaultValue 1500 + */ + max_tokens?: number | null; + + /** + * 生成文本的多样性 + * + * @defaultValue 0.8 + */ + top_p?: number | null; + + /** + * 生成时,采样候选集的大小。 + * + * 例如, + * 取值为50时,仅将单次生成中得分最高的50个token组成随机采样的候选集。 + * 取值越大,生成的随机性越高;取值越小,生成的确定性越高。 + * + * 注意:如果top_k参数为空或者top_k的值大于100,表示不启用top_k策略,此时仅有top_p策略生效,默认是空。 + * + * @defaultValue 80 + */ + top_k?: number | null; + + /** + * 用于控制模型生成时的重复度。提高repetition_penalty时可以降低模型生成的重复度。1.0表示不做惩罚。默认为1.1。 + */ + repetition_penalty?: number | null; + + /** + * 用户控制模型生成时整个序列中的重复度。 + * + * 提高时可以降低模型生成的重复度,取值范围[-2.0, 2.0]。 + */ + presence_penalty?: number | null; + + /** + * 内容随机性 + * + * @defaultValue 1.0 + */ + temperature?: number | null; + + /** + * 生成停止标识符 + */ + stop?: string | string[] | null; + + /** + * 生成时,是否参考搜索的结果。 + * + * 注意:打开搜索并不意味着一定会使用搜索结果; + * 如果打开搜索,模型会将搜索结果作为prompt,进而“自行判断”是否生成结合搜索结果的文本,默认为false + */ + enable_search?: boolean | null; + }; + + export interface CompletionCreateParams { + model: ({} & string) | CompletionModel; + input: { + prompt: string; + }; + parameters?: DashscopeCompletions.CompletionParametersParam; + } + + export interface CompletionUsage { + output_tokens: number; + input_tokens: number; + total_tokens: number; + } + + export namespace Completion { + export interface Output { + text: string; + finish_reason: DashscopeCompletions.ResponseFinish; + } + } + + export interface Completion { + request_id: string; + usage: CompletionUsage; + output: Completion.Output; + } +} diff --git a/src/main/gptproxy/qwen/dashscope/types/embeddings.ts b/src/main/gptproxy/qwen/dashscope/types/embeddings.ts new file mode 100644 index 0000000..d39438d --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/types/embeddings.ts @@ -0,0 +1,47 @@ +export namespace DashscopeEmbeddings { + export type EmbeddingModel = + | 'text-embedding-v1' + | 'text-embedding-async-v1' + | 'text-embedding-v2' + | 'text-embedding-async-v2'; + + export interface EmbeddingCreateParams { + /** + * 模型 + */ + model: ({} & string) | EmbeddingModel; + input: { + /** + * 输入文本 + */ + texts: string | Array | Array | Array>; + }; + parameters: { + /** + * 文本转换为向量后可以应用于检索、聚类、分类等下游任务,对检索这类非对称任务为了达到更好的检索效果 + * 建议区分查询文本(query)和底库文本(document)类型, + * 聚类、分类等对称任务可以不用特殊指定,采用系统默认值"document"即可 + * + * @defaultValue 'query' + */ + text_type?: 'query' | 'document'; + }; + } + + export type Embedding = { + text_index: number; + embedding: number[]; + }; + + export type CreateEmbeddingResponse = { + request_id: string; + code: string; + message: string; + output: { + embeddings: Embedding[]; + }; + usage: { + total_tokens: number; + }; + }; +} diff --git a/src/main/gptproxy/qwen/dashscope/types/index.ts b/src/main/gptproxy/qwen/dashscope/types/index.ts new file mode 100644 index 0000000..7b183b0 --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/types/index.ts @@ -0,0 +1,4 @@ +export * from './chat'; +export * from './completions'; +export * from './embeddings'; +export * from './openai'; diff --git a/src/main/gptproxy/qwen/dashscope/types/openai.ts b/src/main/gptproxy/qwen/dashscope/types/openai.ts new file mode 100644 index 0000000..b358ae4 --- /dev/null +++ b/src/main/gptproxy/qwen/dashscope/types/openai.ts @@ -0,0 +1,191 @@ +import type OpenAI from 'openai'; + +import type { DashscopeChat } from './chat'; +import type { DashscopeCompletions } from './completions'; +import { DashscopeEmbeddings } from './embeddings'; + +export namespace OpenAICompletionsCompatibility { + export type CompletionModel = DashscopeCompletions.CompletionModel; + + export interface StreamOptions { + /** + * 启用增量输出 + * + * 在启用流输出参数后,是否每次输出是否每次都包含前面输出的内容。 + * + * @defaultValue true + */ + incremental_output?: boolean | null; + } + + export interface CompletionCreateParamsBase + extends Pick< + DashscopeCompletions.CompletionParametersParam, + | 'enable_search' + | 'temperature' + | 'presence_penalty' + | 'repetition_penalty' + | 'top_k' + | 'top_p' + | 'seed' + | 'stop' + | 'max_tokens' + | 'stream' + > { + /** + * 生成模型 + * + * 内置的 {@link CompletionModel} 是经过测试的,但你可以通过 [模型列表](https://help.aliyun.com/zh/dashscope/developer-reference/model-square) 测试其他支持的模型。 + */ + model: ({} & string) | CompletionModel; + + /** + * 用户输入的指令,用于指导模型生成回复 + */ + prompt: string; + + /** + * 流输出额外参数 + */ + stream_options?: StreamOptions | null; + + /** + * 响应格式 + */ + response_format?: { + type?: 'text'; + }; + } + + export interface CompletionCreateParamsNonStreaming + extends CompletionCreateParamsBase { + /** + * 启用流式输出 + * + * 默认每次输出为当前生成的整个序列,最后一次输出为最终全部生成结果 + * 可以使用 {@link StreamOptions stream_options} 参数关闭。 + */ + stream?: false | null; + } + + export interface CompletionCreateParamsStreaming + extends CompletionCreateParamsBase { + /** + * 启用流式输出 + * + * 默认每次输出为当前生成的整个序列,最后一次输出为最终全部生成结果 + * 可以使用 {@link StreamOptions stream_options} 参数关闭。 + */ + stream: true; + } + + export type CompletionCreateParams = + | CompletionCreateParamsNonStreaming + | CompletionCreateParamsStreaming; +} + +export namespace OpenAIChatCompatibility { + export type ChatModel = DashscopeChat.ChatModel; + + export interface ChatCompletionCreateParamsBase + extends Pick< + DashscopeCompletions.CompletionParametersParam, + | 'enable_search' + | 'temperature' + | 'presence_penalty' + | 'repetition_penalty' + | 'top_k' + | 'top_p' + | 'seed' + | 'stop' + | 'max_tokens' + | 'stream' + > { + /** + * 聊天模型 + * + * 内置的 {@link ChatModel} 是经过测试的,但你可以通过 [模型列表](https://help.aliyun.com/zh/dashscope/developer-reference/model-square) 测试其他支持的模型。 + */ + model: ({} & string) | ChatModel; + + /** + * 聊天上下文信息 + */ + messages: OpenAI.ChatCompletionMessageParam[]; + + /** + * 指定可供模型调用的工具列表 + * + * 当输入多个工具时,模型会选择其中一个生成结果。 + */ + tools?: OpenAI.ChatCompletionTool[]; + + /** + * SDK 内部有特殊的多模型消息适配机制 + * + * 设置为 true 可以直接采用外部传递的消息格式 + */ + raw?: boolean | null; + + /** + * 流输出额外参数 + */ + stream_options?: OpenAICompletionsCompatibility.StreamOptions | null; + + /** + * 响应格式 + */ + response_format?: { + type?: 'text'; + }; + } + + export interface ChatCompletionCreateParamsNonStreaming + extends ChatCompletionCreateParamsBase { + /** + * 启用流式输出 + * + * 默认每次输出为当前生成的整个序列,最后一次输出为最终全部生成结果 + * 可以使用 {@link ChatCompletionCreateParamsBase.stream_options stream_options} 参数关闭。 + */ + stream?: false | null; + } + + export interface ChatCompletionCreateParamsStreaming + extends ChatCompletionCreateParamsBase { + /** + * 启用流式输出 + * + * 默认每次输出为当前生成的整个序列,最后一次输出为最终全部生成结果 + * 可以使用 {@link ChatCompletionCreateParamsBase.stream_options stream_options} 参数关闭。 + */ + stream: true; + } + + export type ChatCompletionCreateParams = + | ChatCompletionCreateParamsNonStreaming + | ChatCompletionCreateParamsStreaming; +} + +export namespace OpenAIEmbeddingsCompatibility { + export interface EmbeddingCreateParams { + /** + * 模型 + */ + model: ({} & string) | DashscopeEmbeddings.EmbeddingModel; + + /** + * 输入文本 + */ + input: string | Array | Array | Array>; + + /** + * 文本转换为向量后可以应用于检索、聚类、分类等下游任务,对检索这类非对称任务为了达到更好的检索效果 + * 建议区分查询文本(query)和底库文本(document)类型, + * 聚类、分类等对称任务可以不用特殊指定,采用系统默认值"document"即可 + * + * @defaultValue 'query' + */ + type?: 'query' | 'document'; + } +} diff --git a/src/main/gptproxy/qwen/index.ts b/src/main/gptproxy/qwen/index.ts new file mode 100644 index 0000000..66b5e31 --- /dev/null +++ b/src/main/gptproxy/qwen/index.ts @@ -0,0 +1,129 @@ +import type { Agent } from 'node:http'; + +import { APIError } from 'openai'; +import { + APIClient, + type DefaultQuery, + type Fetch, + type FinalRequestOptions, + type Headers, +} from 'openai/core'; + +import * as API from './resources'; + +export interface QWenAIOptions { + baseURL?: string; + apiKey?: string; + timeout?: number | undefined; + httpAgent?: Agent; + fetch?: Fetch | undefined; + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `undefined` or `null` in request options. + */ + defaultHeaders?: Headers; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: DefaultQuery; +} + +/** + * 基于阿里云 [DashScope 灵积模型服务](https://help.aliyun.com/zh/dashscope/product-overview/product-introduction) 的接口封装 + * + * @deprecated 请重点关注阿里云的 [OpenAI接口兼容](https://help.aliyun.com/zh/dashscope/developer-reference/compatibility-of-openai-with-dashscope/) 计划。 + */ +export class QWenAI extends APIClient { + protected apiKey: string; + + private _options: QWenAIOptions; + + constructor(options: QWenAIOptions = {}) { + const { + apiKey = process.env.QWEN_API_KEY || '', + baseURL = 'https://dashscope.aliyuncs.com/api/v1/', + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.apiKey = apiKey; + } + + chat = new API.Chat(this); + + completions = new API.Completions(this); + + embeddings = new API.Embeddings(this); + + images = new API.Images(this); + + protected override authHeaders() { + return { + Authorization: `Bearer ${this.apiKey}`, + }; + } + + protected override defaultHeaders(opts: FinalRequestOptions): Headers { + return { + ...super.defaultHeaders(opts), + ...this._options.defaultHeaders, + }; + } + + protected override defaultQuery(): DefaultQuery | undefined { + return this._options.defaultQuery; + } + + protected override makeStatusError( + status: number | undefined, + error: Record | undefined, + message: string | undefined, + headers: Headers | undefined, + ) { + return APIError.generate(status, { error }, message, headers); + } +} + +// eslint-disable-next-line no-redeclare +export namespace QWenAI { + export import Chat = API.Chat; + export import ChatModel = API.ChatModel; + export import ChatCompletionCreateParams = API.ChatCompletionCreateParams; + export import ChatCompletionCreateParamsNonStreaming = API.ChatCompletionCreateParamsNonStreaming; + export import ChatCompletionCreateParamsStreaming = API.ChatCompletionCreateParamsStreaming; + + export import Completions = API.Completions; + export import CompletionModel = API.CompletionModel; + export type CompletionCreateParams = API.CompletionCreateParams; + export type CompletionCreateParamsStreaming = + API.CompletionCreateParamsStreaming; + export type CompletionCreateParamsNonStreaming = + API.CompletionCreateParamsNonStreaming; + + export import Embeddings = API.Embeddings; + export type EmbeddingModel = API.EmbeddingModel; + export type EmbeddingCreateParams = API.EmbeddingCreateParams; + + export import Images = API.Images; + export type ImageModel = API.ImageModel; + export type ImageGenerateParams = API.ImageGenerateParams; +} + +export default QWenAI; diff --git a/src/main/gptproxy/qwen/resources/chat/chat.ts b/src/main/gptproxy/qwen/resources/chat/chat.ts new file mode 100644 index 0000000..30083b1 --- /dev/null +++ b/src/main/gptproxy/qwen/resources/chat/chat.ts @@ -0,0 +1,15 @@ +import { APIResource } from '../../../resource'; +import { OpenAIChatCompatibility } from '../../dashscope'; +import { Completions } from './completions'; + +export class Chat extends APIResource { + completions = new Completions(this._client); +} + +export type ChatModel = OpenAIChatCompatibility.ChatModel; +export type ChatCompletionCreateParams = + OpenAIChatCompatibility.ChatCompletionCreateParams; +export type ChatCompletionCreateParamsNonStreaming = + OpenAIChatCompatibility.ChatCompletionCreateParams; +export type ChatCompletionCreateParamsStreaming = + OpenAIChatCompatibility.ChatCompletionCreateParamsStreaming; diff --git a/src/main/gptproxy/qwen/resources/chat/completions.ts b/src/main/gptproxy/qwen/resources/chat/completions.ts new file mode 100644 index 0000000..479936a --- /dev/null +++ b/src/main/gptproxy/qwen/resources/chat/completions.ts @@ -0,0 +1,67 @@ +import OpenAI from 'openai'; +import { type Headers } from 'openai/core'; +import { Stream } from 'openai/streaming'; + +import { APIResource } from '../../../resource'; +import { + fromChatCompletionCreateParams, + getCompletionCreateEndpoint, + type OpenAIChatCompatibility, + toChatCompletion, + toChatCompletionStream, +} from '../../dashscope'; + +export class Completions extends APIResource { + /** + * Creates a model response for the given chat conversation. + * + * See https://help.aliyun.com/zh/dashscope/developer-reference/api-details + */ + create( + body: OpenAIChatCompatibility.ChatCompletionCreateParamsNonStreaming, + options?: OpenAI.RequestOptions, + ): Promise; + + create( + body: OpenAIChatCompatibility.ChatCompletionCreateParamsStreaming, + options?: OpenAI.RequestOptions, + ): Promise>; + + async create( + body: OpenAIChatCompatibility.ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, + ) { + const headers: Headers = { + ...options?.headers, + }; + + if (body.stream) { + headers.Accept = 'text/event-stream'; + } + + const path = getCompletionCreateEndpoint(body.model); + const params = fromChatCompletionCreateParams(body); + + const response: Response = await this._client.post(path, { + ...options, + body: params, + headers, + // 通义千问的响应内容被包裹了一层,需要解构并转换为 OpenAI 的格式 + // 设置 __binaryResponse 为 true, 是为了让 client 返回原始的 response + stream: false, + __binaryResponse: true, + }); + + if (body.stream) { + const controller = new AbortController(); + + options?.signal?.addEventListener('abort', () => { + controller.abort(); + }); + + return toChatCompletionStream(params, response, controller); + } + + return toChatCompletion(params, await response.json()); + } +} diff --git a/src/main/gptproxy/qwen/resources/chat/index.ts b/src/main/gptproxy/qwen/resources/chat/index.ts new file mode 100644 index 0000000..043e5b3 --- /dev/null +++ b/src/main/gptproxy/qwen/resources/chat/index.ts @@ -0,0 +1 @@ +export * from './chat'; diff --git a/src/main/gptproxy/qwen/resources/completions.ts b/src/main/gptproxy/qwen/resources/completions.ts new file mode 100644 index 0000000..256547a --- /dev/null +++ b/src/main/gptproxy/qwen/resources/completions.ts @@ -0,0 +1,78 @@ +import OpenAI from 'openai'; +import { type Headers } from 'openai/core'; +import { Stream } from 'openai/streaming'; + +import { APIResource } from '../../resource'; +import { + fromCompletionCreateParams, + getCompletionCreateEndpoint, + type OpenAICompletionsCompatibility, + toCompletion, + toCompletionStream, +} from '../dashscope'; + +export class Completions extends APIResource { + /** + * Creates a completion for the provided prompt and parameters. + */ + create( + body: OpenAICompletionsCompatibility.CompletionCreateParamsNonStreaming, + options?: OpenAI.RequestOptions, + ): Promise; + + create( + body: OpenAICompletionsCompatibility.CompletionCreateParamsStreaming, + options?: OpenAI.RequestOptions, + ): Promise>; + + create( + body: OpenAICompletionsCompatibility.CompletionCreateParamsBase, + options?: OpenAI.RequestOptions, + ): Promise | OpenAI.Completion>; + + async create( + body: OpenAICompletionsCompatibility.CompletionCreateParams, + options?: OpenAI.RequestOptions, + ): Promise> { + const headers: Headers = { + ...options?.headers, + }; + + if (body.stream) { + headers.Accept = 'text/event-stream'; + } + + const path = getCompletionCreateEndpoint(body.model); + const params = fromCompletionCreateParams(body); + + const response: Response = await this._client.post(path, { + ...options, + body: params, + headers, + // 通义千问的响应内容被包裹了一层,需要解构并转换为 OpenAI 的格式 + // 设置 __binaryResponse 为 true, 是为了让 client 返回原始的 response + stream: false, + __binaryResponse: true, + }); + + if (body.stream) { + const controller = new AbortController(); + + options?.signal?.addEventListener('abort', () => { + controller.abort(); + }); + + return toCompletionStream(params, response, controller); + } + + return toCompletion(params, await response.json()); + } +} + +export type CompletionModel = OpenAICompletionsCompatibility.CompletionModel; +export type CompletionCreateParams = + OpenAICompletionsCompatibility.CompletionCreateParams; +export type CompletionCreateParamsStreaming = + OpenAICompletionsCompatibility.CompletionCreateParamsStreaming; +export type CompletionCreateParamsNonStreaming = + OpenAICompletionsCompatibility.CompletionCreateParamsNonStreaming; diff --git a/src/main/gptproxy/qwen/resources/embeddings.ts b/src/main/gptproxy/qwen/resources/embeddings.ts new file mode 100644 index 0000000..bb87320 --- /dev/null +++ b/src/main/gptproxy/qwen/resources/embeddings.ts @@ -0,0 +1,46 @@ +import OpenAI from 'openai'; +import { type RequestOptions } from 'openai/core'; + +import { APIResource } from '../../resource'; +import { DashscopeEmbeddings } from '../dashscope'; +import { + fromEmbeddingCreatePrams, + toEmbedding, +} from '../dashscope/resolvers/embeddings'; + +export class Embeddings extends APIResource { + /** + * Creates an embedding vector representing the input text. + * + * See https://help.aliyun.com/zh/dashscope/developer-reference/generic-text-vector + */ + async create( + params: OpenAI.EmbeddingCreateParams, + options?: RequestOptions, + ): Promise { + const body = fromEmbeddingCreatePrams(params); + + const response: Response = await this._client.post( + '/services/embeddings/text-embedding/text-embedding', + { + ...options, + body, + __binaryResponse: true, + }, + ); + + return toEmbedding(params, await response.json()); + } +} + +export type EmbeddingModel = Embeddings.EmbeddingModel; + +export type EmbeddingCreateParams = Embeddings.EmbeddingCreateParams; + +// eslint-disable-next-line no-redeclare +export namespace Embeddings { + // eslint-disable-next-line @typescript-eslint/no-shadow + export type EmbeddingModel = DashscopeEmbeddings.EmbeddingModel; + // eslint-disable-next-line @typescript-eslint/no-shadow + export type EmbeddingCreateParams = DashscopeEmbeddings.EmbeddingCreateParams; +} diff --git a/src/main/gptproxy/qwen/resources/images.ts b/src/main/gptproxy/qwen/resources/images.ts new file mode 100644 index 0000000..f2f371a --- /dev/null +++ b/src/main/gptproxy/qwen/resources/images.ts @@ -0,0 +1,315 @@ +import OpenAI, { OpenAIError } from 'openai'; +import { type RequestOptions } from 'openai/core'; + +import { APIResource } from '../../resource'; + +export class Images extends APIResource { + /** + * Creates an image given a prompt. + */ + async generate( + params: ImageGenerateParams, + options: RequestOptions = {}, + ): Promise { + const client = this._client; + + const { headers, ...config } = options; + const { model = 'wanx-v1', prompt, n = 1, cfg, ...rest } = params; + + const taskId = await client + .post('/services/aigc/text2image/image-synthesis', { + ...config, + headers: { 'X-DashScope-Async': 'enable', ...headers }, + body: { + model, + input: { + prompt, + }, + parameters: { + ...rest, + scale: cfg, + n, + }, + }, + __binaryResponse: true, + }) + .then((res) => res.json()) + .then((res) => res.output.task_id); + + return this.waitTask(taskId, options).then((images) => { + return { + created: Date.now() / 1000, + data: images, + }; + }); + } + + protected async waitTask( + taskId: string, + options?: RequestOptions, + ): Promise { + const response = await this._client + .get(`/tasks/${taskId}`, { + ...options, + __binaryResponse: true, + }) + // eslint-disable-next-line @typescript-eslint/no-shadow + .then((response) => response.json()); + + const { task_status, message } = response.output; + + if (task_status === 'PENDING' || task_status === 'RUNNING') { + return new Promise((resolve) => { + setTimeout(() => resolve(this.waitTask(taskId, options)), 5000); + }); + } + + if (task_status === 'SUCCEEDED') { + return response.output.results.filter( + (result) => 'url' in result, + ) as ImageTask.Image[]; + } + + if (task_status === 'FAILED') { + throw new OpenAIError(message); + } + + throw new OpenAIError('Unknown task status'); + } +} + +type ImageCreateTaskResponse = { + request_id: string; + output: { + task_id: string; + task_status: ImageTask.Status; + code: string; + message: string; + }; +}; + +type ImageTaskQueryResponse = + | ImageTaskPendingResponse + | ImageTaskRunningResponse + | ImageTaskFinishedResponse + | ImageTaskFailedResponse + | ImageTaskUnknownResponse; + +type ImageTaskPendingResponse = { + request_id: string; + output: { + task_id: string; + task_status: 'PENDING'; + task_metrics: ImageTask.Metrics; + submit_time: string; + scheduled_time: string; + code: string; + message: string; + }; +}; + +type ImageTaskRunningResponse = { + request_id: string; + output: { + task_id: string; + task_status: 'RUNNING'; + task_metrics: ImageTask.Metrics; + submit_time: string; + scheduled_time: string; + code: string; + message: string; + }; +}; + +type ImageTaskFinishedResponse = { + request_id: string; + output: { + task_id: string; + task_status: 'SUCCEEDED'; + task_metrics: ImageTask.Metrics; + results: (ImageTask.Image | ImageTask.FailedError)[]; + submit_time: string; + scheduled_time: string; + end_time: string; + code: string; + message: string; + }; + usage: { + image_count: number; + }; +}; + +type ImageTaskFailedResponse = { + request_id: string; + code: string; + message: string; + output: { + task_status: 'FAILED'; + task_metrics: ImageTask.Metrics; + submit_time: string; + scheduled_time: string; + code: string; + message: string; + }; +}; + +type ImageTaskUnknownResponse = { + request_id: string; + output: { + task_status: 'UNKNOWN'; + task_metrics: ImageTask.Metrics; + code: string; + message: string; + }; +}; + +namespace ImageTask { + export type Image = { + url: string; + }; + + export type FailedError = { + code: string; + message: string; + }; + + export type Status = + | 'PENDING' + | 'RUNNING' + | 'SUCCEEDED' + | 'FAILED' + | 'UNKNOWN'; + + export type Metrics = { + TOTAL: number; + SUCCEEDED: number; + FAILED: number; + }; +} + +export type ImageModel = Images.ImageModel; + +export type ImageGenerateParams = Images.ImageGenerateParams; + +// eslint-disable-next-line no-redeclare +export namespace Images { + // eslint-disable-next-line @typescript-eslint/no-shadow + export type ImageModel = + | (string & NonNullable) + // 通义万相 + | 'wanx-v1' + // Stable Diffusion + | 'stable-diffusion-v1.5' + | 'stable-diffusion-xl'; + + // eslint-disable-next-line @typescript-eslint/no-shadow + export interface ImageGenerateParams { + /** + * The model to use for image generation. + * + * @defaultValue wanx-v1 + */ + model?: ImageModel | null; + + /** + * A prompt is the text input that guides the AI in generating visual content. + * It defines the textual description or concept for the image you wish to generate. + * Think of it as the creative vision you want the AI to bring to life. + * Crafting clear and creative prompts is crucial for achieving the desired results with Imagine's API. + * For example, A serene forest with a river under the moonlight, can be a prompt. + */ + prompt: string; + + /** + * The negative_prompt parameter empowers you to provide additional + * guidance to the AI by specifying what you don't want in the image. + * It helps refine the creative direction, ensuring that the generated + * content aligns with your intentions. + */ + negative_prompt?: string | null; + + /** + * The size of the generated images. + * + * @defaultValue 1024*1024 + */ + size?: (string & NonNullable) | '1024*1024' | null; + + /** + * The style of the generated images. + * + * - \ 摄影 + * - \ 人像写真 + * - \<3d cartoon\> 3D卡通 + * - \ 动画 + * - \ 油画 + * - \水彩 + * - \ 素描 + * - \ 中国画 + * - \ 扁平插画 + * - \ 默认 + * + * 仅 wanx-v1 模型支持 + * + * @defaultValue + */ + style?: + | '' + | '' + | '<3d cartoon>' + | '' + | '' + | '' + | '' + | '' + | '' + | '' + | null; + + /** + * The number of images to generate. Must be between 1 and 4. + * + * @defaultValue 1 + */ + n?: number | null; + + /** + * The steps parameter defines the number of operations or iterations that the + * generator will perform during image creation. It can impact the complexity + * and detail of the generated image. + * + * Range: 30-50 + * + * 仅 StableDiffusion 模型支持 + * + * @defaultValue 40 + */ + steps?: number | null; + + /** + * The cfg parameter acts as a creative control knob. + * You can adjust it to fine-tune the level of artistic innovation in the image. + * Lower values encourage faithful execution of the prompt, + * while higher values introduce more creative and imaginative variations. + * + * Range: 1 - 15 + * + * @defaultValue 10 + */ + cfg?: number | null; + + /** + * The seed parameter serves as the initial value for the random number generator. + * By setting a specific seed value, you can ensure that the AI generates the same + * image or outcome each time you use that exact seed. + * + * range: 1-Infinity + */ + seed?: number | null; + + /** + * The format in which the generated images are returned. + */ + response_format?: 'url' | null; + } +} diff --git a/src/main/gptproxy/qwen/resources/index.ts b/src/main/gptproxy/qwen/resources/index.ts new file mode 100644 index 0000000..fc5c092 --- /dev/null +++ b/src/main/gptproxy/qwen/resources/index.ts @@ -0,0 +1,8 @@ +export * from './chat/index'; +export * from './completions'; +export { Images, type ImageModel, type ImageGenerateParams } from './images'; +export { + Embeddings, + type EmbeddingModel, + type EmbeddingCreateParams, +} from './embeddings'; diff --git a/src/main/gptproxy/resource.ts b/src/main/gptproxy/resource.ts new file mode 100644 index 0000000..476eb5a --- /dev/null +++ b/src/main/gptproxy/resource.ts @@ -0,0 +1,9 @@ +import { APIClient } from 'openai/core'; + +export class APIResource { + protected _client: Client; + + constructor(client: Client) { + this._client = client; + } +} diff --git a/src/main/gptproxy/spark/index.ts b/src/main/gptproxy/spark/index.ts new file mode 100644 index 0000000..82c2506 --- /dev/null +++ b/src/main/gptproxy/spark/index.ts @@ -0,0 +1,149 @@ +import { createHmac } from 'node:crypto'; + +import { + APIClient, + type DefaultQuery, + type Fetch, + type Headers, +} from 'openai/core'; + +import * as API from './resources'; + +export interface SparkAIOptions { + baseURL?: string; + appId?: string; + apiKey?: string; + apiSecret?: string; + timeout?: number | undefined; + httpAgent?: unknown; + fetch?: Fetch | undefined; + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `undefined` or `null` in request options. + */ + defaultHeaders?: Headers; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: DefaultQuery; +} + +export class SparkAI extends APIClient { + appId: string; + + protected apiKey: string; + + protected apiSecret: string; + + private _options: SparkAIOptions; + + constructor(options: SparkAIOptions = {}) { + const { + appId = process.env.SPARK_APP_ID || '', + apiKey = process.env.SPARK_API_KEY || '', + apiSecret = process.env.SPARK_API_SECRET || '', + baseURL = 'https://spark-api.xf-yun.com', + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.appId = appId; + this.apiKey = apiKey; + this.apiSecret = apiSecret; + } + + chat = new API.Chat(this); + + images = new API.Images(this); + + protected override defaultQuery(): DefaultQuery | undefined { + return this._options.defaultQuery; + } + + /** + * @param url - 需要签名的 URL + * @param method - HTTP method + * @returns 签名后的 URL + */ + generateAuthorizationURL(url: string | URL, method: string = 'GET'): string { + const target = new URL(url, this.baseURL); + + const date = new Date().toUTCString(); + + const authorization = this.generateAuthorization({ + method, + path: target.pathname, + host: target.host, + date, + }); + + target.searchParams.set('authorization', authorization); + target.searchParams.set('host', target.host); + target.searchParams.set('date', date); + + return target.toString(); + } + + /** + * 生成鉴权信息 + * + * See https://www.xfyun.cn/doc/spark/general_url_authentication.html + */ + generateAuthorization({ + method, + host, + path, + date, + }: { + method: string; + host: string; + path: string; + date: string; + }) { + // 生成签名原文 + const rawSignature = `host: ${host}\ndate: ${date}\n${method} ${path} HTTP/1.1`; + + // 生成签名,需要转为 base64 编码 + const signature = this.hash(rawSignature); + + return btoa( + `api_key="${this.apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`, + ); + } + + protected hash(data: string) { + const sha256Hmac = createHmac('sha256', this.apiSecret); + sha256Hmac.update(data); + return sha256Hmac.digest('base64'); + } +} + +// eslint-disable-next-line no-redeclare +export namespace SparkAI { + export type Chat = API.Chat; + export type ChatModel = API.ChatModel; + export type ChatCompletionCreateParams = API.ChatCompletionCreateParams; + export type ChatCompletionCreateParamsNonStreaming = + API.ChatCompletionCreateParamsNonStreaming; + export type ChatCompletionCreateParamsStreaming = + API.ChatCompletionCreateParamsStreaming; +} + +export default SparkAI; diff --git a/src/main/gptproxy/spark/resource.ts b/src/main/gptproxy/spark/resource.ts new file mode 100644 index 0000000..77e88a3 --- /dev/null +++ b/src/main/gptproxy/spark/resource.ts @@ -0,0 +1,9 @@ +import type { SparkAI } from './index'; + +export class APIResource { + protected _client: SparkAI; + + constructor(client: SparkAI) { + this._client = client; + } +} diff --git a/src/main/gptproxy/spark/resources/chat/chat.ts b/src/main/gptproxy/spark/resources/chat/chat.ts new file mode 100644 index 0000000..214edf9 --- /dev/null +++ b/src/main/gptproxy/spark/resources/chat/chat.ts @@ -0,0 +1,6 @@ +import { APIResource } from '../../resource'; +import { Completions } from './completions'; + +export class Chat extends APIResource { + completions = new Completions(this._client); +} diff --git a/src/main/gptproxy/spark/resources/chat/completions.ts b/src/main/gptproxy/spark/resources/chat/completions.ts new file mode 100644 index 0000000..7f957d3 --- /dev/null +++ b/src/main/gptproxy/spark/resources/chat/completions.ts @@ -0,0 +1,277 @@ +import OpenAI, { APIError } from 'openai'; +import { RequestOptions } from 'openai/core'; +import { Stream } from 'openai/streaming'; + +import { APIResource } from '../../resource'; + +export class Completions extends APIResource { + protected resources: Record< + ChatModel, + { + domain: string; + url: string; + } + > = { + 'spark-1.5': { + domain: 'general', + url: 'wss://spark-api.xf-yun.com/v1.1/chat', + }, + 'spark-2': { + domain: 'generalv2', + url: 'wss://spark-api.xf-yun.com/v2.1/chat', + }, + 'spark-3': { + domain: 'generalv3', + url: 'wss://spark-api.xf-yun.com/v3.1/chat', + }, + }; + + /** + * Creates a model response for the given chat conversation. + * + * See https://help.aliyun.com/zh/dashscope/developer-reference/api-details + */ + create( + body: ChatCompletionCreateParamsNonStreaming, + options?: RequestOptions, + ): Promise; + + create( + body: ChatCompletionCreateParamsStreaming, + options?: RequestOptions, + ): Promise>; + + async create( + params: ChatCompletionCreateParams, + options?: RequestOptions, + ): Promise | OpenAI.ChatCompletion> { + const { model, messages, functions, user, ...rest } = params; + + const resource = this.resources[model]; + + const url = this._client.generateAuthorizationURL(resource.url, 'GET'); + + const body: ChatCompletions.ChatCompletionParameters = { + header: { + app_id: this._client.appId, + }, + parameter: { + chat: { + ...rest, + domain: resource.domain, + }, + }, + payload: { + message: { + text: messages, + }, + }, + }; + + if (functions) { + body.payload.functions = { text: functions }; + } + + if (user) { + body.header.uid = user; + } + + const controller = new AbortController(); + + if (options?.signal) { + options.signal.addEventListener('abort', () => { + controller.abort(); + }); + } + + const ws: WebSocket = new WebSocket(url); + + ws.onopen = () => { + ws.send(JSON.stringify(body)); + }; + + if (params.stream) { + const readableStream = new ReadableStream({ + pull(ctrl) { + const encoder = new TextEncoder(); + + ws.onmessage = (event) => { + const data: ChatCompletions.ChatCompletionResponse = JSON.parse( + event.data, + ); + + const { header, payload } = data; + + if (header.code !== 0) { + ctrl.error( + new APIError(undefined, data.header, undefined, undefined), + ); + return; + } + + const choices = payload.choices.text; + + const [message] = choices; + + const choice: OpenAI.ChatCompletionChunk.Choice = { + index: 0, + delta: { + role: message.role, + content: message.content, + }, + finish_reason: null, + }; + + if (header.status === 2) { + choice.finish_reason = 'stop'; + } + + if (message.function_call) { + choice.delta.function_call = message.function_call; + } + + const completion: OpenAI.ChatCompletionChunk = { + id: header.sid, + model, + choices: [choice], + object: 'chat.completion.chunk', + created: Date.now() / 1000, + }; + + ctrl.enqueue(encoder.encode(`${JSON.stringify(completion)}\n`)); + }; + ws.onerror = (error) => { + ctrl.error(error); + }; + }, + cancel() { + ws.close(); + }, + }); + + controller.signal.addEventListener('abort', () => { + ws.close(); + }); + + return Stream.fromReadableStream(readableStream, controller); + } + + return new Promise((resolve, reject) => { + ws.onmessage = (event) => { + const data: ChatCompletions.ChatCompletionResponse = JSON.parse( + event.data, + ); + + const { header, payload } = data; + + // 2 代表完成 + if (header.status !== 2) return; + + const usage = payload.usage.text; + const choices = payload.choices.text; + + const [message] = choices; + + const choice: OpenAI.ChatCompletion.Choice = { + index: 0, + message: { + role: 'assistant', + content: message.content, + }, + logprobs: null, + finish_reason: 'stop', + }; + + const completion: OpenAI.ChatCompletion = { + id: header.sid, + object: 'chat.completion', + created: Date.now() / 1000, + model, + choices: [choice], + usage: { + completion_tokens: usage.completion_tokens, + total_tokens: usage.total_tokens, + prompt_tokens: usage.prompt_tokens, + }, + }; + + resolve(completion); + }; + + ws.onerror = (error) => reject(error); + }); + } +} + +export interface ChatCompletionCreateParamsNonStreaming + extends OpenAI.ChatCompletionCreateParamsNonStreaming { + model: ChatModel; + top_k?: number | null; + chat_id?: string | null; +} + +export interface ChatCompletionCreateParamsStreaming + extends OpenAI.ChatCompletionCreateParamsStreaming { + model: ChatModel; + top_k?: number | null; + chat_id?: string | null; +} + +export type ChatCompletionCreateParams = + | ChatCompletionCreateParamsNonStreaming + | ChatCompletionCreateParamsStreaming; + +export type ChatModel = 'spark-1.5' | 'spark-2' | 'spark-3'; + +export namespace ChatCompletions { + export type ChatCompletionParameters = { + header: { + app_id: string; + uid?: string; + }; + + parameter: { + chat: { + domain: string; + temperature?: number | null; + max_tokens?: number | null; + top_k?: number | null; + chat_id?: string | null; + }; + }; + + payload: { + message: { + text: OpenAI.ChatCompletionMessageParam[]; + }; + + functions?: { + text: OpenAI.ChatCompletionCreateParams.Function[]; + }; + }; + }; + + export type ChatCompletionResponse = { + header: { + code: number; + message: string; + sid: string; + status: number; + }; + payload: { + choices: { + status: number; + seq: number; + text: OpenAI.ChatCompletionMessage[]; + }; + usage: { + text: { + question_tokens: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; + }; + }; + }; +} diff --git a/src/main/gptproxy/spark/resources/chat/index.ts b/src/main/gptproxy/spark/resources/chat/index.ts new file mode 100644 index 0000000..4805791 --- /dev/null +++ b/src/main/gptproxy/spark/resources/chat/index.ts @@ -0,0 +1,8 @@ +export { Chat } from './chat'; +export { + type ChatModel, + type ChatCompletionCreateParams, + type ChatCompletionCreateParamsNonStreaming, + type ChatCompletionCreateParamsStreaming, + Completions, +} from './completions'; diff --git a/src/main/gptproxy/spark/resources/images.ts b/src/main/gptproxy/spark/resources/images.ts new file mode 100644 index 0000000..65070f4 --- /dev/null +++ b/src/main/gptproxy/spark/resources/images.ts @@ -0,0 +1,117 @@ +import OpenAI, { APIError } from 'openai'; +import { type RequestOptions } from 'openai/core'; + +import { APIResource } from '../resource'; + +// TODO: 没有权限,暂未测试 +export class Images extends APIResource { + /** + * See https://www.xfyun.cn/doc/spark/ImageGeneration.html + */ + async generate( + params: OpenAI.ImageGenerateParams, + options?: RequestOptions, + ): Promise { + const { prompt, user } = params; + + const body: ImagesAPI.ImageGenerateParams = { + header: { + app_id: this._client.appId, + uid: user, + }, + parameter: { + chat: { + max_tokens: 4096, + domain: 'general', + temperature: 0.5, + }, + }, + payload: { + message: { + text: [{ role: 'user', content: prompt }], + }, + }, + }; + + const url = this._client.generateAuthorizationURL( + 'https://spark-api.cn-huabei-1.xf-yun.com/v2.1/tti', + 'POST', + ); + + const response: Response = await this._client.post(url, { + ...options, + body, + __binaryResponse: true, + }); + + const resp: ImagesAPI.ImageGenerateResponse = await response.json(); + + if (resp.header.code > 0) { + throw new APIError(undefined, resp.header, undefined, undefined); + } + + return { + created: Date.now() / 1000, + data: [ + { + // base64 encoded image + url: resp.payload.choices.text[0].content, + }, + ], + }; + } +} + +namespace ImagesAPI { + export type ImageGenerateMessageParam = { + role: 'user'; + content: string; + }; + + export type ImageGenerateParams = { + header: { + /** + * 应用ID + */ + app_id: string; + /** + * 用户唯一标识 + */ + uid?: string; + }; + parameter: { + chat: { + max_tokens: number; + domain: string; + temperature: number; + }; + }; + payload: { + message: { + text: ImageGenerateMessageParam[]; + }; + }; + }; + + type ImageGenerateAssistantMessage = { + index: 0; + role: 'assistant'; + content: string; + }; + + export type ImageGenerateResponse = { + header: { + code: number; + message: string; + sid: string; + status: number; + }; + payload: { + choices: { + status: number; + seq: number; + text: ImageGenerateAssistantMessage[]; + }; + }; + }; +} diff --git a/src/main/gptproxy/spark/resources/index.ts b/src/main/gptproxy/spark/resources/index.ts new file mode 100644 index 0000000..4ee219b --- /dev/null +++ b/src/main/gptproxy/spark/resources/index.ts @@ -0,0 +1,3 @@ +export * from './chat/index'; + +export { Images } from './images'; diff --git a/src/main/gptproxy/streaming.ts b/src/main/gptproxy/streaming.ts new file mode 100644 index 0000000..e4a59cd --- /dev/null +++ b/src/main/gptproxy/streaming.ts @@ -0,0 +1,262 @@ +import { OpenAIError } from 'openai'; + +export type Bytes = + | string + | ArrayBuffer + | Uint8Array + | Buffer + | null + | undefined; + +export type ServerSentEvent = { + event: string | null; + data: string; + raw: string[]; +}; + +export async function* iterMessages( + response: Response, + decoder: SSEDecoder, + controller: AbortController, +): AsyncGenerator { + if (!response.body) { + controller.abort(); + throw new OpenAIError(`Attempted to iterate over a response with no body`); + } + + const lineDecoder = new LineDecoder(); + + const iter = readableStreamAsyncIterable(response.body); + // eslint-disable-next-line no-restricted-syntax + for await (const chunk of iter) { + // eslint-disable-next-line no-restricted-syntax + for (const line of lineDecoder.decode(chunk)) { + const sse = decoder.decode(line); + if (sse) yield sse; + } + } + + // eslint-disable-next-line no-restricted-syntax + for (const line of lineDecoder.flush()) { + const sse = decoder.decode(line); + if (sse) yield sse; + } +} + +export class SSEDecoder { + private data: string[]; + + private event: string | null; + + private chunks: string[]; + + constructor() { + this.event = null; + this.data = []; + this.chunks = []; + } + + decode(line: string) { + if (line.endsWith('\r')) { + // eslint-disable-next-line no-param-reassign + line = line.substring(0, line.length - 1); + } + + if (!line) { + // empty line and we didn't previously encounter any messages + if (!this.event && !this.data.length) return null; + + const sse: ServerSentEvent = { + event: this.event, + data: this.data.join('\n'), + raw: this.chunks, + }; + + this.event = null; + this.data = []; + this.chunks = []; + + return sse; + } + + this.chunks.push(line); + + if (line.startsWith(':')) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, prefer-const + let [fieldname, _, value] = partition(line, ':'); + + if (value.startsWith(' ')) { + value = value.substring(1); + } + + if (fieldname === 'event') { + this.event = value; + } else if (fieldname === 'data') { + this.data.push(value); + } + + return null; + } +} + +/** + * A re-implementation of httpx's `LineDecoder` in Python that handles incrementally + * reading lines from text. + * + * https://github.com/encode/httpx/blob/920333ea98118e9cf617f246905d7b202510941c/httpx/_decoders.py#L258 + */ +export class LineDecoder { + // prettier-ignore + static NEWLINE_CHARS = new Set(['\n', '\r', '\x0b', '\x0c', '\x1c', '\x1d', '\x1e', '\x85', '\u2028', '\u2029']); + + // eslint-disable-next-line no-control-regex + static NEWLINE_REGEXP = /\r\n|[\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029]/g; + + buffer: string[]; + + trailingCR: boolean; + + textDecoder: any; // TextDecoder found in browsers; not typed to avoid pulling in either "dom" or "node" types. + + constructor() { + this.buffer = []; + this.trailingCR = false; + } + + decode(chunk: Bytes): string[] { + let text = this.decodeText(chunk); + + if (this.trailingCR) { + text = `\r${text}`; + this.trailingCR = false; + } + if (text.endsWith('\r')) { + this.trailingCR = true; + text = text.slice(0, -1); + } + + if (!text) { + return []; + } + + const trailingNewline = LineDecoder.NEWLINE_CHARS.has( + text[text.length - 1] || '', + ); + let lines = text.split(LineDecoder.NEWLINE_REGEXP); + + if (lines.length === 1 && !trailingNewline) { + this.buffer.push(lines[0]!); + return []; + } + + if (this.buffer.length > 0) { + lines = [this.buffer.join('') + lines[0], ...lines.slice(1)]; + this.buffer = []; + } + + if (!trailingNewline) { + this.buffer = [lines.pop() || '']; + } + + return lines; + } + + decodeText(bytes: Bytes): string { + if (bytes == null) return ''; + if (typeof bytes === 'string') return bytes; + + // Node: + if (typeof Buffer !== 'undefined') { + if (bytes instanceof Buffer) { + return bytes.toString(); + } + if (bytes instanceof Uint8Array) { + return Buffer.from(bytes).toString(); + } + + throw new OpenAIError( + `Unexpected: received non-Uint8Array (${bytes.constructor.name}) stream chunk in an environment with a global "Buffer" defined, which this library assumes to be Node. Please report this error.`, + ); + } + + // Browser + if (typeof TextDecoder !== 'undefined') { + if (bytes instanceof Uint8Array || bytes instanceof ArrayBuffer) { + this.textDecoder ??= new TextDecoder('utf8'); + return this.textDecoder.decode(bytes); + } + + throw new OpenAIError( + `Unexpected: received non-Uint8Array/ArrayBuffer (${ + (bytes as any).constructor.name + }) in a web platform. Please report this error.`, + ); + } + + throw new OpenAIError( + `Unexpected: neither Buffer nor TextDecoder are available as globals. Please report this error.`, + ); + } + + flush(): string[] { + if (!this.buffer.length && !this.trailingCR) { + return []; + } + + const lines = [this.buffer.join('')]; + this.buffer = []; + this.trailingCR = false; + return lines; + } +} + +function partition(str: string, delimiter: string): [string, string, string] { + const index = str.indexOf(delimiter); + if (index !== -1) { + return [ + str.substring(0, index), + delimiter, + str.substring(index + delimiter.length), + ]; + } + + return [str, '', '']; +} + +/** + * Most browsers don't yet have async iterable support for ReadableStream, + * and Node has a very different way of reading bytes from its "ReadableStream". + * + * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 + */ +export function readableStreamAsyncIterable( + stream: any, +): AsyncIterableIterator { + if (stream[Symbol.asyncIterator]) return stream; + + const reader = stream.getReader(); + return { + async next() { + try { + const result = await reader.read(); + if (result?.done) reader.releaseLock(); // release lock when stream becomes closed + return result; + } catch (e) { + reader.releaseLock(); // release lock when stream becomes errored + throw e; + } + }, + async return() { + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/src/main/gptproxy/util.ts b/src/main/gptproxy/util.ts new file mode 100644 index 0000000..e7b0c9f --- /dev/null +++ b/src/main/gptproxy/util.ts @@ -0,0 +1,12 @@ +export const castToError = (err: any): Error => { + if (err instanceof Error) return err; + return new Error(err); +}; + +export function ensureArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} + +export function random(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} diff --git a/src/main/gptproxy/vyro/index.ts b/src/main/gptproxy/vyro/index.ts new file mode 100644 index 0000000..648d9c6 --- /dev/null +++ b/src/main/gptproxy/vyro/index.ts @@ -0,0 +1,80 @@ +import type { Agent } from 'node:http'; + +import { + APIClient, + type DefaultQuery, + type Fetch, + type Headers, +} from 'openai/core'; + +import * as API from './resources'; + +export interface VYroAIOptions { + baseURL?: string; + apiKey?: string; + timeout?: number | undefined; + httpAgent?: Agent; + apiType?: (string & NonNullable) | 'api'; + fetch?: Fetch | undefined; + defaultHeaders?: Headers; + defaultQuery?: DefaultQuery; +} + +export class VYroAI extends APIClient { + public apiType: (string & NonNullable) | 'api'; + + protected apiKey: string; + + private _options: VYroAIOptions; + + constructor(options: VYroAIOptions = {}) { + const { + apiKey = process.env.VYRO_API_KEY || '', + apiType = process.env.VYRO_API_TYPE || 'api', + baseURL = 'https://api.vyro.ai/v1', + timeout = 30000, + httpAgent = undefined, + ...rest + } = options; + + super({ + baseURL, + timeout, + fetch, + httpAgent, + ...rest, + }); + + this._options = options; + + this.apiKey = apiKey; + this.apiType = apiType; + } + + images = new API.Images(this); + + protected override authHeaders() { + return { + Authorization: `Bearer ${this.apiKey}`, + }; + } + + protected override defaultHeaders(): Headers { + return { + ...this.authHeaders(), + ...this._options.defaultHeaders, + }; + } + + protected override defaultQuery(): DefaultQuery | undefined { + return this._options.defaultQuery; + } +} + +// eslint-disable-next-line no-redeclare +export namespace VYroAI { + export type Images = API.Images; + export type ImageGenerateParams = API.ImageGenerateParams; +} + +export default VYroAI; diff --git a/src/main/gptproxy/vyro/resource.ts b/src/main/gptproxy/vyro/resource.ts new file mode 100644 index 0000000..a5f857c --- /dev/null +++ b/src/main/gptproxy/vyro/resource.ts @@ -0,0 +1,9 @@ +import type { VYroAI } from './index'; + +export class APIResource { + protected _client: VYroAI; + + constructor(client: VYroAI) { + this._client = client; + } +} diff --git a/src/main/gptproxy/vyro/resources/images.ts b/src/main/gptproxy/vyro/resources/images.ts new file mode 100644 index 0000000..c14cf3b --- /dev/null +++ b/src/main/gptproxy/vyro/resources/images.ts @@ -0,0 +1,610 @@ +import { ReadableStream } from 'node:stream/web'; + +import { type RequestOptions, type Uploadable } from 'openai/core'; +import { toFile } from 'openai/uploads'; + +import { random } from '../../util'; +import { APIResource } from '../resource'; + +export class Images extends APIResource { + protected models: Record = { + 'imagine-v5': 33, + 'anime-v5': 34, + 'imagine-v4.1': 32, + 'imagine-v4': 31, + 'imagine-v3': 30, + 'imagine-v1': 28, + realistic: 29, + anime: 21, + portrait: 26, + 'sdxl-1.0': 122, + }; + + /** + * Creates a variation of a given image. + */ + async createVariation( + params: ImageCreateVariationParams, + options?: RequestOptions, + ): Promise { + const client = this._client; + + const formData = new FormData(); + + const { model, style = this.models[model ?? 'realistic'] } = params; + + // @ts-expect-error + formData.append('image', await toFile(params.image)); + formData.append('style_id', (style || 29).toString()); + formData.append('prompt', params.prompt); + formData.append('negative_prompt', params.negative_prompt || ''); + formData.append('strength', (params.strength || 0).toString()); + formData.append('steps', (params.steps || 30).toString()); + formData.append('cfg', (params.cfg || 7.5).toString()); + formData.append('seed', (params.seed || random(1, 1000000)).toString()); + + const response: Response = await client.post( + `/imagine/${client.apiType}/generations/variations`, + { + ...options, + body: { + body: formData, + [Symbol.toStringTag]: 'MultipartBody', + }, + __binaryResponse: true, + }, + ); + + return { + data: [ + { + binary: response.body as unknown as ReadableStream, + }, + ], + created: Math.floor(Date.now() / 1000), + }; + } + + /** + * Experience the magic of Imagine's Image Remix feature, designed to breathe new life into your existing images. + */ + async edit( + params: ImageEditParams, + options?: RequestOptions, + ): Promise { + const client = this._client; + + const formData = new FormData(); + + const { model, style = this.models[model ?? 'realistic'] } = params; + + // @ts-expect-error + formData.append('image', await toFile(params.image)); + formData.append('style_id', (style || 29).toString()); + formData.append('prompt', params.prompt); + formData.append('negative_prompt', params.negative_prompt || ''); + formData.append('strength', (params.strength || 0).toString()); + formData.append('control', params.control || 'openpose'); + formData.append('steps', (params.steps || 30).toString()); + formData.append('cfg', (params.cfg || 7.5).toString()); + formData.append('seed', (params.seed || random(1, 1000000)).toString()); + + const response: Response = await client.post( + `/imagine/${client.apiType}/edits/remix`, + { + ...options, + body: { + body: formData, + [Symbol.toStringTag]: 'MultipartBody', + }, + __binaryResponse: true, + }, + ); + + return { + data: [ + { + binary: response.body as unknown as ReadableStream, + }, + ], + created: Math.floor(Date.now() / 1000), + }; + } + + /** + * Creates an image given a prompt. + */ + async generate( + params: ImageGenerateParams, + options?: RequestOptions, + ): Promise { + const client = this._client; + + const formData = new FormData(); + + const { model, style = this.models[model ?? 'imagine-v4'] } = params; + + formData.append('style_id', (style || 30).toString()); + formData.append('prompt', params.prompt); + formData.append('negative_prompt', params.negative_prompt || ''); + formData.append('aspect_ratio', params.aspect_ratio || '1:1'); + formData.append('steps', (params.steps || 30).toString()); + formData.append('cfg', (params.cfg || 7.5).toString()); + formData.append('seed', (params.seed || random(1, 1000000)).toString()); + formData.append('high_res_results', params.quality === 'hd' ? '1' : '0'); + + const response: Response = await client.post( + `/imagine/${client.apiType}/generations`, + { + ...options, + body: { + body: formData, + [Symbol.toStringTag]: 'MultipartBody', + }, + __binaryResponse: true, + }, + ); + + return { + created: Math.floor(Date.now() / 1000), + data: [ + { + binary: response.body as unknown as ReadableStream, + }, + ], + }; + } + + /** + * The image upscale feature provides a better image to the user by increasing its resolution. + */ + async upscale( + params: ImageUpscaleParams, + options?: RequestOptions, + ): Promise { + const client = this._client; + + const formData = new FormData(); + + // @ts-expect-error + formData.append('image', await toFile(params.image)); + + const response: Response = await client.post( + `/imagine/${client.apiType}/upscale`, + { + ...options, + body: { + body: formData, + [Symbol.toStringTag]: 'MultipartBody', + }, + __binaryResponse: true, + }, + ); + + return { + created: Math.floor(Date.now() / 1000), + data: [ + { + binary: response.body as unknown as ReadableStream, + }, + ], + }; + } + + /** + * Inpaint is an advanced feature of the Text-to-Image Stable Diffusion pipeline. + * It allows users to remove unwanted objects or elements from an image by intelligently filling in the missing areas. + */ + async restoration( + params: ImageRestorationParams, + options?: RequestOptions, + ): Promise { + const client = this._client; + + const formData = new FormData(); + + // @ts-expect-error + formData.append('image', await toFile(params.image)); + // @ts-expect-error + formData.append('mask', await toFile(params.mask)); + formData.append('style_id', '1'); + formData.append('prompt', params.prompt); + formData.append('neg_prompt', params.negative_prompt || ''); + formData.append('inpaint_strength', (params.strength || 0).toString()); + formData.append('cfg', (params.cfg || 7.5).toString()); + + const response: Response = await client.post( + `/imagine/${client.apiType}/generations/variations`, + { + ...options, + body: { + body: formData, + [Symbol.toStringTag]: 'MultipartBody', + }, + __binaryResponse: true, + }, + ); + + return { + data: [ + { + binary: response.body as unknown as ReadableStream, + }, + ], + created: Math.floor(Date.now() / 1000), + }; + } +} + +export type ImageModel = + | 'imagine-v5' + | 'anime-v5' + | 'imagine-v4.1' + | 'imagine-v4' + | 'imagine-v3' + | 'imagine-v1' + | 'realistic' + | 'anime' + | 'portrait' + | 'sdxl-1.0'; + +export interface ImageRestorationParams { + /** + * The image to use as the basis for the variation(s). Must be a valid PNG file, + * less than 4MB, and square. + */ + image: Uploadable; + + /** + * The mask indicating the areas to be inpainted. + */ + mask: Uploadable; + + /** + * The text guides the image generation. + */ + prompt: string; + + /** + * The model to use for image generation. + */ + model?: 'vyro-inpaint' | null; + + /** + * The negative_prompt parameter empowers you to provide additional + * guidance to the AI by specifying what you don't want in the image. + * It helps refine the creative direction, ensuring that the generated + * content aligns with your intentions. + */ + negative_prompt?: string | null; + + /** + * Specifies the model to be used. Currently supports only 1 for realism. + * + * @defaultValue 1 + */ + style?: 1 | null; + + /** + * Weightage to be given to text + * + * Range: 3 - 15 + * + * @defaultValue 7.5 + */ + cfg?: number | null; + + /** + * Weightage given to initial image. Greater this parameter more the output will be close to starting image and far from prompt. + * + * Range: 0 - 1 + * + * @defaultValue 0.5 + */ + strength?: number | null; + + /** + * 目前仅支持 binary 格式 + */ + response_format?: 'binary' | null; +} + +export interface ImageCreateVariationParams { + /** + * The image to use as the basis for the variation(s). Must be a valid PNG file, + * less than 4MB, and square. + */ + image: Uploadable; + + /** + * The text guides the image generation. + */ + prompt: string; + + /** + * The model to use for image generation. + */ + model?: ImageModel | null; + + /** + * The negative_prompt parameter empowers you to provide additional + * guidance to the AI by specifying what you don't want in the image. + * It helps refine the creative direction, ensuring that the generated + * content aligns with your intentions. + */ + negative_prompt?: string | null; + + /** + * The style_id parameter is like choosing an artistic palette for your image. + * By selecting a style id, you guide the AI in crafting the image with a particular visual aesthetic. + * Style IDs range from 1 to N, each representing a unique artistic style. + * + * @defaultValue 30 + */ + style?: number | null; + + /** + * The steps parameter defines the number of operations or iterations that the + * generator will perform during image creation. It can impact the complexity + * and detail of the generated image. + * + * Range: 30-50 + * + * @defaultValue 30 + */ + steps?: number | null; + + /** + * The cfg parameter acts as a creative control knob. + * You can adjust it to fine-tune the level of artistic innovation in the image. + * Lower values encourage faithful execution of the prompt, + * while higher values introduce more creative and imaginative variations. + * + * Range: 3 - 15 + * + * @defaultValue 7.5 + */ + cfg?: number | null; + + /** + * The seed parameter serves as the initial value for the random number generator. + * By setting a specific seed value, you can ensure that the AI generates the same + * image or outcome each time you use that exact seed. + * + * range: 1-Infinity + */ + seed?: number | null; + + /** + * Influences the impact of the control image on output. + * + * Range: 0 - 1 + * + * @defaultValue 0 + */ + strength?: number | null; + + /** + * 目前仅支持 binary 格式 + */ + response_format?: 'binary' | null; +} + +export interface ImageEditParams { + /** + * The image to use as the basis for the variation(s). Must be a valid PNG file, + * less than 4MB, and square. + */ + image: Uploadable; + + /** + * The text guides the image generation. + */ + prompt: string; + + /** + * The model to use for image generation. + */ + model?: ImageModel | null; + + /** + * The negative_prompt parameter empowers you to provide additional + * guidance to the AI by specifying what you don't want in the image. + * It helps refine the creative direction, ensuring that the generated + * content aligns with your intentions. + */ + negative_prompt?: string | null; + + /** + * The style_id parameter is like choosing an artistic palette for your image. + * By selecting a style id, you guide the AI in crafting the image with a particular visual aesthetic. + * Style IDs range from 1 to N, each representing a unique artistic style. + * + * @defaultValue 29 + */ + style?: number | null; + + /** + * The steps parameter defines the number of operations or iterations that the + * generator will perform during image creation. It can impact the complexity + * and detail of the generated image. + * + * Range: 30-50 + * + * @defaultValue 30 + */ + steps?: number | null; + + /** + * The cfg parameter acts as a creative control knob. + * You can adjust it to fine-tune the level of artistic innovation in the image. + * Lower values encourage faithful execution of the prompt, + * while higher values introduce more creative and imaginative variations. + * + * Range: 3 - 15 + * + * @defaultValue 7.5 + */ + cfg?: number | null; + + /** + * The seed parameter serves as the initial value for the random number generator. + * By setting a specific seed value, you can ensure that the AI generates the same + * image or outcome each time you use that exact seed. + * + * range: 1-Infinity + */ + seed?: number | null; + + /** + * Influences the impact of the control image on output. + * + * Range: 0 - 1 + * + * @defaultValue 0 + */ + strength?: number | null; + + /** + * The method/control used to guide image generation. + * + * @defaultValue openpose + */ + control?: 'openpose' | 'scribble' | 'canny' | 'lineart' | 'depth' | null; + + /** + * 目前仅支持 binary 格式 + */ + response_format?: 'binary' | null; +} + +export interface ImageGenerateParams { + /** + * A prompt is the text input that guides the AI in generating visual content. + * It defines the textual description or concept for the image you wish to generate. + * Think of it as the creative vision you want the AI to bring to life. + * Crafting clear and creative prompts is crucial for achieving the desired results with Imagine's API. + * For example, A serene forest with a river under the moonlight, can be a prompt. + */ + prompt: string; + + /** + * The model to use for image generation. + */ + model?: ImageModel | null; + + /** + * The negative_prompt parameter empowers you to provide additional + * guidance to the AI by specifying what you don't want in the image. + * It helps refine the creative direction, ensuring that the generated + * content aligns with your intentions. + */ + negative_prompt?: string | null; + + /** + * The aspect_ratio parameter allows you to specify the proportions and dimensions of the generated image. + * You can set it to different ratios like 1:1 for square images, 16:9 for widescreen, or 3:4 for vertical formats, + * shaping the visual composition to your liking. + * + * @defaultValue 1:1 + */ + aspect_ratio?: '1:1' | '3:2' | '4:3' | '3:4' | '16:9' | '9:16' | null; + + /** + * The quality parameter is a flag that, when set to hd, + * requests high-resolution results from the AI. + * + * @defaultValue standard + */ + quality?: 'standard' | 'hd'; + + /** + * The style_id parameter is like choosing an artistic palette for your image. + * By selecting a style id, you guide the AI in crafting the image with a particular visual aesthetic. + * Style IDs range from 1 to N, each representing a unique artistic style. + * + * @defaultValue 30 + */ + style?: number | null; + + /** + * The steps parameter defines the number of operations or iterations that the + * generator will perform during image creation. It can impact the complexity + * and detail of the generated image. + * + * Range: 30-50 + * + * @defaultValue 30 + */ + steps?: number | null; + + /** + * The cfg parameter acts as a creative control knob. + * You can adjust it to fine-tune the level of artistic innovation in the image. + * Lower values encourage faithful execution of the prompt, + * while higher values introduce more creative and imaginative variations. + * + * Range: 3 - 15 + * + * @defaultValue 7.5 + */ + cfg?: number | null; + + /** + * The seed parameter serves as the initial value for the random number generator. + * By setting a specific seed value, you can ensure that the AI generates the same + * image or outcome each time you use that exact seed. + * + * range: 1-Infinity + */ + seed?: number | null; + + /** + * 目前仅支持 binary 格式 + */ + response_format?: 'binary' | null; +} + +export interface ImageUpscaleParams { + /** + * The image to use as the basis for the variation(s). Must be a valid PNG file, + * less than 4MB, and square. + */ + image: Uploadable; +} + +export interface Image { + /** + * The binary of the generated image. + */ + binary?: ReadableStream; + + /** + * The base64-encoded JSON of the generated image, if `response_format` is + * `b64_json`. + */ + b64_json?: string; + + /** + * The prompt that was used to generate the image, if there was any revision to the + * prompt. + */ + revised_prompt?: string; + + /** + * The URL of the generated image, if `response_format` is `url` (default). + */ + url?: string; +} + +export interface ImagesResponse { + /** + * When the request was made. + */ + created: number; + + /** + * The generated images. + */ + data: Image[]; +} diff --git a/src/main/gptproxy/vyro/resources/index.ts b/src/main/gptproxy/vyro/resources/index.ts new file mode 100644 index 0000000..fa8dc84 --- /dev/null +++ b/src/main/gptproxy/vyro/resources/index.ts @@ -0,0 +1 @@ +export { Images, type ImageGenerateParams } from './images'; diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index 57a090a..08776a3 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import socketIo from 'socket.io'; export const getTempPath = () => { const tempDir = os.tmpdir(); @@ -10,3 +11,42 @@ export const getTempPath = () => { } return logDir; }; + +export async function emitAndWait( + io: socketIo.Server, + event: string, + data?: any, + timeout: number = 5000, +): Promise { + let response; + if (data === undefined) { + response = await io.timeout(timeout).emitWithAck(event); + } else { + response = await io.timeout(timeout).emitWithAck(event, data); + } + + // 判断是否是 Array + if (Array.isArray(response) && response.length === 1) { + if (typeof response[0] === 'undefined') { + return {} as T; + } + + if (response[0] === 'string') { + return {} as T; + } + + // 如果 response[0] 也是数组,那么直接返回 + if (Array.isArray(response[0])) { + return response[0] as T; + } + + return JSON.parse(response[0]); + } + + // 判断是否是字符串 + if (typeof response === 'string') { + return JSON.parse(response); + } + + return response; +} diff --git a/src/main/utils/strings.ts b/src/main/utils/strings.ts new file mode 100644 index 0000000..1a2c347 --- /dev/null +++ b/src/main/utils/strings.ts @@ -0,0 +1,95 @@ +/** + * 范围匹配 + * @param ptt 范围查询关键词 e.g. 'hello [and] world' + * @param msg 消息 + * @returns + */ +export function rangeMatch(ptt: string, msg: string): boolean { + if (ptt.includes('[and]')) { + const keywords = ptt.split('[and]'); + return keywords.every((keyword) => matchKeyword(keyword.trim(), msg)); + } + return matchKeyword(ptt, msg); +} + +/** + * 匹配关键词 + * @param ptt 匹配模式 + * @param msg 消息 + * @returns + */ +export function matchKeyword(ptt: string, msg: string): boolean { + let pattern = ptt.trim(); + + // 如果模式只是一个星号,它应该匹配任何消息。 + if (pattern === '*') { + return true; + } + + // 合并连续的 '*' 字符为一个 '*' + pattern = pattern.replace(/\*+/g, '*'); + + // 如果模式不包含 '*',则直接比较是否相等 + if (!pattern.includes('*')) { + return pattern === msg; + } + + const parts = pattern.split('*'); + let lastIndex = 0; + + // eslint-disable-next-line no-restricted-syntax + for (const part of parts) { + // 跳过空字符串(它们来自模式开始、结束或连续 '*') + if (part === '') continue; + + const index = msg.indexOf(part, lastIndex); + // 如果找不到部分或部分不是按顺序出现,则匹配失败 + if (index === -1 || index < lastIndex) { + return false; + } + lastIndex = index + part.length; + } + + // 确保消息的剩余部分可以被模式尾部的 '*' 匹配 + return parts[parts.length - 1] === '' || lastIndex <= msg.length; +} + +/** + * 替换文本中的 token + * @param text 原始文本 + * @param replacements 替换文本 + * @returns + */ +export function tokenReplace( + text: string, + replacements: Record, +): string { + return text.replace(/\{(\w+)\}/g, (match, key) => { + return replacements[key] || match; + }); +} + +/** + * 替换特殊 token + * @param text 原始文本 + * @returns + */ +export function specialTokenReplace(text: string): string { + // eslint-disable-next-line no-useless-escape + return text.replace(/\[\~\]/g, () => { + const randomChoices = [ + ...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + '🌸', + '😊', + '🌷', + '🌹', + '💖', + '🪷', + '💐', + '🌺', + '🌼', + '🌻', + ]; + return randomChoices[Math.floor(Math.random() * randomChoices.length)]; + }); +}