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 <heheer@sealos.io>
This commit is contained in:
Archer 2025-08-14 15:48:22 +08:00 committed by GitHub
parent 6a02d2a2e5
commit 9fbfabac61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1968 additions and 202 deletions

View File

@ -6,10 +6,12 @@ description: 'FastGPT V4.12.1 更新说明'
## 🚀 新增内容
1. Prompt 自动生成和优化。
## ⚙️ 优化
1. 工作流响应优化,主动指定响应值进入历史记录,而不是根据 key 决定。
2. 避免工作流中,变量替换导致的死循环或深度递归风险。
## 🐛 修复

View File

@ -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",

View File

@ -34,15 +34,75 @@ export const valToStr = (val: any) => {
};
// replace {{variable}} to value
export function replaceVariable(text: any, obj: Record<string, string | number | undefined>) {
export function replaceVariable(
text: any,
obj: Record<string, string | number | undefined>,
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<string>();
// 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 */

View File

@ -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<string, any>;
};
export type ChatWithAppSchema = Omit<ChatSchema, 'appId'> & {
export type ChatWithAppSchema = Omit<ChatSchemaType, 'appId'> & {
appId: AppSchema;
};

View File

@ -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<string, any>; // global variables
depth?: number;
}) {
if (typeof text !== 'string') return text;
if (text === '') return text;
const MAX_REPLACEMENT_DEPTH = 10;
const processedVariables = new Set<string>();
// 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 = ({

View File

@ -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')
}
};

View File

@ -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<ChatType>(chatCollectionName, ChatSchema);
export const MongoChat = getMongoModel<ChatSchemaType>(chatCollectionName, ChatSchema);

View File

@ -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);

View File

@ -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<H
variables: allVariables
});
};
/* Replace the JSON string to reduce parsing errors
1. Replace undefined values with null
2. Replace newline strings
*/
const replaceJsonBodyString = (text: string) => {
// 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(/(?<!\\)"/g, '\\"');
}
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);
};
// 1. Replace {{key.key}} variables
const regex1 = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g;
const matches1 = [...text.matchAll(regex1)];
matches1.forEach((match) => {
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<H
}
if (!httpJsonBody) return {};
if (httpContentType === ContentTypes.json) {
httpJsonBody = replaceJsonBodyString(httpJsonBody);
httpJsonBody = replaceJsonBodyString(
{ text: httpJsonBody },
{ variables, allVariables, runtimeNodes }
);
return json5.parse(httpJsonBody);
}
@ -360,7 +272,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
Object.keys(results).length > 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<H
}
};
/* Replace the JSON string to reduce parsing errors
1. Replace undefined values with null
2. Replace newline strings
*/
export const replaceJsonBodyString = (
{ text, depth = 0 }: { text: string; depth?: number },
props: {
variables: Record<string, any>;
allVariables: Record<string, any>;
runtimeNodes: RuntimeNodeItemType[];
}
) => {
const { variables, allVariables, runtimeNodes } = props;
const MAX_REPLACEMENT_DEPTH = 10;
const processedVariables = new Set<string>();
// 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,

View File

@ -96,7 +96,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes
[DispatchNodeResponseKeyEnum.toolResponses]: rawResponse
};
} catch (error) {
addLog.error('Http request error', error);
addLog.warn('Http request error', formatHttpError(error));
return {
error: {
[NodeOutputKeyEnum.errorText]: getErrText(error)

View File

@ -450,6 +450,7 @@ export const iconPaths = {
'model/yi': () => 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'),

View File

@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.02489 5.13636C6.02489 4.7096 6.37085 4.36364 6.79762 4.36364H11.434C11.8607 4.36364 12.2067 4.7096 12.2067 5.13636C12.2067 5.56313 11.8607 5.90909 11.434 5.90909H9.88853V12.0909H11.434C11.8607 12.0909 12.2067 12.4369 12.2067 12.8636C12.2067 13.2904 11.8607 13.6364 11.434 13.6364H6.79762C6.37085 13.6364 6.02489 13.2904 6.02489 12.8636C6.02489 12.4369 6.37085 12.0909 6.79762 12.0909H8.34307V5.90909H6.79762C6.37085 5.90909 6.02489 5.56313 6.02489 5.13636Z" fill="url(#paint0_linear_25014_10095)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.33398 12.669C2.01342 12.8153 1.63496 12.674 1.48865 12.3535C0.540947 10.2771 0.497952 7.89092 1.35999 5.78813C1.49365 5.4621 1.86631 5.30615 2.19234 5.43981C2.51837 5.57346 2.67433 5.94612 2.54067 6.27215C1.81207 8.04942 1.84846 10.0686 2.64949 11.8237C2.7958 12.1442 2.65454 12.5227 2.33398 12.669Z" fill="url(#paint1_linear_25014_10095)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6702 12.6774C15.3495 12.5313 15.208 12.153 15.354 11.8323C16.1583 10.0664 16.1874 8.03144 15.4407 6.24392C15.3049 5.91878 15.4584 5.5451 15.7835 5.40929C16.1087 5.27347 16.4824 5.42695 16.6182 5.75209C17.5012 7.86613 17.4673 10.271 16.5153 12.3612C16.3692 12.6819 15.9909 12.8235 15.6702 12.6774Z" fill="url(#paint2_linear_25014_10095)"/>
<path d="M3.77789 0.747892C3.85803 0.41737 4.32811 0.417369 4.40826 0.747892L4.62879 1.65735C4.6574 1.77535 4.74954 1.86749 4.86754 1.8961L5.777 2.11663C6.10752 2.19678 6.10752 2.66686 5.777 2.747L4.86754 2.96753C4.85279 2.97111 4.83845 2.97568 4.82458 2.98117C4.7275 3.01959 4.65382 3.10304 4.62879 3.20629L4.40826 4.11574C4.40325 4.1364 4.39672 4.15577 4.38885 4.17384C4.27087 4.44498 3.85302 4.42561 3.77789 4.11575L3.55736 3.20629C3.55557 3.19892 3.55353 3.19164 3.55126 3.18448C3.51712 3.07702 3.42923 2.99436 3.3186 2.96753L2.40914 2.747C2.38849 2.74199 2.36912 2.73546 2.35105 2.7276C2.07991 2.60962 2.09928 2.19177 2.40914 2.11663L3.3186 1.8961C3.4366 1.86749 3.52874 1.77535 3.55736 1.65735L3.77789 0.747892Z" fill="url(#paint3_linear_25014_10095)"/>
<path d="M13.8233 13.8843C13.9035 13.5537 14.3736 13.5537 14.4537 13.8843L14.6742 14.7937C14.7029 14.9117 14.795 15.0039 14.913 15.0325L15.8225 15.253C16.153 15.3331 16.153 15.8032 15.8225 15.8834L14.913 16.1039C14.8982 16.1075 14.8839 16.112 14.87 16.1175C14.773 16.1559 14.6993 16.2394 14.6742 16.3427L14.4537 17.2521C14.4487 17.2728 14.4422 17.2921 14.4343 17.3102C14.3163 17.5813 13.8985 17.562 13.8233 17.2521L13.6028 16.3427C13.601 16.3353 13.599 16.328 13.5967 16.3208C13.5626 16.2134 13.4747 16.1307 13.3641 16.1039L12.4546 15.8834C12.4339 15.8784 12.4146 15.8718 12.3965 15.864C12.1254 15.746 12.1447 15.3281 12.4546 15.253L13.3641 15.0325C13.4821 15.0039 13.5742 14.9117 13.6028 14.7937L13.8233 13.8843Z" fill="url(#paint4_linear_25014_10095)"/>
<path d="M14.5249 2.04545C14.5249 2.47222 14.1789 2.81818 13.7522 2.81818C13.3254 2.81818 12.9794 2.47222 12.9794 2.04545C12.9794 1.61869 13.3254 1.27273 13.7522 1.27273C14.1789 1.27273 14.5249 1.61869 14.5249 2.04545Z" fill="url(#paint5_linear_25014_10095)"/>
<path d="M5.25216 15.9545C5.25216 16.3813 4.9062 16.7273 4.47943 16.7273C4.05267 16.7273 3.70671 16.3813 3.70671 15.9545C3.70671 15.5278 4.05267 15.1818 4.47943 15.1818C4.9062 15.1818 5.25216 15.5278 5.25216 15.9545Z" fill="url(#paint6_linear_25014_10095)"/>
<defs>
<linearGradient id="paint0_linear_25014_10095" x1="8.99999" y1="1.05195" x2="8.99999" y2="16.9481" gradientUnits="userSpaceOnUse">
<stop stop-color="#499DFF"/>
<stop offset="0.432292" stop-color="#2770FF"/>
<stop offset="1" stop-color="#6E80FF"/>
</linearGradient>
<linearGradient id="paint1_linear_25014_10095" x1="8.99999" y1="1.05195" x2="8.99999" y2="16.9481" gradientUnits="userSpaceOnUse">
<stop stop-color="#499DFF"/>
<stop offset="0.432292" stop-color="#2770FF"/>
<stop offset="1" stop-color="#6E80FF"/>
</linearGradient>
<linearGradient id="paint2_linear_25014_10095" x1="8.99999" y1="1.05195" x2="8.99999" y2="16.9481" gradientUnits="userSpaceOnUse">
<stop stop-color="#499DFF"/>
<stop offset="0.432292" stop-color="#2770FF"/>
<stop offset="1" stop-color="#6E80FF"/>
</linearGradient>
<linearGradient id="paint3_linear_25014_10095" x1="8.99999" y1="1.05195" x2="8.99999" y2="16.9481" gradientUnits="userSpaceOnUse">
<stop stop-color="#499DFF"/>
<stop offset="0.432292" stop-color="#2770FF"/>
<stop offset="1" stop-color="#6E80FF"/>
</linearGradient>
<linearGradient id="paint4_linear_25014_10095" x1="8.99999" y1="1.05195" x2="8.99999" y2="16.9481" gradientUnits="userSpaceOnUse">
<stop stop-color="#499DFF"/>
<stop offset="0.432292" stop-color="#2770FF"/>
<stop offset="1" stop-color="#6E80FF"/>
</linearGradient>
<linearGradient id="paint5_linear_25014_10095" x1="8.99999" y1="1.05195" x2="8.99999" y2="16.9481" gradientUnits="userSpaceOnUse">
<stop stop-color="#499DFF"/>
<stop offset="0.432292" stop-color="#2770FF"/>
<stop offset="1" stop-color="#6E80FF"/>
</linearGradient>
<linearGradient id="paint6_linear_25014_10095" x1="8.99999" y1="1.05195" x2="8.99999" y2="16.9481" gradientUnits="userSpaceOnUse">
<stop stop-color="#499DFF"/>
<stop offset="0.432292" stop-color="#2770FF"/>
<stop offset="1" stop-color="#6E80FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -57,7 +57,7 @@ const MyModal = ({
closeOnOverlayClick={closeOnOverlayClick}
returnFocusOnClose={false}
>
<ModalOverlay />
<ModalOverlay zIndex={props.zIndex} />
<ModalContent
w={w}
minW={['90vw', '400px']}
@ -65,6 +65,9 @@ const MyModal = ({
position={'relative'}
maxH={'85vh'}
boxShadow={'7'}
containerProps={{
zIndex: props.zIndex
}}
{...props}
>
{!title && onClose && showCloseButton && <ModalCloseButton zIndex={1} />}

View File

@ -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}
>
<PopoverTrigger>{Trigger}</PopoverTrigger>
<PopoverContent {...props}>
{hasArrow && <PopoverArrow />}
{children({ onClose })}
</PopoverContent>
{isOpen && onBackdropClick && (
<Portal>
<Box position="fixed" zIndex={1000} inset={0} onClick={() => onBackdropClick()} />
</Portal>
)}
<Portal>
<PopoverContent zIndex={1001} {...props}>
{hasArrow && <PopoverArrow />}
{children({ onClose })}
</PopoverContent>
</Portal>
</Popover>
);
};

View File

@ -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<string, any>;
}) => 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 (
<Box
className="nowheel"
@ -146,17 +179,15 @@ export default function Editor({
<VariablePickerPlugin variables={variableLabels.length > 0 ? [] : variables} />
<OnBlurPlugin onBlur={onBlur} />
</LexicalComposer>
{showOpenModal && scrollHeight > maxH && (
<Box
zIndex={10}
position={'absolute'}
bottom={-1}
right={2}
cursor={'pointer'}
onClick={onOpenModal}
>
<MyIcon name={'common/fullScreenLight'} w={'14px'} color={'myGray.500'} />
</Box>
{onChangeText &&
ExtensionPopover?.map((Item, index) => (
<Item key={index} iconButtonStyle={iconButtonStyle} onChangeText={onChangeText} />
))}
{showFullScreenIcon && (
<Flex onClick={onOpenModal} {...iconButtonStyle} right={2}>
<MyIcon name={'common/fullScreenLight'} w={'1rem'} color={'myGray.500'} />
</Flex>
)}
</Box>
);

View File

@ -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 = ({
<>
<Box position="relative">
<Editor
{...props}
showOpenModal={showOpenModal}
onOpenModal={onOpen}
variables={variables}
variableLabels={variableLabels}
minH={minH}
maxH={maxH}
maxLength={maxLength}
value={formattedValue}
onChange={onChangeInput}
onChangeText={onChange}
onBlur={onBlurInput}
placeholder={placeholder}
isInvalid={isInvalid}
{...props}
/>
{isDisabled && (
<Box
@ -106,16 +83,14 @@ const PromptEditor = ({
>
<ModalBody>
<Editor
{...props}
minH={400}
maxH={400}
maxLength={maxLength}
showOpenModal={false}
variables={variables}
variableLabels={variableLabels}
value={value}
onChange={onChangeInput}
onChangeText={onChange}
onBlur={onBlurInput}
placeholder={placeholder}
/>
</ModalBody>
<ModalFooter>

View File

@ -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",

View File

@ -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",

View File

@ -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": "搜索知识库",

View File

@ -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": "生成时间",

View File

@ -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": "搜尋知識庫",

View File

@ -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": "產生時間",

View File

@ -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<string>(
'prompt-editor-selected-model',
{
defaultValue: defaultModels.llm?.model || ''
}
);
const [abortController, setAbortController] = useState<AbortController | null>(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: (
<Flex alignItems={'center'}>
<Avatar
src={model.avatar || HUGGING_FACE_ICON}
fallbackSrc={HUGGING_FACE_ICON}
mr={1.5}
w={5}
/>
<Box fontWeight={'normal'} fontSize={'14px'} color={'myGray.900'}>
{model.name}
</Box>
</Flex>
),
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 (
<>
<MyPopover
Trigger={
<Flex {...iconButtonStyle}>
<MyIcon name={'optimizer'} w={'18px'} />
</Flex>
}
trigger="click"
placement={'auto'}
w="482px"
onBackdropClick={() => {
if (optimizedResult) {
onOpenConfirm();
} else {
closePopoverRef.current?.();
}
}}
>
{({ onClose }) => {
closePopoverRef.current = onClose;
return (
<Box p={optimizedResult ? 8 : 4}>
{/* Result */}
{optimizedResult && (
<Box
px={'10px'}
maxHeight={'300px'}
overflowY={'auto'}
fontSize={'14px'}
color={'gray.700'}
whiteSpace={'pre-wrap'}
wordBreak={'break-word'}
mb={4}
>
{optimizedResult}
</Box>
)}
{/* Button */}
<Flex mb={3} alignItems={'center'} gap={3}>
{!loading && (
<>
{!optimizedResult && !!defaultPrompt && (
<Button
variant={'whiteBase'}
size={'sm'}
color={'myGray.600'}
onClick={() => handleSendOptimization(true)}
>
{t('app:AutoOptimize')}
</Button>
)}
{optimizedResult && (
<>
<Button
variant={'primaryGhost'}
size={'sm'}
px={2}
border={'0.5px solid'}
color={'primary.600'}
onClick={() => {
onChangeText?.(optimizedResult);
setOptimizedResult('');
setOptimizerInput('');
onClose();
}}
>
{t('app:Optimizer_Replace')}
</Button>
<Button
variant={'whiteBase'}
size={'sm'}
fontSize={'12px'}
onClick={() => {
setOptimizedResult('');
handleSendOptimization();
}}
>
{t('app:Optimizer_Reoptimize')}
</Button>
</>
)}
</>
)}
<Box flex={1} />
{modelOptions && modelOptions.length > 0 && (
<AIModelSelector
borderColor={'transparent'}
_hover={{
border: '1px solid',
borderColor: 'primary.400'
}}
size={'sm'}
value={selectedModel}
list={modelOptions}
onChange={setSelectedModel}
/>
)}
</Flex>
{/* Input */}
<Flex
alignItems={'center'}
gap={2}
border={'1px solid'}
borderColor={'gray.200'}
borderRadius={'md'}
p={2}
mb={3}
_focusWithin={{ borderColor: 'primary.600' }}
>
<MyIcon name={'optimizer'} alignSelf={'flex-start'} mt={0.5} w={5} />
<Textarea
placeholder={t('app:Optimizer_Placeholder')}
resize={'none'}
rows={1}
minHeight={'24px'}
lineHeight={'24px'}
maxHeight={'96px'}
overflowY={'hidden'}
border={'none'}
_focus={{
boxShadow: 'none'
}}
fontSize={'sm'}
p={0}
borderRadius={'none'}
value={optimizerInput}
autoFocus
onKeyDown={handleKeyDown}
isDisabled={loading}
onChange={(e) => {
const textarea = e.target;
setOptimizerInput(e.target.value);
textarea.style.height = '24px';
const maxHeight = 96;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
if (textarea.scrollHeight > maxHeight) {
textarea.style.overflowY = 'auto';
} else {
textarea.style.overflowY = 'hidden';
}
}}
flex={1}
/>
<MyIcon
name={loading ? 'stop' : 'core/chat/sendLight'}
w={'1rem'}
alignSelf={'flex-end'}
mb={1}
color={loading || !isEmptyOptimizerInput ? 'primary.600' : 'gray.400'}
cursor={loading || !isEmptyOptimizerInput ? 'pointer' : 'not-allowed'}
onClick={() => {
if (loading) {
handleStopRequest();
} else {
void handleSendOptimization();
}
}}
/>
</Flex>
</Box>
);
}}
</MyPopover>
<MyModal
isOpen={isConfirmOpen}
onClose={onCloseConfirm}
title={t('app:Optimizer_CloseConfirm')}
iconSrc={'common/confirm/deleteTip'}
size="md"
zIndex={2000}
>
<Box p={4}>
<Box fontSize={'sm'} color={'myGray.700'} mb={4}>
{t('app:Optimizer_CloseConfirmText')}
</Box>
<Flex justifyContent={'flex-end'} gap={3}>
<Button variant={'whiteBase'} onClick={onCloseConfirm}>
{t('common:Cancel')}
</Button>
<Button
variant={'dangerFill'}
onClick={() => {
setOptimizedResult('');
setOptimizerInput('');
if (abortController) {
abortController.abort();
setAbortController(null);
}
onCloseConfirm();
closePopoverRef.current?.();
}}
>
{t('app:Optimizer_CloseConfirm')}
</Button>
</Flex>
</Box>
</MyModal>
</>
);
};
export default OptimizerPopover;

View File

@ -73,6 +73,7 @@ const InputRender = (props: InputRenderProps) => {
maxLength={props.maxLength}
minH={100}
maxH={300}
ExtensionPopover={props.ExtensionPopover}
/>
);
}

View File

@ -5,6 +5,7 @@ import type {
import type { InputTypeEnum } from './constant';
import type { UseFormReturn } from 'react-hook-form';
import type { BoxProps } from '@chakra-ui/react';
import type { EditorProps } from '@fastgpt/web/components/common/Textarea/PromptEditor/Editor';
type CommonRenderProps = {
placeholder?: string;
@ -18,14 +19,16 @@ type CommonRenderProps = {
} & Omit<BoxProps, 'onChange' | 'list' | 'value'>;
type SpecificProps =
| {
| ({
// input & textarea
inputType: InputTypeEnum.input | InputTypeEnum.textarea;
variables?: EditorVariablePickerType[];
variableLabels?: EditorVariableLabelPickerType[];
title?: string;
maxLength?: number;
}
} & {
ExtensionPopover?: EditorProps['ExtensionPopover'];
})
| {
// numberInput
inputType: InputTypeEnum.numberInput;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useTransition } from 'react';
import React, { useCallback, useEffect, useMemo, useTransition } from 'react';
import {
Box,
Flex,
@ -31,6 +31,8 @@ import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import VariableTip from '@/components/common/Textarea/MyTextarea/VariableTip';
import { getWebLLMModel } from '@/web/common/system/utils';
import ToolSelect from './components/ToolSelect';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover';
const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal'));
const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal'));
@ -69,6 +71,7 @@ const EditForm = ({
const { appDetail } = useContextSelector(AppContext, (v) => v);
const selectDatasets = useMemo(() => appForm?.dataset?.datasets, [appForm]);
const [, startTst] = useTransition();
const { llmModelList, defaultModels } = useSystemStore();
const {
isOpen: isOpenDatasetSelect,
@ -126,6 +129,27 @@ const EditForm = ({
}
}, [selectedModel, setAppForm]);
const OptimizerPopverComponent = useCallback(
({ iconButtonStyle }: { iconButtonStyle: Record<string, any> }) => {
return (
<OptimizerPopover
iconButtonStyle={iconButtonStyle}
defaultPrompt={appForm.aiSettings.systemPrompt}
onChangeText={(e) => {
setAppForm((state) => ({
...state,
aiSettings: {
...state.aiSettings,
systemPrompt: e
}
}));
}}
/>
);
},
[appForm.aiSettings.systemPrompt, setAppForm]
);
return (
<>
<Box>
@ -196,6 +220,7 @@ const EditForm = ({
variables={formatVariables}
placeholder={t('common:core.app.tip.systemPromptTip')}
title={t('common:core.ai.Prompt')}
ExtensionPopover={[OptimizerPopverComponent]}
/>
</Box>
</Box>

View File

@ -13,6 +13,8 @@ import { getEditorVariables } from '@/pageComponents/app/detail/WorkflowComponen
import { InputTypeEnum } from '@/components/core/app/formRender/constant';
import { llmModelTypeFilterMap } from '@fastgpt/global/core/ai/constants';
import { getWebDefaultLLMModel } from '@/web/common/system/utils';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover';
const CommonInputForm = ({ item, nodeId }: RenderInputProps) => {
const { t } = useTranslation();
@ -80,6 +82,22 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => {
return item.value;
}, [inputType, item.value, defaultModel, handleChange]);
const canOptimizePrompt = item.key === NodeInputKeyEnum.aiSystemPrompt;
const OptimizerPopverComponent = useCallback(
({ iconButtonStyle }: { iconButtonStyle: Record<string, any> }) => {
return (
<OptimizerPopover
iconButtonStyle={iconButtonStyle}
defaultPrompt={item.value}
onChangeText={(e) => {
handleChange(e);
}}
/>
);
},
[item.value, handleChange]
);
return (
<InputRender
inputType={inputType}
@ -93,6 +111,7 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => {
max={item.max}
list={item.list}
modelList={modelList}
ExtensionPopover={canOptimizePrompt ? [OptimizerPopverComponent] : undefined}
/>
);
};

View File

@ -0,0 +1,167 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { MongoAppChatLog } from '@fastgpt/service/core/app/logs/chatLogsSchema';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { addLog } from '@fastgpt/service/common/system/log';
import type { ChatSchemaType } from '@fastgpt/global/core/chat/type';
export type SyncAppChatLogQuery = {};
export type SyncAppChatLogBody = {
batchSize?: number;
};
export type SyncAppChatLogResponse = {};
/*
chats
*/
async function handler(
req: ApiRequestProps<SyncAppChatLogBody, SyncAppChatLogQuery>,
res: ApiResponseType<SyncAppChatLogResponse>
) {
await authCert({ req, authRoot: true });
const { batchSize = 10 } = req.body;
console.log('开始同步AppChatLog数据...');
console.log(`批处理大小: ${batchSize}`);
let success = 0;
const total = await MongoChat.countDocuments({});
console.log(`总共需要处理的chat记录数: ${total}`);
res.json({
data: '同步任务已开始,可在日志中看到进度'
});
while (true) {
console.log(`对话同步处理进度: ${success}/${total}`);
try {
const chats = await MongoChat.find({
initStatistics: { $exists: false }
})
.sort({ _id: -1 })
.limit(batchSize)
.lean();
if (chats.length === 0) break;
const result = await Promise.allSettled(chats.map((chat) => processChatRecord(chat)));
success += result.filter((r) => r.status === 'fulfilled').length;
} catch (error) {
addLog.error('处理chat记录失败', error);
}
}
console.log('同步对话完成');
}
async function processChatRecord(chat: ChatSchemaType) {
async function calculateChatItemStats(chatId: string) {
const chatItems = await MongoChatItem.find({ chatId }).lean();
let chatItemCount = chatItems.length;
let errorCount = 0;
let totalPoints = 0;
let goodFeedbackCount = 0;
let badFeedbackCount = 0;
let totalResponseTime = 0;
for (const item of chatItems) {
const itemData = item as any;
if (itemData.userGoodFeedback && itemData.userGoodFeedback.trim() !== '') {
goodFeedbackCount++;
}
if (itemData.userBadFeedback && itemData.userBadFeedback.trim() !== '') {
badFeedbackCount++;
}
if (itemData.durationSeconds) {
totalResponseTime += itemData.durationSeconds;
} else if (
itemData[DispatchNodeResponseKeyEnum.nodeResponse] &&
Array.isArray(itemData[DispatchNodeResponseKeyEnum.nodeResponse])
) {
for (const response of itemData[DispatchNodeResponseKeyEnum.nodeResponse]) {
if (response.runningTime) {
totalResponseTime += response.runningTime / 1000;
}
}
}
if (
itemData[DispatchNodeResponseKeyEnum.nodeResponse] &&
Array.isArray(itemData[DispatchNodeResponseKeyEnum.nodeResponse])
) {
for (const response of itemData[DispatchNodeResponseKeyEnum.nodeResponse]) {
if (response.errorText) {
errorCount++;
break;
}
if (response.totalPoints) {
totalPoints += response.totalPoints;
}
}
}
}
return {
chatItemCount,
errorCount,
totalPoints,
goodFeedbackCount,
badFeedbackCount,
totalResponseTime
};
}
async function checkIsFirstChat(chat: any): Promise<boolean> {
const earliestChat = await MongoChat.findOne(
{
userId: chat.userId,
appId: chat.appId
},
{},
{ sort: { createTime: 1 } }
).lean();
return earliestChat?._id.toString() === chat._id.toString();
}
const chatItemStats = await calculateChatItemStats(chat.chatId);
const isFirstChat = await checkIsFirstChat(chat);
const chatLogData = {
appId: chat.appId,
teamId: chat.teamId,
chatId: chat.chatId,
userId: String(chat.outLinkUid || chat.tmbId),
source: chat.source,
sourceName: chat.sourceName,
createTime: chat.createTime,
updateTime: chat.updateTime,
chatItemCount: chatItemStats.chatItemCount,
errorCount: chatItemStats.errorCount,
totalPoints: chatItemStats.totalPoints,
goodFeedbackCount: chatItemStats.goodFeedbackCount,
badFeedbackCount: chatItemStats.badFeedbackCount,
totalResponseTime: chatItemStats.totalResponseTime,
isFirstChat
};
await MongoAppChatLog.updateOne(
{ appId: chat.appId, chatId: chat.chatId },
{ $set: chatLogData },
{ upsert: true }
);
await MongoChat.updateOne({ _id: chat._id }, { $set: { initStatistics: true } });
}
export default NextAPI(handler);

View File

@ -0,0 +1,223 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { responseWrite } from '@fastgpt/service/common/response';
import { sseErrRes } from '@fastgpt/service/common/response';
import { createChatCompletion } from '@fastgpt/service/core/ai/config';
import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { loadRequestMessages } from '@fastgpt/service/core/chat/utils';
import { llmCompletionsBodyFormat, parseLLMStreamResponse } from '@fastgpt/service/core/ai/utils';
import { countGptMessagesTokens } from '@fastgpt/service/common/string/tiktoken/index';
import { formatModelChars2Points } from '@fastgpt/service/support/wallet/usage/utils';
import { createUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import { ModelTypeEnum } from '@fastgpt/global/core/ai/model';
import { i18nT } from '@fastgpt/web/i18n/utils';
import { addLog } from '@fastgpt/service/common/system/log';
type OptimizePromptBody = {
originalPrompt: string;
optimizerInput: string;
model: string;
};
const getPromptOptimizerSystemPrompt = () => {
return `# Role
Prompt工程师
## Skills
- LLM的技术原理和局限性便Prompt
- Prompt
- Prompt的表现Prompt质量
- Prompt使LLM生成的内容符合业务要求
- Prompt框架
## Goals
- Prompt
- Prompt框架
- Prompt
-
## Constrains
-
-
-
-
-
## Suggestions
- Prompt的核心意图
-
- Prompt应该能够直接使用
-
- Prompt符合行业最佳实践
- ****Suggestions部分应该专注于角色内在的工作方法`;
};
const getPromptOptimizerUserPrompt = (originalPrompt: string, optimizerInput: string) => {
return `请严格遵循用户的优化需求:
<OptimizerInput>
${optimizerInput}
</OptimizerInput>
PromptPrompt
<OriginalPrompt>
${originalPrompt}
</OriginalPrompt>
##
- Prompt
- 使
- ****SkillsGoalsConstrainsWorkflowSuggestions各部分需要5个要点OutputFormat需要3个要点
- **Suggestions是给角色的内在工作方法论**
- ****RoleBackgroundAttentionProfileSkillsGoalsConstrainsWorkflowOutputFormatSuggestionsInitialization等所有部分
- `;
};
async function handler(req: ApiRequestProps<OptimizePromptBody>, res: ApiResponseType) {
try {
const { originalPrompt, optimizerInput, model } = req.body;
const { teamId, tmbId } = await authCert({
req,
authToken: true,
authApiKey: true
});
res.setHeader('Content-Type', 'text/event-stream;charset=utf-8');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Cache-Control', 'no-cache, no-transform');
const messages: ChatCompletionMessageParam[] = [
{
role: 'system',
content: getPromptOptimizerSystemPrompt()
},
{
role: 'user',
content: getPromptOptimizerUserPrompt(originalPrompt, optimizerInput)
}
];
const requestMessages = await loadRequestMessages({
messages,
useVision: false
});
const { response, isStreamResponse } = await createChatCompletion({
body: llmCompletionsBodyFormat(
{
model,
messages: requestMessages,
temperature: 0.1,
max_tokens: 2000,
stream: true
},
model
)
});
const { inputTokens, outputTokens } = await (async () => {
if (isStreamResponse) {
const { parsePart, getResponseData } = parseLLMStreamResponse();
let optimizedText = '';
for await (const part of response) {
const { responseContent } = parsePart({
part,
parseThinkTag: true,
retainDatasetCite: false
});
if (responseContent) {
optimizedText += responseContent;
responseWrite({
res,
event: SseResponseEventEnum.answer,
data: JSON.stringify({
choices: [
{
delta: {
content: responseContent
}
}
]
})
});
}
}
const { content: answer, usage } = getResponseData();
return {
content: answer,
inputTokens: usage?.prompt_tokens || (await countGptMessagesTokens(requestMessages)),
outputTokens:
usage?.completion_tokens ||
(await countGptMessagesTokens([{ role: 'assistant', content: optimizedText }]))
};
} else {
const usage = response.usage;
const content = response.choices?.[0]?.message?.content || '';
responseWrite({
res,
event: SseResponseEventEnum.answer,
data: JSON.stringify({
choices: [
{
delta: {
content
}
}
]
})
});
return {
content,
inputTokens: usage?.prompt_tokens || (await countGptMessagesTokens(requestMessages)),
outputTokens:
usage?.completion_tokens ||
(await countGptMessagesTokens([{ role: 'assistant', content: content }]))
};
}
})();
responseWrite({
res,
event: SseResponseEventEnum.answer,
data: '[DONE]'
});
const { totalPoints, modelName } = formatModelChars2Points({
model,
inputTokens,
outputTokens,
modelType: ModelTypeEnum.llm
});
createUsage({
teamId,
tmbId,
appName: i18nT('common:support.wallet.usage.Optimize Prompt'),
totalPoints,
source: UsageSourceEnum.optimize_prompt,
list: [
{
moduleName: i18nT('common:support.wallet.usage.Optimize Prompt'),
amount: totalPoints,
model: modelName,
inputTokens,
outputTokens
}
]
});
} catch (error: any) {
addLog.error('Optimize prompt error', error);
sseErrRes(res, error);
}
res.end();
}
export default NextAPI(handler);

View File

@ -1,4 +1,4 @@
import { type ChatHistoryItemResType, type ChatSchema } from '@fastgpt/global/core/chat/type';
import { type ChatHistoryItemResType, type ChatSchemaType } from '@fastgpt/global/core/chat/type';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { type AuthModeType } from '@fastgpt/service/support/permission/type';
import { authOutLink } from './outLink';
@ -51,7 +51,7 @@ export async function authChatCrud({
teamId: string;
tmbId: string;
uid: string;
chat?: ChatSchema;
chat?: ChatSchemaType;
responseDetail: boolean;
showNodeStatus: boolean;
showRawSource: boolean;

View File

@ -10,7 +10,6 @@ import {
} from '@fastgpt/global/core/workflow/type/io.d';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node';
import type { ChatSchema } from '@fastgpt/global/core/chat/type';
import type { AppSchema } from '@fastgpt/global/core/app/type';
import { ChatModelType } from '@/constants/model';
import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant';

View File

@ -10,6 +10,7 @@ import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { useSystemStore } from '../system/useSystemStore';
import { formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import type { OnOptimizePromptProps } from '@/components/common/PromptEditor/OptimizerPopover';
type StreamFetchProps = {
url?: string;
@ -272,3 +273,27 @@ export const streamFetch = ({
failedFinish(err);
}
});
export const onOptimizePrompt = async ({
originalPrompt,
model,
input,
onResult,
abortController
}: OnOptimizePromptProps) => {
const controller = abortController || new AbortController();
await streamFetch({
url: '/api/core/ai/optimizePrompt',
data: {
originalPrompt,
optimizerInput: input,
model
},
onMessage: ({ event, text }) => {
if (event === SseResponseEventEnum.answer && text) {
onResult(text);
}
},
abortCtrl: controller
});
};

View File

@ -103,7 +103,7 @@ export const useSystemStore = create<State>()(
return null;
},
gitStar: 20000,
gitStar: 25000,
async loadGitStar() {
if (!get().feConfigs?.show_git) return;
try {

View File

@ -0,0 +1,632 @@
import { describe, it, expect } from 'vitest';
import { replaceJsonBodyString } from '@fastgpt/service/core/workflow/dispatch/tools/http468';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
describe('replaceJsonBodyString', () => {
// Mock runtime nodes for testing
const mockRuntimeNodes = [
{
nodeId: 'node1',
outputs: [
{ id: 'output1', value: 'Hello World', valueType: 'string' },
{ id: 'output2', value: 42, valueType: 'number' },
{ id: 'output3', value: true, valueType: 'boolean' },
{ id: 'output4', value: { nested: 'value' }, valueType: 'object' },
{ id: 'output5', value: [1, 2, 3], valueType: 'array' }
],
inputs: []
}
] as unknown as RuntimeNodeItemType[];
const mockVariables = {
userName: 'John Doe',
userAge: 30,
isActive: true,
userProfile: { name: 'John', email: 'john@example.com' },
tags: ['developer', 'tester']
};
const mockAllVariables = {
...mockVariables,
systemVar: 'system_value',
nullVar: null,
undefinedVar: undefined,
emptyString: '',
zeroNumber: 0,
falseBool: false
};
const mockProps = {
variables: mockVariables,
allVariables: mockAllVariables,
runtimeNodes: mockRuntimeNodes
};
describe('Basic variable replacement functionality', () => {
it('should correctly replace string variables', () => {
const input = '{"name": "{{userName}}", "greeting": "Hello {{userName}}"}';
const expected = '{"name": "John Doe", "greeting": "Hello John Doe"}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(expected);
});
it('should correctly replace number variables', () => {
const input =
'{"age": {{userAge}}, "doubled": {{userAge}}, "calculation": "{{userAge}} years old"}';
const expected = '{"age": 30, "doubled": 30, "calculation": "30 years old"}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(expected);
});
it('should correctly replace boolean variables', () => {
const input =
'{"active": {{isActive}}, "inactive": {{falseBool}}, "status": "User is {{isActive}}"}';
const expected = '{"active": true, "inactive": false, "status": "User is true"}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(expected);
});
it('should correctly replace object variables', () => {
const input = '{"profile": {{userProfile}}, "info": "Profile: {{userProfile}}"}';
const expected =
'{"profile": {"name":"John","email":"john@example.com"}, "info": "Profile: {"name":"John","email":"john@example.com"}"}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(expected);
});
it('should correctly replace array variables and node output variables', () => {
const input =
'{"tags": {{tags}}, "nodeOutput": "{{$node1.output1$}}", "numbers": {{$node1.output5$}}}';
const expected =
'{"tags": ["developer","tester"], "nodeOutput": "Hello World", "numbers": [1,2,3]}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(expected);
});
});
describe('Security and boundary testing', () => {
it('should prevent circular references', () => {
const maliciousProps = {
...mockProps,
allVariables: {
...mockAllVariables,
selfRef: '{{selfRef}}', // Self reference
circularA: '{{circularB}}',
circularB: '{{circularA}}'
}
};
const input = '{"self": "{{selfRef}}", "circular": "{{circularA}}"}';
// Should keep circular references unreplaced
const result = replaceJsonBodyString({ text: input }, maliciousProps);
expect(result).toContain('{{selfRef}}');
expect(result).toContain('{{circularB}}');
});
it('should prevent excessive nesting depth', () => {
const deepNestingProps = {
...mockProps,
allVariables: {
...mockAllVariables,
level1: '{{level2}}',
level2: '{{level3}}',
level3: '{{level4}}',
level4: '{{level5}}',
level5: '{{level6}}',
level6: '{{level7}}',
level7: '{{level8}}',
level8: '{{level9}}',
level9: '{{level10}}',
level10: '{{level11}}',
level11: '{{level12}}',
level12: 'final_value'
}
};
const input = '{"deep": "{{level1}}"}';
// Should handle but limit depth
const result = replaceJsonBodyString({ text: input }, deepNestingProps);
expect(result).toBeDefined();
expect(result).toBe('{"deep": "{{level12}}"}');
// Should eventually resolve to a valid value
});
it('should handle large number of variables performance test', () => {
const manyVariables: Record<string, any> = {};
let input = '{';
// Create 1000 variables
for (let i = 0; i < 1000; i++) {
manyVariables[`var${i}`] = `value${i}`;
input += `"var${i}": "{{var${i}}}",`;
}
input = input.slice(0, -1) + '}'; // Remove last comma
const largeProps = {
...mockProps,
allVariables: { ...mockAllVariables, ...manyVariables }
};
const result = replaceJsonBodyString({ text: input }, largeProps);
expect(result).toBeDefined();
expect(result).toContain('value0');
expect(result).toContain('value999');
// Verify performance and correctness
});
it('should prevent regex attacks', () => {
const maliciousProps = {
...mockProps,
allVariables: {
...mockAllVariables,
// Contains special regex characters
maliciousVar: '.*+?^${}()|[]\\',
regexAttack: '(.*){999999}', // Potential regex attack
specialChars: '\\n\\r\\t"\'`<>&'
}
};
const input =
'{"malicious": "{{maliciousVar}}", "attack": "{{regexAttack}}", "special": "{{specialChars}}"}';
const result = replaceJsonBodyString({ text: input }, maliciousProps);
expect(result).toBeDefined();
expect(typeof result).toBe('string');
// Should properly escape special characters
expect(result).toContain('.*+?^${}()|[]\\\\');
});
it('should handle quote injection and escape characters', () => {
const maliciousProps = {
...mockProps,
allVariables: {
...mockAllVariables,
quotedVar: '"malicious"value"',
escapedVar: '\\"escaped\\"',
jsonInjection: '", "injected": "hacked',
scriptTag: '<script>alert("xss")</script>',
newlineAttack: 'line1\\nline2\\r\\nline3'
}
};
const input =
'{"quoted": "{{quotedVar}}", "escaped": "{{escapedVar}}", "injection": "{{jsonInjection}}", "script": "{{scriptTag}}", "newlines": "{{newlineAttack}}"}';
const result = replaceJsonBodyString({ text: input }, maliciousProps);
expect(result).toBeDefined();
expect(typeof result).toBe('string');
// Should properly escape quotes
expect(result).toContain('\\"malicious\\"value\\"');
// Should handle newlines
expect(result).toContain('line1\\\\nline2\\\\r\\\\nline3');
// Should generate valid JSON
try {
JSON.parse(result);
} catch (e) {
// If parsing fails, there might be injection attacks or escaping issues
// Further validation needed here
expect(e).toBeDefined();
}
});
});
describe('Edge cases', () => {
it('should handle empty string', () => {
const result = replaceJsonBodyString({ text: '' }, mockProps);
expect(result).toBe('');
});
it('should handle static content without variables', () => {
const input = '{"static": "value", "number": 123}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(input);
});
it('should handle null and undefined values', () => {
const input = '{"nullVar": {{nullVar}}, "undefinedVar": {{undefinedVar}}}';
const expected = '{"nullVar": null, "undefinedVar": null}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(expected);
});
it('should handle non-existent variables', () => {
const input = '{"missing": "{{nonExistentVar}}", "static": "value"}';
const expected = '{"missing": "null", "static": "value"}';
const result = replaceJsonBodyString({ text: input }, mockProps);
expect(result).toBe(expected);
});
});
describe('Complex robustness and compatibility tests', () => {
// Test Case 1: Nested JSON with mixed variable types and complex escape scenarios
it('Complex Test 1: Deep nested JSON with mixed data types, escape characters and Unicode', () => {
const complexProps = {
...mockProps,
allVariables: {
...mockAllVariables,
unicodeText: '你好世界 🌍 Hello\nWorld',
complexObject: {
nested: {
array: [{ key: 'value"with"quotes' }, null, undefined],
special: 'tab\there\nnewline\rcarriage\\"quotes\\\'single'
}
},
htmlContent: '<div class="test" data-value=\'mixed"quotes\'>Content & more</div>',
jsonLikeString: '{"fake": "json", "number": 123, "bool": true}',
emptyValues: { empty: '', zero: 0, false: false, null: null }
}
};
const input = `{
"metadata": {
"title": "{{unicodeText}}",
"description": "Testing \\"complex\\" scenarios",
"nested": {{complexObject}},
"htmlSnippet": "{{htmlContent}}"
},
"payload": {
"jsonString": "{{jsonLikeString}}",
"emptyData": {{emptyValues}},
"nodeOutput": "{{$node1.output4$}}",
"arrayData": {{$node1.output5$}}
},
"validation": {
"hasNewlines": "Text with\\nembedded\\tcharacters: {{unicodeText}}",
"quoteMixing": "\\"Outer quotes\\" and '{{htmlContent}}' content"
}
}`;
const result = replaceJsonBodyString({ text: input }, complexProps);
// Verify it's valid JSON after replacement
expect(() => JSON.parse(result)).not.toThrow();
// Verify complex nested structures are preserved
const parsed = JSON.parse(result);
expect(parsed.metadata.nested.nested.array).toHaveLength(3);
expect(parsed.payload.emptyData.zero).toBe(0);
expect(parsed.payload.emptyData.false).toBe(false);
expect(parsed.payload.emptyData.null).toBe(null);
// Verify Unicode and special characters are handled
expect(parsed.metadata.title).toContain('你好世界');
expect(parsed.metadata.title).toContain('🌍');
});
// Test Case 2: Variable substitution in complex nested structures with edge cases
it('Complex Test 2: Complex variable interpolation and data type handling', () => {
const edgeCaseProps = {
...mockProps,
allVariables: {
...mockAllVariables,
leadingTrailingSpaces: ' spaced content ',
numberAsString: '12345',
boolAsString: 'true',
arrayAsString: '[1,2,3]',
objectAsString: '{"key":"value"}',
commaInValue: 'value,with,commas',
colonInValue: 'key:value:pair',
braceInValue: 'value{with}braces',
safeJsonString: 'safe_content_123'
}
};
const input = `{
"testDataTypes": {
"data": "{{leadingTrailingSpaces}}",
"number": {{numberAsString}},
"bool": {{boolAsString}},
"array": {{arrayAsString}},
"object": {{objectAsString}}
},
"testStringNumbers": {
"stringAsNumber": "Value is {{numberAsString}}",
"boolAsString": "Boolean: {{boolAsString}}",
"safeContent": "{{safeJsonString}}"
},
"testSpecialChars": {
"commas": "{{commaInValue}}",
"colons": "{{colonInValue}}",
"braces": "{{braceInValue}}"
},
"testMixedQuoting": {
"inQuotes": "{{arrayAsString}}",
"withoutQuotes": {{arrayAsString}},
"safeQuote": "Content: {{safeJsonString}}"
}
}`;
const result = replaceJsonBodyString({ text: input }, edgeCaseProps);
// Should produce valid JSON
expect(() => JSON.parse(result)).not.toThrow();
const parsed = JSON.parse(result);
// Verify trimming and data type handling
expect(parsed.testDataTypes.data).toBe(' spaced content ');
expect(parsed.testDataTypes.number).toBe(12345);
expect(parsed.testDataTypes.bool).toBe(true);
// Verify arrays and objects are properly embedded
expect(Array.isArray(parsed.testDataTypes.array)).toBe(true);
expect(typeof parsed.testDataTypes.object).toBe('object');
// Verify special characters in values don't break JSON
expect(parsed.testSpecialChars.commas).toContain(',');
expect(parsed.testSpecialChars.colons).toContain(':');
expect(parsed.testSpecialChars.braces).toContain('{');
// Verify string interpolation works correctly
expect(parsed.testStringNumbers.stringAsNumber).toContain('12345');
expect(parsed.testMixedQuoting.safeQuote).toContain('safe_content_123');
});
// Test Case 3: Extreme recursion and circular reference prevention
it('Complex Test 3: Advanced circular reference patterns and recursion limits', () => {
const recursionProps = {
...mockProps,
allVariables: {
...mockAllVariables,
// Complex circular patterns
chainA: '{{chainB}}',
chainB: '{{chainC}}',
chainC: '{{chainD}}',
chainD: '{{chainE}}',
chainE: '{{chainA}}', // Creates cycle
// Indirect self-reference
selfIndirect: 'prefix-{{selfIndirect}}-suffix',
// Node reference cycles
nodeRef1: '{{$node1.output1$}} -> {{nodeRef2}}',
nodeRef2: '{{nodeRef1}}',
// Deep nested variable chains
level1: '{{level2}}-{{level3}}',
level2: '{{level4}}-{{level5}}',
level3: '{{level6}}-{{level7}}',
level4: '{{level8}}-{{level9}}',
level5: '{{level10}}-{{level11}}',
level6: '{{level12}}-final',
level7: 'end',
level8: 'deep8',
level9: 'deep9',
level10: 'deep10',
level11: 'deep11',
level12: 'deep12'
}
};
const input = `{
"circularTests": {
"simpleChain": "{{chainA}}",
"selfReference": "{{selfIndirect}}",
"nodeCircular": "{{nodeRef1}}",
"deepNesting": "{{level1}}"
},
"mixedCircular": {
"valid": "{{userName}}",
"circular": "{{chainB}}",
"nodeData": "{{$node1.output1$}}",
"combined": "Valid: {{userName}}, Circular: {{chainC}}"
}
}`;
const result = replaceJsonBodyString({ text: input }, recursionProps);
// Should not crash or hang
expect(result).toBeDefined();
expect(typeof result).toBe('string');
// Should contain unreplaced circular references
expect(result).toMatch(/\{\{chain[A-E]\}\}/);
expect(result).toContain('{{selfIndirect}}');
// Valid variables should still be replaced
expect(result).toContain('John Doe');
expect(result).toContain('Hello World');
// Should handle deep nesting up to limit
const parsed = JSON.parse(result);
expect(parsed.circularTests.deepNesting).toBeDefined();
});
// Test Case 4: Large payload stress test with performance validation
it('Complex Test 4: Large payload stress test with varied data patterns', () => {
const stressTestVariables: Record<string, any> = {};
// Generate large number of variables with different patterns
for (let i = 0; i < 500; i++) {
stressTestVariables[`str_${i}`] = `string_value_${i}_with_special_chars_"'\\\\n\\\\t`;
stressTestVariables[`num_${i}`] = Math.random() * 1000000;
stressTestVariables[`bool_${i}`] = i % 2 === 0;
stressTestVariables[`obj_${i}`] = {
id: i,
data: `nested_${i}`,
list: [i, i + 1, i + 2],
nested: { deep: `value_${i}` }
};
stressTestVariables[`arr_${i}`] = Array.from(
{ length: (i % 10) + 1 },
(_, j) => `item_${i}_${j}`
);
}
// Add some problematic variables
stressTestVariables['largeText'] = 'x'.repeat(10000);
stressTestVariables['jsonBomb'] = JSON.stringify({ large: 'x'.repeat(1000) });
stressTestVariables['specialChars'] = '\\n\\r\\t\\"\\\\\'`~!@#$%^&*()[]{}|;:,.<>?/+=';
const stressProps = {
...mockProps,
allVariables: { ...mockAllVariables, ...stressTestVariables }
};
// Create large JSON structure
let input = '{\n';
for (let i = 0; i < 200; i++) {
input += ` "section_${i}": {\n`;
input += ` "str": "{{str_${i}}}",\n`;
input += ` "num": {{num_${i}}},\n`;
input += ` "bool": {{bool_${i}}},\n`;
input += ` "obj": {{obj_${i}}},\n`;
input += ` "arr": {{arr_${i}}},\n`;
input += ` "mixed": "String {{str_${i}}} with number {{num_${i}}}"\n`;
input += ` }${i < 199 ? ',' : ''}\n`;
}
input += '}';
const startTime = Date.now();
const result = replaceJsonBodyString({ text: input }, stressProps);
const endTime = Date.now();
// Performance check - should complete within reasonable time
expect(endTime - startTime).toBeLessThan(5000); // 5 seconds max
// Should produce valid JSON
expect(() => JSON.parse(result)).not.toThrow();
const parsed = JSON.parse(result);
// Verify structure integrity
expect(Object.keys(parsed)).toHaveLength(200);
expect(parsed.section_0.str).toContain('string_value_0');
expect(typeof parsed.section_0.num).toBe('number');
expect(typeof parsed.section_0.bool).toBe('boolean');
expect(Array.isArray(parsed.section_0.arr)).toBe(true);
});
// Test Case 5: Security and injection attack prevention
it('Complex Test 5: Comprehensive security and injection attack prevention', () => {
const securityProps = {
...mockProps,
allVariables: {
...mockAllVariables,
// SQL injection patterns
sqlInjection: "'; DROP TABLE users; --",
sqlInjection2: "' OR '1'='1",
// JSON injection attempts
jsonBreak: '", "hacked": true, "original": "',
jsonBreak2: '}}, "injected": {"evil": "payload"}, "fake": {{',
// XSS attempts
xssScript: '<script>alert("XSS")</script>',
xssOnload: '<img src=x onerror=alert("XSS")>',
// Template injection attempts
templateInject: '{{constructor.constructor("alert(1)")()}}',
prototypePolute: '__proto__.polluted',
// Path traversal
pathTraversal: '../../../etc/passwd',
pathTraversal2: '..\\..\\..\\windows\\system32\\config\\sam',
// Unicode and encoding attacks
unicodeAttack: '\u0000\u0001\u0002\u003c\u003e',
utf8Attack: '\uFEFF\uFFFE\uFFFF',
// Control characters
controlChars: '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0E\x0F',
// Regex DoS attempts
regexDos: '(' + 'a'.repeat(10000) + ')*',
regexDos2: '(a+)+$',
// Large payload attack
payloadBomb: 'A'.repeat(100000),
// Mixed attack vectors
mixedAttack: '"><script>alert(/XSS/)</script><"{{malicious}}">'
}
};
const input = `{
"sqlTests": {
"query1": "{{sqlInjection}}",
"query2": "SELECT * FROM users WHERE name='{{sqlInjection2}}'"
},
"jsonTests": {
"break1": "{{jsonBreak}}",
"break2": "{{jsonBreak2}}",
"safe": "normal_value"
},
"xssTests": {
"script": "{{xssScript}}",
"onload": "{{xssOnload}}",
"content": "<div>{{xssScript}}</div>"
},
"templateTests": {
"inject": "{{templateInject}}",
"prototype": "{{prototypePolute}}"
},
"pathTests": {
"traversal1": "{{pathTraversal}}",
"traversal2": "{{pathTraversal2}}"
},
"encodingTests": {
"unicode": "{{unicodeAttack}}",
"utf8": "{{utf8Attack}}",
"control": "{{controlChars}}"
},
"performanceTests": {
"regex1": "{{regexDos}}",
"regex2": "{{regexDos2}}",
"large": "{{payloadBomb}}"
},
"mixedAttack": "{{mixedAttack}}"
}`;
const startTime = Date.now();
const result = replaceJsonBodyString({ text: input }, securityProps);
const endTime = Date.now();
// Should not take excessive time (DoS protection)
expect(endTime - startTime).toBeLessThan(10000); // 10 seconds max
// Should produce valid JSON despite malicious input
expect(() => JSON.parse(result)).not.toThrow();
const parsed = JSON.parse(result);
// Verify dangerous content is properly handled
expect(parsed.sqlTests.query1).toBe("'; DROP TABLE users; --");
expect(parsed.xssTests.script).toBe('<script>alert("XSS")</script>'); // Content is preserved as-is in JSON string
// The function properly escapes strings even with injection attempts
expect(typeof parsed.jsonTests.break1).toBe('string'); // Should be a string, not break JSON structure
expect(parsed.jsonTests.break1).toBe('", "hacked": true, "original": "'); // The injection content is escaped
// Verify the JSON structure wasn't broken by injection attempts
expect(Object.keys(parsed)).toEqual([
'sqlTests',
'jsonTests',
'xssTests',
'templateTests',
'pathTests',
'encodingTests',
'performanceTests',
'mixedAttack'
]);
// Verify large payloads are handled
expect(parsed.performanceTests.large).toHaveLength(100000);
// Verify no code execution occurred (template injection variable was replaced with null because it doesn't exist)
expect(typeof parsed.templateTests.inject).toBe('string');
expect(parsed.templateTests.inject).toBe('null'); // Non-existent variables become "null"
});
});
});

29
test/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": "..",
"paths": {
"@fastgpt/*": ["packages/*"],
"@test/*": ["test/*"],
"@/*": ["projects/app/src/*"]
},
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": false,
"types": ["vitest/globals", "node"]
},
"include": [
"**/*.ts",
"**/*.tsx",
"../packages/**/*.ts",
"../packages/**/*.tsx",
"../packages/**/*.d.ts",
"../projects/app/src/**/*.ts",
"../projects/app/src/**/*.tsx"
],
"exclude": ["node_modules", "../node_modules", "dist", "../dist"]
}