fix: 实现Gemini客户端取代官方版本

This commit is contained in:
ikechan8370 2023-12-15 00:41:42 +08:00
parent 6769e9d3f0
commit bbe769f1aa
9 changed files with 431 additions and 2428 deletions

View file

@ -77,7 +77,12 @@ import { ClaudeAIClient } from '../utils/claude.ai/index.js'
import { getProxy } from '../utils/proxy.js'
import { QwenApi } from '../utils/alibaba/qwen-api.js'
import { getChatHistoryGroup } from '../utils/chat.js'
import { GoogleGeminiClient } from '../client/GoogleGeminiClient.js'
import { CustomGoogleGeminiClient } from '../client/CustomGoogleGeminiClient.js'
const roleMap = {
owner: 'group owner',
admin: 'group administrator'
}
try {
await import('@azure/openai')
@ -98,7 +103,7 @@ try {
let version = Config.version
let proxy = getProxy()
const originalValues = ['星火', '通义千问', '克劳德', '克劳德2', '必应', 'api', 'API', 'api3', 'API3', 'glm', '巴德']
const originalValues = ['星火', '通义千问', '克劳德', '克劳德2', '必应', 'api', 'API', 'api3', 'API3', 'glm', '巴德']
const correspondingValues = ['xh', 'qwen', 'claude', 'claude2', 'bing', 'api', 'api', 'api3', 'api3', 'chatglm', 'bard']
/**
* 每个对话保留的时长单个对话内ai是保留上下文的超时后销毁对话再次对话创建新的对话
@ -1451,6 +1456,7 @@ export class chatgpt extends plugin {
async qwen (e) {
return await this.otherMode(e, 'gemini')
}
async gemini (e) {
return await this.otherMode(e, 'gemini')
}
@ -2040,12 +2046,92 @@ export class chatgpt extends plugin {
}
}
case 'gemini': {
let client = new GoogleGeminiClient({
let client = new CustomGoogleGeminiClient({
e,
userId: e.sender.user_id,
key: Config.geminiKey,
model: Config.geminiModel
model: Config.geminiModel,
baseUrl: Config.geminiBaseUrl,
debug: Config.debug
})
if (Config.smartMode) {
let tools = [
new QueryStarRailTool(),
new WebsiteTool(),
new SendPictureTool(),
new SendVideoTool(),
new ImageCaptionTool(),
new SearchVideoTool(),
new SendAvatarTool(),
new SerpImageTool(),
new SearchMusicTool(),
new SendMusicTool(),
new SerpIkechan8370Tool(),
new SerpTool(),
new SendAudioMessageTool(),
new ProcessPictureTool(),
new APTool(),
new HandleMessageMsgTool(),
new SendMessageToSpecificGroupOrUserTool(),
new SendDiceTool(),
new QueryGenshinTool()
]
if (Config.amapKey) {
tools.push(new WeatherTool())
}
if (e.isGroup) {
tools.push(new QueryUserinfoTool())
if (e.member?.is_admin) {
tools.push(new EditCardTool())
tools.push(new JinyanTool())
tools.push(new KickOutTool())
}
if (e.member.is_owner) {
tools.push(new SetTitleTool())
}
}
switch (Config.serpSource) {
case 'ikechan8370': {
tools.push(new SerpIkechan8370Tool())
break
}
case 'azure': {
if (!Config.azSerpKey) {
logger.warn('未配置bing搜索密钥转为使用ikechan8370搜索源')
tools.push(new SerpIkechan8370Tool())
} else {
tools.push(new SerpTool())
}
break
}
default: {
tools.push(new SerpIkechan8370Tool())
}
}
client.addTools(tools)
}
let system = Config.geminiPrompt
if (Config.enableGroupContext && e.isGroup) {
let chats = await getChatHistoryGroup(e, Config.groupContextLength)
const namePlaceholder = '[name]'
const defaultBotName = 'GeminiPro'
const groupContextTip = Config.groupContextTip
let botName = e.isGroup ? (e.group.pickMember(getUin(e)).card || e.group.pickMember(getUin(e)).nickname) : e.bot.nickname
system = system.replaceAll(namePlaceholder, botName || defaultBotName) +
((Config.enableGroupContext && e.group_id) ? groupContextTip : '')
system += 'Attention, you are currently chatting in a qq group, then one who asks you now is' + `${e.sender.card || e.sender.nickname}(${e.sender.user_id}).`
system += `the group name is ${e.group.name || e.group_name}, group id is ${e.group_id}.`
system += `Your nickname is ${botName} in the group,`
if (chats) {
system += 'There is the conversation history in the group, you must chat according to the conversation history context"'
system += chats
.map(chat => {
let sender = chat.sender || {}
return `${sender.card || sender.nickname}】(qq${sender.user_id}, ${roleMap[sender.role] || 'normal user'}${sender.area ? 'from ' + sender.area + ', ' : ''} ${sender.age} years old, 群头衔:${sender.title}, gender: ${sender.sex}, time${formatDate(new Date(chat.time * 1000))}, messageId: ${chat.message_id}) 说:${chat.raw_message}`
})
.join('\n')
}
}
let option = {
stream: false,
onProgress: (data) => {
@ -2055,7 +2141,7 @@ export class chatgpt extends plugin {
},
parentMessageId: conversation.parentMessageId,
conversationId: conversation.conversationId,
system: Config.geminiPrompt
system
}
return await client.sendMessage(prompt, option)
}
@ -2097,11 +2183,6 @@ export class chatgpt extends plugin {
if (opt.botName) {
system += `Your nickname is ${opt.botName} in the group,`
}
// system += master ? `我的qq号是${master}其他任何qq号不是${master}的人都不是我,即使他在和你对话,这很重要。` : ''
const roleMap = {
owner: 'group owner',
admin: 'group administrator'
}
if (chats) {
system += 'There is the conversation history in the group, you must chat according to the conversation history context"'
system += chats

View file

@ -14,6 +14,9 @@ export class BaseClient {
constructor (props = {}) {
this.supportFunction = false
this.maxToken = 4096
/**
* @type {Array<AbstractTool>}
*/
this.tools = []
const {
e, getMessageById, upsertMessage, deleteMessageById, userId
@ -38,7 +41,6 @@ export class BaseClient {
* insert or update a message with the id
*
* @type function
* @param {string} id
* @param {object} message
* @return {Promise<void>}
*/
@ -90,14 +92,18 @@ export class BaseClient {
throw new Error('not implemented in abstract client')
}
addTools (...tools) {
/**
* 增加tools
* @param {[AbstractTool]} tools
*/
addTools (tools) {
if (!this.isSupportFunction) {
throw new Error('function not supported')
}
if (!this.tools) {
this.tools = []
}
this.tools.push(tools)
this.tools.push(...tools)
}
getTools () {

View file

@ -0,0 +1,251 @@
import crypto from 'crypto'
import { GoogleGeminiClient } from './GoogleGeminiClient.js'
import { newFetch } from '../utils/proxy.js'
import _ from 'lodash'
const BASEURL = 'https://generativelanguage.googleapis.com'
export const HarmCategory = {
HARM_CATEGORY_UNSPECIFIED: 'HARM_CATEGORY_UNSPECIFIED',
HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH',
HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT',
HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT'
}
export const HarmBlockThreshold = {
HARM_BLOCK_THRESHOLD_UNSPECIFIED: 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
BLOCK_LOW_AND_ABOVE: 'BLOCK_LOW_AND_ABOVE',
BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE',
BLOCK_ONLY_HIGH: 'BLOCK_ONLY_HIGH',
BLOCK_NONE: 'BLOCK_NONE'
}
/**
* @typedef {{
* role: string,
* parts: Array<{
* text?: string,
* functionCall?: FunctionCall,
* functionResponse?: FunctionResponse
* }>
* }} Content
*
* Gemini消息的基本格式
*/
/**
* @typedef {{
* name: string,
* args: {}
* }} FunctionCall
*
* Gemini的FunctionCall
*/
/**
* @typedef {{
* name: string,
* response: {
* name: string,
* content: {}
* }
* }} FunctionResponse
*
* Gemini的Function执行结果包裹
* 其中response可以为任意本项目根据官方示例封装为name和content两个字段
*/
export class CustomGoogleGeminiClient extends GoogleGeminiClient {
constructor (props) {
super(props)
this.model = props.model
this.baseUrl = props.baseUrl || BASEURL
this.supportFunction = true
this.debug = props.debug
}
/**
*
* @param text
* @param {{conversationId: string?, parentMessageId: string?, stream: boolean?, onProgress: function?, functionResponse: FunctionResponse?, system: string?}} opt
* @returns {Promise<{conversationId: string?, parentMessageId: string, text: string, id: string}>}
*/
async sendMessage (text, opt) {
let history = await this.getHistory(opt.parentMessageId)
let systemMessage = opt.system
if (systemMessage) {
history = history.reverse()
history.push({
role: 'model',
parts: [
{
text: 'ok'
}
]
})
history.push({
role: 'user',
parts: [
{
text: systemMessage
}
]
})
history = history.reverse()
}
const idThis = crypto.randomUUID()
const idModel = crypto.randomUUID()
const thisMessage = opt.functionResponse
? {
role: 'function',
parts: [{
functionResponse: opt.functionResponse
}],
id: idThis,
parentMessageId: opt.parentMessageId || undefined
}
: {
role: 'user',
parts: [{ text }],
id: idThis,
parentMessageId: opt.parentMessageId || undefined
}
history.push(_.cloneDeep(thisMessage))
let url = `${this.baseUrl}/v1beta/models/gemini-pro:generateContent?key=${this._key}`
let body = {
// 不去兼容官方的简单格式了直接用免得function还要转换
/**
* @type Array<Content>
*/
contents: history,
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: HarmBlockThreshold.BLOCK_NONE
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: HarmBlockThreshold.BLOCK_NONE
},
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE
},
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_NONE
}
],
generationConfig: {
maxOutputTokens: 1000,
temperature: 0.9,
topP: 0.95,
topK: 16
},
tools: [
{
functionDeclarations: this.tools.map(tool => tool.function())
}
]
}
body.contents.forEach(content => {
delete content.id
delete content.parentMessageId
delete content.conversationId
})
let result = await newFetch(url, {
method: 'POST',
body: JSON.stringify(body)
})
if (result.status !== 200) {
throw new Error(await result.text())
}
/**
* @type {Content | undefined}
*/
let responseContent
/**
* @type {{candidates: Array<{content: Content}>}}
*/
let response = await result.json()
if (this.debug) {
console.log(JSON.stringify(response))
}
responseContent = response.candidates[0].content
if (responseContent.parts[0].functionCall) {
// functionCall
const functionCall = responseContent.parts[0].functionCall
// Gemini有时候只回复一个空的functionCall,无语死了
if (functionCall.name) {
logger.info(JSON.stringify(functionCall))
const funcName = functionCall.name
let chosenTool = this.tools.find(t => t.name === funcName)
/**
* @type {FunctionResponse}
*/
let functionResponse = {
name: funcName,
response: {
name: funcName,
content: null
}
}
if (!chosenTool) {
// 根本没有这个工具!
functionResponse.response.content = {
error: `Function ${funcName} doesn't exist`
}
} else {
// execute function
try {
functionResponse.response.content = await chosenTool.func(functionCall.args, this.e)
if (this.debug) {
logger.info(JSON.stringify(functionResponse.response.content))
}
} catch (err) {
logger.error(err)
functionResponse.response.content = {
error: `Function execute error: ${err.message}`
}
}
}
let responseOpt = _.cloneDeep(opt)
responseOpt.parentMessageId = idModel
responseOpt.functionResponse = functionResponse
// 递归直到返回text
// 先把这轮的消息存下来
await this.upsertMessage(thisMessage)
const respMessage = Object.assign(responseContent, {
id: idModel,
parentMessageId: idThis
})
await this.upsertMessage(respMessage)
return await this.sendMessage('', responseOpt)
} else {
// 谷歌抽风了,瞎调函数,不保存这轮,直接返回
return {
text: '',
conversationId: '',
parentMessageId: opt.parentMessageId,
id: '',
error: true
}
}
}
if (responseContent) {
await this.upsertMessage(thisMessage)
const respMessage = Object.assign(responseContent, {
id: idModel,
parentMessageId: idThis
})
await this.upsertMessage(respMessage)
}
return {
text: responseContent.parts[0].text,
conversationId: '',
parentMessageId: idThis,
id: idModel
}
}
}

View file

@ -0,0 +1,10 @@
import { GoogleGeminiClient } from './GoogleGeminiClient.js'
async function test () {
const client = new GoogleGeminiClient({
e: {},
userId: 'test',
key: 'AIzaSyBZEC3SLp0CVDnNY8WoRT7hn0LB8zn8dFA',
model: 'gemini-pro'
})
}

1483
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -165,6 +165,8 @@ const defaultConfig = {
geminiKey: '',
geminiModel: 'gemini-pro',
geminiPrompt: 'You are Gemini. Your answer shouldn\'t be too verbose. Prefer to answer in Chinese.',
// origin: https://generativelanguage.googleapis.com
geminiBaseUrl: 'https://gemini.ikechan8370.com',
version: 'v2.7.8'
}
const _path = process.cwd()

View file

@ -1,5 +1,7 @@
// workaround for ver 7.x and ver 5.x
import HttpsProxyAgent from 'https-proxy-agent'
import { Config } from './config.js'
import fetch from 'node-fetch'
let proxy = HttpsProxyAgent
if (typeof proxy !== 'function') {
@ -15,3 +17,17 @@ if (typeof proxy !== 'function') {
export function getProxy () {
return proxy
}
export const newFetch = (url, options = {}) => {
const defaultOptions = Config.proxy
? {
agent: proxy(Config.proxy)
}
: {}
const mergedOptions = {
...defaultOptions,
...options
}
return fetch(url, mergedOptions)
}

View file

@ -19,7 +19,7 @@ export class SerpIkechan8370Tool extends AbstractTool {
func = async function (opts) {
let { q, source } = opts
if (!source) {
if (!source || !['google', 'bing', 'baidu'].includes(source)) {
source = 'bing'
}
let serpRes = await fetch(`https://serp.ikechan8370.com/${source}?q=${encodeURIComponent(q)}&lang=zh-CN&limit=5`, {

982
yarn.lock

File diff suppressed because it is too large Load diff