From 9fbfabac6193119ebb712d3b6bbe6800d0eb8f8a Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Thu, 14 Aug 2025 15:48:22 +0800 Subject: [PATCH] perf: variabel replace;Feat: prompt optimizer code (#5453) * feat: add prompt optimizer (#5444) * feat: add prompt optimizer * fix * perf: variabel replace * perf: prompt optimizer code * feat: init charts shell * perf: user error remove --------- Co-authored-by: heheer --- document/content/docs/upgrading/4-12/4121.mdx | 2 + document/data/doc-last-modified.json | 5 +- packages/global/common/string/tools.ts | 72 +- packages/global/core/chat/type.d.ts | 6 +- .../global/core/workflow/runtime/utils.ts | 70 +- .../global/support/wallet/usage/constants.ts | 6 +- packages/service/core/chat/chatSchema.ts | 9 +- packages/service/core/chat/saveChat.ts | 2 +- .../core/workflow/dispatch/tools/http468.ts | 291 +++++--- .../core/workflow/dispatch/tools/runLaf.ts | 2 +- .../web/components/common/Icon/constants.ts | 1 + .../common/Icon/icons/optimizer.svg | 46 ++ .../web/components/common/MyModal/index.tsx | 5 +- .../web/components/common/MyPopover/index.tsx | 21 +- .../common/Textarea/PromptEditor/Editor.tsx | 93 ++- .../common/Textarea/PromptEditor/index.tsx | 49 +- packages/web/i18n/en/app.json | 8 + packages/web/i18n/en/common.json | 1 + packages/web/i18n/zh-CN/app.json | 8 + packages/web/i18n/zh-CN/common.json | 1 + packages/web/i18n/zh-Hant/app.json | 8 + packages/web/i18n/zh-Hant/common.json | 1 + .../PromptEditor/OptimizerPopover/index.tsx | 326 +++++++++ .../components/core/app/formRender/index.tsx | 1 + .../components/core/app/formRender/type.d.ts | 7 +- .../app/detail/SimpleApp/EditForm.tsx | 27 +- .../RenderInput/templates/CommonInputForm.tsx | 19 + projects/app/src/pages/api/admin/initv4121.ts | 167 +++++ .../src/pages/api/core/ai/optimizePrompt.ts | 223 ++++++ .../service/support/permission/auth/chat.ts | 4 +- projects/app/src/types/app.d.ts | 1 - projects/app/src/web/common/api/fetch.ts | 25 + .../src/web/common/system/useSystemStore.ts | 2 +- .../core/app/workflow/dispatch/http.test.ts | 632 ++++++++++++++++++ test/tsconfig.json | 29 + 35 files changed, 1968 insertions(+), 202 deletions(-) create mode 100644 packages/web/components/common/Icon/icons/optimizer.svg create mode 100644 projects/app/src/components/common/PromptEditor/OptimizerPopover/index.tsx create mode 100644 projects/app/src/pages/api/admin/initv4121.ts create mode 100644 projects/app/src/pages/api/core/ai/optimizePrompt.ts create mode 100644 test/cases/service/core/app/workflow/dispatch/http.test.ts create mode 100644 test/tsconfig.json diff --git a/document/content/docs/upgrading/4-12/4121.mdx b/document/content/docs/upgrading/4-12/4121.mdx index 069850b32..deac0cee4 100644 --- a/document/content/docs/upgrading/4-12/4121.mdx +++ b/document/content/docs/upgrading/4-12/4121.mdx @@ -6,10 +6,12 @@ description: 'FastGPT V4.12.1 更新说明' ## 🚀 新增内容 +1. Prompt 自动生成和优化。 ## ⚙️ 优化 1. 工作流响应优化,主动指定响应值进入历史记录,而不是根据 key 决定。 +2. 避免工作流中,变量替换导致的死循环或深度递归风险。 ## 🐛 修复 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 022790b72..48505bf35 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -31,7 +31,7 @@ "document/content/docs/introduction/development/modelConfig/ppio.mdx": "2025-08-05T23:20:39+08:00", "document/content/docs/introduction/development/modelConfig/siliconCloud.mdx": "2025-08-05T23:20:39+08:00", "document/content/docs/introduction/development/openapi/chat.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/development/openapi/dataset.mdx": "2025-08-12T22:22:18+08:00", + "document/content/docs/introduction/development/openapi/dataset.mdx": "2025-08-13T16:31:28+08:00", "document/content/docs/introduction/development/openapi/intro.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/introduction/development/openapi/share.mdx": "2025-08-05T23:20:39+08:00", "document/content/docs/introduction/development/proxy/cloudflare.mdx": "2025-07-23T21:35:03+08:00", @@ -97,12 +97,13 @@ "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-08-12T22:22:18+08:00", + "document/content/docs/toc.mdx": "2025-08-13T14:29:13+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-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-11/4110.mdx": "2025-08-05T23:20:39+08:00", "document/content/docs/upgrading/4-11/4111.mdx": "2025-08-07T22:49:09+08:00", "document/content/docs/upgrading/4-12/4120.mdx": "2025-08-12T22:45:19+08:00", + "document/content/docs/upgrading/4-12/4121.mdx": "2025-08-13T21:31:30+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 e616e8141..e89d40292 100644 --- a/packages/global/common/string/tools.ts +++ b/packages/global/common/string/tools.ts @@ -34,15 +34,75 @@ export const valToStr = (val: any) => { }; // replace {{variable}} to value -export function replaceVariable(text: any, obj: Record) { +export function replaceVariable( + text: any, + obj: Record, + depth = 0 +) { if (typeof text !== 'string') return text; - for (const key in obj) { - const val = obj[key]; - const formatVal = valToStr(val); - text = text.replace(new RegExp(`{{(${key})}}`, 'g'), () => formatVal); + const MAX_REPLACEMENT_DEPTH = 10; + const processedVariables = new Set(); + + // Prevent infinite recursion + if (depth > MAX_REPLACEMENT_DEPTH) { + return text; } - return text || ''; + + // Check for circular references in variable values + const hasCircularReference = (value: any, targetKey: string): boolean => { + if (typeof value !== 'string') return false; + + // Check if the value contains the target variable pattern (direct self-reference) + const selfRefPattern = new RegExp( + `\\{\\{${targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, + 'g' + ); + return selfRefPattern.test(value); + }; + + let result = text; + let hasReplacements = false; + + // Build replacement map first to avoid modifying string during iteration + const replacements: { pattern: string; replacement: string }[] = []; + + for (const key in obj) { + // Skip if already processed to avoid immediate circular reference + if (processedVariables.has(key)) { + continue; + } + + const val = obj[key]; + + // Check for direct circular reference + if (hasCircularReference(String(val), key)) { + continue; + } + + const formatVal = valToStr(val); + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + replacements.push({ + pattern: `{{(${escapedKey})}}`, + replacement: formatVal + }); + + processedVariables.add(key); + hasReplacements = true; + } + + // Apply all replacements + replacements.forEach(({ pattern, replacement }) => { + result = result.replace(new RegExp(pattern, 'g'), () => replacement); + }); + + // If we made replacements and there might be nested variables, recursively process + if (hasReplacements && /\{\{[^}]+\}\}/.test(result)) { + result = replaceVariable(result, obj, depth + 1); + } + + return result || ''; } /* replace sensitive text */ diff --git a/packages/global/core/chat/type.d.ts b/packages/global/core/chat/type.d.ts index 8d2c7b0dd..df10bd3fd 100644 --- a/packages/global/core/chat/type.d.ts +++ b/packages/global/core/chat/type.d.ts @@ -20,7 +20,7 @@ import type { WorkflowInteractiveResponseType } from '../workflow/template/syste import type { FlowNodeInputItemType } from '../workflow/type/io'; import type { FlowNodeTemplateType } from '../workflow/type/node.d'; -export type ChatSchema = { +export type ChatSchemaType = { _id: string; chatId: string; userId: string; @@ -33,6 +33,8 @@ export type ChatSchema = { customTitle: string; top: boolean; source: `${ChatSourceEnum}`; + sourceName?: string; + shareId?: string; outLinkUid?: string; @@ -43,7 +45,7 @@ export type ChatSchema = { metadata?: Record; }; -export type ChatWithAppSchema = Omit & { +export type ChatWithAppSchema = Omit & { appId: AppSchema; }; diff --git a/packages/global/core/workflow/runtime/utils.ts b/packages/global/core/workflow/runtime/utils.ts index 7100b46fa..fdebbe2e0 100644 --- a/packages/global/core/workflow/runtime/utils.ts +++ b/packages/global/core/workflow/runtime/utils.ts @@ -467,27 +467,63 @@ export const formatVariableValByType = (val: any, valueType?: WorkflowIOValueTyp return val; }; + // replace {{$xx.xx$}} variables for text export function replaceEditorVariable({ text, nodes, - variables + variables, + depth = 0 }: { text: any; nodes: RuntimeNodeItemType[]; variables: Record; // global variables + depth?: number; }) { if (typeof text !== 'string') return text; + if (text === '') return text; + + const MAX_REPLACEMENT_DEPTH = 10; + const processedVariables = new Set(); + + // Prevent infinite recursion + if (depth > MAX_REPLACEMENT_DEPTH) { + return text; + } text = replaceVariable(text, variables); + // Check for circular references in variable values + const hasCircularReference = (value: any, targetKey: string): boolean => { + if (typeof value !== 'string') return false; + + // Check if the value contains the target variable pattern (direct self-reference) + const selfRefPattern = new RegExp( + `\\{\\{\\$${targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\$\\}\\}`, + 'g' + ); + return selfRefPattern.test(value); + }; + const variablePattern = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g; const matches = [...text.matchAll(variablePattern)]; if (matches.length === 0) return text; - matches.forEach((match) => { + let result = text; + let hasReplacements = false; + + // Build replacement map first to avoid modifying string during iteration + const replacements: Array<{ pattern: string; replacement: string }> = []; + + for (const match of matches) { const nodeId = match[1]; const id = match[2]; + const variableKey = `${nodeId}.${id}`; + + // Skip if already processed to avoid immediate circular reference + if (processedVariables.has(variableKey)) { + continue; + } const variableVal = (() => { if (nodeId === VARIABLE_NODE_ID) { @@ -505,13 +541,35 @@ export function replaceEditorVariable({ if (input) return getReferenceVariableValue({ value: input.value, nodes, variables }); })(); - const formatVal = valToStr(variableVal); + // Check for direct circular reference + if (hasCircularReference(String(variableVal), variableKey)) { + continue; + } - const regex = new RegExp(`\\{\\{\\$(${nodeId}\\.${id})\\$\\}\\}`, 'g'); - text = text.replace(regex, () => formatVal); + const formatVal = valToStr(variableVal); + const escapedNodeId = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + replacements.push({ + pattern: `\\{\\{\\$(${escapedNodeId}\\.${escapedId})\\$\\}\\}`, + replacement: formatVal + }); + + processedVariables.add(variableKey); + hasReplacements = true; + } + + // Apply all replacements + replacements.forEach(({ pattern, replacement }) => { + result = result.replace(new RegExp(pattern, 'g'), replacement); }); - return text || ''; + // If we made replacements and there might be nested variables, recursively process + if (hasReplacements && /\{\{\$[^.]+\.[^$]+\$\}\}/.test(result)) { + result = replaceEditorVariable({ text: result, nodes, variables, depth: depth + 1 }); + } + + return result || ''; } export const textAdaptGptResponse = ({ diff --git a/packages/global/support/wallet/usage/constants.ts b/packages/global/support/wallet/usage/constants.ts index 34bc5bce3..ac5581238 100644 --- a/packages/global/support/wallet/usage/constants.ts +++ b/packages/global/support/wallet/usage/constants.ts @@ -13,7 +13,8 @@ export enum UsageSourceEnum { official_account = 'official_account', pdfParse = 'pdfParse', mcp = 'mcp', - evaluation = 'evaluation' + evaluation = 'evaluation', + optimize_prompt = 'optimize_prompt' } export const UsageSourceMap = { @@ -55,5 +56,8 @@ export const UsageSourceMap = { }, [UsageSourceEnum.evaluation]: { label: i18nT('account_usage:evaluation') + }, + [UsageSourceEnum.optimize_prompt]: { + label: i18nT('common:support.wallet.usage.Optimize Prompt') } }; diff --git a/packages/service/core/chat/chatSchema.ts b/packages/service/core/chat/chatSchema.ts index b7564d6fe..7819bd025 100644 --- a/packages/service/core/chat/chatSchema.ts +++ b/packages/service/core/chat/chatSchema.ts @@ -1,6 +1,6 @@ import { connectionMongo, getMongoModel } from '../../common/mongo'; const { Schema } = connectionMongo; -import { type ChatSchema as ChatType } from '@fastgpt/global/core/chat/type.d'; +import { type ChatSchemaType } from '@fastgpt/global/core/chat/type.d'; import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; import { TeamCollectionName, @@ -83,10 +83,13 @@ const ChatSchema = new Schema({ //For special storage type: Object, default: {} - } + }, + + initStatistics: Boolean }); try { + ChatSchema.index({ initCharts: 1 }); ChatSchema.index({ chatId: 1 }); // get user history ChatSchema.index({ tmbId: 1, appId: 1, top: -1, updateTime: -1 }); @@ -104,4 +107,4 @@ try { console.log(error); } -export const MongoChat = getMongoModel(chatCollectionName, ChatSchema); +export const MongoChat = getMongoModel(chatCollectionName, ChatSchema); diff --git a/packages/service/core/chat/saveChat.ts b/packages/service/core/chat/saveChat.ts index 084217e79..eeedc29b7 100644 --- a/packages/service/core/chat/saveChat.ts +++ b/packages/service/core/chat/saveChat.ts @@ -165,7 +165,7 @@ export async function saveChat({ }); try { - const userId = outLinkUid || tmbId; + const userId = String(outLinkUid || tmbId); const now = new Date(); const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000); diff --git a/packages/service/core/workflow/dispatch/tools/http468.ts b/packages/service/core/workflow/dispatch/tools/http468.ts index 3f6edf04e..8df1576f8 100644 --- a/packages/service/core/workflow/dispatch/tools/http468.ts +++ b/packages/service/core/workflow/dispatch/tools/http468.ts @@ -6,19 +6,18 @@ import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import { - DispatchNodeResponseKeyEnum, - SseResponseEventEnum -} from '@fastgpt/global/core/workflow/runtime/constants'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import axios from 'axios'; import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils'; import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; -import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; +import type { + ModuleDispatchProps, + RuntimeNodeItemType +} from '@fastgpt/global/core/workflow/runtime/type'; import { formatVariableValByType, getReferenceVariableValue, - replaceEditorVariable, - textAdaptGptResponse + replaceEditorVariable } from '@fastgpt/global/core/workflow/runtime/utils'; import json5 from 'json5'; import { JSONPath } from 'jsonpath-plus'; @@ -121,96 +120,6 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise { - // Check if the variable is in quotes - const isVariableInQuotes = (text: string, variable: string) => { - const index = text.indexOf(variable); - if (index === -1) return false; - - // 计算变量前面的引号数量 - const textBeforeVar = text.substring(0, index); - const matches = textBeforeVar.match(/"/g) || []; - - // 如果引号数量为奇数,则变量在引号内 - return matches.length % 2 === 1; - }; - const valToStr = (val: any, isQuoted = false) => { - if (val === undefined) return 'null'; - if (val === null) return 'null'; - - if (typeof val === 'object') return JSON.stringify(val); - - if (typeof val === 'string') { - if (isQuoted) { - // Replace newlines with escaped newlines - return val.replace(/\n/g, '\\n').replace(/(? { - const nodeId = match[1]; - const id = match[2]; - const fullMatch = match[0]; - - // 检查变量是否在引号内 - const isInQuotes = isVariableInQuotes(text, fullMatch); - - const variableVal = (() => { - if (nodeId === VARIABLE_NODE_ID) { - return variables[id]; - } - // Find upstream node input/output - const node = runtimeNodes.find((node) => node.nodeId === nodeId); - if (!node) return; - - const output = node.outputs.find((output) => output.id === id); - if (output) return formatVariableValByType(output.value, output.valueType); - - const input = node.inputs.find((input) => input.key === id); - if (input) - return getReferenceVariableValue({ value: input.value, nodes: runtimeNodes, variables }); - })(); - - const formatVal = valToStr(variableVal, isInQuotes); - - const regex = new RegExp(`\\{\\{\\$(${nodeId}\\.${id})\\$\\}\\}`, ''); - text = text.replace(regex, () => formatVal); - }); - - // 2. Replace {{key}} variables - const regex2 = /{{([^}]+)}}/g; - const matches2 = text.match(regex2) || []; - const uniqueKeys2 = [...new Set(matches2.map((match) => match.slice(2, -2)))]; - for (const key of uniqueKeys2) { - const fullMatch = `{{${key}}}`; - // 检查变量是否在引号内 - const isInQuotes = isVariableInQuotes(text, fullMatch); - - text = text.replace(new RegExp(`{{(${key})}}`, ''), () => - valToStr(allVariables[key], isInQuotes) - ); - } - - return text.replace(/(".*?")\s*:\s*undefined\b/g, '$1:null'); - }; httpReqUrl = replaceStringVariables(httpReqUrl); @@ -273,7 +182,10 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise 0 ? results : rawResponse }; } catch (error) { - addLog.error('Http request error', error); + addLog.warn('Http request error', formatHttpError(error)); // @adapt if (node.catchError === undefined) { @@ -391,6 +303,187 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise; + allVariables: Record; + runtimeNodes: RuntimeNodeItemType[]; + } +) => { + const { variables, allVariables, runtimeNodes } = props; + + const MAX_REPLACEMENT_DEPTH = 10; + const processedVariables = new Set(); + + // Prevent infinite recursion + if (depth > MAX_REPLACEMENT_DEPTH) { + return text; + } + + // Check if the variable is in quotes + const isVariableInQuotes = (text: string, variable: string) => { + const index = text.indexOf(variable); + if (index === -1) return false; + + // 计算变量前面的引号数量 + const textBeforeVar = text.substring(0, index); + const matches = textBeforeVar.match(/"/g) || []; + + // 如果引号数量为奇数,则变量在引号内 + return matches.length % 2 === 1; + }; + + const valToStr = (val: any, isQuoted = false) => { + if (val === undefined) return 'null'; + if (val === null) return 'null'; + + if (typeof val === 'object') { + const jsonStr = JSON.stringify(val); + if (isQuoted) { + // Only escape quotes for JSON strings inside quotes (backslashes are already properly escaped by JSON.stringify) + return jsonStr.replace(/"/g, '\\"'); + } + return jsonStr; + } + + if (typeof val === 'string') { + if (isQuoted) { + const jsonStr = JSON.stringify(val); + return jsonStr.slice(1, -1); // 移除首尾的双引号 + } + try { + JSON.parse(val); + return val; + } catch (error) { + const str = JSON.stringify(val); + return str.startsWith('"') && str.endsWith('"') ? str.slice(1, -1) : str; + } + } + + return String(val); + }; + + // Check for circular references in variable values + const hasCircularReference = (value: any, targetKey: string): boolean => { + if (typeof value !== 'string') return false; + + // Check if the value contains the target variable pattern (direct self-reference) + const selfRefPattern = new RegExp( + `\\{\\{${targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, + 'g' + ); + return selfRefPattern.test(value); + }; + + let result = text; + let hasReplacements = false; + + // 1. Replace {{$nodeId.id$}} variables + const regex1 = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g; + const matches1 = [...result.matchAll(regex1)]; + + // Build replacement map first to avoid modifying string during iteration + const replacements1: Array<{ pattern: string; replacement: string }> = []; + + for (const match of matches1) { + const nodeId = match[1]; + const id = match[2]; + const fullMatch = match[0]; + const variableKey = `${nodeId}.${id}`; + + // Skip if already processed to avoid immediate circular reference + if (processedVariables.has(variableKey)) { + continue; + } + + // 检查变量是否在引号内 + const isInQuotes = isVariableInQuotes(result, fullMatch); + + const variableVal = (() => { + if (nodeId === VARIABLE_NODE_ID) { + return variables[id]; + } + // Find upstream node input/output + const node = runtimeNodes.find((node) => node.nodeId === nodeId); + if (!node) return; + + const output = node.outputs.find((output) => output.id === id); + if (output) return formatVariableValByType(output.value, output.valueType); + + const input = node.inputs.find((input) => input.key === id); + if (input) + return getReferenceVariableValue({ value: input.value, nodes: runtimeNodes, variables }); + })(); + + const formatVal = valToStr(variableVal, isInQuotes); + // Check for direct circular reference + if (hasCircularReference(String(variableVal), variableKey)) { + continue; + } + + const escapedPattern = `\\{\\{\\$(${nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\$\\}\\}`; + + replacements1.push({ + pattern: escapedPattern, + replacement: formatVal + }); + + processedVariables.add(variableKey); + hasReplacements = true; + } + replacements1.forEach(({ pattern, replacement }) => { + result = result.replace(new RegExp(pattern, 'g'), replacement); + }); + + // 2. Replace {{key}} variables + const regex2 = /{{([^}]+)}}/g; + const matches2 = result.match(regex2) || []; + const uniqueKeys2 = [...new Set(matches2.map((match) => match.slice(2, -2)))]; + // Build replacement map for simple variables + const replacements2: Array<{ pattern: string; replacement: string }> = []; + for (const key of uniqueKeys2) { + if (processedVariables.has(key)) { + continue; + } + + const fullMatch = `{{${key}}}`; + const variableVal = allVariables[key]; + + // Check for direct circular reference + if (hasCircularReference(variableVal, key)) { + continue; + } + + // 检查变量是否在引号内 + const isInQuotes = isVariableInQuotes(result, fullMatch); + const formatVal = valToStr(variableVal, isInQuotes); + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + replacements2.push({ + pattern: `{{(${escapedKey})}}`, + replacement: formatVal + }); + + processedVariables.add(key); + hasReplacements = true; + } + replacements2.forEach(({ pattern, replacement }) => { + result = result.replace(new RegExp(pattern, 'g'), replacement); + }); + + // If we made replacements and there might be nested variables, recursively process + if (hasReplacements && /\{\{[^}]*\}\}/.test(result)) { + result = replaceJsonBodyString({ text: result, depth: depth + 1 }, props); + } + + return result.replace(/(".*?")\s*:\s*undefined\b/g, '$1:null'); +}; + async function fetchData({ method, url, diff --git a/packages/service/core/workflow/dispatch/tools/runLaf.ts b/packages/service/core/workflow/dispatch/tools/runLaf.ts index 7cf061a9b..b57cf19a7 100644 --- a/packages/service/core/workflow/dispatch/tools/runLaf.ts +++ b/packages/service/core/workflow/dispatch/tools/runLaf.ts @@ -96,7 +96,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise import('./icons/model/yi.svg'), more: () => import('./icons/more.svg'), moreLine: () => import('./icons/moreLine.svg'), + optimizer: () => import('./icons/optimizer.svg'), out: () => import('./icons/out.svg'), paragraph: () => import('./icons/paragraph.svg'), 'phoneTabbar/me': () => import('./icons/phoneTabbar/me.svg'), diff --git a/packages/web/components/common/Icon/icons/optimizer.svg b/packages/web/components/common/Icon/icons/optimizer.svg new file mode 100644 index 000000000..5f9f4bd0c --- /dev/null +++ b/packages/web/components/common/Icon/icons/optimizer.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/web/components/common/MyModal/index.tsx b/packages/web/components/common/MyModal/index.tsx index 2e5bb86d8..3f1a33ab0 100644 --- a/packages/web/components/common/MyModal/index.tsx +++ b/packages/web/components/common/MyModal/index.tsx @@ -57,7 +57,7 @@ const MyModal = ({ closeOnOverlayClick={closeOnOverlayClick} returnFocusOnClose={false} > - + {!title && onClose && showCloseButton && } diff --git a/packages/web/components/common/MyPopover/index.tsx b/packages/web/components/common/MyPopover/index.tsx index 3d80893a8..24ed31a9c 100644 --- a/packages/web/components/common/MyPopover/index.tsx +++ b/packages/web/components/common/MyPopover/index.tsx @@ -6,7 +6,9 @@ import { useDisclosure, type PlacementWithLogical, PopoverArrow, - type PopoverContentProps + type PopoverContentProps, + Box, + Portal } from '@chakra-ui/react'; interface Props extends PopoverContentProps { @@ -15,6 +17,7 @@ interface Props extends PopoverContentProps { offset?: [number, number]; trigger?: 'hover' | 'click'; hasArrow?: boolean; + onBackdropClick?: () => void; children: (e: { onClose: () => void }) => React.ReactNode; onCloseFunc?: () => void; onOpenFunc?: () => void; @@ -31,6 +34,7 @@ const MyPopover = ({ onOpenFunc, onCloseFunc, closeOnBlur = false, + onBackdropClick, ...props }: Props) => { const firstFieldRef = React.useRef(null); @@ -60,10 +64,17 @@ const MyPopover = ({ autoFocus={false} > {Trigger} - - {hasArrow && } - {children({ onClose })} - + {isOpen && onBackdropClick && ( + + onBackdropClick()} /> + + )} + + + {hasArrow && } + {children({ onClose })} + + ); }; diff --git a/packages/web/components/common/Textarea/PromptEditor/Editor.tsx b/packages/web/components/common/Textarea/PromptEditor/Editor.tsx index 4167232bb..be3cf96ad 100644 --- a/packages/web/components/common/Textarea/PromptEditor/Editor.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/Editor.tsx @@ -6,7 +6,7 @@ * */ -import { useState, useTransition } from 'react'; +import { useMemo, useState, useTransition } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; @@ -14,7 +14,7 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; import VariableLabelPickerPlugin from './plugins/VariableLabelPickerPlugin'; -import { Box } from '@chakra-ui/react'; +import { Box, Flex } from '@chakra-ui/react'; import styles from './index.module.scss'; import VariablePlugin from './plugins/VariablePlugin'; import { VariableNode } from './plugins/VariablePlugin/node'; @@ -32,36 +32,47 @@ import VariableLabelPlugin from './plugins/VariableLabelPlugin'; import { useDeepCompareEffect } from 'ahooks'; import VariablePickerPlugin from './plugins/VariablePickerPlugin'; +export type EditorProps = { + variables?: EditorVariablePickerType[]; + variableLabels?: EditorVariableLabelPickerType[]; + value?: string; + showOpenModal?: boolean; + minH?: number; + maxH?: number; + maxLength?: number; + placeholder?: string; + isInvalid?: boolean; + + ExtensionPopover?: ((e: { + onChangeText: (text: string) => void; + iconButtonStyle: Record; + }) => React.ReactNode)[]; +}; + export default function Editor({ minH = 200, maxH = 400, maxLength, showOpenModal = true, onOpenModal, - variables, - variableLabels, + variables = [], + variableLabels = [], onChange, + onChangeText, onBlur, value, placeholder = '', bg = 'white', + isInvalid, - isInvalid -}: { - minH?: number; - maxH?: number; - maxLength?: number; - showOpenModal?: boolean; - onOpenModal?: () => void; - variables: EditorVariablePickerType[]; - variableLabels: EditorVariableLabelPickerType[]; - onChange?: (editorState: EditorState, editor: LexicalEditor) => void; - onBlur?: (editor: LexicalEditor) => void; - value?: string; - placeholder?: string; - - isInvalid?: boolean; -} & FormPropsType) { + ExtensionPopover +}: EditorProps & + FormPropsType & { + onOpenModal?: () => void; + onChange: (editorState: EditorState, editor: LexicalEditor) => void; + onChangeText?: ((text: string) => void) | undefined; + onBlur: (editor: LexicalEditor) => void; + }) { const [key, setKey] = useState(getNanoid(6)); const [_, startSts] = useTransition(); const [focus, setFocus] = useState(false); @@ -81,6 +92,28 @@ export default function Editor({ setKey(getNanoid(6)); }, [value, variables, variableLabels]); + const showFullScreenIcon = useMemo(() => { + return showOpenModal && scrollHeight > maxH; + }, [showOpenModal, scrollHeight, maxH]); + + const iconButtonStyle = useMemo( + () => ({ + position: 'absolute' as const, + bottom: 1, + right: showFullScreenIcon ? '34px' : 2, + zIndex: 10, + cursor: 'pointer', + borderRadius: '6px', + background: 'rgba(255, 255, 255, 0.01)', + backdropFilter: 'blur(6.6666669845581055px)', + alignItems: 'center', + justifyContent: 'center', + w: 6, + h: 6 + }), + [showFullScreenIcon] + ); + return ( 0 ? [] : variables} /> - {showOpenModal && scrollHeight > maxH && ( - - - + + {onChangeText && + ExtensionPopover?.map((Item, index) => ( + + ))} + {showFullScreenIcon && ( + + + )} ); diff --git a/packages/web/components/common/Textarea/PromptEditor/index.tsx b/packages/web/components/common/Textarea/PromptEditor/index.tsx index 291835f1c..48763aa26 100644 --- a/packages/web/components/common/Textarea/PromptEditor/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/index.tsx @@ -1,46 +1,29 @@ -import type { BoxProps } from '@chakra-ui/react'; import { Box, Button, ModalBody, ModalFooter, useDisclosure } from '@chakra-ui/react'; import React, { useMemo } from 'react'; import { editorStateToText } from './utils'; +import type { EditorProps } from './Editor'; import Editor from './Editor'; import MyModal from '../../MyModal'; import { useTranslation } from 'next-i18next'; import type { EditorState, LexicalEditor } from 'lexical'; import type { FormPropsType } from './type.d'; -import { type EditorVariableLabelPickerType, type EditorVariablePickerType } from './type.d'; import { useCallback } from 'react'; const PromptEditor = ({ showOpenModal = true, - variables = [], - variableLabels = [], value, onChange, onBlur, - minH, - maxH, - maxLength, - placeholder, title, - isInvalid, isDisabled, ...props -}: { - showOpenModal?: boolean; - variables?: EditorVariablePickerType[]; - variableLabels?: EditorVariableLabelPickerType[]; - value?: string; - onChange?: (text: string) => void; - onBlur?: (text: string) => void; - minH?: number; - maxH?: number; - maxLength?: number; - placeholder?: string; - title?: string; - - isInvalid?: boolean; - isDisabled?: boolean; -} & FormPropsType) => { +}: FormPropsType & + EditorProps & { + title?: string; + isDisabled?: boolean; + onChange?: (text: string) => void; + onBlur?: (text: string) => void; + }) => { const { isOpen, onOpen, onClose } = useDisclosure(); const { t } = useTranslation(); @@ -69,19 +52,13 @@ const PromptEditor = ({ <> {isDisabled && ( diff --git a/packages/web/i18n/en/app.json b/packages/web/i18n/en/app.json index db3482b78..e1227afbe 100644 --- a/packages/web/i18n/en/app.json +++ b/packages/web/i18n/en/app.json @@ -1,4 +1,5 @@ { + "AutoOptimize": "Automatic optimization", "Click_to_delete_this_field": "Click to delete this field", "Filed_is_deprecated": "This field is deprecated", "Index": "Index", @@ -12,6 +13,13 @@ "MCP_tools_url_is_empty": "The MCP address cannot be empty", "MCP_tools_url_placeholder": "After filling in the MCP address, click Analysis", "No_selected_dataset": "No selected dataset", + "Optimizer_CloseConfirm": "Confirm to close", + "Optimizer_CloseConfirmText": "Optimization results have been generated, confirming that closing will lose the current result. Will it continue?", + "Optimizer_EmptyPrompt": "Please enter optimization requirements", + "Optimizer_Generating": "Generating...", + "Optimizer_Placeholder": "How do you want to write or optimize prompt words?", + "Optimizer_Reoptimize": "Re-optimize", + "Optimizer_Replace": "replace", "Role_setting": "Permission", "Run": "Execute", "Search_dataset": "Search dataset", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index be318d24b..8bebdb92b 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -1213,6 +1213,7 @@ "support.wallet.usage.Bill Module": "Billing Module", "support.wallet.usage.Duration": "Duration (seconds)", "support.wallet.usage.Module name": "Module Name", + "support.wallet.usage.Optimize Prompt": "Prompt word optimization", "support.wallet.usage.Source": "Source", "support.wallet.usage.Text Length": "Text Length", "support.wallet.usage.Time": "Generation Time", diff --git a/packages/web/i18n/zh-CN/app.json b/packages/web/i18n/zh-CN/app.json index 748c0f4f0..a5e45ec21 100644 --- a/packages/web/i18n/zh-CN/app.json +++ b/packages/web/i18n/zh-CN/app.json @@ -1,4 +1,5 @@ { + "AutoOptimize": "自动优化", "Click_to_delete_this_field": "点击删除该字段", "Filed_is_deprecated": "该字段已弃用", "Index": "索引", @@ -12,6 +13,13 @@ "MCP_tools_url_is_empty": "MCP 地址不能为空", "MCP_tools_url_placeholder": "填入 MCP 地址后,点击解析", "No_selected_dataset": "未选择知识库", + "Optimizer_CloseConfirm": "确认关闭", + "Optimizer_CloseConfirmText": "已经生成了优化结果,确认关闭将丢失当前结果,是否继续?", + "Optimizer_EmptyPrompt": "请输入优化要求", + "Optimizer_Generating": "生成中…", + "Optimizer_Placeholder": "你希望如何编写或优化提示词?", + "Optimizer_Reoptimize": "重新优化", + "Optimizer_Replace": "替换", "Role_setting": "权限设置", "Run": "运行", "Search_dataset": "搜索知识库", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index cab77ff8f..a357c7fba 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -1214,6 +1214,7 @@ "support.wallet.usage.Bill Module": "扣费模块", "support.wallet.usage.Duration": "时长(秒)", "support.wallet.usage.Module name": "模块名", + "support.wallet.usage.Optimize Prompt": "提示词优化", "support.wallet.usage.Source": "来源", "support.wallet.usage.Text Length": "文本长度", "support.wallet.usage.Time": "生成时间", diff --git a/packages/web/i18n/zh-Hant/app.json b/packages/web/i18n/zh-Hant/app.json index 834edbbdc..496648e23 100644 --- a/packages/web/i18n/zh-Hant/app.json +++ b/packages/web/i18n/zh-Hant/app.json @@ -1,4 +1,5 @@ { + "AutoOptimize": "自動優化", "Click_to_delete_this_field": "點擊刪除該字段", "Filed_is_deprecated": "該字段已棄用", "Index": "索引", @@ -12,6 +13,13 @@ "MCP_tools_url_is_empty": "MCP 地址不能為空", "MCP_tools_url_placeholder": "填入 MCP 地址後,點擊解析", "No_selected_dataset": "未選擇知識庫", + "Optimizer_CloseConfirm": "確認關閉", + "Optimizer_CloseConfirmText": "已經生成了優化結果,確認關閉將丟失當前結果,是否繼續?", + "Optimizer_EmptyPrompt": "請輸入優化要求", + "Optimizer_Generating": "生成中…", + "Optimizer_Placeholder": "你希望如何編寫或優化提示詞?", + "Optimizer_Reoptimize": "重新優化", + "Optimizer_Replace": "替換", "Role_setting": "權限設定", "Run": "執行", "Search_dataset": "搜尋知識庫", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 2647b5f8a..c69fce292 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -1212,6 +1212,7 @@ "support.wallet.usage.Bill Module": "計費模組", "support.wallet.usage.Duration": "時長(秒)", "support.wallet.usage.Module name": "模組名稱", + "support.wallet.usage.Optimize Prompt": "提示詞優化", "support.wallet.usage.Source": "來源", "support.wallet.usage.Text Length": "文字長度", "support.wallet.usage.Time": "產生時間", diff --git a/projects/app/src/components/common/PromptEditor/OptimizerPopover/index.tsx b/projects/app/src/components/common/PromptEditor/OptimizerPopover/index.tsx new file mode 100644 index 000000000..62eac2de1 --- /dev/null +++ b/projects/app/src/components/common/PromptEditor/OptimizerPopover/index.tsx @@ -0,0 +1,326 @@ +import { useMemo, useRef, useState } from 'react'; +import type { FlexProps } from '@chakra-ui/react'; +import { Box, Button, Flex, Textarea, useDisclosure } from '@chakra-ui/react'; +import { HUGGING_FACE_ICON } from '@fastgpt/global/common/system/constants'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import MyPopover from '@fastgpt/web/components/common/MyPopover'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { useTranslation } from 'next-i18next'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { useLocalStorageState } from 'ahooks'; +import AIModelSelector from '../../../Select/AIModelSelector'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { onOptimizePrompt } from '@/web/common/api/fetch'; + +export type OptimizerPromptProps = { + onChangeText: (text: string) => void; + defaultPrompt?: string; +}; + +export type OnOptimizePromptProps = { + originalPrompt?: string; + input: string; + model: string; + onResult: (result: string) => void; + abortController?: AbortController; +}; + +const OptimizerPopover = ({ + onChangeText, + iconButtonStyle, + defaultPrompt +}: OptimizerPromptProps & { + iconButtonStyle?: FlexProps; +}) => { + const { t } = useTranslation(); + const { llmModelList, defaultModels } = useSystemStore(); + + const [optimizerInput, setOptimizerInput] = useState(''); + const [optimizedResult, setOptimizedResult] = useState(''); + const [selectedModel = '', setSelectedModel] = useLocalStorageState( + 'prompt-editor-selected-model', + { + defaultValue: defaultModels.llm?.model || '' + } + ); + + const [abortController, setAbortController] = useState(null); + const { isOpen: isConfirmOpen, onOpen: onOpenConfirm, onClose: onCloseConfirm } = useDisclosure(); + + const closePopoverRef = useRef<() => void>(); + + const modelOptions = useMemo(() => { + return llmModelList.map((model) => { + // const provider = getModelProvider(model.model) + return { + label: ( + + + + {model.name} + + + ), + value: model.model + }; + }); + }, [llmModelList]); + + const isEmptyOptimizerInput = useMemo(() => { + return !optimizerInput.trim(); + }, [optimizerInput]); + + const { runAsync: handleSendOptimization, loading } = useRequest2(async (isAuto?: boolean) => { + if (isEmptyOptimizerInput && !isAuto) return; + + setOptimizedResult(''); + setOptimizerInput(''); + const controller = new AbortController(); + setAbortController(controller); + + await onOptimizePrompt({ + originalPrompt: defaultPrompt, + input: optimizerInput, + model: selectedModel, + onResult: (result: string) => { + if (!controller.signal.aborted) { + setOptimizedResult((prev) => prev + result); + } + }, + abortController: controller + }); + + setAbortController(null); + }); + + const handleStopRequest = () => { + if (abortController) { + abortController.abort(); + setAbortController(null); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + if (!loading) { + handleSendOptimization(); + } + } + }; + + return ( + <> + + + + } + trigger="click" + placement={'auto'} + w="482px" + onBackdropClick={() => { + if (optimizedResult) { + onOpenConfirm(); + } else { + closePopoverRef.current?.(); + } + }} + > + {({ onClose }) => { + closePopoverRef.current = onClose; + return ( + + {/* Result */} + {optimizedResult && ( + + {optimizedResult} + + )} + {/* Button */} + + {!loading && ( + <> + {!optimizedResult && !!defaultPrompt && ( + + )} + {optimizedResult && ( + <> + + + + )} + + )} + + + {modelOptions && modelOptions.length > 0 && ( + + )} + + + {/* Input */} + + +