From 85ce23869d16bba8f1a330aa2816c0dffdcf248e Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Thu, 3 Apr 2025 00:21:34 +0800 Subject: [PATCH] Test completion v2 (#4438) * add v2 completions (#4364) * add v2 completions * completion config * config version * fix * frontend * doc * fix * fix: completions v2 api --------- Co-authored-by: heheer --- .../zh-cn/docs/development/openapi/chat.md | 440 +++++++++++- .../global/core/workflow/runtime/constants.ts | 1 + .../global/core/workflow/runtime/type.d.ts | 1 + .../service/core/workflow/dispatch/index.ts | 16 + .../service/core/workflow/dispatch/utils.ts | 3 +- .../core/chat/ChatContainer/ChatBox/index.tsx | 40 +- .../core/chat/ChatContainer/type.d.ts | 7 +- .../app/src/pages/api/core/chat/chatTest.ts | 8 +- .../app/src/pages/api/v2/chat/completions.ts | 641 ++++++++++++++++++ projects/app/src/web/common/api/fetch.ts | 23 +- 10 files changed, 1140 insertions(+), 40 deletions(-) create mode 100644 projects/app/src/pages/api/v2/chat/completions.ts diff --git a/docSite/content/zh-cn/docs/development/openapi/chat.md b/docSite/content/zh-cn/docs/development/openapi/chat.md index 7a54c312d..75095b3a0 100644 --- a/docSite/content/zh-cn/docs/development/openapi/chat.md +++ b/docSite/content/zh-cn/docs/development/openapi/chat.md @@ -18,12 +18,14 @@ weight: 852 {{% alert icon="🤖 " context="success" %}} * 该接口的 API Key 需使用`应用特定的 key`,否则会报错。 +* 对话现在有`v1`和`v2`两个接口,可以按需使用,v2 自 4.9.4 版本新增,v1 接口同时不再维护 + * 有些包调用时,`BaseUrl`需要添加`v1`路径,有些不需要,如果出现404情况,可补充`v1`重试。 {{% /alert %}} ## 请求简易应用和工作流 -对话接口兼容`GPT`的接口!如果你的项目使用的是标准的`GPT`官方接口,可以直接通过修改`BaseUrl`和 `Authorization`来访问 FastGpt 应用,不过需要注意下面几个规则: +`v1`对话接口兼容`GPT`的接口!如果你的项目使用的是标准的`GPT`官方接口,可以直接通过修改`BaseUrl`和 `Authorization`来访问 FastGpt 应用,不过需要注意下面几个规则: {{% alert icon="🤖 " context="success" %}} * 传入的`model`,`temperature`等参数字段均无效,这些字段由编排决定,不会根据 API 参数改变。 @@ -32,6 +34,100 @@ weight: 852 ### 请求 +#### v2 + +v1,v2 接口请求参数一致,仅请求地址不一样。 + +{{< tabs tabTotal="3" >}} +{{< tab tabName="基础请求示例" >}} +{{< markdownify >}} + +```bash +curl --location --request POST 'http://localhost:3000/api/v2/chat/completions' \ +--header 'Authorization: fastgpt-xxxxxx' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "chatId": "my_chatId", + "stream": false, + "detail": false, + "responseChatItemId": "my_responseChatItemId", + "variables": { + "uid": "asdfadsfasfd2323", + "name": "张三" + }, + "messages": [ + { + "role": "user", + "content": "你是谁" + } + ] +}' +``` + +{{< /markdownify >}} +{{< /tab >}} + +{{< tab tabName="图片/文件请求示例" >}} +{{< markdownify >}} + +* 仅`messages`有部分区别,其他参数一致。 +* 目前不支持上传文件,需上传到自己的对象存储中,获取对应的文件链接。 + +```bash +curl --location --request POST 'http://localhost:3000/api/v2/chat/completions' \ +--header 'Authorization: Bearer fastgpt-xxxxxx' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "chatId": "abcd", + "stream": false, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "导演是谁" + }, + { + "type": "image_url", + "image_url": { + "url": "图片链接" + } + }, + { + "type": "file_url", + "name": "文件名", + "url": "文档链接,支持 txt md html word pdf ppt csv excel" + } + ] + } + ] +}' +``` + +{{< /markdownify >}} +{{< /tab >}} + +{{< tab tabName="参数说明" >}} +{{< markdownify >}} + +{{% alert context="info" %}} +- headers.Authorization: Bearer {{apikey}} +- chatId: string | undefined 。 + - 为 `undefined` 时(不传入),不使用 FastGpt 提供的上下文功能,完全通过传入的 messages 构建上下文。 + - 为`非空字符串`时,意味着使用 chatId 进行对话,自动从 FastGpt 数据库取历史记录,并使用 messages 数组最后一个内容作为用户问题,其余 message 会被忽略。请自行确保 chatId 唯一,长度小于250,通常可以是自己系统的对话框ID。 +- messages: 结构与 [GPT接口](https://platform.openai.com/docs/api-reference/chat/object) chat模式一致。 +- responseChatItemId: string | undefined 。如果传入,则会将该值作为本次对话的响应消息的 ID,FastGPT 会自动将该 ID 存入数据库。请确保,在当前`chatId`下,`responseChatItemId`是唯一的。 +- detail: 是否返回中间值(模块状态,响应的完整结果等),`stream模式`下会通过`event`进行区分,`非stream模式`结果保存在`responseData`中。 +- variables: 模块变量,一个对象,会替换模块中,输入框内容里的`{{key}}` +{{% /alert %}} + +{{< /markdownify >}} +{{< /tab >}} +{{< /tabs >}} + +#### v1 + {{< tabs tabTotal="3" >}} {{< tab tabName="基础请求示例" >}} {{< markdownify >}} @@ -65,7 +161,7 @@ curl --location --request POST 'http://localhost:3000/api/v1/chat/completions' \ {{< markdownify >}} * 仅`messages`有部分区别,其他参数一致。 -* 目前不支持上次文件,需上传到自己的对象存储中,获取对应的文件链接。 +* 目前不支持上传文件,需上传到自己的对象存储中,获取对应的文件链接。 ```bash curl --location --request POST 'http://localhost:3000/api/v1/chat/completions' \ @@ -116,14 +212,188 @@ curl --location --request POST 'http://localhost:3000/api/v1/chat/completions' \ - variables: 模块变量,一个对象,会替换模块中,输入框内容里的`{{key}}` {{% /alert %}} - - {{< /markdownify >}} {{< /tab >}} {{< /tabs >}} ### 响应 +#### v2 + +v2 接口比起 v1,主要变变化在于:会在每个节点运行结束后及时返回 response,而不是等工作流结束后再统一返回。 + +{{< tabs tabTotal="5" >}} +{{< tab tabName="detail=false,stream=false 响应" >}} +{{< markdownify >}} + +```json +{ + "id": "", + "model": "", + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 1 + }, + "choices": [ + { + "message": { + "role": "assistant", + "content": "我是一个人工智能助手,旨在回答问题和提供信息。如果你有任何问题或者需要帮助,随时问我!" + }, + "finish_reason": "stop", + "index": 0 + } + ] +} +``` + +{{< /markdownify >}} +{{< /tab >}} + +{{< tab tabName="detail=false,stream=true 响应" >}} +{{< markdownify >}} + + +```bash +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"你好"},"index":0,"finish_reason":null}]} + +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"!"},"index":0,"finish_reason":null}]} + +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"今天"},"index":0,"finish_reason":null}]} + +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"过得怎么样?"},"index":0,"finish_reason":null}]} + +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":null},"index":0,"finish_reason":"stop"}]} + +data: [DONE] +``` + +{{< /markdownify >}} +{{< /tab >}} + +{{< tab tabName="detail=true,stream=false 响应" >}} +{{< markdownify >}} + +```json +{ + "responseData": [ + { + "id": "iSol79OFrBH1I9kC", + "nodeId": "448745", + "moduleName": "common:core.module.template.work_start", + "moduleType": "workflowStart", + "runningTime": 0 + }, + { + "id": "t1T94WCy6Su3BK4V", + "nodeId": "fjLpE3XPegmoGtbU", + "moduleName": "AI 对话", + "moduleType": "chatNode", + "runningTime": 1.46, + "totalPoints": 0, + "model": "GPT-4o-mini", + "tokens": 64, + "inputTokens": 10, + "outputTokens": 54, + "query": "你是谁", + "reasoningText": "", + "historyPreview": [ + { + "obj": "Human", + "value": "你是谁" + }, + { + "obj": "AI", + "value": "我是一个人工智能助手,旨在帮助回答问题和提供信息。如果你有任何问题或需要帮助,请告诉我!" + } + ], + "contextTotalLen": 2 + } + ], + "newVariables": { + + }, + "id": "", + "model": "", + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 1 + }, + "choices": [ + { + "message": { + "role": "assistant", + "content": "我是一个人工智能助手,旨在帮助回答问题和提供信息。如果你有任何问题或需要帮助,请告诉我!" + }, + "finish_reason": "stop", + "index": 0 + } + ] +} +``` + +{{< /markdownify >}} +{{< /tab >}} + + +{{< tab tabName="detail=true,stream=true 响应" >}} +{{< markdownify >}} + +```bash +event: flowNodeResponse +data: {"id":"iYv2uA9rCWAtulWo","nodeId":"workflowStartNodeId","moduleName":"流程开始","moduleType":"workflowStart","runningTime":0} + +event: flowNodeStatus +data: {"status":"running","name":"AI 对话"} + +event: answer +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"你好"},"index":0,"finish_reason":null}]} + +event: answer +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"!"},"index":0,"finish_reason":null}]} + +event: answer +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"今天"},"index":0,"finish_reason":null}]} + +event: answer +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":"过得怎么样?"},"index":0,"finish_reason":null}]} + +event: flowNodeResponse +data: {"id":"pVzLBF7M3Ol4n7s6","nodeId":"ixe20AHN3jy74pKf","moduleName":"AI 对话","moduleType":"chatNode","runningTime":1.48,"totalPoints":0.0042,"model":"Qwen-plus","tokens":28,"inputTokens":8,"outputTokens":20,"query":"你好","reasoningText":"","historyPreview":[{"obj":"Human","value":"你好"},{"obj":"AI","value":"你好!今天过得怎么样?"}],"contextTotalLen":2} + +event: answer +data: {"id":"","object":"","created":0,"model":"","choices":[{"delta":{"role":"assistant","content":null},"index":0,"finish_reason":"stop"}]} + +event: answer +data: [DONE] +``` + +{{< /markdownify >}} +{{< /tab >}} + +{{< tab tabName="event值" >}} +{{< markdownify >}} + +event取值: + +- answer: 返回给客户端的文本(最终会算作回答) +- fastAnswer: 指定回复返回给客户端的文本(最终会算作回答) +- toolCall: 执行工具 +- toolParams: 工具参数 +- toolResponse: 工具返回 +- flowNodeStatus: 运行到的节点状态 +- flowNodeResponse: 单个节点详细响应 +- updateVariables: 更新变量 +- error: 报错 + +{{< /markdownify >}} +{{< /tab >}} +{{< /tabs >}} + +#### v1 + {{< tabs tabTotal="5" >}} {{< tab tabName="detail=false,stream=false 响应" >}} {{< markdownify >}} @@ -475,6 +745,8 @@ curl --location --request POST 'https://api.fastgpt.in/api/v1/chat/completions' ### 请求示例 +#### v1 + ```bash curl --location --request POST 'http://localhost:3000/api/v1/chat/completions' \ --header 'Authorization: Bearer test-xxxxx' \ @@ -488,8 +760,25 @@ curl --location --request POST 'http://localhost:3000/api/v1/chat/completions' \ }' ``` +#### v2 + +```bash +curl --location --request POST 'http://localhost:3000/api/v2/chat/completions' \ +--header 'Authorization: Bearer test-xxxxx' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "stream": false, + "chatId": "test", + "variables": { + "query":"你好" + } +}' +``` + ### 响应示例 +#### v1 + {{< tabs tabTotal="3" >}} {{< tab tabName="detail=true,stream=false 响应" >}} @@ -649,6 +938,149 @@ event取值: {{< /tabs >}} +#### v2 + +{{< tabs tabTotal="3" >}} + +{{< tab tabName="detail=true,stream=false 响应" >}} +{{< markdownify >}} + +* 插件的输出可以通过查找`responseData`中, `moduleType=pluginOutput`的元素,其`pluginOutput`是插件的输出。 +* 流输出,仍可以通过`choices`进行获取。 + +```json +{ + "responseData": [ + { + "id": "bsH1ZdbYkz9iJwYa", + "nodeId": "pluginInput", + "moduleName": "workflow:template.plugin_start", + "moduleType": "pluginInput", + "runningTime": 0 + }, + { + "id": "zDgfqSPhbYZFHVIn", + "nodeId": "h4Gr4lJtFVQ6qI4c", + "moduleName": "AI 对话", + "moduleType": "chatNode", + "runningTime": 1.44, + "totalPoints": 0, + "model": "GPT-4o-mini", + "tokens": 34, + "inputTokens": 8, + "outputTokens": 26, + "query": "你好", + "reasoningText": "", + "historyPreview": [ + { + "obj": "Human", + "value": "你好" + }, + { + "obj": "AI", + "value": "你好!有什么我可以帮助你的吗?" + } + ], + "contextTotalLen": 2 + }, + { + "id": "uLLwKKRZvufXzgF4", + "nodeId": "pluginOutput", + "moduleName": "common:core.module.template.self_output", + "moduleType": "pluginOutput", + "runningTime": 0, + "totalPoints": 0, + "pluginOutput": { + "result": "你好!有什么我可以帮助你的吗?" + } + } + ], + "newVariables": { + + }, + "id": "test", + "model": "", + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 1 + }, + "choices": [ + { + "message": { + "role": "assistant", + "content": "你好!有什么我可以帮助你的吗?" + }, + "finish_reason": "stop", + "index": 0 + } + ] +} +``` + +{{< /markdownify >}} +{{< /tab >}} + + +{{< tab tabName="detail=true,stream=true 响应" >}} +{{< markdownify >}} + +* 插件的输出可以通过获取`event=flowResponses`中的字符串,并将其反序列化后得到一个数组。同样的,查找 `moduleType=pluginOutput`的元素,其`pluginOutput`是插件的输出。 +* 流输出,仍和对话接口一样获取。 + +```bash +data: {"event":"flowNodeResponse","data":"{\"id\":\"q8ablUOqHGgqLIRM\",\"nodeId\":\"pluginInput\",\"moduleName\":\"workflow:template.plugin_start\",\"moduleType\":\"pluginInput\",\"runningTime\":0}"} + +data: {"event":"flowNodeStatus","data":"{\"status\":\"running\",\"name\":\"AI 对话\"}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"你好\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"!\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"有什么\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"我\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"可以\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"帮助\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"你\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"的吗\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"?\"},\"index\":0,\"finish_reason\":null}]}"} + +data: {"event":"flowNodeResponse","data":"{\"id\":\"rqlXLUap8QeiN7Kf\",\"nodeId\":\"h4Gr4lJtFVQ6qI4c\",\"moduleName\":\"AI 对话\",\"moduleType\":\"chatNode\",\"runningTime\":1.79,\"totalPoints\":0,\"model\":\"GPT-4o-mini\",\"tokens\":137,\"inputTokens\":111,\"outputTokens\":26,\"query\":\"你好\",\"reasoningText\":\"\",\"historyPreview\":[{\"obj\":\"Human\",\"value\":\"[{\\\"renderTypeList\\\":[\\\"reference\\\"],\\\"selectedTypeInde\\n\\n...[hide 174 chars]...\\n\\ncanSelectImg\\\":true,\\\"required\\\":false,\\\"value\\\":\\\"你好\\\"}]\"},{\"obj\":\"AI\",\"value\":\"你好!有什么我可以帮助你的吗?\"},{\"obj\":\"Human\",\"value\":\"你好\"},{\"obj\":\"AI\",\"value\":\"你好!有什么我可以帮助你的吗?\"}],\"contextTotalLen\":4}"} + +data: {"event":"flowNodeResponse","data":"{\"id\":\"lHCpHI0MrM00HQlX\",\"nodeId\":\"pluginOutput\",\"moduleName\":\"common:core.module.template.self_output\",\"moduleType\":\"pluginOutput\",\"runningTime\":0,\"totalPoints\":0,\"pluginOutput\":{\"result\":\"你好!有什么我可以帮助你的吗?\"}}"} + +data: {"event":"answer","data":"{\"id\":\"\",\"object\":\"\",\"created\":0,\"model\":\"\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":null},\"index\":0,\"finish_reason\":\"stop\"}]}"} + +data: {"event":"answer","data":"[DONE]"} +``` + +{{< /markdownify >}} +{{< /tab >}} + +{{< tab tabName="输出获取" >}} +{{< markdownify >}} + +event取值: + +- answer: 返回给客户端的文本(最终会算作回答) +- fastAnswer: 指定回复返回给客户端的文本(最终会算作回答) +- toolCall: 执行工具 +- toolParams: 工具参数 +- toolResponse: 工具返回 +- flowNodeStatus: 运行到的节点状态 +- flowNodeResponse: 单个节点详细响应 +- updateVariables: 更新变量 +- error: 报错 + +{{< /markdownify >}} +{{< /tab >}} +{{< /tabs >}} # 对话 CRUD diff --git a/packages/global/core/workflow/runtime/constants.ts b/packages/global/core/workflow/runtime/constants.ts index 10737d282..0293d0b0c 100644 --- a/packages/global/core/workflow/runtime/constants.ts +++ b/packages/global/core/workflow/runtime/constants.ts @@ -5,6 +5,7 @@ export enum SseResponseEventEnum { answer = 'answer', // animation stream fastAnswer = 'fastAnswer', // direct answer text, not animation flowNodeStatus = 'flowNodeStatus', // update node status + flowNodeResponse = 'flowNodeResponse', // node response toolCall = 'toolCall', // tool start toolParams = 'toolParams', // tool params return diff --git a/packages/global/core/workflow/runtime/type.d.ts b/packages/global/core/workflow/runtime/type.d.ts index 2ab34bfbe..70e43a182 100644 --- a/packages/global/core/workflow/runtime/type.d.ts +++ b/packages/global/core/workflow/runtime/type.d.ts @@ -59,6 +59,7 @@ export type ChatDispatchProps = { isToolCall?: boolean; workflowStreamResponse?: WorkflowResponseType; workflowDispatchDeep?: number; + version?: 'v1' | 'v2'; }; export type ModuleDispatchProps = ChatDispatchProps & { diff --git a/packages/service/core/workflow/dispatch/index.ts b/packages/service/core/workflow/dispatch/index.ts index 24aae6154..1d30e8cc9 100644 --- a/packages/service/core/workflow/dispatch/index.ts +++ b/packages/service/core/workflow/dispatch/index.ts @@ -130,6 +130,7 @@ export async function dispatchWorkFlow(data: Props): Promise { if (!item.required) return; diff --git a/packages/service/core/workflow/dispatch/utils.ts b/packages/service/core/workflow/dispatch/utils.ts index 002272a57..10f5cabfe 100644 --- a/packages/service/core/workflow/dispatch/utils.ts +++ b/packages/service/core/workflow/dispatch/utils.ts @@ -53,7 +53,8 @@ export const getWorkflowResponseWrite = ({ [SseResponseEventEnum.toolCall]: 1, [SseResponseEventEnum.toolParams]: 1, [SseResponseEventEnum.toolResponse]: 1, - [SseResponseEventEnum.updateVariables]: 1 + [SseResponseEventEnum.updateVariables]: 1, + [SseResponseEventEnum.flowNodeResponse]: 1 }; if (!detail && detailEvent[event]) return; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx index 177011aec..f9e965dc4 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx @@ -219,7 +219,8 @@ const ChatBox = ({ tool, interactive, autoTTSResponse, - variables + variables, + nodeResponse }: generatingMessageProps & { autoTTSResponse?: boolean }) => { setChatRecords((state) => state.map((item, index) => { @@ -232,7 +233,14 @@ const ChatBox = ({ JSON.stringify(item.value[item.value.length - 1]) ); - if (event === SseResponseEventEnum.flowNodeStatus && status) { + if (event === SseResponseEventEnum.flowNodeResponse && nodeResponse) { + return { + ...item, + responseData: item.responseData + ? [...item.responseData, nodeResponse] + : [nodeResponse] + }; + } else if (event === SseResponseEventEnum.flowNodeStatus && status) { return { ...item, status, @@ -518,36 +526,34 @@ const ChatBox = ({ reserveTool: true }); - const { - responseData, - responseText, - isNewChat = false - } = await onStartChat({ + const { responseText } = await onStartChat({ messages, // 保证最后一条是 Human 的消息 responseChatItemId: responseChatId, controller: abortSignal, generatingMessage: (e) => generatingMessage({ ...e, autoTTSResponse }), variables: requestVariables }); - if (responseData?.[responseData.length - 1]?.error) { - toast({ - title: t(responseData[responseData.length - 1].error?.message), - status: 'error' - }); - } // Set last chat finish status let newChatHistories: ChatSiteItemType[] = []; setChatRecords((state) => { newChatHistories = state.map((item, index) => { if (index !== state.length - 1) return item; + + // Check node response error + const responseData = mergeChatResponseData(item.responseData || []); + if (responseData[responseData.length - 1]?.error) { + toast({ + title: t(responseData[responseData.length - 1].error?.message), + status: 'error' + }); + } + return { ...item, status: ChatStatusEnum.finish, time: new Date(), - responseData: item.responseData - ? mergeChatResponseData([...item.responseData, ...responseData]) - : responseData + responseData }; }); return newChatHistories; @@ -567,7 +573,7 @@ const ChatBox = ({ } catch (err: any) { console.log(err); toast({ - title: t(getErrText(err, 'core.chat.error.Chat error') as any), + title: t(getErrText(err, t('common:core.chat.error.Chat error') as any)), status: 'error', duration: 5000, isClosable: true diff --git a/projects/app/src/components/core/chat/ChatContainer/type.d.ts b/projects/app/src/components/core/chat/ChatContainer/type.d.ts index a8b8494a7..d7d0e5340 100644 --- a/projects/app/src/components/core/chat/ChatContainer/type.d.ts +++ b/projects/app/src/components/core/chat/ChatContainer/type.d.ts @@ -1,6 +1,10 @@ import { StreamResponseType } from '@/web/common/api/fetch'; import { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; -import { ChatSiteItemType, ToolModuleResponseItemType } from '@fastgpt/global/core/chat/type'; +import { + ChatHistoryItemResType, + ChatSiteItemType, + ToolModuleResponseItemType +} from '@fastgpt/global/core/chat/type'; import { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; export type generatingMessageProps = { @@ -12,6 +16,7 @@ export type generatingMessageProps = { tool?: ToolModuleResponseItemType; interactive?: WorkflowInteractiveResponseType; variables?: Record; + nodeResponse?: ChatHistoryItemResType; }; export type StartChatFnProps = { diff --git a/projects/app/src/pages/api/core/chat/chatTest.ts b/projects/app/src/pages/api/core/chat/chatTest.ts index 38875aef2..402ea353e 100644 --- a/projects/app/src/pages/api/core/chat/chatTest.ts +++ b/projects/app/src/pages/api/core/chat/chatTest.ts @@ -182,7 +182,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { histories: newHistories, stream: true, maxRunTimes: WORKFLOW_MAX_RUN_TIMES, - workflowStreamResponse: workflowResponseWrite + workflowStreamResponse: workflowResponseWrite, + version: 'v2' }); workflowResponseWrite({ @@ -197,11 +198,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { event: SseResponseEventEnum.answer, data: '[DONE]' }); - responseWrite({ - res, - event: SseResponseEventEnum.flowResponses, - data: JSON.stringify(flowResponses) - }); // save chat const isInteractiveRequest = !!getLastInteractiveValue(histories); diff --git a/projects/app/src/pages/api/v2/chat/completions.ts b/projects/app/src/pages/api/v2/chat/completions.ts new file mode 100644 index 000000000..0c20fcf99 --- /dev/null +++ b/projects/app/src/pages/api/v2/chat/completions.ts @@ -0,0 +1,641 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { authApp } from '@fastgpt/service/support/permission/app/auth'; +import { authCert } from '@fastgpt/service/support/permission/auth/common'; +import { sseErrRes, jsonRes } from '@fastgpt/service/common/response'; +import { addLog } from '@fastgpt/service/common/system/log'; +import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; +import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch'; +import type { ChatCompletionCreateParams } from '@fastgpt/global/core/ai/type.d'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type.d'; +import { + getWorkflowEntryNodeIds, + getMaxHistoryLimitFromNodes, + initWorkflowEdgeStatus, + storeNodes2RuntimeNodes, + textAdaptGptResponse, + getLastInteractiveValue +} from '@fastgpt/global/core/workflow/runtime/utils'; +import { GPTMessages2Chats, chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; +import { getChatItems } from '@fastgpt/service/core/chat/controller'; +import { saveChat, updateInteractiveChat } from '@fastgpt/service/core/chat/saveChat'; +import { responseWrite } from '@fastgpt/service/common/response'; +import { createChatUsage } from '@fastgpt/service/support/wallet/usage/controller'; +import { authOutLinkChatStart } from '@/service/support/permission/auth/outLink'; +import { pushResult2Remote, addOutLinkUsage } from '@fastgpt/service/support/outLink/tools'; +import requestIp from 'request-ip'; +import { getUsageSourceByAuthType } from '@fastgpt/global/support/wallet/usage/tools'; +import { authTeamSpaceToken } from '@/service/support/permission/auth/team'; +import { + concatHistories, + filterPublicNodeResponseData, + getChatTitleFromChatMessage, + removeEmptyUserInput +} from '@fastgpt/global/core/chat/utils'; +import { updateApiKeyUsage } from '@fastgpt/service/support/openapi/tools'; +import { getUserChatInfoAndAuthTeamPoints } from '@fastgpt/service/support/permission/auth/team'; +import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { AppSchema } from '@fastgpt/global/core/app/type'; +import { AuthOutLinkChatProps } from '@fastgpt/global/support/outLink/api'; +import { MongoChat } from '@fastgpt/service/core/chat/chatSchema'; +import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat'; +import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; +import { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; + +import { NextAPI } from '@/service/middleware/entry'; +import { getAppLatestVersion } from '@fastgpt/service/core/app/version/controller'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { + getPluginRunUserQuery, + updatePluginInputByVariables +} from '@fastgpt/global/core/workflow/utils'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { getSystemTime } from '@fastgpt/global/common/time/timezone'; +import { rewriteNodeOutputByHistories } from '@fastgpt/global/core/workflow/runtime/utils'; +import { getWorkflowResponseWrite } from '@fastgpt/service/core/workflow/dispatch/utils'; +import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants'; +import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils'; +import { ExternalProviderType } from '@fastgpt/global/core/workflow/runtime/type'; + +type FastGptWebChatProps = { + chatId?: string; // undefined: get histories from messages, '': new chat, 'xxxxx': get histories from db + appId?: string; + customUid?: string; // non-undefined: will be the priority provider for the logger. + metadata?: Record; +}; + +export type Props = ChatCompletionCreateParams & + FastGptWebChatProps & + OutLinkChatAuthProps & { + messages: ChatCompletionMessageParam[]; + responseChatItemId?: string; + stream?: boolean; + detail?: boolean; + variables: Record; // Global variables or plugin inputs + }; + +type AuthResponseType = { + teamId: string; + tmbId: string; + timezone: string; + externalProvider: ExternalProviderType; + app: AppSchema; + responseDetail?: boolean; + showNodeStatus?: boolean; + authType: `${AuthUserTypeEnum}`; + apikey?: string; + responseAllData: boolean; + outLinkUserId?: string; + sourceName?: string; +}; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + res.on('close', () => { + res.end(); + }); + res.on('error', () => { + console.log('error: ', 'request error'); + res.end(); + }); + + let { + chatId, + appId, + customUid, + // share chat + shareId, + outLinkUid, + // team chat + teamId: spaceTeamId, + teamToken, + + stream = false, + detail = false, + messages = [], + variables = {}, + responseChatItemId = getNanoid(), + metadata + } = req.body as Props; + + const originIp = requestIp.getClientIp(req); + + const startTime = Date.now(); + + try { + if (!Array.isArray(messages)) { + throw new Error('messages is not array'); + } + + /* + Web params: chatId + [Human] + API params: chatId + [Human] + API params: [histories, Human] + */ + const chatMessages = GPTMessages2Chats(messages); + + // Computed start hook params + const startHookText = (() => { + // Chat + const userQuestion = chatMessages[chatMessages.length - 1] as UserChatItemType | undefined; + if (userQuestion) return chatValue2RuntimePrompt(userQuestion.value).text; + + // plugin + return JSON.stringify(variables); + })(); + + /* + 1. auth app permission + 2. auth balance + 3. get app + 4. parse outLink token + */ + const { + teamId, + tmbId, + timezone, + externalProvider, + app, + responseDetail, + authType, + sourceName, + apikey, + responseAllData, + outLinkUserId = customUid, + showNodeStatus + } = await (async () => { + // share chat + if (shareId && outLinkUid) { + return authShareChat({ + shareId, + outLinkUid, + chatId, + ip: originIp, + question: startHookText + }); + } + // team space chat + if (spaceTeamId && appId && teamToken) { + return authTeamSpaceChat({ + teamId: spaceTeamId, + teamToken, + appId, + chatId + }); + } + + /* parse req: api or token */ + return authHeaderRequest({ + req, + appId, + chatId + }); + })(); + const isPlugin = app.type === AppTypeEnum.plugin; + + // Check message type + if (isPlugin) { + detail = true; + } else { + if (messages.length === 0) { + throw new Error('messages is empty'); + } + } + + // Get obj=Human history + const userQuestion: UserChatItemType = (() => { + if (isPlugin) { + return getPluginRunUserQuery({ + pluginInputs: getPluginInputsFromStoreNodes(app.modules), + variables, + files: variables.files + }); + } + + const latestHumanChat = chatMessages.pop() as UserChatItemType | undefined; + if (!latestHumanChat) { + throw new Error('User question is empty'); + } + return latestHumanChat; + })(); + + // Get and concat history; + const limit = getMaxHistoryLimitFromNodes(app.modules); + const [{ histories }, { nodes, edges, chatConfig }, chatDetail] = await Promise.all([ + getChatItems({ + appId: app._id, + chatId, + offset: 0, + limit, + field: `dataId obj value nodeOutputs` + }), + getAppLatestVersion(app._id, app), + MongoChat.findOne({ appId: app._id, chatId }, 'source variableList variables') + ]); + + // Get store variables(Api variable precedence) + if (chatDetail?.variables) { + variables = { + ...chatDetail.variables, + ...variables + }; + } + + // Get chat histories + const newHistories = concatHistories(histories, chatMessages); + + // Get runtimeNodes + let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes, newHistories)); + if (isPlugin) { + // Assign values to runtimeNodes using variables + runtimeNodes = updatePluginInputByVariables(runtimeNodes, variables); + // Plugin runtime does not need global variables(It has been injected into the pluginInputNode) + variables = {}; + } + runtimeNodes = rewriteNodeOutputByHistories(newHistories, runtimeNodes); + + const workflowResponseWrite = getWorkflowResponseWrite({ + res, + detail, + streamResponse: stream, + id: chatId, + showNodeStatus + }); + + /* start flow controller */ + const { flowResponses, flowUsages, assistantResponses, newVariables } = await (async () => { + if (app.version === 'v2') { + return dispatchWorkFlow({ + res, + requestOrigin: req.headers.origin, + mode: 'chat', + timezone, + externalProvider, + + runningAppInfo: { + id: String(app._id), + teamId: String(app.teamId), + tmbId: String(app.tmbId) + }, + runningUserInfo: { + teamId, + tmbId + }, + uid: String(outLinkUserId || tmbId), + + chatId, + responseChatItemId, + runtimeNodes, + runtimeEdges: initWorkflowEdgeStatus(edges, newHistories), + variables, + query: removeEmptyUserInput(userQuestion.value), + chatConfig, + histories: newHistories, + stream, + maxRunTimes: WORKFLOW_MAX_RUN_TIMES, + workflowStreamResponse: workflowResponseWrite, + version: 'v2' + }); + } + return Promise.reject('您的工作流版本过低,请重新发布一次'); + })(); + + // save chat + const isOwnerUse = !shareId && !spaceTeamId && String(tmbId) === String(app.tmbId); + const source = (() => { + if (shareId) { + return ChatSourceEnum.share; + } + if (authType === 'apikey') { + return ChatSourceEnum.api; + } + if (spaceTeamId) { + return ChatSourceEnum.team; + } + return ChatSourceEnum.online; + })(); + + const isInteractiveRequest = !!getLastInteractiveValue(histories); + const { text: userInteractiveVal } = chatValue2RuntimePrompt(userQuestion.value); + + const newTitle = isPlugin + ? variables.cTime ?? getSystemTime(timezone) + : getChatTitleFromChatMessage(userQuestion); + + const aiResponse: AIChatItemType & { dataId?: string } = { + dataId: responseChatItemId, + obj: ChatRoleEnum.AI, + value: assistantResponses, + [DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses + }; + + const saveChatId = chatId || getNanoid(24); + if (isInteractiveRequest) { + await updateInteractiveChat({ + chatId: saveChatId, + appId: app._id, + userInteractiveVal, + aiResponse, + newVariables + }); + } else { + await saveChat({ + chatId: saveChatId, + appId: app._id, + teamId, + tmbId: tmbId, + nodes, + appChatConfig: chatConfig, + variables: newVariables, + isUpdateUseTime: isOwnerUse && source === ChatSourceEnum.online, // owner update use time + newTitle, + shareId, + outLinkUid: outLinkUserId, + source, + sourceName: sourceName || '', + content: [userQuestion, aiResponse], + metadata: { + originIp, + ...metadata + } + }); + } + + addLog.info(`completions running time: ${(Date.now() - startTime) / 1000}s`); + + /* select fe response field */ + const feResponseData = responseAllData + ? flowResponses + : filterPublicNodeResponseData({ flowResponses, responseDetail }); + + if (stream) { + workflowResponseWrite({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ + text: null, + finish_reason: 'stop' + }) + }); + responseWrite({ + res, + event: detail ? SseResponseEventEnum.answer : undefined, + data: '[DONE]' + }); + + res.end(); + } else { + const responseContent = (() => { + if (assistantResponses.length === 0) return ''; + if (assistantResponses.length === 1 && assistantResponses[0].text?.content) + return assistantResponses[0].text?.content; + + if (!detail) { + return assistantResponses + .map((item) => item?.text?.content) + .filter(Boolean) + .join('\n'); + } + + return assistantResponses; + })(); + const error = flowResponses[flowResponses.length - 1]?.error; + + res.json({ + ...(detail ? { responseData: feResponseData, newVariables } : {}), + error, + id: chatId || '', + model: '', + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 1 }, + choices: [ + { + message: { role: 'assistant', content: responseContent }, + finish_reason: 'stop', + index: 0 + } + ] + }); + } + + // add record + const { totalPoints } = createChatUsage({ + appName: app.name, + appId: app._id, + teamId, + tmbId: tmbId, + source: getUsageSourceByAuthType({ shareId, authType }), + flowUsages + }); + + if (shareId) { + pushResult2Remote({ outLinkUid, shareId, appName: app.name, flowResponses }); + addOutLinkUsage({ + shareId, + totalPoints + }); + } + if (apikey) { + updateApiKeyUsage({ + apikey, + totalPoints + }); + } + } catch (err) { + if (stream) { + sseErrRes(res, err); + res.end(); + } else { + jsonRes(res, { + code: 500, + error: err + }); + } + } +} +export default NextAPI(handler); + +const authShareChat = async ({ + chatId, + ...data +}: AuthOutLinkChatProps & { + shareId: string; + chatId?: string; +}): Promise => { + const { + teamId, + tmbId, + timezone, + externalProvider, + appId, + authType, + responseDetail, + showNodeStatus, + uid, + sourceName + } = await authOutLinkChatStart(data); + const app = await MongoApp.findById(appId).lean(); + + if (!app) { + return Promise.reject('app is empty'); + } + + // get chat + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + if (chat && (chat.shareId !== data.shareId || chat.outLinkUid !== uid)) { + return Promise.reject(ChatErrEnum.unAuthChat); + } + + return { + sourceName, + teamId, + tmbId, + app, + timezone, + externalProvider, + apikey: '', + authType, + responseAllData: false, + responseDetail, + outLinkUserId: uid, + showNodeStatus + }; +}; +const authTeamSpaceChat = async ({ + appId, + teamId, + teamToken, + chatId +}: { + appId: string; + teamId: string; + teamToken: string; + chatId?: string; +}): Promise => { + const { uid } = await authTeamSpaceToken({ + teamId, + teamToken + }); + + const app = await MongoApp.findById(appId).lean(); + if (!app) { + return Promise.reject('app is empty'); + } + + const [chat, { timezone, externalProvider }] = await Promise.all([ + MongoChat.findOne({ appId, chatId }).lean(), + getUserChatInfoAndAuthTeamPoints(app.tmbId) + ]); + + if (chat && (String(chat.teamId) !== teamId || chat.outLinkUid !== uid)) { + return Promise.reject(ChatErrEnum.unAuthChat); + } + + return { + teamId, + tmbId: app.tmbId, + app, + timezone, + externalProvider, + authType: AuthUserTypeEnum.outLink, + apikey: '', + responseAllData: false, + responseDetail: true, + outLinkUserId: uid + }; +}; +const authHeaderRequest = async ({ + req, + appId, + chatId +}: { + req: NextApiRequest; + appId?: string; + chatId?: string; +}): Promise => { + const { + appId: apiKeyAppId, + teamId, + tmbId, + authType, + sourceName, + apikey + } = await authCert({ + req, + authToken: true, + authApiKey: true + }); + + const { app } = await (async () => { + if (authType === AuthUserTypeEnum.apikey) { + const currentAppId = apiKeyAppId || appId; + if (!currentAppId) { + return Promise.reject( + 'Key is error. You need to use the app key rather than the account key.' + ); + } + const app = await MongoApp.findById(currentAppId); + + if (!app) { + return Promise.reject('app is empty'); + } + + appId = String(app._id); + + return { + app + }; + } else { + // token_auth + if (!appId) { + return Promise.reject('appId is empty'); + } + const { app } = await authApp({ + req, + authToken: true, + appId, + per: ReadPermissionVal + }); + + return { + app + }; + } + })(); + + const [{ timezone, externalProvider }, chat] = await Promise.all([ + getUserChatInfoAndAuthTeamPoints(tmbId), + MongoChat.findOne({ appId, chatId }).lean() + ]); + + if ( + chat && + (String(chat.teamId) !== teamId || + // There's no need to distinguish who created it if it's apiKey auth + (authType === AuthUserTypeEnum.token && String(chat.tmbId) !== tmbId)) + ) { + return Promise.reject(ChatErrEnum.unAuthChat); + } + + return { + teamId, + tmbId, + timezone, + externalProvider, + app, + apikey, + authType, + sourceName, + responseAllData: true, + responseDetail: true + }; +}; + +export const config = { + api: { + bodyParser: { + sizeLimit: '20mb' + }, + responseLimit: '20mb' + } +}; diff --git a/projects/app/src/web/common/api/fetch.ts b/projects/app/src/web/common/api/fetch.ts index 424117b70..7a85ce8ff 100644 --- a/projects/app/src/web/common/api/fetch.ts +++ b/projects/app/src/web/common/api/fetch.ts @@ -1,8 +1,6 @@ import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { getErrText } from '@fastgpt/global/common/error/utils'; -import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d'; import type { StartChatFnProps } from '@/components/core/chat/ChatContainer/type'; -import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { // refer to https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web EventStreamContentType, @@ -21,7 +19,6 @@ type StreamFetchProps = { }; export type StreamResponseType = { responseText: string; - [DispatchNodeResponseKeyEnum.nodeResponse]: ChatHistoryItemResType[]; }; type ResponseQueueItemType = | { @@ -40,7 +37,7 @@ type ResponseQueueItemType = class FatalError extends Error {} export const streamFetch = ({ - url = '/api/v1/chat/completions', + url = '/api/v2/chat/completions', data, onMessage, abortCtrl @@ -55,7 +52,6 @@ export const streamFetch = ({ let responseText = ''; let responseQueue: ResponseQueueItemType[] = []; let errMsg: string | undefined; - let responseData: ChatHistoryItemResType[] = []; let finished = false; const finish = () => { @@ -63,8 +59,7 @@ export const streamFetch = ({ return failedFinish(); } return resolve({ - responseText, - responseData + responseText }); }; const failedFinish = (err?: any) => { @@ -168,7 +163,7 @@ export const streamFetch = ({ } } }, - onmessage({ event, data }) { + onmessage: ({ event, data }) => { if (data === '[DONE]') { return; } @@ -178,9 +173,12 @@ export const streamFetch = ({ try { return JSON.parse(data); } catch (error) { - return {}; + return; } })(); + + if (typeof parseJson !== 'object') return; + // console.log(parseJson, event); if (event === SseResponseEventEnum.answer) { const reasoningText = parseJson.choices?.[0]?.delta?.reasoning_content || ''; @@ -222,8 +220,11 @@ export const streamFetch = ({ event, ...parseJson }); - } else if (event === SseResponseEventEnum.flowResponses && Array.isArray(parseJson)) { - responseData = parseJson; + } else if (event === SseResponseEventEnum.flowNodeResponse) { + onMessage({ + event, + nodeResponse: parseJson + }); } else if (event === SseResponseEventEnum.updateVariables) { onMessage({ event,