diff --git a/document/content/docs/introduction/development/openapi/chat.mdx b/document/content/docs/introduction/development/openapi/chat.mdx index 866151de6..e349f0725 100644 --- a/document/content/docs/introduction/development/openapi/chat.mdx +++ b/document/content/docs/introduction/development/openapi/chat.mdx @@ -16,7 +16,7 @@ description: FastGPT OpenAPI 对话接口 {/* * 对话现在有`v1`和`v2`两个接口,可以按需使用,v2 自 4.9.4 版本新增,v1 接口同时不再维护 */} -## 请求简易应用和工作流 +## 请求对话 Agent 和工作流 `v1`对话接口兼容`GPT`的接口!如果你的项目使用的是标准的`GPT`官方接口,可以直接通过修改`BaseUrl`和 `Authorization`来访问 FastGpt 应用,不过需要注意下面几个规则: diff --git a/document/content/docs/toc.mdx b/document/content/docs/toc.mdx index 98cb49863..cf008df48 100644 --- a/document/content/docs/toc.mdx +++ b/document/content/docs/toc.mdx @@ -112,6 +112,7 @@ description: FastGPT 文档目录 - [/docs/upgrading/4-13/4132](/docs/upgrading/4-13/4132) - [/docs/upgrading/4-14/4140](/docs/upgrading/4-14/4140) - [/docs/upgrading/4-14/4141](/docs/upgrading/4-14/4141) +- [/docs/upgrading/4-14/4142](/docs/upgrading/4-14/4142) - [/docs/upgrading/4-8/40](/docs/upgrading/4-8/40) - [/docs/upgrading/4-8/41](/docs/upgrading/4-8/41) - [/docs/upgrading/4-8/42](/docs/upgrading/4-8/42) diff --git a/document/content/docs/upgrading/4-14/4142.mdx b/document/content/docs/upgrading/4-14/4142.mdx new file mode 100644 index 000000000..930d2268b --- /dev/null +++ b/document/content/docs/upgrading/4-14/4142.mdx @@ -0,0 +1,20 @@ +--- +title: 'V4.14.2(进行中)' +description: 'FastGPT V4.14.2 更新说明' +--- + + + +## 🚀 新增内容 + +1. 封装底层 Agent Call 方式,支持工具连续调用时上下文的压缩,以及单个工具长响应的压缩。 +2. 模板市场新 UI。 + +## ⚙️ 优化 + +1. 30 分钟模板市场缓存时长。 + +## 🐛 修复 + +1. 简易应用模板未正常转化。 +2. 工具调用中,包含两个以上连续用户选择时候,第二个用户选择异常。 diff --git a/document/content/docs/upgrading/4-14/meta.json b/document/content/docs/upgrading/4-14/meta.json index 7e7bfc15e..92abb0c9e 100644 --- a/document/content/docs/upgrading/4-14/meta.json +++ b/document/content/docs/upgrading/4-14/meta.json @@ -1,5 +1,5 @@ { "title": "4.14.x", "description": "", - "pages": ["4141", "4140"] + "pages": ["4142", "4141", "4140"] } diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 84fd8b379..e7d850644 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -101,7 +101,7 @@ "document/content/docs/protocol/terms.en.mdx": "2025-08-03T22:37:45+08:00", "document/content/docs/protocol/terms.mdx": "2025-08-03T22:37:45+08:00", "document/content/docs/toc.en.mdx": "2025-08-04T13:42:36+08:00", - "document/content/docs/toc.mdx": "2025-11-06T14:47:55+08:00", + "document/content/docs/toc.mdx": "2025-11-13T13:36:41+08:00", "document/content/docs/upgrading/4-10/4100.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-10/4101.mdx": "2025-09-08T20:07:20+08:00", "document/content/docs/upgrading/4-11/4110.mdx": "2025-08-05T23:20:39+08:00", @@ -115,7 +115,8 @@ "document/content/docs/upgrading/4-13/4131.mdx": "2025-09-30T15:47:06+08:00", "document/content/docs/upgrading/4-13/4132.mdx": "2025-10-21T11:46:53+08:00", "document/content/docs/upgrading/4-14/4140.mdx": "2025-11-06T15:43:00+08:00", - "document/content/docs/upgrading/4-14/4141.mdx": "2025-11-11T14:05:02+08:00", + "document/content/docs/upgrading/4-14/4141.mdx": "2025-11-12T12:19:02+08:00", + "document/content/docs/upgrading/4-14/4142.mdx": "2025-11-13T20:49:04+08:00", "document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00", diff --git a/packages/global/common/string/tools.ts b/packages/global/common/string/tools.ts index e89d40292..25f00da01 100644 --- a/packages/global/common/string/tools.ts +++ b/packages/global/common/string/tools.ts @@ -143,10 +143,7 @@ export const getRegQueryStr = (text: string, flags = 'i') => { /* slice json str */ export const sliceJsonStr = (str: string) => { - str = str - .trim() - .replace(/(\\n|\\)/g, '') - .replace(/ /g, ''); + str = str.trim(); // Find first opening bracket let start = -1; diff --git a/packages/global/core/ai/type.d.ts b/packages/global/core/ai/type.d.ts index 5ce40bab1..d7e7e61fe 100644 --- a/packages/global/core/ai/type.d.ts +++ b/packages/global/core/ai/type.d.ts @@ -80,7 +80,8 @@ export type CompletionFinishReason = | 'tool_calls' | 'content_filter' | 'function_call' - | null; + | null + | undefined; export default openai; export * from 'openai'; diff --git a/packages/global/core/app/type.d.ts b/packages/global/core/app/type.d.ts index 40876e55a..c099c3c53 100644 --- a/packages/global/core/app/type.d.ts +++ b/packages/global/core/app/type.d.ts @@ -230,6 +230,8 @@ export type AppTemplateSchemaType = { type: string; author?: string; isActive?: boolean; + isPromoted?: boolean; + recommendText?: string; userGuide?: { type: 'markdown' | 'link'; content?: string; @@ -237,6 +239,7 @@ export type AppTemplateSchemaType = { }; isQuickTemplate?: boolean; order?: number; + // TODO: 对于建议应用,是另一个格式 workflow: WorkflowTemplateBasicType; }; diff --git a/packages/global/core/chat/utils.ts b/packages/global/core/chat/utils.ts index 259184d14..c1e1d651e 100644 --- a/packages/global/core/chat/utils.ts +++ b/packages/global/core/chat/utils.ts @@ -213,21 +213,10 @@ export const getChatSourceByPublishChannel = (publishChannel: PublishChannelEnum export const mergeChatResponseData = ( responseDataList: ChatHistoryItemResType[] ): ChatHistoryItemResType[] => { - // Merge children reponse data(Children has interactive response) - const responseWithMergedPlugins = responseDataList.map((item) => { - if (item.pluginDetail && item.pluginDetail.length > 1) { - return { - ...item, - pluginDetail: mergeChatResponseData(item.pluginDetail) - }; - } - return item; - }); - const result: ChatHistoryItemResType[] = []; const mergeMap = new Map(); // mergeSignId -> result index - for (const item of responseWithMergedPlugins) { + for (const item of responseDataList) { if (item.mergeSignId && mergeMap.has(item.mergeSignId)) { // Merge with existing item const existingIndex = mergeMap.get(item.mergeSignId)!; @@ -238,9 +227,18 @@ export const mergeChatResponseData = ( runningTime: +((existing.runningTime || 0) + (item.runningTime || 0)).toFixed(2), totalPoints: (existing.totalPoints || 0) + (item.totalPoints || 0), childTotalPoints: (existing.childTotalPoints || 0) + (item.childTotalPoints || 0), - toolDetail: [...(existing.toolDetail || []), ...(item.toolDetail || [])], - loopDetail: [...(existing.loopDetail || []), ...(item.loopDetail || [])], - pluginDetail: [...(existing.pluginDetail || []), ...(item.pluginDetail || [])] + toolDetail: mergeChatResponseData([ + ...(existing.toolDetail || []), + ...(item.toolDetail || []) + ]), + loopDetail: mergeChatResponseData([ + ...(existing.loopDetail || []), + ...(item.loopDetail || []) + ]), + pluginDetail: mergeChatResponseData([ + ...(existing.pluginDetail || []), + ...(item.pluginDetail || []) + ]) }; } else { // Add new item diff --git a/packages/global/core/workflow/runtime/type.d.ts b/packages/global/core/workflow/runtime/type.d.ts index 0909c98a5..867550cdf 100644 --- a/packages/global/core/workflow/runtime/type.d.ts +++ b/packages/global/core/workflow/runtime/type.d.ts @@ -2,9 +2,9 @@ import type { ChatNodeUsageType } from '../../../support/wallet/bill/type'; import type { ChatItemType, ToolRunResponseItemType, - AIChatItemValueItemType + AIChatItemValueItemType, + ChatHistoryItemResType } from '../../chat/type'; -import { NodeOutputItemType } from '../../chat/type'; import type { FlowNodeInputItemType, FlowNodeOutputItemType } from '../type/io.d'; import type { NodeToolConfigType, StoreNodeItemType } from '../type/node'; import type { DispatchNodeResponseKeyEnum } from './constants'; @@ -112,7 +112,6 @@ export type RuntimeNodeItemType = { flowNodeType: StoreNodeItemType['flowNodeType']; showStatus?: StoreNodeItemType['showStatus']; isEntry?: boolean; - isStart?: boolean; version?: string; inputs: FlowNodeInputItemType[]; diff --git a/packages/global/core/workflow/runtime/utils.ts b/packages/global/core/workflow/runtime/utils.ts index f79770486..c4d9dcf15 100644 --- a/packages/global/core/workflow/runtime/utils.ts +++ b/packages/global/core/workflow/runtime/utils.ts @@ -20,6 +20,7 @@ import type { StoreNodeItemType } from '../type/node'; import { isValidReferenceValueFormat } from '../utils'; import type { RuntimeEdgeItemType, RuntimeNodeItemType } from './type'; import { isSecretValue } from '../../../common/secret/utils'; +import { isChildInteractive } from '../template/system/interactive/constants'; export const extractDeepestInteractive = ( interactive: WorkflowInteractiveResponseType @@ -28,11 +29,7 @@ export const extractDeepestInteractive = ( let current = interactive; let depth = 0; - while ( - depth < MAX_DEPTH && - (current?.type === 'childrenInteractive' || current?.type === 'loopInteractive') && - current.params?.childrenResponse - ) { + while (depth < MAX_DEPTH && 'childrenResponse' in current.params) { current = current.params.childrenResponse; depth++; } @@ -181,10 +178,7 @@ export const getLastInteractiveValue = ( return; } - if ( - lastValue.interactive.type === 'childrenInteractive' || - lastValue.interactive.type === 'loopInteractive' - ) { + if (isChildInteractive(lastValue.interactive.type)) { return lastValue.interactive; } @@ -297,7 +291,6 @@ export const checkNodeRunStatus = ({ node: RuntimeNodeItemType; runtimeEdges: RuntimeEdgeItemType[]; }) => { - const filterRuntimeEdges = filterWorkflowEdges(runtimeEdges); const isStartNode = (nodeType: string) => { const map: Record = { [FlowNodeTypeEnum.workflowStart]: true, @@ -310,7 +303,7 @@ export const checkNodeRunStatus = ({ const commonEdges: RuntimeEdgeItemType[] = []; const recursiveEdgeGroupsMap = new Map(); - const sourceEdges = filterRuntimeEdges.filter((item) => item.target === targetNode.nodeId); + const sourceEdges = runtimeEdges.filter((item) => item.target === targetNode.nodeId); sourceEdges.forEach((sourceEdge) => { const stack: Array<{ @@ -333,7 +326,7 @@ export const checkNodeRunStatus = ({ const sourceNode = nodesMap.get(edge.source); if (!sourceNode) continue; - if (isStartNode(sourceNode.flowNodeType) || sourceNode.isStart) { + if (isStartNode(sourceNode.flowNodeType) || sourceEdge.sourceHandle === 'selectedTools') { commonEdges.push(sourceEdge); continue; } @@ -355,7 +348,7 @@ export const checkNodeRunStatus = ({ newVisited.add(edge.source); // 查找目标节点的 source edges 并加入栈中 - const nextEdges = filterRuntimeEdges.filter((item) => item.target === edge.source); + const nextEdges = runtimeEdges.filter((item) => item.target === edge.source); for (const nextEdge of nextEdges) { stack.push({ diff --git a/packages/global/core/workflow/template/system/interactive/constants.ts b/packages/global/core/workflow/template/system/interactive/constants.ts new file mode 100644 index 000000000..93f48fbbb --- /dev/null +++ b/packages/global/core/workflow/template/system/interactive/constants.ts @@ -0,0 +1,12 @@ +import type { InteractiveNodeResponseType } from './type'; + +export const isChildInteractive = (type: InteractiveNodeResponseType['type']) => { + if ( + type === 'childrenInteractive' || + type === 'toolChildrenInteractive' || + type === 'loopInteractive' + ) { + return true; + } + return false; +}; diff --git a/packages/global/core/workflow/template/system/interactive/type.d.ts b/packages/global/core/workflow/template/system/interactive/type.d.ts index e4346a5f8..1abaaa5d6 100644 --- a/packages/global/core/workflow/template/system/interactive/type.d.ts +++ b/packages/global/core/workflow/template/system/interactive/type.d.ts @@ -9,11 +9,6 @@ type InteractiveBasicType = { memoryEdges: RuntimeEdgeItemType[]; nodeOutputs: NodeOutputItemType[]; skipNodeQueue?: { id: string; skippedNodeIdList: string[] }[]; // 需要记录目前在 queue 里的节点 - toolParams?: { - entryNodeIds: string[]; // 记录工具中,交互节点的 Id,而不是起始工作流的入口 - memoryMessages: ChatCompletionMessageParam[]; // 这轮工具中,产生的新的 messages - toolCallId: string; // 记录对应 tool 的id,用于后续交互节点可以替换掉 tool 的 response - }; usageId?: string; }; @@ -27,7 +22,17 @@ type InteractiveNodeType = { type ChildrenInteractive = InteractiveNodeType & { type: 'childrenInteractive'; params: { - childrenResponse?: WorkflowInteractiveResponseType; + childrenResponse: WorkflowInteractiveResponseType; + }; +}; +type ToolCallChildrenInteractive = InteractiveNodeType & { + type: 'toolChildrenInteractive'; + params: { + childrenResponse: WorkflowInteractiveResponseType; + toolParams: { + memoryRequestMessages: ChatCompletionMessageParam[]; // 这轮工具中,产生的新的 messages + toolCallId: string; // 记录对应 tool 的id,用于后续交互节点可以替换掉 tool 的 response + }; }; }; @@ -94,6 +99,7 @@ export type InteractiveNodeResponseType = | UserSelectInteractive | UserInputInteractive | ChildrenInteractive + | ToolCallChildrenInteractive | LoopInteractive | PaymentPauseInteractive; diff --git a/packages/service/core/ai/llm/agentCall/index.ts b/packages/service/core/ai/llm/agentCall/index.ts new file mode 100644 index 000000000..219a64e16 --- /dev/null +++ b/packages/service/core/ai/llm/agentCall/index.ts @@ -0,0 +1,313 @@ +import type { + ChatCompletionMessageParam, + ChatCompletionTool, + ChatCompletionMessageToolCall, + CompletionFinishReason +} from '@fastgpt/global/core/ai/type'; +import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; +import type { + ToolCallChildrenInteractive, + WorkflowInteractiveResponseType +} from '@fastgpt/global/core/workflow/template/system/interactive/type'; +import type { CreateLLMResponseProps, ResponseEvents } from '../request'; +import { createLLMResponse } from '../request'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import { compressRequestMessages } from '../compress'; +import { computedMaxToken } from '../../utils'; +import { filterGPTMessageByMaxContext } from '../utils'; +import { getLLMModel } from '../../model'; +import { filterEmptyAssistantMessages } from './utils'; + +type RunAgentCallProps = { + maxRunAgentTimes: number; + compressTaskDescription?: string; + + body: CreateLLMResponseProps['body'] & { + tools: ChatCompletionTool[]; + + temperature?: number; + top_p?: number; + stream?: boolean; + }; + + userKey?: CreateLLMResponseProps['userKey']; + isAborted?: CreateLLMResponseProps['isAborted']; + + childrenInteractiveParams?: ToolCallChildrenInteractive['params']; + handleInteractiveTool: (e: ToolCallChildrenInteractive['params']) => Promise<{ + response: string; + assistantMessages: ChatCompletionMessageParam[]; + usages: ChatNodeUsageType[]; + interactive?: WorkflowInteractiveResponseType; + stop?: boolean; + }>; + + handleToolResponse: (e: { + call: ChatCompletionMessageToolCall; + messages: ChatCompletionMessageParam[]; + }) => Promise<{ + response: string; + assistantMessages: ChatCompletionMessageParam[]; + usages: ChatNodeUsageType[]; + interactive?: WorkflowInteractiveResponseType; + stop?: boolean; + }>; +} & ResponseEvents; + +type RunAgentResponse = { + completeMessages: ChatCompletionMessageParam[]; // Step request complete messages + assistantMessages: ChatCompletionMessageParam[]; // Step assistant response messages + interactiveResponse?: ToolCallChildrenInteractive; + + // Usage + inputTokens: number; + outputTokens: number; + subAppUsages: ChatNodeUsageType[]; + + finish_reason: CompletionFinishReason | undefined; +}; + +/* + 一个循环进行工具调用的 LLM 请求封装。 + + AssistantMessages 组成: + 1. 调用 AI 时生成的 messages + 2. tool 内部调用产生的 messages + 3. tool 响应的值,role=tool,content=tool response + + RequestMessages 为模型请求的消息,组成: + 1. 历史对话记录 + 2. 调用 AI 时生成的 messages + 3. tool 响应的值,role=tool,content=tool response + + memoryRequestMessages 为上一轮中断时,requestMessages 的内容 +*/ +export const runAgentCall = async ({ + maxRunAgentTimes, + body: { model, messages, max_tokens, tools, ...body }, + userKey, + isAborted, + + childrenInteractiveParams, + handleInteractiveTool, + handleToolResponse, + + onReasoning, + onStreaming, + onToolCall, + onToolParam +}: RunAgentCallProps): Promise => { + const modelData = getLLMModel(model); + + let runTimes = 0; + let interactiveResponse: ToolCallChildrenInteractive | undefined; + + // Init messages + const maxTokens = computedMaxToken({ + model: modelData, + maxToken: max_tokens || 8000, + min: 100 + }); + + // 本轮产生的 assistantMessages,包括 tool 内产生的 + const assistantMessages: ChatCompletionMessageParam[] = []; + // 多轮运行时候的请求 messages + let requestMessages = ( + await filterGPTMessageByMaxContext({ + messages, + maxContext: modelData.maxContext - (maxTokens || 0) // filter token. not response maxToken + }) + ).map((item) => { + if (item.role === 'assistant' && item.tool_calls) { + return { + ...item, + tool_calls: item.tool_calls.map((tool) => ({ + id: tool.id, + type: tool.type, + function: tool.function + })) + }; + } + return item; + }); + + let inputTokens: number = 0; + let outputTokens: number = 0; + let finish_reason: CompletionFinishReason | undefined; + const subAppUsages: ChatNodeUsageType[] = []; + + // 处理 tool 里的交互 + if (childrenInteractiveParams) { + const { + response, + assistantMessages: toolAssistantMessages, + usages, + interactive, + stop + } = await handleInteractiveTool(childrenInteractiveParams); + + // 将 requestMessages 复原成上一轮中断时的内容,并附上 tool response + requestMessages = childrenInteractiveParams.toolParams.memoryRequestMessages.map((item) => + item.role === 'tool' && item.tool_call_id === childrenInteractiveParams.toolParams.toolCallId + ? { + ...item, + content: response + } + : item + ); + + // 只需要推送本轮产生的 assistantMessages + assistantMessages.push(...filterEmptyAssistantMessages(toolAssistantMessages)); + subAppUsages.push(...usages); + + // 相同 tool 触发了多次交互, 调用的 toolId 认为是相同的 + if (interactive) { + // console.dir(interactive, { depth: null }); + interactiveResponse = { + type: 'toolChildrenInteractive', + params: { + childrenResponse: interactive, + toolParams: { + memoryRequestMessages: requestMessages, + toolCallId: childrenInteractiveParams.toolParams.toolCallId + } + } + }; + } + + if (interactiveResponse || stop) { + return { + inputTokens: 0, + outputTokens: 0, + subAppUsages, + completeMessages: requestMessages, + assistantMessages, + interactiveResponse, + finish_reason: 'stop' + }; + } + + // 正常完成该工具的响应,继续进行工具调用 + } + + // 自循环运行 + while (runTimes < maxRunAgentTimes) { + // TODO: 费用检测 + + runTimes++; + + // 1. Compress request messages + const result = await compressRequestMessages({ + messages: requestMessages, + model: modelData + }); + requestMessages = result.messages; + inputTokens += result.usage?.inputTokens || 0; + outputTokens += result.usage?.outputTokens || 0; + + // 2. Request LLM + let { + reasoningText: reasoningContent, + answerText: answer, + toolCalls = [], + usage, + getEmptyResponseTip, + assistantMessage: llmAssistantMessage, + finish_reason: finishReason + } = await createLLMResponse({ + body: { + ...body, + model, + messages: requestMessages, + tool_choice: 'auto', + toolCallMode: modelData.toolChoice ? 'toolChoice' : 'prompt', + tools, + parallel_tool_calls: true + }, + userKey, + isAborted, + onReasoning, + onStreaming, + onToolCall, + onToolParam + }); + + finish_reason = finishReason; + + if (!answer && !reasoningContent && !toolCalls.length) { + return Promise.reject(getEmptyResponseTip()); + } + + // 3. 更新 messages + const cloneRequestMessages = requestMessages.slice(); + // 推送 AI 生成后的 assistantMessages + assistantMessages.push(...llmAssistantMessage); + requestMessages.push(...llmAssistantMessage); + + // 4. Call tools + let toolCallStep = false; + for await (const tool of toolCalls) { + const { + response, + assistantMessages: toolAssistantMessages, + usages, + interactive, + stop + } = await handleToolResponse({ + call: tool, + messages: cloneRequestMessages + }); + + const toolMessage: ChatCompletionMessageParam = { + tool_call_id: tool.id, + role: ChatCompletionRequestMessageRoleEnum.Tool, + content: response + }; + + // 5. Add tool response to messages + assistantMessages.push(toolMessage); + assistantMessages.push(...filterEmptyAssistantMessages(toolAssistantMessages)); // 因为 toolAssistantMessages 也需要记录成 AI 响应,所以这里需要推送。 + requestMessages.push(toolMessage); // 请求的 Request 只需要工具响应,不需要工具中 assistant 的内容,所以不推送 toolAssistantMessages + + subAppUsages.push(...usages); + + if (interactive) { + interactiveResponse = { + type: 'toolChildrenInteractive', + params: { + childrenResponse: interactive, + toolParams: { + memoryRequestMessages: [], + toolCallId: tool.id + } + } + }; + } + if (stop) { + toolCallStep = true; + } + } + + // 6 Record usage + inputTokens += usage.inputTokens; + outputTokens += usage.outputTokens; + + if (toolCalls.length === 0 || !!interactiveResponse || toolCallStep) { + break; + } + } + + if (interactiveResponse) { + interactiveResponse.params.toolParams.memoryRequestMessages = requestMessages; + } + + return { + inputTokens, + outputTokens, + subAppUsages, + completeMessages: requestMessages, + assistantMessages, + interactiveResponse, + finish_reason + }; +}; diff --git a/packages/service/core/ai/llm/agentCall/utils.ts b/packages/service/core/ai/llm/agentCall/utils.ts new file mode 100644 index 000000000..aeebba57b --- /dev/null +++ b/packages/service/core/ai/llm/agentCall/utils.ts @@ -0,0 +1,11 @@ +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; + +export const filterEmptyAssistantMessages = (messages: ChatCompletionMessageParam[]) => { + return messages.filter((item) => { + if (item.role === 'assistant') { + if (!item.content) return false; + if (item.content.length === 0) return false; + } + return true; + }); +}; diff --git a/packages/service/core/ai/llm/compress/constants.ts b/packages/service/core/ai/llm/compress/constants.ts new file mode 100644 index 000000000..f55790e51 --- /dev/null +++ b/packages/service/core/ai/llm/compress/constants.ts @@ -0,0 +1,103 @@ +/** + * Agent 上下文压缩配置常量 + * + * ## 设计原则 + * + * 1. **空间分配** + * - 输出预留:30%(模型生成答案 + 缓冲) + * - 系统提示词(Depends on):15% + * - Agent 对话历史:55% + * + * 2. **压缩策略** + * - 触发阈值:接近空间上限时触发 + * - 压缩目标:激进压缩,预留增长空间 + * - 约束机制:单个 tool 有绝对大小限制 + * + * 3. **协调关系** + * - Depends on 使用完整 response,需要较大空间(15%) + * - Agent 历史包含所有 tool responses,是动态主体(55%) + * - 单个 tool 不能过大,避免挤占其他空间(10%) + */ + +export const COMPRESSION_CONFIG = { + /** + * === Depends on(系统提示词中的步骤历史)=== + * + * 触发场景:拼接依赖步骤的完整 response 后,token 数超过阈值 + * 内容特点:包含多个步骤的完整执行结果(使用 response 而非 summary) + * + * 示例(maxContext=100k): + * - 依赖 3 个步骤,每个 4k → 12k (12%) ✅ 不触发 + * - 依赖 5 个步骤,每个 4k → 20k (20%) ⚠️ 触发压缩 → 12k + */ + DEPENDS_ON_THRESHOLD: 0.15, // 15% 触发压缩 + DEPENDS_ON_TARGET: 0.12, // 压缩到 12%(预留 3% 缓冲) + + /** + * === 对话历史 === + * + * 触发场景:对话历史(含所有 user/assistant/tool 消息)超过阈值 + * 内容特点:动态累积,包含所有 tool responses + * + * 示例(maxContext=100k): + * - 初始 20k + 6 轮对话(34k) = 54k (54%) ✅ 不触发 + * - 再 1 轮 = 60k (60%) ⚠️ 触发压缩 → 30k + * - 预留:55k - 30k = 25k(还能跑 4 轮) + */ + MESSAGE_THRESHOLD: 0.8, // 55% 触发压缩 + MESSAGE_TARGET_RATIO: 0.5, // 压缩到 50%(即原 55% → 27.5%) + + /** + * === 单个 tool response === + * + * 触发场景:单个 tool 返回的内容超过绝对大小限制 + * 内容特点:单次 tool 调用的响应(如搜索结果、文件内容等) + * + * 示例(maxContext=100k): + * - tool response = 8k (8%) ✅ 不触发 + * - tool response = 15k (15%) ⚠️ 触发压缩 → 7k + */ + SINGLE_TOOL_MAX: 0.5, + SINGLE_TOOL_TARGET: 0.25, + + /** + * === 分块压缩 === + * + * 触发场景:当内容需要分块处理时(超过 LLM 单次处理能力) + * 用途:将超大内容切分成多个块,分别压缩后合并 + * + * 示例(maxContext=100k): + * - 单块最大:40k tokens + * - 50k 内容 → 切分成 2 块,每块约 25k + */ + CHUNK_SIZE_RATIO: 0.5 // 40%(单块不超过此比例) +} as const; + +/** + * 计算各场景的压缩阈值 + * @param maxContext - 模型的最大上下文长度 + * @returns 各场景的具体 token 数阈值 + */ +export const calculateCompressionThresholds = (maxContext: number) => { + return { + // Depends on 压缩阈值 + dependsOn: { + threshold: Math.floor(maxContext * COMPRESSION_CONFIG.DEPENDS_ON_THRESHOLD), + target: Math.floor(maxContext * COMPRESSION_CONFIG.DEPENDS_ON_TARGET) + }, + // 对话历史压缩阈值 + messages: { + threshold: Math.floor(maxContext * COMPRESSION_CONFIG.MESSAGE_THRESHOLD), + targetRatio: COMPRESSION_CONFIG.MESSAGE_TARGET_RATIO + }, + + // 单个 tool response 压缩阈值 + singleTool: { + threshold: Math.floor(maxContext * COMPRESSION_CONFIG.SINGLE_TOOL_MAX), + target: Math.floor(maxContext * COMPRESSION_CONFIG.SINGLE_TOOL_TARGET) + }, + + // 分块大小 + chunkSize: Math.floor(maxContext * COMPRESSION_CONFIG.CHUNK_SIZE_RATIO) + }; +}; diff --git a/packages/service/core/ai/llm/compress/index.ts b/packages/service/core/ai/llm/compress/index.ts new file mode 100644 index 000000000..b4aa75ff5 --- /dev/null +++ b/packages/service/core/ai/llm/compress/index.ts @@ -0,0 +1,140 @@ +import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.d'; +import { countGptMessagesTokens } from '../../../../common/string/tiktoken'; +import { addLog } from '../../../../common/system/log'; +import { calculateCompressionThresholds } from './constants'; +import { createLLMResponse } from '../request'; +import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; +import { getCompressRequestMessagesPrompt } from './prompt'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; +import { i18nT } from '../../../../../web/i18n/utils'; +import { parseToolArgs } from '../../utils'; + +/** + * 压缩 对话历史 + * 当 messages 的 token 长度超过阈值时,调用 LLM 进行压缩 + */ +export const compressRequestMessages = async ({ + messages, + model +}: { + messages: ChatCompletionMessageParam[]; + model: LLMModelItemType; +}): Promise<{ + messages: ChatCompletionMessageParam[]; + usage?: ChatNodeUsageType; +}> => { + if (!messages || messages.length === 0) { + return { + messages + }; + } + + // Save the system messages + const [systemMessages, otherMessages]: [ + ChatCompletionMessageParam[], + ChatCompletionMessageParam[] + ] = [[], []]; + messages.forEach((message) => { + if (message.role === ChatCompletionRequestMessageRoleEnum.System) { + systemMessages.push(message); + } else { + otherMessages.push(message); + } + }); + + const messageTokens = await countGptMessagesTokens(otherMessages); + const thresholds = calculateCompressionThresholds(model.maxContext).messages; + + if (messageTokens < thresholds.threshold) { + return { + messages + }; + } + + addLog.info('[Compression messages] Start', { + tokens: messageTokens + }); + + const compressPrompt = await getCompressRequestMessagesPrompt({ + messages: otherMessages, + rawTokens: messageTokens, + model + }); + + const userPrompt = '请执行压缩操作,严格按照JSON格式返回结果。'; + + try { + const { answerText, usage } = await createLLMResponse({ + body: { + model, + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.System, + content: compressPrompt + }, + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: userPrompt + } + ], + temperature: 0.1, + stream: true + } + }); + + if (!answerText) { + addLog.warn('[Compression messages] failed: empty response, return original messages'); + return { messages }; + } + + const { totalPoints, modelName } = formatModelChars2Points({ + model: model.model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens + }); + const compressedUsage = { + moduleName: i18nT('account_usage:compress_llm_messages'), + model: modelName, + totalPoints, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens + }; + + const compressResult = parseToolArgs<{ + compressed_messages: ChatCompletionMessageParam[]; + compression_summary: string; + }>(answerText); + + if ( + !compressResult || + !Array.isArray(compressResult) || + compressResult.compressed_messages.length === 0 + ) { + addLog.warn('[Compression messages] failed: cannot parse JSON, return original messages', { + messages: compressResult?.compressed_messages + }); + return { messages, usage: compressedUsage }; + } + + const compressedTokens = usage.outputTokens; + addLog.info('[Compression messages] successfully', { + originalTokens: messageTokens, + compressedTokens, + actualRatio: (compressedTokens / messageTokens).toFixed(2), + summary: compressResult.compression_summary + }); + + // 如果之前提取了 system 消息,现在插回去 + const finalMessages = [...systemMessages, ...compressResult.compressed_messages]; + + return { + messages: finalMessages, + usage: compressedUsage + }; + } catch (error) { + addLog.error('[Compression messages] failed', error); + return { messages }; + } +}; diff --git a/packages/service/core/ai/llm/compress/prompt.ts b/packages/service/core/ai/llm/compress/prompt.ts new file mode 100644 index 000000000..3d298000c --- /dev/null +++ b/packages/service/core/ai/llm/compress/prompt.ts @@ -0,0 +1,296 @@ +import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.d'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; +import { calculateCompressionThresholds } from './constants'; + +export const getCompressRequestMessagesPrompt = async ({ + rawTokens, + messages, + model +}: { + messages: ChatCompletionMessageParam[]; + rawTokens: number; + model: LLMModelItemType; +}) => { + const thresholds = calculateCompressionThresholds(model.maxContext); + const targetTokens = Math.round(rawTokens * thresholds.messages.targetRatio); + + return `你是 Agent 对话历史压缩专家。你的任务是将对话历史压缩到目标 token 数,同时确保对话逻辑连贯性和工具调用的 ID 映射关系完全正确。 + +## 核心原则(最高优先级) + +### ⚠️ 忠实性铁律 +**你只能对原始内容进行删除和截断,绝不能添加、推测、改写或创造任何不存在的内容。** + +**绝对禁止的行为**: +- ❌ 添加原文中不存在的信息、数据或结论 +- ❌ 推测或补充用户可能的意图 +- ❌ 修改数字、日期、人名、地名等任何事实性信息 +- ❌ 更改 tool_call 的参数值(即使看起来更合理) + +**允许的操作**: +- ✅ 删除整条消息(但要保持 tool_call 原子性) +- ✅ 截断消息的 content(删除部分句子或段落) +- ✅ 删除冗余的重复表达(保留其中一次) +- ✅ 删除寒暄、过程描述等低价值内容 +- ✅ 改写或重新表述原文,总结或概括原文内容 + +**验证方法**:压缩后的每个词、每个数字、每个 ID 都必须能在原始消息中找到。 + +--- + +## 压缩目标 +- **原始 token 数**: ${rawTokens} tokens +- **目标 token 数**: ${targetTokens} tokens (压缩比例: ${Math.round(thresholds.messages.targetRatio * 100)}%) +- **约束**: 输出的 JSON 内容必须接近 ${targetTokens} tokens + +--- + +## 三阶段压缩工作流 + +### 【第一阶段:扫描与标注】(内部思考,不输出) + +在开始压缩前,请先在内心完成以下分析: + +1. **构建 ID 映射表** + - 扫描所有 assistant 消息中的 tool_calls,提取每个 tool_call 的 id + - 找到对应的 tool 消息的 tool_call_id + - 建立一一对应的映射关系表,例如: + \`\`\` + call_abc123 → tool 消息 #5 + call_def456 → tool 消息 #7 + \`\`\` + +2. **评估消息价值** + 基于以下四个维度,为每条消息标注价值等级: + + **维度 1:信息密度** + - **[高密度]**: 包含数据、结论、决策、关键引用、成功的执行结果 + - **[中密度]**: 提供背景信息、过程性描述 + - **[低密度]**: 寒暄、重复、冗余表达、调试日志 + - **[负价值]**: 空内容、纯错误信息、失败的尝试、无意义的响应 + + **维度 2:对话连贯性** + - **[关键节点]**: 话题转折点、问题解决的关键步骤、问题定位的关键错误 + - **[上下文依赖]**: 被后续消息引用或依赖的内容 + - **[独立片段]**: 与上下文关联较弱的内容 + - **[断裂节点]**: 失败后被重试的操作、未被引用的错误、中间步骤的失败 + + **维度 3:时间权重** + - **[近期]**: 越接近对话尾部,权重越高(保留完整度优先) + - **[早期]**: 早期消息可适度精简,但需保留关键定义/约束 + + **维度 4:工具调用有效性** + - **[成功响应]**: tool 消息返回了有效数据或成功执行的确认 + - **[有价值错误]**: 错误信息帮助定位问题或被后续消息引用分析 + - **[无价值错误]**: 纯粹的失败尝试,后续有成功重试,未被引用 + - **[空响应]**: content 为空、null 或仅包含"无结果"、"未找到"等无效信息 + + **错误和空响应识别标准**: + 判断 tool 消息是否为错误或空响应: + - content 包含"失败"、"错误"、"Error"、"Exception"、"超时"、"Timeout" + - content 为空字符串、null、"无结果"、"未找到"、"No results" + - 检查后续是否有 assistant 引用该错误来调整策略 + - 如果是孤立的错误且后续有成功重试 → 标记为负价值,优先删除 + - **关键**:删除错误消息时,必须同时删除对应的 tool_call(保持原子性) + +3. **确定压缩策略** + 综合三个维度,制定压缩策略: + - **tool_call 相关消息**:作为原子单元,必须成对保留(见第二阶段的原子性约束) + - **高价值消息**(高密度 或 关键节点 或 近期消息):保留 70-90% 内容 + - **中等价值消息**(中密度 + 有上下文依赖):保留 40-60% 内容 + - **低价值消息**(低密度 + 独立片段 + 早期):保留 10-20% 或删除 + +--- + +### 【第二阶段:执行压缩】 + +基于第一阶段的分析,执行压缩操作: + +**压缩原则**: +1. **工具调用原子性(最高优先级)**: + - ⚠️ **强制约束**:assistant 的 tool_calls 消息和对应的 tool 响应消息必须作为**不可分割的原子单元** + - 如果要删除某个工具调用,必须**同时删除** assistant 消息中的 tool_call 和对应的 tool 消息 + - **绝不允许**出现以下情况: + - ❌ 保留 tool_call 但删除 tool 响应 + - ❌ 保留 tool 响应但删除 tool_call + - ❌ tool_call 的 id 与 tool 的 tool_call_id 不匹配 + - 验证方法:遍历所有 tool_call 的 id,确保每个 id 都有且仅有一个对应的 tool 消息 + +2. **ID 不可变**: 所有 tool_call 的 id 和 tool_call_id 必须原样保留,绝不修改 + +3. **结构完整**: 每个 tool_call 对象必须包含 \`id\`, \`type\`, \`function\` 字段 + +4. **顺序保持**: 严格保持对话的时间顺序,assistant 的 tool_calls 和对应的 tool 响应按原始顺序出现 + +5. **逻辑连贯**: 确保压缩后的对话仍然能体现完整的逻辑流程(问题→分析→工具调用→结论) + +6. **大幅精简 content**: + - tool 消息的 content:删除冗长描述、重复信息,只保留核心结论和关键数据 + - user/assistant 消息:精简表达,但保留关键信息和逻辑转折 + - 可合并相似的工具结果(但必须保留各自的 tool_call_id) + +**压缩技巧**: +- **删除类内容**:详细过程描述、重复信息、失败尝试、调试日志、冗余寒暄 +- **保留类内容**:具体数据、关键结论、错误信息、链接引用、决策依据 +- **精简技巧**: + - 用"核心发现:A、B、C"代替长篇叙述 + - 用"报错:具体错误"代替详细堆栈 + - 用"已完成:操作X"代替冗长确认 + +--- + +### 【第三阶段:自校验】 + +输出前,必须检查: + +1. **ID 一致性校验** + - 每个 assistant 消息中的 tool_calls[i].id 是否有对应的 tool 消息? + - 每个 tool 消息的 tool_call_id 是否能在前面的 assistant 消息中找到? + - 是否所有 ID 都原样保留,没有修改或生成新 ID? + +2. **逻辑连贯性校验** + - 对话流程是否完整?(提问→分析→执行→结论) + - 是否存在突兀的跳跃或缺失关键步骤? + - 工具调用的上下文是否清晰? + +3. **压缩比例校验** + - 估算输出的 JSON 字符串长度,是否接近 ${targetTokens} tokens? + - 如果超出目标,需进一步精简 content 字段(优先精简低价值消息) + +4. **格式完整性校验** + - 所有 tool_call 对象是否包含完整的 \`id\`, \`type\`, \`function\` 字段? + - JSON 结构是否正确? + +--- + +## 输出格式 + +请按照以下 JSON 格式输出(必须使用 \`\`\`json 代码块): + +\`\`\`json +{ + "compressed_messages": [ + {"role": "user", "content": "用户请求(精简表达)"}, + { + "role": "assistant", + "content": "分析说明(精简但保留逻辑)", + "tool_calls": [ + { + "id": "call_原始ID", + "type": "function", + "function": { + "name": "工具名", + "arguments": "{\\"param\\":\\"精简后的值\\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_原始ID", + "content": "工具返回的核心结果(已大幅精简,只保留关键信息)" + }, + {"role": "assistant", "content": "基于工具结果的结论(精简表达)"} + ], + "compression_summary": "原始${rawTokens}tokens → 约X tokens (压缩比例Y%)。操作:删除了Z条低价值消息,精简了N个工具响应,M条用户/助手消息。对话逻辑保持完整,ID映射关系已验证正确。" +} +\`\`\` + +--- + +## 压缩示例 + +**示例 1:忠实性压缩(只删除,不改写)** + +原始(约 500 tokens): +\`\`\`json +[ + {"role": "user", "content": "你好,我想了解一下 Python 性能优化的相关技术和最佳实践,能帮我搜索一些资料吗?"}, + {"role": "assistant", "content": "当然可以!我会帮您搜索 Python 性能优化相关的资料。让我先搜索相关文章和教程。"}, + {"role": "assistant", "tool_calls": [{"id": "call_abc", "type": "function", "function": {"name": "search", "arguments": "{\\"query\\":\\"Python性能优化完整指南\\",\\"max_results\\":10}"}}]}, + {"role": "tool", "tool_call_id": "call_abc", "content": "找到10篇文章:\\n1. 标题:Python性能优化完整指南\\n 作者:张三\\n 发布时间:2024-01-15\\n 摘要:本文详细介绍了Python性能优化的各种技巧,包括使用Cython进行编译优化,NumPy向量化计算,以及内存优化技术...(此处省略400字详细内容)\\n URL: https://example.com/article1\\n\\n2. 标题:高性能Python编程实战\\n 作者:李四\\n ..."}, + {"role": "assistant", "content": "根据搜索结果,我为您总结了Python性能优化的主要技术..."} +] +\`\`\` + +压缩后(约 200 tokens,注意:所有内容都直接来自原文,只是删除了冗余部分): +\`\`\`json +[ + {"role": "user", "content": "我想了解 Python 性能优化的相关技术和最佳实践"}, + {"role": "assistant", "tool_calls": [{"id": "call_abc", "type": "function", "function": {"name": "search", "arguments": "{\\"query\\":\\"Python性能优化完整指南\\",\\"max_results\\":10}"}}]}, + {"role": "tool", "tool_call_id": "call_abc", "content": "找到10篇文章:\\n1. 标题:Python性能优化完整指南\\n 摘要:使用Cython进行编译优化,NumPy向量化计算,以及内存优化技术"}, + {"role": "assistant", "content": "根据搜索结果,我为您总结了Python性能优化的主要技术"} +] +\`\`\` + +**关键**:压缩后的每个词都能在原文找到,只是删除了"你好"、"能帮我搜索"、"作者"、"发布时间"等冗余信息。 + +**示例 2:删除失败的工具调用** + +原始(约 600 tokens): +\`\`\`json +[ + {"role": "user", "content": "搜索北京的五星级酒店"}, + {"role": "assistant", "tool_calls": [{"id": "call_fail1", "type": "function", "function": {"name": "search", "arguments": "{\\"query\\":\\"北京五星级酒店\\",\\"location\\":\\"Beijing\\"}"}}]}, + {"role": "tool", "tool_call_id": "call_fail1", "content": "Error: 网络超时,请重试"}, + {"role": "assistant", "content": "搜索遇到网络问题,让我重试"}, + {"role": "assistant", "tool_calls": [{"id": "call_fail2", "type": "function", "function": {"name": "search", "arguments": "{\\"query\\":\\"北京酒店\\"}"}}]}, + {"role": "tool", "tool_call_id": "call_fail2", "content": "未找到相关结果"}, + {"role": "assistant", "content": "没找到结果,我换个搜索方式"}, + {"role": "assistant", "tool_calls": [{"id": "call_ok", "type": "function", "function": {"name": "search", "arguments": "{\\"query\\":\\"北京五星酒店推荐\\"}"}}]}, + {"role": "tool", "tool_call_id": "call_ok", "content": "找到5家酒店:1. 北京王府半岛酒店 2. 北京四季酒店..."}, + {"role": "assistant", "content": "为您找到了5家五星级酒店推荐"} +] +\`\`\` + +压缩后(约 120 tokens): +\`\`\`json +[ + {"role": "user", "content": "搜索北京的五星级酒店"}, + {"role": "assistant", "tool_calls": [{"id": "call_ok", "type": "function", "function": {"name": "search", "arguments": "{\\"query\\":\\"北京五星酒店推荐\\"}"}}]}, + {"role": "tool", "tool_call_id": "call_ok", "content": "找到5家酒店:1. 北京王府半岛酒店 2. 北京四季酒店..."}, + {"role": "assistant", "content": "为您找到5家五星级酒店"} +] +\`\`\` + +**示例 3:多轮对话合并(通过删除中间过程)** + +原始(约 400 tokens): +\`\`\`json +[ + {"role": "user", "content": "帮我创建一个新文件"}, + {"role": "assistant", "content": "好的,我需要知道文件名和内容。请问文件名是什么?"}, + {"role": "user", "content": "文件名叫 test.txt"}, + {"role": "assistant", "content": "明白了,文件名是 test.txt。那么您想在文件中写入什么内容呢?"}, + {"role": "user", "content": "写入 'Hello World'"}, + {"role": "assistant", "content": "收到!我现在帮您创建文件 test.txt,并写入内容 'Hello World'"}, + {"role": "assistant", "tool_calls": [{"id": "call_xyz", "type": "function", "function": {"name": "write_file", "arguments": "{\\"path\\":\\"test.txt\\",\\"content\\":\\"Hello World\\"}"}}]}, + {"role": "tool", "tool_call_id": "call_xyz", "content": "文件创建成功。文件路径:/workspace/test.txt。文件大小:11 bytes。创建时间:2024-01-15 10:30:00"}, + {"role": "assistant", "content": "太好了!文件 test.txt 已经成功创建,内容为 'Hello World'。"} +] +\`\`\` + +压缩后(约 150 tokens,删除了询问过程,保留最终状态): +\`\`\`json +[ + {"role": "user", "content": "帮我创建一个新文件"}, + {"role": "user", "content": "文件名叫 test.txt"}, + {"role": "user", "content": "写入 'Hello World'"}, + {"role": "assistant", "tool_calls": [{"id": "call_xyz", "type": "function", "function": {"name": "write_file", "arguments": "{\\"path\\":\\"test.txt\\",\\"content\\":\\"Hello World\\"}"}}]}, + {"role": "tool", "tool_call_id": "call_xyz", "content": "文件创建成功。文件路径:/workspace/test.txt。文件大小:11 bytes"}, + {"role": "assistant", "content": "文件 test.txt 已经成功创建,内容为 'Hello World'"} +] +\`\`\` + +**关键**:删除了 assistant 的询问消息,但保留了所有 user 消息和最终结果,所有内容都来自原文。 + +--- + +## 待压缩的对话历史 + +${JSON.stringify(messages, null, 2)} + +--- + +请严格按照三阶段工作流执行,确保对话逻辑连贯、ID 映射关系完全正确,输出接近目标 token 数。`; +}; diff --git a/packages/service/core/ai/llm/promptToolCall.ts b/packages/service/core/ai/llm/promptCall/index.ts similarity index 100% rename from packages/service/core/ai/llm/promptToolCall.ts rename to packages/service/core/ai/llm/promptCall/index.ts diff --git a/packages/service/core/ai/llm/prompt.ts b/packages/service/core/ai/llm/promptCall/prompt.ts similarity index 100% rename from packages/service/core/ai/llm/prompt.ts rename to packages/service/core/ai/llm/promptCall/prompt.ts diff --git a/packages/service/core/ai/llm/request.ts b/packages/service/core/ai/llm/request.ts index a3efb052c..0b2bfd369 100644 --- a/packages/service/core/ai/llm/request.ts +++ b/packages/service/core/ai/llm/request.ts @@ -15,7 +15,7 @@ import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; import { getAIApi } from '../config'; import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; -import { parsePromptToolCall, promptToolCallMessageRewrite } from './promptToolCall'; +import { parsePromptToolCall, promptToolCallMessageRewrite } from './promptCall'; import { getLLMModel } from '../model'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; import { countGptMessagesTokens } from '../../../common/string/tiktoken/index'; @@ -26,14 +26,14 @@ import { i18nT } from '../../../../web/i18n/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; import json5 from 'json5'; -type ResponseEvents = { +export type ResponseEvents = { onStreaming?: ({ text }: { text: string }) => void; onReasoning?: ({ text }: { text: string }) => void; onToolCall?: ({ call }: { call: ChatCompletionMessageToolCall }) => void; onToolParam?: ({ tool, params }: { tool: ChatCompletionMessageToolCall; params: string }) => void; }; -type CreateLLMResponseProps = { +export type CreateLLMResponseProps = { userKey?: OpenaiAccountType; body: LLMRequestBodyType; isAborted?: () => boolean | undefined; @@ -86,7 +86,7 @@ export const createLLMResponse = async ( messages: rewriteMessages }); - // console.log(JSON.stringify(requestBody, null, 2)); + // console.dir(requestBody, { depth: null }); const { response, isStreamResponse, getEmptyResponseTip } = await createChatCompletion({ body: requestBody, userKey, diff --git a/packages/service/core/ai/utils.ts b/packages/service/core/ai/utils.ts index 75eca8998..83287ddc0 100644 --- a/packages/service/core/ai/utils.ts +++ b/packages/service/core/ai/utils.ts @@ -2,6 +2,8 @@ import { type LLMModelItemType } from '@fastgpt/global/core/ai/model.d'; import type { CompletionFinishReason, CompletionUsage } from '@fastgpt/global/core/ai/type'; import { getLLMDefaultUsage } from '@fastgpt/global/core/ai/constants'; import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; +import json5 from 'json5'; +import { sliceJsonStr } from '@fastgpt/global/common/string/tools'; /* Count response max token @@ -317,3 +319,11 @@ export const parseLLMStreamResponse = () => { updateFinishReason }; }; + +export const parseToolArgs = >(toolArgs: string) => { + try { + return json5.parse(sliceJsonStr(toolArgs)) as T; + } catch { + return; + } +}; diff --git a/packages/service/core/app/templates/register.ts b/packages/service/core/app/templates/register.ts index 2a6d936b8..54aafb6af 100644 --- a/packages/service/core/app/templates/register.ts +++ b/packages/service/core/app/templates/register.ts @@ -3,6 +3,7 @@ import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants'; import { type AppTemplateSchemaType } from '@fastgpt/global/core/app/type'; import { MongoAppTemplate } from './templateSchema'; import { pluginClient } from '../../../thirdProvider/fastgptPlugin'; +import { addMinutes } from 'date-fns'; const getFileTemplates = async (): Promise => { const res = await pluginClient.workflow.getTemplateList(); @@ -11,9 +12,15 @@ const getFileTemplates = async (): Promise => { }; const getAppTemplates = async () => { - const communityTemplates = await getFileTemplates(); + const originCommunityTemplates = await getFileTemplates(); + const communityTemplates = originCommunityTemplates.map((template) => { + return { + ...template, + templateId: `${AppToolSourceEnum.community}-${template.templateId.split('.')[0]}` + }; + }); - const dbTemplates = await MongoAppTemplate.find(); + const dbTemplates = await MongoAppTemplate.find().lean(); // Merge db data to community templates const communityTemplateConfig = communityTemplates.map((template) => { @@ -22,17 +29,12 @@ const getAppTemplates = async () => { if (config) { return { ...template, - isActive: config.isActive ?? template.isActive, - tags: config.tags ?? template.tags, - userGuide: config.userGuide ?? template.userGuide, - isQuickTemplate: config.isQuickTemplate ?? template.isQuickTemplate, - order: config.order ?? template.order + ...config }; } return template; }); - const res = [ ...communityTemplateConfig, ...dbTemplates.filter((t) => isCommercialTemaplte(t.templateId)) @@ -42,20 +44,31 @@ const getAppTemplates = async () => { }; export const getAppTemplatesAndLoadThem = async (refresh = false) => { - if (isProduction && global.appTemplates && global.appTemplates.length > 0 && !refresh) - return global.appTemplates; - + // 首次强制刷新 + if (!global.templatesRefreshTime) { + global.templatesRefreshTime = Date.now() - 10000; + } if (!global.appTemplates) { global.appTemplates = []; } + if ( + isProduction && + // 有模板缓存 + global.appTemplates.length > 0 && + // 缓存时间未过期 + global.templatesRefreshTime > Date.now() && + !refresh + ) { + return global.appTemplates; + } + try { const appTemplates = await getAppTemplates(); global.appTemplates = appTemplates; + global.templatesRefreshTime = addMinutes(new Date(), 30).getTime(); // 缓存30分钟 return appTemplates; } catch (error) { - // @ts-ignore - global.appTemplates = undefined; return []; } }; @@ -66,4 +79,5 @@ export const isCommercialTemaplte = (templateId: string) => { declare global { var appTemplates: AppTemplateSchemaType[]; + var templatesRefreshTime: number; } diff --git a/packages/service/core/app/templates/templateSchema.ts b/packages/service/core/app/templates/templateSchema.ts index 6b53aaae1..1c428d4e1 100644 --- a/packages/service/core/app/templates/templateSchema.ts +++ b/packages/service/core/app/templates/templateSchema.ts @@ -19,6 +19,8 @@ const AppTemplateSchema = new Schema({ }, type: String, isActive: Boolean, + isPromoted: Boolean, + recommendText: String, userGuide: Object, isQuickTemplate: Boolean, order: { diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts index 7ae5e9477..4a79ba0bd 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts @@ -178,12 +178,11 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< const { toolWorkflowInteractiveResponse, - dispatchFlowResponse, // tool flow response + toolDispatchFlowResponses, // tool flow response toolCallInputTokens, toolCallOutputTokens, completeMessages = [], // The actual message sent to AI(just save text) assistantResponses = [], // FastGPT system store assistant.value response - runTimes, finish_reason } = await (async () => { const adaptMessages = chats2GPTMessages({ @@ -191,22 +190,20 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< reserveId: false // reserveTool: !!toolModel.toolChoice }); - const requestParams = { + + return runToolCall({ + ...props, runtimeNodes, runtimeEdges, toolNodes, toolModel, messages: adaptMessages, - interactiveEntryToolParams: lastInteractive?.toolParams - }; - - return runToolCall({ - ...props, - ...requestParams, - maxRunToolTimes: 100 + childrenInteractiveParams: + lastInteractive?.type === 'toolChildrenInteractive' ? lastInteractive.params : undefined }); })(); + // Usage computed const { totalPoints: modelTotalPoints, modelName } = formatModelChars2Points({ model, inputTokens: toolCallInputTokens, @@ -214,12 +211,13 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< }); const modelUsage = externalProvider.openaiAccount?.key ? 0 : modelTotalPoints; - const toolUsages = dispatchFlowResponse.map((item) => item.flowUsages).flat(); + const toolUsages = toolDispatchFlowResponses.map((item) => item.flowUsages).flat(); const toolTotalPoints = toolUsages.reduce((sum, item) => sum + item.totalPoints, 0); // concat tool usage const totalPointsUsage = modelUsage + toolTotalPoints; + // Preview assistant responses const previewAssistantResponses = filterToolResponseToPreview(assistantResponses); return { @@ -229,7 +227,10 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< .map((item) => item.text?.content || '') .join('') }, - [DispatchNodeResponseKeyEnum.runTimes]: runTimes, + [DispatchNodeResponseKeyEnum.runTimes]: toolDispatchFlowResponses.reduce( + (sum, item) => sum + item.runTimes, + 0 + ), [DispatchNodeResponseKeyEnum.assistantResponses]: previewAssistantResponses, [DispatchNodeResponseKeyEnum.nodeResponse]: { // 展示的积分消耗 @@ -244,7 +245,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< 10000, useVision ), - toolDetail: dispatchFlowResponse.map((item) => item.flowResponses).flat(), + toolDetail: toolDispatchFlowResponses.map((item) => item.flowResponses).flat(), mergeSignId: nodeId, finishReason: finish_reason }, diff --git a/packages/service/core/workflow/dispatch/ai/agent/toolCall.ts b/packages/service/core/workflow/dispatch/ai/agent/toolCall.ts index d9328c65a..e442d8323 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/toolCall.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/toolCall.ts @@ -1,85 +1,22 @@ -import { filterGPTMessageByMaxContext } from '../../../../ai/llm/utils'; -import type { - ChatCompletionToolMessageParam, - ChatCompletionMessageParam, - ChatCompletionTool -} from '@fastgpt/global/core/ai/type'; +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/type'; import { responseWriteController } from '../../../../../common/response'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { textAdaptGptResponse } from '@fastgpt/global/core/workflow/runtime/utils'; -import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; import { runWorkflow } from '../../index'; import type { DispatchToolModuleProps, RunToolResponse, ToolNodeItemType } from './type'; -import json5 from 'json5'; import type { DispatchFlowResponse } from '../../type'; -import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; -import type { AIChatItemType } from '@fastgpt/global/core/chat/type'; +import { chats2GPTMessages, GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; +import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import { formatToolResponse, initToolCallEdges, initToolNodes } from './utils'; -import { computedMaxToken } from '../../../../ai/utils'; +import { parseToolArgs } from '../../../../ai/utils'; import { sliceStrStartEnd } from '@fastgpt/global/common/string/tools'; -import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; -import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants'; -import { getErrText } from '@fastgpt/global/common/error/utils'; -import { createLLMResponse } from '../../../../ai/llm/request'; +import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { toolValueTypeList, valueTypeJsonSchemaMap } from '@fastgpt/global/core/workflow/constants'; +import { runAgentCall } from '../../../../ai/llm/agentCall'; -type ToolRunResponseType = { - toolRunResponse?: DispatchFlowResponse; - toolMsgParams: ChatCompletionToolMessageParam; -}[]; - -/* - 调用思路: - 先Check 是否是交互节点触发 - - 交互模式: - 1. 从缓存中获取工作流运行数据 - 2. 运行工作流 - 3. 检测是否有停止信号或交互响应 - - 无:汇总结果,递归运行工具 - - 有:缓存结果,结束调用 - - 非交互模式: - 1. 组合 tools - 2. 过滤 messages - 3. Load request llm messages: system prompt, histories, human question, (assistant responses, tool responses, assistant responses....) - 4. 请求 LLM 获取结果 - - - 有工具调用 - 1. 批量运行工具的工作流,获取结果(工作流原生结果,工具执行结果) - 2. 合并递归中,所有工具的原生运行结果 - 3. 组合 assistants tool 响应 - 4. 组合本次 request 和 llm response 的 messages,并计算出消耗的 tokens - 5. 组合本次 request、llm response 和 tool response 结果 - 6. 组合本次的 assistant responses: history assistant + tool assistant + tool child assistant - 7. 判断是否还有停止信号或交互响应 - - 无:递归运行工具 - - 有:缓存结果,结束调用 - - 无工具调用 - 1. 汇总结果,递归运行工具 - 2. 计算 completeMessages 和 tokens 后返回。 - - 交互节点额外缓存结果包括: - 1. 入口的节点 id - 2. toolCallId: 本次工具调用的 ID,可以找到是调用了哪个工具,入口并不会记录工具的 id - 3. messages:本次递归中,assistants responses 和 tool responses -*/ - -export const runToolCall = async ( - props: DispatchToolModuleProps & { - maxRunToolTimes: number; - }, - response?: RunToolResponse -): Promise => { +export const runToolCall = async (props: DispatchToolModuleProps): Promise => { + const { messages, toolNodes, toolModel, childrenInteractiveParams, ...workflowProps } = props; const { - messages, - toolNodes, - toolModel, - maxRunToolTimes, - interactiveEntryToolParams, - ...workflowProps - } = props; - let { res, requestOrigin, runtimeNodes, @@ -100,101 +37,7 @@ export const runToolCall = async ( } } = workflowProps; - if (maxRunToolTimes <= 0 && response) { - return response; - } - - // Interactive - if (interactiveEntryToolParams) { - initToolNodes(runtimeNodes, interactiveEntryToolParams.entryNodeIds); - initToolCallEdges(runtimeEdges, interactiveEntryToolParams.entryNodeIds); - - // Run entry tool - const toolRunResponse = await runWorkflow({ - ...workflowProps, - usageId: undefined, - isToolCall: true - }); - const stringToolResponse = formatToolResponse(toolRunResponse.toolResponses); - - // Response to frontend - workflowStreamResponse?.({ - event: SseResponseEventEnum.toolResponse, - data: { - tool: { - id: interactiveEntryToolParams.toolCallId, - toolName: '', - toolAvatar: '', - params: '', - response: sliceStrStartEnd(stringToolResponse, 5000, 5000) - } - } - }); - - // Check stop signal - const hasStopSignal = toolRunResponse.flowResponses?.some((item) => item.toolStop); - // Check interactive response(Only 1 interaction is reserved) - const workflowInteractiveResponse = toolRunResponse.workflowInteractiveResponse; - - const requestMessages = [ - ...messages, - ...interactiveEntryToolParams.memoryMessages.map((item) => - item.role === 'tool' && item.tool_call_id === interactiveEntryToolParams.toolCallId - ? { - ...item, - content: stringToolResponse - } - : item - ) - ]; - - if (hasStopSignal || workflowInteractiveResponse) { - // Get interactive tool data - const toolWorkflowInteractiveResponse: WorkflowInteractiveResponseType | undefined = - workflowInteractiveResponse - ? { - ...workflowInteractiveResponse, - toolParams: { - entryNodeIds: workflowInteractiveResponse.entryNodeIds, - toolCallId: interactiveEntryToolParams.toolCallId, - memoryMessages: interactiveEntryToolParams.memoryMessages - } - } - : undefined; - - return { - dispatchFlowResponse: [toolRunResponse], - toolCallInputTokens: 0, - toolCallOutputTokens: 0, - completeMessages: requestMessages, - assistantResponses: toolRunResponse.assistantResponses, - runTimes: toolRunResponse.runTimes, - toolWorkflowInteractiveResponse - }; - } - - return runToolCall( - { - ...props, - interactiveEntryToolParams: undefined, - maxRunToolTimes: maxRunToolTimes - 1, - // Rewrite toolCall messages - messages: requestMessages - }, - { - dispatchFlowResponse: [toolRunResponse], - toolCallInputTokens: 0, - toolCallOutputTokens: 0, - assistantResponses: toolRunResponse.assistantResponses, - runTimes: toolRunResponse.runTimes - } - ); - } - - // ------------------------------------------------------------ - - const assistantResponses = response?.assistantResponses || []; - + // 构建 tools 参数 const toolNodesMap = new Map(); const tools: ChatCompletionTool[] = toolNodes.map((item) => { toolNodesMap.set(item.nodeId, item); @@ -246,64 +89,44 @@ export const runToolCall = async ( } }; }); + const getToolInfo = (name: string) => { + const toolNode = toolNodesMap.get(name); + return { + name: toolNode?.name || '', + avatar: toolNode?.avatar || '' + }; + }; - const max_tokens = computedMaxToken({ - model: toolModel, - maxToken, - min: 100 - }); - - // Filter histories by maxToken - const filterMessages = ( - await filterGPTMessageByMaxContext({ - messages, - maxContext: toolModel.maxContext - (max_tokens || 0) // filter token. not response maxToken - }) - ).map((item) => { - if (item.role === 'assistant' && item.tool_calls) { - return { - ...item, - tool_calls: item.tool_calls.map((tool) => ({ - id: tool.id, - type: tool.type, - function: tool.function - })) - }; - } - return item; - }); - + // SSE 响应实例 const write = res ? responseWriteController({ res, readStream: stream }) : undefined; + // 工具响应原始值 + const toolRunResponses: DispatchFlowResponse[] = []; - let { - reasoningText: reasoningContent, - answerText: answer, - toolCalls = [], - finish_reason, - usage, - getEmptyResponseTip, - assistantMessage, - completeMessages - } = await createLLMResponse({ + const { + inputTokens, + outputTokens, + completeMessages, + assistantMessages, + interactiveResponse, + finish_reason + } = await runAgentCall({ + maxRunAgentTimes: 50, body: { - model: toolModel.model, - stream, - messages: filterMessages, - tool_choice: 'auto', - toolCallMode: toolModel.toolChoice ? 'toolChoice' : 'prompt', + messages, tools, - parallel_tool_calls: true, + model: toolModel.model, + max_tokens: maxToken, + stream, temperature, - max_tokens, top_p: aiChatTopP, stop: aiChatStopSign, response_format: { - type: aiChatResponseFormat as any, + type: aiChatResponseFormat, json_schema: aiChatJsonSchema }, + requestOrigin, retainDatasetCite, - useVision: aiChatVision, - requestOrigin + useVision: aiChatVision }, isAborted: () => res?.closed, userKey: externalProvider.openaiAccount, @@ -358,52 +181,39 @@ export const runToolCall = async ( } } }); - } - }); + }, + handleToolResponse: async ({ call, messages }) => { + const toolNode = toolNodesMap.get(call.function?.name); - if (!answer && !reasoningContent && !toolCalls.length) { - return Promise.reject(getEmptyResponseTip()); - } - - /* Run the selected tool by LLM. - Since only reference parameters are passed, if the same tool is run in parallel, it will get the same run parameters - */ - const toolsRunResponse: ToolRunResponseType = []; - for await (const tool of toolCalls) { - try { - const toolNode = toolNodesMap.get(tool.function?.name); - - if (!toolNode) continue; - - const startParams = (() => { - try { - return json5.parse(tool.function.arguments); - } catch (error) { - return {}; - } - })(); + if (!toolNode) { + return { + response: 'Call tool not found', + assistantMessages: [], + usages: [], + interactive: undefined + }; + } + // Init tool params and run + const startParams = parseToolArgs(call.function.arguments); initToolNodes(runtimeNodes, [toolNode.nodeId], startParams); + initToolCallEdges(runtimeEdges, [toolNode.nodeId]); + const toolRunResponse = await runWorkflow({ ...workflowProps, + runtimeNodes, usageId: undefined, isToolCall: true }); + // Format tool response const stringToolResponse = formatToolResponse(toolRunResponse.toolResponses); - const toolMsgParams: ChatCompletionToolMessageParam = { - tool_call_id: tool.id, - role: ChatCompletionRequestMessageRoleEnum.Tool, - name: tool.function.name, - content: stringToolResponse - }; - workflowStreamResponse?.({ event: SseResponseEventEnum.toolResponse, data: { tool: { - id: tool.id, + id: call.id, toolName: '', toolAvatar: '', params: '', @@ -412,166 +222,91 @@ export const runToolCall = async ( } }); - toolsRunResponse.push({ - toolRunResponse, - toolMsgParams + toolRunResponses.push(toolRunResponse); + + const assistantMessages = chats2GPTMessages({ + messages: [ + { + obj: ChatRoleEnum.AI, + value: toolRunResponse.assistantResponses + } + ], + reserveId: false }); - } catch (error) { - const err = getErrText(error); + + return { + response: stringToolResponse, + assistantMessages, + usages: toolRunResponse.flowUsages, + interactive: toolRunResponse.workflowInteractiveResponse, + stop: toolRunResponse.flowResponses?.some((item) => item.toolStop) + }; + }, + childrenInteractiveParams, + handleInteractiveTool: async ({ childrenResponse, toolParams }) => { + initToolNodes(runtimeNodes, childrenResponse.entryNodeIds); + initToolCallEdges(runtimeEdges, childrenResponse.entryNodeIds); + + const toolRunResponse = await runWorkflow({ + ...workflowProps, + lastInteractive: childrenResponse, + runtimeNodes, + runtimeEdges, + usageId: undefined, + isToolCall: true + }); + // console.dir(runtimeEdges, { depth: null }); + const stringToolResponse = formatToolResponse(toolRunResponse.toolResponses); + workflowStreamResponse?.({ event: SseResponseEventEnum.toolResponse, data: { tool: { - id: tool.id, + id: toolParams.toolCallId, toolName: '', toolAvatar: '', params: '', - response: sliceStrStartEnd(err, 5000, 5000) + response: sliceStrStartEnd(stringToolResponse, 5000, 5000) } } }); - toolsRunResponse.push({ - toolRunResponse: undefined, - toolMsgParams: { - tool_call_id: tool.id, - role: ChatCompletionRequestMessageRoleEnum.Tool, - name: tool.function.name, - content: sliceStrStartEnd(err, 5000, 5000) - } + toolRunResponses.push(toolRunResponse); + const assistantMessages = chats2GPTMessages({ + messages: [ + { + obj: ChatRoleEnum.AI, + value: toolRunResponse.assistantResponses + } + ], + reserveId: false }); - } - } - - const flatToolsResponseData = toolsRunResponse - .map((item) => item.toolRunResponse) - .flat() - .filter(Boolean) as DispatchFlowResponse[]; - // concat tool responses - const dispatchFlowResponse = response - ? response.dispatchFlowResponse.concat(flatToolsResponseData) - : flatToolsResponseData; - - const inputTokens = response - ? response.toolCallInputTokens + usage.inputTokens - : usage.inputTokens; - const outputTokens = response - ? response.toolCallOutputTokens + usage.outputTokens - : usage.outputTokens; - - if (toolCalls.length > 0) { - /* - ... - user - assistant: tool data - tool: tool response - */ - const nextRequestMessages: ChatCompletionMessageParam[] = [ - ...completeMessages, - ...toolsRunResponse.map((item) => item?.toolMsgParams) - ]; - - /* - Get tool node assistant response - - history assistant - - current tool assistant - - tool child assistant - */ - const toolNodeAssistant = GPTMessages2Chats({ - messages: [...assistantMessage, ...toolsRunResponse.map((item) => item?.toolMsgParams)], - getToolInfo: (id) => { - const toolNode = toolNodesMap.get(id); - return { - name: toolNode?.name || '', - avatar: toolNode?.avatar || '' - }; - } - })[0] as AIChatItemType; - const toolChildAssistants = flatToolsResponseData - .map((item) => item.assistantResponses) - .flat() - .filter((item) => item.type !== ChatItemValueTypeEnum.interactive); // 交互节点留着下次记录 - const concatAssistantResponses = [ - ...assistantResponses, - ...toolNodeAssistant.value, - ...toolChildAssistants - ]; - - const runTimes = - (response?.runTimes || 0) + - flatToolsResponseData.reduce((sum, item) => sum + item.runTimes, 0); - - // Check stop signal - const hasStopSignal = flatToolsResponseData.some( - (item) => !!item.flowResponses?.find((item) => item.toolStop) - ); - // Check interactive response(Only 1 interaction is reserved) - const workflowInteractiveResponseItem = toolsRunResponse.find( - (item) => item.toolRunResponse?.workflowInteractiveResponse - ); - if (hasStopSignal || workflowInteractiveResponseItem) { - // Get interactive tool data - const workflowInteractiveResponse = - workflowInteractiveResponseItem?.toolRunResponse?.workflowInteractiveResponse; - - // Flashback traverses completeMessages, intercepting messages that know the first user - const firstUserIndex = nextRequestMessages.findLastIndex((item) => item.role === 'user'); - const newMessages = nextRequestMessages.slice(firstUserIndex + 1); - - const toolWorkflowInteractiveResponse: WorkflowInteractiveResponseType | undefined = - workflowInteractiveResponse - ? { - ...workflowInteractiveResponse, - toolParams: { - entryNodeIds: workflowInteractiveResponse.entryNodeIds, - toolCallId: workflowInteractiveResponseItem?.toolMsgParams.tool_call_id, - memoryMessages: newMessages - } - } - : undefined; return { - dispatchFlowResponse, - toolCallInputTokens: inputTokens, - toolCallOutputTokens: outputTokens, - completeMessages: nextRequestMessages, - assistantResponses: concatAssistantResponses, - toolWorkflowInteractiveResponse, - runTimes, - finish_reason + response: stringToolResponse, + assistantMessages, + usages: toolRunResponse.flowUsages, + interactive: toolRunResponse.workflowInteractiveResponse, + stop: toolRunResponse.flowResponses?.some((item) => item.toolStop) }; } + }); - return runToolCall( - { - ...props, - maxRunToolTimes: maxRunToolTimes - 1, - messages: nextRequestMessages - }, - { - dispatchFlowResponse, - toolCallInputTokens: inputTokens, - toolCallOutputTokens: outputTokens, - assistantResponses: concatAssistantResponses, - runTimes, - finish_reason - } - ); - } else { - // concat tool assistant - const toolNodeAssistant = GPTMessages2Chats({ - messages: assistantMessage - })[0] as AIChatItemType; + const assistantResponses = GPTMessages2Chats({ + messages: assistantMessages, + reserveTool: true, + getToolInfo + }) + .map((item) => item.value as AIChatItemValueItemType[]) + .flat(); - return { - dispatchFlowResponse: response?.dispatchFlowResponse || [], - toolCallInputTokens: inputTokens, - toolCallOutputTokens: outputTokens, - - completeMessages, - assistantResponses: [...assistantResponses, ...toolNodeAssistant.value], - runTimes: (response?.runTimes || 0) + 1, - finish_reason - }; - } + return { + toolDispatchFlowResponses: toolRunResponses, + toolCallInputTokens: inputTokens, + toolCallOutputTokens: outputTokens, + completeMessages, + assistantResponses, + finish_reason, + toolWorkflowInteractiveResponse: interactiveResponse + }; }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/type.d.ts b/packages/service/core/workflow/dispatch/ai/agent/type.d.ts index 944dba7ce..7b24328e5 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/type.d.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/type.d.ts @@ -14,7 +14,11 @@ import type { DispatchFlowResponse } from '../../type'; import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import { ChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; +import type { + ToolCallChildrenInteractive, + InteractiveNodeResponseType, + WorkflowInteractiveResponseType +} from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model'; import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema'; @@ -37,18 +41,17 @@ export type DispatchToolModuleProps = ModuleDispatchProps<{ messages: ChatCompletionMessageParam[]; toolNodes: ToolNodeItemType[]; toolModel: LLMModelItemType; - interactiveEntryToolParams?: WorkflowInteractiveResponseType['toolParams']; + childrenInteractiveParams?: ToolCallChildrenInteractive['params']; }; export type RunToolResponse = { - dispatchFlowResponse: DispatchFlowResponse[]; + toolDispatchFlowResponses: DispatchFlowResponse[]; toolCallInputTokens: number; toolCallOutputTokens: number; - completeMessages?: ChatCompletionMessageParam[]; - assistantResponses?: AIChatItemValueItemType[]; - toolWorkflowInteractiveResponse?: WorkflowInteractiveResponseType; - [DispatchNodeResponseKeyEnum.runTimes]: number; - finish_reason?: CompletionFinishReason; + completeMessages: ChatCompletionMessageParam[]; + assistantResponses: AIChatItemValueItemType[]; + finish_reason: CompletionFinishReason; + toolWorkflowInteractiveResponse?: ToolCallChildrenInteractive; }; export type ToolNodeItemType = RuntimeNodeItemType & { toolParams: RuntimeNodeItemType['inputs']; diff --git a/packages/service/core/workflow/dispatch/ai/agent/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/utils.ts index 9939dae10..4855ee724 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/utils.ts @@ -62,12 +62,9 @@ export const initToolNodes = ( nodes.forEach((node) => { if (entryNodeIds.includes(node.nodeId)) { node.isEntry = true; - node.isStart = true; if (startParams) { node.inputs = updateToolInputValue({ params: startParams, inputs: node.inputs }); } - } else { - node.isStart = false; } }); }; diff --git a/packages/service/core/workflow/dispatch/index.ts b/packages/service/core/workflow/dispatch/index.ts index 51a5c0344..518e9ecaf 100644 --- a/packages/service/core/workflow/dispatch/index.ts +++ b/packages/service/core/workflow/dispatch/index.ts @@ -747,6 +747,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise item.source === node.nodeId ); @@ -957,6 +958,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise ({ ...edge, + // 入口前面的边全部激活,保证下次进来一定能执行。 status: entryNodeIds.includes(edge.target) ? 'active' : edge.status })), nodeOutputs, diff --git a/packages/service/core/workflow/dispatch/tools/answer.ts b/packages/service/core/workflow/dispatch/tools/answer.ts index 595160f95..11e1683c8 100644 --- a/packages/service/core/workflow/dispatch/tools/answer.ts +++ b/packages/service/core/workflow/dispatch/tools/answer.ts @@ -36,6 +36,7 @@ export const dispatchAnswer = (props: Record): AnswerResponse => { [DispatchNodeResponseKeyEnum.answerText]: responseText, [DispatchNodeResponseKeyEnum.nodeResponse]: { textOutput: formatText - } + }, + [DispatchNodeResponseKeyEnum.toolResponses]: responseText }; }; diff --git a/packages/web/i18n/en/account_usage.json b/packages/web/i18n/en/account_usage.json index 4c810e2c1..dce1fa96a 100644 --- a/packages/web/i18n/en/account_usage.json +++ b/packages/web/i18n/en/account_usage.json @@ -4,6 +4,7 @@ "app_name": "Application name", "auto_index": "Auto index", "billing_module": "Deduction module", + "compress_llm_messages": "AI history compression", "confirm_export": "A total of {{total}} pieces of data were filtered out. Are you sure to export?", "count": "Number of runs", "current_filter_conditions": "Current filter conditions", diff --git a/packages/web/i18n/en/app.json b/packages/web/i18n/en/app.json index 945c20f56..b84d771d3 100644 --- a/packages/web/i18n/en/app.json +++ b/packages/web/i18n/en/app.json @@ -319,13 +319,14 @@ "template.hard_strict_des": "Based on the question and answer template, stricter requirements are imposed on the model's answers.", "template.qa_template": "Q&A template", "template.qa_template_des": "A knowledge base suitable for QA question and answer structure, which allows AI to answer strictly according to preset content", + "template.recommended": "Promoted", "template.simple_robot": "Simple robot", "template.standard_strict": "Standard strict template", "template.standard_strict_des": "Based on the standard template, stricter requirements are imposed on the model's answers.", "template.standard_template": "Standard template", "template.standard_template_des": "Standard prompt words for knowledge bases with unfixed structures.", "templateMarket.Search_template": "Search Template", - "templateMarket.Use": "Use", + "templateMarket.Use": "Build now", "templateMarket.no_intro": "No introduction yet~", "templateMarket.templateTags.Recommendation": "Recommendation", "templateMarket.template_guide": "Guide", diff --git a/packages/web/i18n/zh-CN/account_usage.json b/packages/web/i18n/zh-CN/account_usage.json index 9dcebe0c9..93c2d518d 100644 --- a/packages/web/i18n/zh-CN/account_usage.json +++ b/packages/web/i18n/zh-CN/account_usage.json @@ -5,6 +5,7 @@ "app_name": "应用名", "auto_index": "索引增强", "billing_module": "扣费模块", + "compress_llm_messages": "AI 历史记录压缩", "confirm_export": "共筛选出 {{total}} 条数据,是否确认导出?", "count": "运行次数", "current_filter_conditions": "当前筛选条件:", diff --git a/packages/web/i18n/zh-CN/app.json b/packages/web/i18n/zh-CN/app.json index bea725aa9..fc2182dac 100644 --- a/packages/web/i18n/zh-CN/app.json +++ b/packages/web/i18n/zh-CN/app.json @@ -333,16 +333,17 @@ "template.hard_strict_des": "在问答模板基础上,对模型的回答做更严格的要求。", "template.qa_template": "问答模板", "template.qa_template_des": "适合 QA 问答结构的知识库,可以让AI较为严格的按预设内容回答", + "template.recommended": "精选", "template.simple_robot": "简易机器人", "template.standard_strict": "标准严格模板", "template.standard_strict_des": "在标准模板基础上,对模型的回答做更严格的要求。", "template.standard_template": "标准模板", "template.standard_template_des": "标准提示词,用于结构不固定的知识库。", "templateMarket.Search_template": "搜索模板", - "templateMarket.Use": "使用", + "templateMarket.Use": "立即搭建", "templateMarket.no_intro": "还没有介绍~", "templateMarket.templateTags.Recommendation": "推荐", - "templateMarket.template_guide": "模板说明", + "templateMarket.template_guide": "说明", "template_market": "模板市场", "template_market_description": "在模板市场探索更多玩法,配置教程与使用引导,带你理解并上手各种应用", "template_market_empty_data": "找不到合适的模板", diff --git a/packages/web/i18n/zh-Hant/account_usage.json b/packages/web/i18n/zh-Hant/account_usage.json index b786d8628..66356e02f 100644 --- a/packages/web/i18n/zh-Hant/account_usage.json +++ b/packages/web/i18n/zh-Hant/account_usage.json @@ -4,6 +4,7 @@ "app_name": "應用程式名", "auto_index": "索引增強", "billing_module": "扣費模組", + "compress_llm_messages": "AI 歷史記錄壓縮", "confirm_export": "共篩選出 {{total}} 條資料,是否確認匯出?", "count": "運行次數", "current_filter_conditions": "目前篩選條件:", diff --git a/packages/web/i18n/zh-Hant/app.json b/packages/web/i18n/zh-Hant/app.json index 856db8f1b..3ba0e3a13 100644 --- a/packages/web/i18n/zh-Hant/app.json +++ b/packages/web/i18n/zh-Hant/app.json @@ -318,16 +318,17 @@ "template.hard_strict_des": "在問答範本基礎上,對模型的回答做出更嚴格的要求。", "template.qa_template": "問答範本", "template.qa_template_des": "適合問答結構的知識庫,可以讓 AI 較為嚴格地按照預設內容回答", + "template.recommended": "精選", "template.simple_robot": "簡易機器人", "template.standard_strict": "標準嚴格範本", "template.standard_strict_des": "在標準範本基礎上,對模型的回答做出更嚴格的要求。", "template.standard_template": "標準範本", "template.standard_template_des": "標準提示詞,用於結構不固定的知識庫。", "templateMarket.Search_template": "搜尋範本", - "templateMarket.Use": "使用", + "templateMarket.Use": "立即搭建", "templateMarket.no_intro": "還沒有介紹~", "templateMarket.templateTags.Recommendation": "推薦", - "templateMarket.template_guide": "範本說明", + "templateMarket.template_guide": "說明", "template_market": "範本市集", "template_market_description": "在範本市集探索更多玩法,設定教學與使用指引,帶您理解並上手各種應用程式", "template_market_empty_data": "找不到合適的範本", diff --git a/projects/app/public/imgs/app/templateBg.svg b/projects/app/public/imgs/app/templateBg.svg new file mode 100644 index 000000000..2db35c329 --- /dev/null +++ b/projects/app/public/imgs/app/templateBg.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/projects/app/public/imgs/app/templateCreateBg.svg b/projects/app/public/imgs/app/templateCreateBg.svg new file mode 100644 index 000000000..100f34478 --- /dev/null +++ b/projects/app/public/imgs/app/templateCreateBg.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/projects/app/src/components/Layout/navbar.tsx b/projects/app/src/components/Layout/navbar.tsx index e07ade1be..ae7012c4d 100644 --- a/projects/app/src/components/Layout/navbar.tsx +++ b/projects/app/src/components/Layout/navbar.tsx @@ -123,7 +123,7 @@ const Navbar = ({ unread }: { unread: number }) => { w={'100%'} userSelect={'none'} pb={2} - bg={isSecondNavbarPage ? 'myGray.50' : 'transparent'} + bg={isSecondNavbarPage ? 'white' : 'transparent'} > {/* logo */} diff --git a/projects/app/src/pageComponents/dashboard/Container.tsx b/projects/app/src/pageComponents/dashboard/Container.tsx index 88679af82..c4b4b3f68 100644 --- a/projects/app/src/pageComponents/dashboard/Container.tsx +++ b/projects/app/src/pageComponents/dashboard/Container.tsx @@ -232,14 +232,13 @@ const DashboardContainer = ({ position={'fixed'} left={isPc ? navbarWidth : 0} top={0} - bg={'myGray.25'} + bg={'white'} w={`220px`} h={'full'} borderLeft={'1px solid'} borderRight={'1px solid'} borderColor={'myGray.200'} pt={4} - px={2.5} pb={2.5} zIndex={100} userSelect={'none'} @@ -247,7 +246,12 @@ const DashboardContainer = ({ flexDirection={'column'} justifyContent={'space-between'} > - + {groupList.map((group) => { const selected = currentTab === group.groupId; @@ -339,7 +343,7 @@ const DashboardContainer = ({ )} - + {children({ templateTags, templateList, diff --git a/projects/app/src/pageComponents/dashboard/agent/TemplateCreatePanel.tsx b/projects/app/src/pageComponents/dashboard/agent/TemplateCreatePanel.tsx index 72dfcd28b..9c41f1019 100644 --- a/projects/app/src/pageComponents/dashboard/agent/TemplateCreatePanel.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/TemplateCreatePanel.tsx @@ -11,7 +11,7 @@ import { SkeletonCircle, useBreakpointValue } from '@chakra-ui/react'; -import type { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import Avatar from '@fastgpt/web/components/common/Avatar'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import MyIcon from '@fastgpt/web/components/common/Icon'; @@ -22,13 +22,16 @@ import MyBox from '@fastgpt/web/components/common/MyBox'; import { useLocalStorageState } from 'ahooks'; import { useState } from 'react'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; +import { form2AppWorkflow } from '@/web/core/app/utils'; +import { webPushTrack } from '@/web/common/middle/tracks/utils'; +import { appTypeTagMap } from '../constant'; const TemplateCreatePanel = ({ type }: { type: AppTypeEnum | 'all' }) => { const { t } = useTranslation(); const router = useRouter(); const randomNumber = - useBreakpointValue({ base: 3, sm: 3, md: 4, lg: 4, xl: 5 }, { ssr: false }) || 4; + useBreakpointValue({ base: 2, sm: 2, md: 3, lg: 3, xl: 4 }, { ssr: false }) || 3; const [isHoverMoreButton, setIsHoverMoreButton] = useState(false); const [isCollapsed, setIsCollapsed] = useLocalStorageState( @@ -43,12 +46,20 @@ const TemplateCreatePanel = ({ type }: { type: AppTypeEnum | 'all' }) => { data: templateData, loading: isFetchingTemplates } = useRequest2( - (excludeIds?: string[]) => - getTemplateMarketItemList({ + (ids?: string[]) => { + const excludeIds = (() => { + try { + return JSON.stringify(ids); + } catch (error) { + return ''; + } + })(); + return getTemplateMarketItemList({ type, randomNumber, excludeIds - }), + }); + }, { manual: false, refreshDeps: [type, randomNumber] @@ -62,6 +73,11 @@ const TemplateCreatePanel = ({ type }: { type: AppTypeEnum | 'all' }) => { setCreatingTemplateId(templateId); const templateDetail = await getTemplateMarketItemDetail(templateId); + if (templateDetail.type === AppTypeEnum.simple) { + const completeWorkflow = form2AppWorkflow(templateDetail.workflow, t); + templateDetail.workflow = completeWorkflow; + } + return postCreateApp({ avatar: templateDetail.avatar, name: templateDetail.name, @@ -70,6 +86,13 @@ const TemplateCreatePanel = ({ type }: { type: AppTypeEnum | 'all' }) => { edges: templateDetail.workflow.edges || [], chatConfig: templateDetail.workflow.chatConfig || {}, templateId: templateDetail.templateId + }).then((res) => { + webPushTrack.useAppTemplate({ + id: res, + name: templateDetail.name + }); + + return res; }); }, { @@ -141,66 +164,167 @@ const TemplateCreatePanel = ({ type }: { type: AppTypeEnum | 'all' }) => { in={!isCollapsed} animateOpacity transition={{ enter: { duration: 0.2 }, exit: { duration: 0.2 } }} + style={{ overflow: 'visible' }} > {isFetchingTemplates && !templateData?.list?.length ? Array.from({ length: randomNumber }).map((_, index) => ( - - + + + + + - )) - : templateData?.list.map((item, index) => ( - { - if (!creatingTemplateId) { - handleCreateFromTemplate(item.templateId); - } - }} - > - - - - {item.name} - - - - {item.intro || ''} - - - ))} + : templateData?.list.map((item, index) => { + return ( + { + if (!creatingTemplateId) { + handleCreateFromTemplate(item.templateId); + } + }} + display={'flex'} + gap={2} + alignItems={'center'} + > + + + + + + + {item.name} + + {item.isPromoted && ( + + + {t('app:template.recommended')} + + + )} + + + + + {(item.isPromoted ? item.recommendText || item.intro : item.intro) || + t('app:templateMarket.no_intro')} + + + + + + {t('app:templateMarket.Use')} + + + + ); + })} { p={0} onMouseEnter={() => setIsHoverMoreButton(true)} onMouseLeave={() => setIsHoverMoreButton(false)} + minH={20} + maxW={160} > { const { t } = useTranslation(); - const map = useRef({ - [AppTypeEnum.simple]: { - label: t('app:type.Chat_Agent'), - icon: 'core/app/type/simple' - }, - [AppTypeEnum.workflow]: { - label: t('app:type.Workflow bot'), - icon: 'core/app/type/workflow' - }, - [AppTypeEnum.workflowTool]: { - label: t('app:toolType_workflow'), - icon: 'core/app/type/plugin' - }, - [AppTypeEnum.httpPlugin]: { - label: t('app:type.Http plugin'), - icon: 'core/app/type/httpPlugin' - }, - [AppTypeEnum.httpToolSet]: { - label: t('app:toolType_http'), - icon: 'core/app/type/httpPlugin' - }, - [AppTypeEnum.mcpToolSet]: { - label: t('app:toolType_mcp'), - icon: 'core/app/type/mcpTools' - }, - [AppTypeEnum.tool]: undefined, - [AppTypeEnum.folder]: undefined, - [AppTypeEnum.hidden]: undefined, - [AppTypeEnum.agent]: undefined - }); - - const data = map.current[type as keyof typeof map.current]; + const data = appTypeTagMap[type as keyof typeof appTypeTagMap]; return data ? ( { > - {data.label} + {t(data.label)} ) : null; diff --git a/projects/app/src/pageComponents/dashboard/constant.ts b/projects/app/src/pageComponents/dashboard/constant.ts new file mode 100644 index 000000000..ab28def94 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/constant.ts @@ -0,0 +1,33 @@ +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { i18nT } from '@fastgpt/web/i18n/utils'; + +export const appTypeTagMap = { + [AppTypeEnum.simple]: { + label: i18nT('app:type.Chat_Agent'), + icon: 'core/app/type/simple' + }, + [AppTypeEnum.workflow]: { + label: i18nT('app:type.Workflow bot'), + icon: 'core/app/type/workflow' + }, + [AppTypeEnum.workflowTool]: { + label: i18nT('app:toolType_workflow'), + icon: 'core/app/type/plugin' + }, + [AppTypeEnum.httpPlugin]: { + label: i18nT('app:type.Http plugin'), + icon: 'core/app/type/httpPlugin' + }, + [AppTypeEnum.httpToolSet]: { + label: i18nT('app:toolType_http'), + icon: 'core/app/type/httpPlugin' + }, + [AppTypeEnum.mcpToolSet]: { + label: i18nT('app:toolType_mcp'), + icon: 'core/app/type/mcpTools' + }, + [AppTypeEnum.tool]: undefined, + [AppTypeEnum.folder]: undefined, + [AppTypeEnum.hidden]: undefined, + [AppTypeEnum.agent]: undefined +}; diff --git a/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx b/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx index 3d6740865..bc07bb227 100644 --- a/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx @@ -1,4 +1,4 @@ -import React, { type Dispatch } from 'react'; +import React, { useEffect, type Dispatch } from 'react'; import { FormControl, Flex, Input, Button, Box } from '@chakra-ui/react'; import { useForm } from 'react-hook-form'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; @@ -10,6 +10,10 @@ import { useTranslation } from 'next-i18next'; import FormLayout from './FormLayout'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import PolicyTip from './PolicyTip'; +import { useSearchParams } from 'next/navigation'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { useRouter } from 'next/router'; +import { useMount } from 'ahooks'; interface Props { setPageType: Dispatch<`${LoginPageTypeEnum}`>; @@ -25,6 +29,9 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => { const { t } = useTranslation(); const { toast } = useToast(); const { feConfigs } = useSystemStore(); + const query = useSearchParams(); + const router = useRouter(); + const { register, handleSubmit, @@ -41,13 +48,28 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => { code }) ); - toast({ - title: t('login:login_success'), - status: 'success' - }); }, { - refreshDeps: [loginSuccess] + refreshDeps: [loginSuccess], + successToast: t('login:login_success'), + onError: (error: any) => { + // 密码错误,需要清空 query 参数 + if (error.statusText === UserErrEnum.account_psw_error) { + router.replace( + router.pathname, + { + query: { + ...router.query, + u: '', + p: '' + } + }, + { + shallow: false + } + ); + } + } } ); @@ -71,6 +93,17 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => { .join('/'); })(); + useMount(() => { + const username = query.get('u'); + const password = query.get('p'); + if (username && password) { + onclickLogin({ + username, + password + }); + } + }); + return ( { await authCert({ req, authToken: true }); - const { isQuickTemplate = false, randomNumber = 0, type = 'all', excludeIds = [] } = req.query; + const { isQuickTemplate = false, randomNumber = 0, type = 'all', excludeIds } = req.query; + const parsedExcludeIds: string[] = (() => { + if (!excludeIds) return []; + try { + return JSON.parse(excludeIds); + } catch (error) { + console.error('Failed to parse excludeIds:', error); + return []; + } + })(); const templateMarketItems = await getAppTemplatesAndLoadThem(); let filteredItems = templateMarketItems.filter((item) => { if (!item.isActive) return false; - if (type === 'all' && !ToolTypeList.includes(item.type as AppTypeEnum)) return true; + if (type === 'all' && !(ToolTypeList.includes(item.type as AppTypeEnum) && randomNumber > 0)) + return true; if (item.type === type) return true; return false; }); const total = filteredItems.length; - if (excludeIds && excludeIds.length > 0) { - filteredItems = filteredItems.filter((item) => !excludeIds.includes(item.templateId)); + if (parsedExcludeIds && parsedExcludeIds.length > 0) { + filteredItems = filteredItems.filter((item) => !parsedExcludeIds.includes(item.templateId)); } if (isQuickTemplate) { @@ -63,6 +73,8 @@ async function handler( templateId: item.templateId, name: item.name, intro: item.intro, + recommendText: item.recommendText, + isPromoted: item.isPromoted, avatar: item.avatar, tags: item.tags, type: item.type, diff --git a/projects/app/src/pages/dashboard/systemTool/index.tsx b/projects/app/src/pages/dashboard/systemTool/index.tsx index f89076555..cb0af9959 100644 --- a/projects/app/src/pages/dashboard/systemTool/index.tsx +++ b/projects/app/src/pages/dashboard/systemTool/index.tsx @@ -146,7 +146,7 @@ const ToolKitProvider = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { }, [tools, searchText, selectedTagIds, installedFilter, tags, i18n.language]); return ( - + import('@/components/common/Modal/UseGuideModal'), { ssr: false }); @@ -40,7 +44,6 @@ const TemplateMarket = ({ }) => { const router = useRouter(); const { t } = useTranslation(); - const { feConfigs } = useSystemStore(); const { isPc } = useSystem(); const containerRef = useRef(null); @@ -51,7 +54,7 @@ const TemplateMarket = ({ } = router.query as { parentId?: ParentIdType; type?: string; appType?: AppTypeEnum | 'all' }; const [searchKey, setSearchKey] = useState(''); - const filterTemplateTags = useMemo(() => { + const tagsWithTemplates = useMemo(() => { return templateTags .map((tag) => { const templates = templateList.filter((template) => template.tags.includes(tag.typeId)); @@ -67,6 +70,11 @@ const TemplateMarket = ({ async (template: AppTemplateSchemaType) => { const templateDetail = await getTemplateMarketItemDetail(template.templateId); + if (template.type === AppTypeEnum.simple) { + const completeWorkflow = form2AppWorkflow(templateDetail.workflow, t); + templateDetail.workflow = completeWorkflow; + } + return postCreateApp({ parentId, avatar: template.avatar, @@ -97,99 +105,132 @@ const TemplateMarket = ({ const TemplateCard = useCallback( ({ item }: { item: AppTemplateSchemaType }) => { const { t } = useTranslation(); + const icon = appTypeTagMap[item.type as keyof typeof appTypeTagMap]?.icon; return ( - - - {item.name} - - - - + + + + + - - {item.intro || t('app:templateMarket.no_intro')} + + + {item.name} + {item.isPromoted && ( + + + {t('app:template.recommended')} + + + )} + + + + {(item.isPromoted ? item.recommendText || item.intro : item.intro) || + t('app:templateMarket.no_intro')} + + - - {`by ${item.author || feConfigs.systemTitle}`} - - {((item.userGuide?.type === 'markdown' && item.userGuide?.content) || - (item.userGuide?.type === 'link' && item.userGuide?.link)) && ( - - {({ onClick }) => ( - - )} - - )} - - - + {({ onClick }) => ( + + + {t('app:templateMarket.template_guide')} + + )} + + ) : ( + + )} + + ); }, - [onUseTemplate, feConfigs.systemTitle] + [onUseTemplate] ); // Scroll to the selected template type @@ -204,20 +245,20 @@ const TemplateMarket = ({ return ( - - + + {isPc ? ( - + {t('app:template_market')} ) : ( MenuIcon )} - - + - + {searchKey ? ( <> @@ -294,11 +336,17 @@ const TemplateMarket = ({ })()} ) : ( - <> - {filterTemplateTags.map((item) => { + + {tagsWithTemplates.map((item) => { return ( - + {t(item.typeName as any)} {item.templates.map((item) => ( @@ -320,7 +367,7 @@ const TemplateMarket = ({ ); })} - + )} diff --git a/projects/app/src/web/core/app/api/template.ts b/projects/app/src/web/core/app/api/template.ts index 42750a60f..b97fc2e66 100644 --- a/projects/app/src/web/core/app/api/template.ts +++ b/projects/app/src/web/core/app/api/template.ts @@ -1,5 +1,5 @@ import type { ListParams, ListResponse } from '@/pages/api/core/app/template/list'; -import { GET } from '@/web/common/api/request'; +import { GET, POST } from '@/web/common/api/request'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import type { AppTemplateSchemaType, TemplateTypeSchemaType } from '@fastgpt/global/core/app/type'; import { defaultTemplateTypes } from '@fastgpt/web/core/workflow/constants'; diff --git a/test/cases/service/core/ai/llm/toolCall.test.ts b/test/cases/service/core/ai/llm/toolCall.test.ts index 982bf8cfd..689d64ff5 100644 --- a/test/cases/service/core/ai/llm/toolCall.test.ts +++ b/test/cases/service/core/ai/llm/toolCall.test.ts @@ -1,7 +1,7 @@ import { parsePromptToolCall, promptToolCallMessageRewrite -} from '@fastgpt/service/core/ai/llm/promptToolCall'; +} from '@fastgpt/service/core/ai/llm/promptCall/index'; import type { ChatCompletionMessageParam, ChatCompletionTool } from '@fastgpt/global/core/ai/type'; import { describe, expect, it } from 'vitest'; diff --git a/test/cases/service/core/app/workflow/dispatch/checkNodeRunStatus.test.ts b/test/cases/service/core/app/workflow/dispatch/checkNodeRunStatus.test.ts index 159298abb..163897b01 100644 --- a/test/cases/service/core/app/workflow/dispatch/checkNodeRunStatus.test.ts +++ b/test/cases/service/core/app/workflow/dispatch/checkNodeRunStatus.test.ts @@ -1187,3 +1187,167 @@ describe('checkNodeRunStatus - 边界情况测试', () => { expect(checkNodeRunStatus({ nodesMap, node: nodeA, runtimeEdges: edges })).toBe('run'); }); }); + +describe('checkNodeRunStatus - 工具调用场景测试', () => { + it('工具调用1: Tool节点作为入口节点 (无workflowStart时)', () => { + // 场景:当工作流中没有 workflowStart/pluginInput 节点时,tool 节点可以作为入口节点 + // Tool → Process → End + const toolNode = createNode('tool1', FlowNodeTypeEnum.tool); + const processNode = createNode('process'); + const endNode = createNode('end'); + + const nodesMap = new Map([ + ['tool1', toolNode], + ['process', processNode], + ['end', endNode] + ]); + + // 场景1: Tool节点作为入口,无输入边 + const edges1: RuntimeEdgeItemType[] = [ + createEdge('tool1', 'process', 'waiting'), + createEdge('process', 'end', 'waiting') + ]; + + // Tool节点作为入口节点应该可以运行 + expect(checkNodeRunStatus({ nodesMap, node: toolNode, runtimeEdges: edges1 })).toBe('run'); + // 注意:由于tool节点没有输入边(是入口),process节点也会没有可追溯到start的边 + // 因此process节点在这个场景下也会返回'run'(因为commonEdges和recursiveEdgeGroups都为空) + expect(checkNodeRunStatus({ nodesMap, node: processNode, runtimeEdges: edges1 })).toBe('run'); + + // 场景2: Tool节点执行完成后,process可以运行但end仍需等待 + const edges2: RuntimeEdgeItemType[] = [ + createEdge('tool1', 'process', 'active'), + createEdge('process', 'end', 'waiting') + ]; + + expect(checkNodeRunStatus({ nodesMap, node: processNode, runtimeEdges: edges2 })).toBe('run'); + // end节点的输入边是waiting状态,需要等待process完成 + expect(checkNodeRunStatus({ nodesMap, node: endNode, runtimeEdges: edges2 })).toBe('wait'); + + // 场景2.1: process完成后,end可以运行 + const edges2_1: RuntimeEdgeItemType[] = [ + createEdge('tool1', 'process', 'active'), + createEdge('process', 'end', 'active') + ]; + + expect(checkNodeRunStatus({ nodesMap, node: endNode, runtimeEdges: edges2_1 })).toBe('run'); + + // 场景3: 有workflowStart时,tool节点不再是入口节点 + const startNode = createNode('start', FlowNodeTypeEnum.workflowStart); + const nodesMapWithStart = new Map([ + ['start', startNode], + ['tool1', toolNode], + ['process', processNode], + ['end', endNode] + ]); + + const edges3: RuntimeEdgeItemType[] = [ + createEdge('start', 'tool1', 'active'), + createEdge('tool1', 'process', 'waiting'), + createEdge('process', 'end', 'waiting') + ]; + + // 此时tool节点不再是入口节点,需要start激活才能运行 + expect( + checkNodeRunStatus({ nodesMap: nodesMapWithStart, node: toolNode, runtimeEdges: edges3 }) + ).toBe('run'); + expect( + checkNodeRunStatus({ nodesMap: nodesMapWithStart, node: processNode, runtimeEdges: edges3 }) + ).toBe('wait'); + + // Tool执行完成后,process可以运行 + const edges4: RuntimeEdgeItemType[] = [ + createEdge('start', 'tool1', 'active'), + createEdge('tool1', 'process', 'active'), + createEdge('process', 'end', 'waiting') + ]; + + expect( + checkNodeRunStatus({ nodesMap: nodesMapWithStart, node: processNode, runtimeEdges: edges4 }) + ).toBe('run'); + }); + + it('工具调用2: ToolSet节点与条件分支和循环组合 (Agent → ToolSet → Tool1/Tool2 → Result → Agent)', () => { + // 场景:Agent调用工具集,工具集根据条件选择不同工具执行,并支持循环调用 + // Start → Agent → ToolSet → (Tool1 | Tool2) → Result → Agent (循环) + const nodeStart = createNode('start', FlowNodeTypeEnum.workflowStart); + const agentNode = createNode('agent', FlowNodeTypeEnum.agent); + const toolSetNode = createNode('toolSet', FlowNodeTypeEnum.toolSet); + const tool1Node = createNode('tool1', FlowNodeTypeEnum.tool); + const tool2Node = createNode('tool2', FlowNodeTypeEnum.tool); + const resultNode = createNode('result'); + + const nodesMap = new Map([ + ['start', nodeStart], + ['agent', agentNode], + ['toolSet', toolSetNode], + ['tool1', tool1Node], + ['tool2', tool2Node], + ['result', resultNode] + ]); + + // 场景1: 第一次执行,Agent选择Tool1 + const edges1: RuntimeEdgeItemType[] = [ + createEdge('start', 'agent', 'active'), + createEdge('agent', 'toolSet', 'active'), + createEdge('toolSet', 'tool1', 'active'), // 选择Tool1 + createEdge('toolSet', 'tool2', 'skipped'), // Tool2未选择 + createEdge('tool1', 'result', 'waiting'), + createEdge('tool2', 'result', 'skipped'), + createEdge('result', 'agent', 'waiting') // 循环边等待 + ]; + + expect(checkNodeRunStatus({ nodesMap, node: agentNode, runtimeEdges: edges1 })).toBe('wait'); + expect(checkNodeRunStatus({ nodesMap, node: toolSetNode, runtimeEdges: edges1 })).toBe('run'); + expect(checkNodeRunStatus({ nodesMap, node: tool1Node, runtimeEdges: edges1 })).toBe('run'); + expect(checkNodeRunStatus({ nodesMap, node: tool2Node, runtimeEdges: edges1 })).toBe('skip'); + expect(checkNodeRunStatus({ nodesMap, node: resultNode, runtimeEdges: edges1 })).toBe('wait'); + + // 场景2: Tool1执行完成,Result处理结果 + const edges2: RuntimeEdgeItemType[] = [ + createEdge('start', 'agent', 'active'), + createEdge('agent', 'toolSet', 'active'), + createEdge('toolSet', 'tool1', 'active'), + createEdge('toolSet', 'tool2', 'skipped'), + createEdge('tool1', 'result', 'active'), // Tool1完成 + createEdge('tool2', 'result', 'skipped'), + createEdge('result', 'agent', 'waiting') + ]; + + expect(checkNodeRunStatus({ nodesMap, node: resultNode, runtimeEdges: edges2 })).toBe('run'); + + // 场景3: 循环回Agent,第二次调用选择Tool2 + const edges3: RuntimeEdgeItemType[] = [ + createEdge('start', 'agent', 'active'), + createEdge('agent', 'toolSet', 'active'), + createEdge('toolSet', 'tool1', 'skipped'), // Tool1未选择 + createEdge('toolSet', 'tool2', 'active'), // 选择Tool2 + createEdge('tool1', 'result', 'skipped'), + createEdge('tool2', 'result', 'active'), // Tool2完成 + createEdge('result', 'agent', 'active') // 循环边激活 + ]; + + // Agent有来自start和result的两条active边 + expect(checkNodeRunStatus({ nodesMap, node: agentNode, runtimeEdges: edges3 })).toBe('run'); + expect(checkNodeRunStatus({ nodesMap, node: tool1Node, runtimeEdges: edges3 })).toBe('skip'); + expect(checkNodeRunStatus({ nodesMap, node: tool2Node, runtimeEdges: edges3 })).toBe('run'); + expect(checkNodeRunStatus({ nodesMap, node: resultNode, runtimeEdges: edges3 })).toBe('run'); + + // 场景4: 循环退出,不再调用工具 + const edges4: RuntimeEdgeItemType[] = [ + createEdge('start', 'agent', 'active'), + createEdge('agent', 'toolSet', 'skipped'), // 不再调用工具集 + createEdge('toolSet', 'tool1', 'skipped'), + createEdge('toolSet', 'tool2', 'skipped'), + createEdge('tool1', 'result', 'skipped'), + createEdge('tool2', 'result', 'skipped'), + createEdge('result', 'agent', 'skipped') // 循环退出 + ]; + + expect(checkNodeRunStatus({ nodesMap, node: agentNode, runtimeEdges: edges4 })).toBe('run'); + expect(checkNodeRunStatus({ nodesMap, node: toolSetNode, runtimeEdges: edges4 })).toBe('skip'); + expect(checkNodeRunStatus({ nodesMap, node: tool1Node, runtimeEdges: edges4 })).toBe('skip'); + expect(checkNodeRunStatus({ nodesMap, node: tool2Node, runtimeEdges: edges4 })).toBe('skip'); + expect(checkNodeRunStatus({ nodesMap, node: resultNode, runtimeEdges: edges4 })).toBe('skip'); + }); +});