From fbe1d8cfede0fab68230a27d489bc0d145cb43f5 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Thu, 16 Nov 2023 16:22:08 +0800 Subject: [PATCH] Fixed the duplicate data check problem, history filter and add tts stream (#477) --- packages/global/core/ai/model.d.ts | 3 + packages/global/core/ai/model.ts | 15 +- packages/global/core/ai/speech/api.d.ts | 8 - packages/global/core/ai/speech/constant.ts | 17 -- packages/global/core/app/type.d.ts | 1 - packages/global/core/chat/type.d.ts | 3 +- packages/service/common/buffer/tts/schema.ts | 35 ++++ packages/service/common/buffer/tts/type.d.ts | 5 + packages/service/core/ai/audio/speech.ts | 51 +++-- packages/service/core/chat/chatItemSchema.ts | 3 - projects/app/data/config.json | 17 +- projects/app/public/locales/en/common.json | 4 +- projects/app/public/locales/zh/common.json | 4 +- projects/app/src/components/ChatBox/index.tsx | 7 +- .../app/src/global/common/api/systemRes.d.ts | 5 +- projects/app/src/global/core/chat/api.d.ts | 1 - .../src/pages/api/core/chat/item/getSpeech.ts | 89 +++++---- .../app/src/pages/api/system/getInitData.ts | 6 +- .../app/src/pages/api/v1/chat/completions.ts | 13 +- .../pages/app/detail/components/TTSSelect.tsx | 19 +- .../dataset/detail/components/Import/Csv.tsx | 3 + .../detail/components/Import/FileSelect.tsx | 3 + .../detail/components/Import/Provider.tsx | 3 + .../detail/components/InputDataModal.tsx | 6 +- .../src/service/core/dataset/data/utils.ts | 16 +- .../app/src/service/moduleDispatch/index.ts | 9 + projects/app/src/types/app.d.ts | 3 +- projects/app/src/web/common/api/fetch.ts | 2 +- .../app/src/web/common/system/staticData.ts | 7 +- projects/app/src/web/common/utils/voice.ts | 184 +++++++++++++----- projects/app/src/web/core/chat/api.ts | 4 - 31 files changed, 359 insertions(+), 187 deletions(-) delete mode 100644 packages/global/core/ai/speech/api.d.ts delete mode 100644 packages/global/core/ai/speech/constant.ts create mode 100644 packages/service/common/buffer/tts/schema.ts create mode 100644 packages/service/common/buffer/tts/type.d.ts diff --git a/packages/global/core/ai/model.d.ts b/packages/global/core/ai/model.d.ts index 3e187510c..71c86c6f6 100644 --- a/packages/global/core/ai/model.d.ts +++ b/packages/global/core/ai/model.d.ts @@ -29,4 +29,7 @@ export type AudioSpeechModelType = { model: string; name: string; price: number; + baseUrl?: string; + key?: string; + voices: { label: string; value: string; bufferId: string }[]; }; diff --git a/packages/global/core/ai/model.ts b/packages/global/core/ai/model.ts index b39dbf348..cbe8591e7 100644 --- a/packages/global/core/ai/model.ts +++ b/packages/global/core/ai/model.ts @@ -105,11 +105,14 @@ export const defaultAudioSpeechModels: AudioSpeechModelType[] = [ { model: 'tts-1', name: 'OpenAI TTS1', - price: 0 - }, - { - model: 'tts-1-hd', - name: 'OpenAI TTS1', - price: 0 + price: 0, + voices: [ + { label: 'Alloy', value: 'Alloy', bufferId: 'openai-Alloy' }, + { label: 'Echo', value: 'Echo', bufferId: 'openai-Echo' }, + { label: 'Fable', value: 'Fable', bufferId: 'openai-Fable' }, + { label: 'Onyx', value: 'Onyx', bufferId: 'openai-Onyx' }, + { label: 'Nova', value: 'Nova', bufferId: 'openai-Nova' }, + { label: 'Shimmer', value: 'Shimmer', bufferId: 'openai-Shimmer' } + ] } ]; diff --git a/packages/global/core/ai/speech/api.d.ts b/packages/global/core/ai/speech/api.d.ts deleted file mode 100644 index b3298f876..000000000 --- a/packages/global/core/ai/speech/api.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Text2SpeechVoiceEnum } from './constant'; - -export type Text2SpeechProps = { - model?: string; - voice?: `${Text2SpeechVoiceEnum}`; - input: string; - speed?: number; -}; diff --git a/packages/global/core/ai/speech/constant.ts b/packages/global/core/ai/speech/constant.ts deleted file mode 100644 index 47636c252..000000000 --- a/packages/global/core/ai/speech/constant.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum Text2SpeechVoiceEnum { - alloy = 'alloy', - echo = 'echo', - fable = 'fable', - onyx = 'onyx', - nova = 'nova', - shimmer = 'shimmer' -} -export const openaiTTSList = [ - Text2SpeechVoiceEnum.alloy, - Text2SpeechVoiceEnum.echo, - Text2SpeechVoiceEnum.fable, - Text2SpeechVoiceEnum.onyx, - Text2SpeechVoiceEnum.nova, - Text2SpeechVoiceEnum.shimmer -]; -export const openaiTTSModel = 'tts-1'; diff --git a/packages/global/core/app/type.d.ts b/packages/global/core/app/type.d.ts index 2e01e7cb3..e61b8e5a4 100644 --- a/packages/global/core/app/type.d.ts +++ b/packages/global/core/app/type.d.ts @@ -1,7 +1,6 @@ import { ModuleItemType } from '../module/type'; import { AppTypeEnum } from './constants'; import { PermissionTypeEnum } from '../../support/permission/constant'; -import { Text2SpeechVoiceEnum } from '../ai/speech/constant'; export interface AppSchema { _id: string; diff --git a/packages/global/core/chat/type.d.ts b/packages/global/core/chat/type.d.ts index 4212decb8..1999b7800 100644 --- a/packages/global/core/chat/type.d.ts +++ b/packages/global/core/chat/type.d.ts @@ -39,7 +39,6 @@ export type ChatItemSchema = { userFeedback?: string; adminFeedback?: AdminFbkType; [TaskResponseKeyEnum.responseData]?: ChatHistoryItemResType[]; - tts?: Buffer; }; export type AdminFbkType = { @@ -62,7 +61,7 @@ export type ChatItemType = { export type ChatSiteItemType = { status: 'loading' | 'running' | 'finish'; moduleName?: string; - ttsBuffer?: Buffer; + ttsBuffer?: Uint8Array; } & ChatItemType; export type HistoryItemType = { diff --git a/packages/service/common/buffer/tts/schema.ts b/packages/service/common/buffer/tts/schema.ts new file mode 100644 index 000000000..9e7cf6b83 --- /dev/null +++ b/packages/service/common/buffer/tts/schema.ts @@ -0,0 +1,35 @@ +import { connectionMongo, type Model } from '../../../common/mongo'; +const { Schema, model, models } = connectionMongo; +import { TTSBufferSchemaType } from './type.d'; + +export const collectionName = 'ttsbuffers'; + +const TTSBufferSchema = new Schema({ + bufferId: { + type: String, + required: true + }, + text: { + type: String, + required: true + }, + buffer: { + type: Buffer, + required: true + }, + createTime: { + type: Date, + default: () => new Date() + } +}); + +try { + TTSBufferSchema.index({ bufferId: 1 }); + // 24 hour + TTSBufferSchema.index({ createTime: 1 }, { expireAfterSeconds: 24 * 60 * 60 }); +} catch (error) { + console.log(error); +} + +export const MongoTTSBuffer: Model = + models[collectionName] || model(collectionName, TTSBufferSchema); diff --git a/packages/service/common/buffer/tts/type.d.ts b/packages/service/common/buffer/tts/type.d.ts new file mode 100644 index 000000000..24a745cc0 --- /dev/null +++ b/packages/service/common/buffer/tts/type.d.ts @@ -0,0 +1,5 @@ +export type TTSBufferSchemaType = { + bufferId: string; + text: string; + buffer: Buffer; +}; diff --git a/packages/service/core/ai/audio/speech.ts b/packages/service/core/ai/audio/speech.ts index a3e09e029..2ff245a0c 100644 --- a/packages/service/core/ai/audio/speech.ts +++ b/packages/service/core/ai/audio/speech.ts @@ -1,26 +1,49 @@ -import { Text2SpeechProps } from '@fastgpt/global/core/ai/speech/api'; +import type { NextApiResponse } from 'next'; import { getAIApi } from '../config'; import { defaultAudioSpeechModels } from '../../../../global/core/ai/model'; -import { Text2SpeechVoiceEnum } from '@fastgpt/global/core/ai/speech/constant'; +import { UserModelSchema } from '@fastgpt/global/support/user/type'; export async function text2Speech({ - model = defaultAudioSpeechModels[0].model, - voice = Text2SpeechVoiceEnum.alloy, + res, + onSuccess, + onError, input, - speed = 1 -}: Text2SpeechProps) { - const ai = getAIApi(); - const mp3 = await ai.audio.speech.create({ + model = defaultAudioSpeechModels[0].model, + voice, + speed = 1, + props +}: { + res: NextApiResponse; + onSuccess: (e: { model: string; buffer: Buffer }) => void; + onError: (e: any) => void; + input: string; + model: string; + voice: string; + speed?: number; + props?: UserModelSchema['openaiAccount']; +}) { + const ai = getAIApi(props); + const response = await ai.audio.speech.create({ model, + // @ts-ignore voice, input, response_format: 'mp3', speed }); - const buffer = Buffer.from(await mp3.arrayBuffer()); - return { - model, - voice, - tts: buffer - }; + + const readableStream = response.body as unknown as NodeJS.ReadableStream; + readableStream.pipe(res); + + let bufferStore = Buffer.from([]); + + readableStream.on('data', (chunk) => { + bufferStore = Buffer.concat([bufferStore, chunk]); + }); + readableStream.on('end', () => { + onSuccess({ model, buffer: bufferStore }); + }); + readableStream.on('error', (e) => { + onError(e); + }); } diff --git a/packages/service/core/chat/chatItemSchema.ts b/packages/service/core/chat/chatItemSchema.ts index ee7ccdf1d..6161f975c 100644 --- a/packages/service/core/chat/chatItemSchema.ts +++ b/packages/service/core/chat/chatItemSchema.ts @@ -68,9 +68,6 @@ const ChatItemSchema = new Schema({ [TaskResponseKeyEnum.responseData]: { type: Array, default: [] - }, - tts: { - type: Buffer } }); diff --git a/projects/app/data/config.json b/projects/app/data/config.json index 83bb305ac..fb3388d37 100644 --- a/projects/app/data/config.json +++ b/projects/app/data/config.json @@ -102,12 +102,17 @@ { "model": "tts-1", "name": "OpenAI TTS1", - "price": 0 - }, - { - "model": "tts-1-hd", - "name": "OpenAI TTS1HD", - "price": 0 + "price": 0, + "baseUrl": "https://api.openai.com/v1", + "key": "", + "voices": [ + { "label": "Alloy", "value": "alloy", "bufferId": "openai-Alloy" }, + { "label": "Echo", "value": "echo", "bufferId": "openai-Echo" }, + { "label": "Fable", "value": "fable", "bufferId": "openai-Fable" }, + { "label": "Onyx", "value": "onyx", "bufferId": "openai-Onyx" }, + { "label": "Nova", "value": "nova", "bufferId": "openai-Nova" }, + { "label": "Shimmer", "value": "shimmer", "bufferId": "openai-Shimmer" } + ] } ] } diff --git a/projects/app/public/locales/en/common.json b/projects/app/public/locales/en/common.json index 360ebb91f..671a828a5 100644 --- a/projects/app/public/locales/en/common.json +++ b/projects/app/public/locales/en/common.json @@ -313,10 +313,12 @@ "Course": "Document", "Delete": "Delete", "Index": "Index({{amount}})" - } + }, + "input is empty": "The data content cannot be empty" }, "deleteDatasetTips": "Are you sure to delete the knowledge base? Data cannot be recovered after deletion, please confirm!", "deleteFolderTips": "Are you sure to delete this folder and all the knowledge bases it contains? Data cannot be recovered after deletion, please confirm!", + "import csv tip": "Ensure that the CSV is in UTF-8 format; otherwise, garbled characters will be displayed", "recall": { "rerank": "Rerank" }, diff --git a/projects/app/public/locales/zh/common.json b/projects/app/public/locales/zh/common.json index dcef53a46..474ccd724 100644 --- a/projects/app/public/locales/zh/common.json +++ b/projects/app/public/locales/zh/common.json @@ -313,10 +313,12 @@ "Course": "说明文档", "Delete": "删除数据", "Index": "数据索引({{amount}})" - } + }, + "input is empty": "数据内容不能为空 " }, "deleteDatasetTips": "确认删除该知识库?删除后数据无法恢复,请确认!", "deleteFolderTips": "确认删除该文件夹及其包含的所有知识库?删除后数据无法恢复,请确认!", + "import csv tip": "请确保CSV为UTF-8格式,否则会乱码", "recall": { "rerank": "结果重排" }, diff --git a/projects/app/src/components/ChatBox/index.tsx b/projects/app/src/components/ChatBox/index.tsx index 133ea7b1a..247729ba0 100644 --- a/projects/app/src/components/ChatBox/index.tsx +++ b/projects/app/src/components/ChatBox/index.tsx @@ -1221,18 +1221,19 @@ function ChatController({ name={'voice'} _hover={{ color: '#E74694' }} onClick={async () => { - const buffer = await playAudio({ + const response = await playAudio({ buffer: chat.ttsBuffer, chatItemId: chat.dataId, text: chat.value }); - if (!setChatHistory) return; + + if (!setChatHistory || !response.buffer) return; setChatHistory((state) => state.map((item) => item.dataId === chat.dataId ? { ...item, - ttsBuffer: buffer + ttsBuffer: response.buffer } : item ) diff --git a/projects/app/src/global/common/api/systemRes.d.ts b/projects/app/src/global/common/api/systemRes.d.ts index 0db364615..1a7d9f216 100644 --- a/projects/app/src/global/common/api/systemRes.d.ts +++ b/projects/app/src/global/common/api/systemRes.d.ts @@ -2,7 +2,8 @@ import type { ChatModelItemType, FunctionModelItemType, LLMModelItemType, - VectorModelItemType + VectorModelItemType, + AudioSpeechModels } from '@fastgpt/global/core/ai/model.d'; import type { FeConfigsType } from '@fastgpt/global/common/system/types/index.d'; @@ -12,8 +13,8 @@ export type InitDateResponse = { qaModels: LLMModelItemType[]; cqModels: FunctionModelItemType[]; extractModels: FunctionModelItemType[]; - qgModels: LLMModelItemType[]; vectorModels: VectorModelItemType[]; + audioSpeechModels: AudioSpeechModels[]; feConfigs: FeConfigsType; priceMd: string; systemVersion: string; diff --git a/projects/app/src/global/core/chat/api.d.ts b/projects/app/src/global/core/chat/api.d.ts index 54d286356..8da11b219 100644 --- a/projects/app/src/global/core/chat/api.d.ts +++ b/projects/app/src/global/core/chat/api.d.ts @@ -1,7 +1,6 @@ import type { AppTTSConfigType } from '@/types/app'; export type GetChatSpeechProps = { - chatItemId?: string; ttsConfig: AppTTSConfigType; input: string; }; diff --git a/projects/app/src/pages/api/core/chat/item/getSpeech.ts b/projects/app/src/pages/api/core/chat/item/getSpeech.ts index 068cec401..277c1ba2e 100644 --- a/projects/app/src/pages/api/core/chat/item/getSpeech.ts +++ b/projects/app/src/pages/api/core/chat/item/getSpeech.ts @@ -1,12 +1,13 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { jsonRes } from '@fastgpt/service/common/response'; import { connectToDatabase } from '@/service/mongo'; -import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; import { GetChatSpeechProps } from '@/global/core/chat/api.d'; import { text2Speech } from '@fastgpt/service/core/ai/audio/speech'; import { pushAudioSpeechBill } from '@/service/support/wallet/bill/push'; import { authCert } from '@fastgpt/service/support/permission/auth/common'; import { authType2BillSource } from '@/service/support/wallet/bill/utils'; +import { getAudioSpeechModel } from '@/service/core/ai/model'; +import { MongoTTSBuffer } from '@fastgpt/service/common/buffer/tts/schema'; /* 1. get tts from chatItem store @@ -18,50 +19,66 @@ import { authType2BillSource } from '@/service/support/wallet/bill/utils'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { await connectToDatabase(); - const { chatItemId, ttsConfig, input } = req.body as GetChatSpeechProps; + const { ttsConfig, input } = req.body as GetChatSpeechProps; + + if (!ttsConfig.model || !ttsConfig.voice) { + throw new Error('model or voice not found'); + } const { teamId, tmbId, authType } = await authCert({ req, authToken: true }); - const chatItem = await (async () => { - if (!chatItemId) return null; - return await MongoChatItem.findOne( - { - dataId: chatItemId - }, - 'tts' - ); - })(); + const ttsModel = getAudioSpeechModel(ttsConfig.model); + const voiceData = ttsModel.voices.find((item) => item.value === ttsConfig.voice); - if (chatItem?.tts) { - return jsonRes(res, { - data: chatItem.tts - }); + if (!voiceData) { + throw new Error('voice not found'); } - const { tts, model } = await text2Speech({ + const ttsBuffer = await MongoTTSBuffer.findOne( + { + bufferId: voiceData.bufferId, + text: input + }, + 'buffer' + ); + + if (ttsBuffer?.buffer) { + return res.end(new Uint8Array(ttsBuffer.buffer.buffer)); + } + + await text2Speech({ + res, + input, model: ttsConfig.model, voice: ttsConfig.voice, - input - }); + props: { + // temp code + baseUrl: ttsModel.baseUrl || '', + key: ttsModel.key || '' + }, + onSuccess: async ({ model, buffer }) => { + try { + pushAudioSpeechBill({ + model: model, + textLength: input.length, + tmbId, + teamId, + source: authType2BillSource({ authType }) + }); - (async () => { - if (!chatItem) return; - try { - chatItem.tts = tts; - await chatItem.save(); - } catch (error) {} - })(); - - jsonRes(res, { - data: tts - }); - - pushAudioSpeechBill({ - model: model, - textLength: input.length, - tmbId, - teamId, - source: authType2BillSource({ authType }) + await MongoTTSBuffer.create({ + bufferId: voiceData.bufferId, + text: input, + buffer + }); + } catch (error) {} + }, + onError: (err) => { + jsonRes(res, { + code: 500, + error: err + }); + } }); } catch (err) { jsonRes(res, { diff --git a/projects/app/src/pages/api/system/getInitData.ts b/projects/app/src/pages/api/system/getInitData.ts index e345f6618..715ef58ba 100644 --- a/projects/app/src/pages/api/system/getInitData.ts +++ b/projects/app/src/pages/api/system/getInitData.ts @@ -34,8 +34,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) qaModels: global.qaModels, cqModels: global.cqModels, extractModels: global.extractModels, - qgModels: global.qgModels, vectorModels: global.vectorModels, + audioSpeechModels: global.audioSpeechModels.map((item) => ({ + ...item, + baseUrl: undefined, + key: undefined + })), priceMd: global.priceMd, systemVersion: global.systemVersion || '0.0.0' } diff --git a/projects/app/src/pages/api/v1/chat/completions.ts b/projects/app/src/pages/api/v1/chat/completions.ts index 5ae2ba315..421740b33 100644 --- a/projects/app/src/pages/api/v1/chat/completions.ts +++ b/projects/app/src/pages/api/v1/chat/completions.ts @@ -86,6 +86,7 @@ export default withNextCors(async function handler(req: NextApiRequest, res: Nex if (chatMessages[chatMessages.length - 1].obj !== ChatRoleEnum.Human) { chatMessages.pop(); } + // user question const question = chatMessages.pop(); if (!question) { @@ -173,15 +174,7 @@ export default withNextCors(async function handler(req: NextApiRequest, res: Nex const isAppOwner = !shareId && String(user.team.tmbId) === String(app.tmbId); /* format prompts */ - const prompts = history.concat(gptMessage2ChatType(messages)); - - // set sse response headers - if (stream) { - res.setHeader('Content-Type', 'text/event-stream;charset=utf-8'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('X-Accel-Buffering', 'no'); - res.setHeader('Cache-Control', 'no-cache, no-transform'); - } + const concatHistory = history.concat(chatMessages); /* start flow controller */ const { responseData, answerText } = await dispatchModules({ @@ -193,7 +186,7 @@ export default withNextCors(async function handler(req: NextApiRequest, res: Nex tmbId: user.team.tmbId, variables, params: { - history: prompts, + history: concatHistory, userChatInput: question.value }, stream, diff --git a/projects/app/src/pages/app/detail/components/TTSSelect.tsx b/projects/app/src/pages/app/detail/components/TTSSelect.tsx index 487c63132..a407e6fdb 100644 --- a/projects/app/src/pages/app/detail/components/TTSSelect.tsx +++ b/projects/app/src/pages/app/detail/components/TTSSelect.tsx @@ -6,10 +6,10 @@ import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'next-i18next'; import MySelect from '@/components/Select'; import { TTSTypeEnum } from '@/constants/app'; -import { Text2SpeechVoiceEnum, openaiTTSModel } from '@fastgpt/global/core/ai/speech/constant'; import { AppTTSConfigType } from '@/types/app'; import { useAudioPlay } from '@/web/common/utils/voice'; import { useLoading } from '@/web/common/hooks/useLoading'; +import { audioSpeechModels } from '@/web/common/system/staticData'; const TTSSelect = ({ value, @@ -37,10 +37,16 @@ const TTSSelect = ({ if (e === TTSTypeEnum.none || e === TTSTypeEnum.web) { onChange({ type: e as `${TTSTypeEnum}` }); } else { + const audioModel = audioSpeechModels.find((item) => + item.voices.find((voice) => voice.value === e) + ); + if (!audioModel) { + return; + } onChange({ type: TTSTypeEnum.model, - model: openaiTTSModel, - voice: e as `${Text2SpeechVoiceEnum}`, + model: audioModel.model, + voice: e, speed: 1 }); } @@ -77,12 +83,7 @@ const TTSSelect = ({ list={[ { label: t('core.app.tts.Close'), value: TTSTypeEnum.none }, { label: t('core.app.tts.Web'), value: TTSTypeEnum.web }, - { label: 'Alloy', value: Text2SpeechVoiceEnum.alloy }, - { label: 'Echo', value: Text2SpeechVoiceEnum.echo }, - { label: 'Fable', value: Text2SpeechVoiceEnum.fable }, - { label: 'Onyx', value: Text2SpeechVoiceEnum.onyx }, - { label: 'Nova', value: Text2SpeechVoiceEnum.nova }, - { label: 'Shimmer', value: Text2SpeechVoiceEnum.shimmer } + ...audioSpeechModels.map((item) => item.voices).flat() ]} onchange={onclickChange} /> diff --git a/projects/app/src/pages/dataset/detail/components/Import/Csv.tsx b/projects/app/src/pages/dataset/detail/components/Import/Csv.tsx index c475894fb..9ff7a8a39 100644 --- a/projects/app/src/pages/dataset/detail/components/Import/Csv.tsx +++ b/projects/app/src/pages/dataset/detail/components/Import/Csv.tsx @@ -2,11 +2,13 @@ import React from 'react'; import { Box, Flex, Button } from '@chakra-ui/react'; import { useConfirm } from '@/web/common/hooks/useConfirm'; import { useImportStore, SelectorContainer, PreviewFileOrChunk } from './Provider'; +import { useTranslation } from 'next-i18next'; const fileExtension = '.csv'; const csvTemplate = `index,content\n"被索引的内容","对应的答案。CSV 中请注意内容不能包含双引号,双引号是列分割符号"\n"什么是 laf","laf 是一个云函数开发平台……",""\n"什么是 sealos","Sealos 是以 kubernetes 为内核的云操作系统发行版,可以……"`; const CsvImport = () => { + const { t } = useTranslation(); const { successChunks, totalChunks, isUnselectedFile, onclickUpload, uploading } = useImportStore(); @@ -24,6 +26,7 @@ const CsvImport = () => { value: csvTemplate, type: 'text/csv' }} + tip={t('dataset.import csv tip')} >