fix: surrender;perf: llm response (#6190)

* feat: workflow route to detail

* llm response

* fix: surrender

* fix: surrender

* fix: surrender

* fix: test
This commit is contained in:
Archer
2026-01-05 20:53:18 +08:00
committed by GitHub
parent 88ed97bc9d
commit f00adcb02d
22 changed files with 389 additions and 136 deletions

View File

@@ -7,17 +7,19 @@ description: 'FastGPT V4.14.5 更新说明'
## 🚀 新增内容 ## 🚀 新增内容
1. 工作流画布增加演示模式,同时优化折叠模式样式。 1. 工作流画布增加演示模式,同时优化折叠模式样式。
2. 对话记录使用侧改成软删除,增加从日志管理里删除对话记录 2. 工作流增加嵌套应用快速跳转按钮
3. 更新Agent/工具时,会更新其上层所有目录的更新时间,以便其会排在列表前面 3. 对话记录使用侧改成软删除,增加从日志管理里删除对话记录
4. 门户页支持配置单个应用运行可见度配 4. 更新Agent/工具时,会更新其上层所有目录的更新时间,以便其会排在列表前面
5. 导出单个知识库集合分块接口 5. 门户页支持配置单个应用运行可见度配
6. 升级 Mongo5.x 至 5.0.32 解决CVE-2025-14847 6. 导出单个知识库集合分块接口
7. 升级 Mongo5.x 至 5.0.32 解决CVE-2025-14847。
## ⚙️ 优化 ## ⚙️ 优化
1. 优化获取 redis 所有 key 的逻辑,避免大量获取时导致阻塞。 1. 优化获取 redis 所有 key 的逻辑,避免大量获取时导致阻塞。
2. MongoDB, Redis 和 MQ 的重连逻辑优化。 2. MongoDB, Redis 和 MQ 的重连逻辑优化。
3. 变量输入框禁用状态可复制。 3. 变量输入框禁用状态可复制。
4. LLM 请求空响应判断,排除敏感过滤错误被误认为无响应。
## 🐛 修复 ## 🐛 修复

View File

@@ -116,11 +116,11 @@
"document/content/docs/upgrading/4-13/4131.mdx": "2025-09-30T15:47:06+08:00", "document/content/docs/upgrading/4-13/4131.mdx": "2025-09-30T15:47:06+08:00",
"document/content/docs/upgrading/4-13/4132.mdx": "2025-12-15T11:50:00+08:00", "document/content/docs/upgrading/4-13/4132.mdx": "2025-12-15T11:50:00+08:00",
"document/content/docs/upgrading/4-14/4140.mdx": "2025-11-06T15:43:00+08:00", "document/content/docs/upgrading/4-14/4140.mdx": "2025-11-06T15:43:00+08:00",
"document/content/docs/upgrading/4-14/4141.mdx": "2025-11-19T10:15:27+08:00", "document/content/docs/upgrading/4-14/4141.mdx": "2025-12-31T09:54:29+08:00",
"document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00", "document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00",
"document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00", "document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00",
"document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00", "document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00",
"document/content/docs/upgrading/4-14/4145.mdx": "2026-01-05T11:19:08+08:00", "document/content/docs/upgrading/4-14/4145.mdx": "2026-01-05T13:44:33+08:00",
"document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+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/41.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/42.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

@@ -44,3 +44,10 @@ export function splitCombineToolId(id: string) {
} }
return { source, pluginId: id }; return { source, pluginId: id };
} }
export const getToolRawId = (id: string) => {
const toolId = splitCombineToolId(id).pluginId;
// 兼容 toolset
return toolId.split('/')[0];
};

View File

@@ -0,0 +1,30 @@
import { ObjectIdSchema } from '../../../../common/type/mongo';
import { z } from 'zod';
/* Get App Permission */
export const GetAppPermissionQuerySchema = z.object({
appId: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '应用 ID'
})
});
export type GetAppPermissionQueryType = z.infer<typeof GetAppPermissionQuerySchema>;
export const GetAppPermissionResponseSchema = z.object({
hasReadPer: z.boolean().meta({
description: '是否有读权限'
}),
hasWritePer: z.boolean().meta({
description: '是否有写权限'
}),
hasManagePer: z.boolean().meta({
description: '是否有管理权限'
}),
hasReadChatLogPer: z.boolean().meta({
description: '是否有读取对话日志权限'
}),
isOwner: z.boolean().meta({
description: '是否为所有者'
})
});
export type GetAppPermissionResponseType = z.infer<typeof GetAppPermissionResponseSchema>;

View File

@@ -0,0 +1,26 @@
import type { OpenAPIPath } from '../../../type';
import { TagsMap } from '../../../tag';
import { GetAppPermissionQuerySchema, GetAppPermissionResponseSchema } from './api';
export const AppCommonPath: OpenAPIPath = {
'/core/app/getPermission': {
get: {
summary: '获取应用权限',
description: '根据应用 ID 获取当前用户对该应用的权限信息',
tags: [TagsMap.appCommon],
requestParams: {
query: GetAppPermissionQuerySchema
},
responses: {
200: {
description: '成功获取应用权限',
content: {
'application/json': {
schema: GetAppPermissionResponseSchema
}
}
}
}
}
}
};

View File

@@ -1,8 +1,10 @@
import type { OpenAPIPath } from '../../type'; import type { OpenAPIPath } from '../../type';
import { AppLogPath } from './log'; import { AppLogPath } from './log';
import { PublishChannelPath } from './publishChannel'; import { PublishChannelPath } from './publishChannel';
import { AppCommonPath } from './common';
export const AppPath: OpenAPIPath = { export const AppPath: OpenAPIPath = {
...AppLogPath, ...AppLogPath,
...PublishChannelPath ...PublishChannelPath,
...AppCommonPath
}; };

View File

@@ -24,7 +24,7 @@ export const openAPIDocument = createDocument({
'x-tagGroups': [ 'x-tagGroups': [
{ {
name: 'Agent 应用', name: 'Agent 应用',
tags: [TagsMap.appLog, TagsMap.publishChannel] tags: [TagsMap.appCommon, TagsMap.appLog, TagsMap.publishChannel]
}, },
{ {
name: '对话管理', name: '对话管理',

View File

@@ -2,6 +2,8 @@ export const TagsMap = {
/* Core */ /* Core */
// Agent - log // Agent - log
appLog: 'Agent 日志', appLog: 'Agent 日志',
// Agent - common
appCommon: 'Agent 管理',
// Chat - home // Chat - home
chatPage: '对话页', chatPage: '对话页',

View File

@@ -211,7 +211,7 @@ export const runAgentCall = async ({
answerText: answer, answerText: answer,
toolCalls = [], toolCalls = [],
usage, usage,
getEmptyResponseTip, responseEmptyTip,
assistantMessage: llmAssistantMessage, assistantMessage: llmAssistantMessage,
finish_reason: finishReason finish_reason: finishReason
} = await createLLMResponse({ } = await createLLMResponse({
@@ -235,8 +235,8 @@ export const runAgentCall = async ({
finish_reason = finishReason; finish_reason = finishReason;
if (!answer && !reasoningContent && !toolCalls.length) { if (responseEmptyTip) {
return Promise.reject(getEmptyResponseTip()); return Promise.reject(responseEmptyTip);
} }
// 3. 更新 messages // 3. 更新 messages

View File

@@ -51,7 +51,7 @@ type LLMResponse = {
reasoningText: string; reasoningText: string;
toolCalls?: ChatCompletionMessageToolCall[]; toolCalls?: ChatCompletionMessageToolCall[];
finish_reason: CompletionFinishReason; finish_reason: CompletionFinishReason;
getEmptyResponseTip: () => string; responseEmptyTip?: string;
usage: { usage: {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
@@ -92,7 +92,7 @@ export const createLLMResponse = async <T extends CompletionsBodyType>(
}); });
// console.log(JSON.stringify(requestBody, null, 2)); // console.log(JSON.stringify(requestBody, null, 2));
const { response, isStreamResponse, getEmptyResponseTip } = await createChatCompletion({ const { response, isStreamResponse } = await createChatCompletion({
body: requestBody, body: requestBody,
modelData, modelData,
userKey, userKey,
@@ -151,9 +151,33 @@ export const createLLMResponse = async <T extends CompletionsBodyType>(
usage?.prompt_tokens || (await countGptMessagesTokens(requestBody.messages, requestBody.tools)); usage?.prompt_tokens || (await countGptMessagesTokens(requestBody.messages, requestBody.tools));
const outputTokens = usage?.completion_tokens || (await countGptMessagesTokens(assistantMessage)); const outputTokens = usage?.completion_tokens || (await countGptMessagesTokens(assistantMessage));
const getEmptyResponseTip = () => {
if (userKey?.baseUrl) {
addLog.warn(`User LLM response empty`, {
baseUrl: userKey?.baseUrl,
requestBody,
finish_reason
});
return `您的 OpenAI key 没有响应: ${JSON.stringify(body)}`;
} else {
addLog.error(`LLM response empty`, {
message: '',
data: requestBody,
finish_reason
});
}
return i18nT('chat:LLM_model_response_empty');
};
const isNotResponse =
!answerText &&
!reasoningText &&
!toolCalls?.length &&
(finish_reason === 'stop' || !finish_reason);
const responseEmptyTip = isNotResponse ? getEmptyResponseTip() : undefined;
return { return {
isStreamResponse, isStreamResponse,
getEmptyResponseTip, responseEmptyTip,
answerText, answerText,
reasoningText, reasoningText,
toolCalls, toolCalls,
@@ -535,7 +559,8 @@ const llmCompletionsBodyFormat = async <T extends CompletionsBodyType>({
maxToken: body.max_tokens || undefined maxToken: body.max_tokens || undefined
}); });
const requestBody = { const formatStop = stop?.split('|').filter((item) => !!item.trim());
let requestBody = {
...body, ...body,
max_tokens: maxTokens, max_tokens: maxTokens,
model: modelData.model, model: modelData.model,
@@ -546,9 +571,8 @@ const llmCompletionsBodyFormat = async <T extends CompletionsBodyType>({
temperature: body.temperature temperature: body.temperature
}) })
: undefined, : undefined,
...modelData?.defaultConfig,
response_format, response_format,
stop: stop?.split('|').filter((item) => !!item.trim()), stop: formatStop?.length ? formatStop : undefined,
...(toolCallMode === 'toolChoice' && { ...(toolCallMode === 'toolChoice' && {
tools, tools,
tool_choice, tool_choice,
@@ -556,6 +580,11 @@ const llmCompletionsBodyFormat = async <T extends CompletionsBodyType>({
}) })
} as T; } as T;
// Filter null value
requestBody = Object.fromEntries(
Object.entries(requestBody).filter(([_, value]) => value !== null)
) as T;
// field map // field map
if (modelData.fieldMap) { if (modelData.fieldMap) {
Object.entries(modelData.fieldMap).forEach(([sourceKey, targetKey]) => { Object.entries(modelData.fieldMap).forEach(([sourceKey, targetKey]) => {
@@ -566,6 +595,11 @@ const llmCompletionsBodyFormat = async <T extends CompletionsBodyType>({
}); });
} }
requestBody = {
...requestBody,
...modelData?.defaultConfig
};
return { return {
requestBody: requestBody as unknown as InferCompletionsBody<T>, requestBody: requestBody as unknown as InferCompletionsBody<T>,
modelData modelData
@@ -584,18 +618,14 @@ const createChatCompletion = async ({
timeout?: number; timeout?: number;
options?: OpenAI.RequestOptions; options?: OpenAI.RequestOptions;
}): Promise< }): Promise<
{ | {
getEmptyResponseTip: () => string; response: StreamChatType;
} & ( isStreamResponse: true;
| { }
response: StreamChatType; | {
isStreamResponse: true; response: UnStreamChatType;
} isStreamResponse: false;
| { }
response: UnStreamChatType;
isStreamResponse: false;
}
)
> => { > => {
try { try {
if (!modelData) { if (!modelData) {
@@ -627,34 +657,16 @@ const createChatCompletion = async ({
response !== null && response !== null &&
('iterator' in response || 'controller' in response); ('iterator' in response || 'controller' in response);
const getEmptyResponseTip = () => {
if (userKey?.baseUrl) {
addLog.warn(`User LLM response empty`, {
baseUrl: userKey?.baseUrl,
requestBody: body
});
return `您的 OpenAI key 没有响应: ${JSON.stringify(body)}`;
} else {
addLog.error(`LLM response empty`, {
message: '',
data: body
});
}
return i18nT('chat:LLM_model_response_empty');
};
if (isStreamResponse) { if (isStreamResponse) {
return { return {
response, response,
isStreamResponse: true, isStreamResponse: true
getEmptyResponseTip
}; };
} }
return { return {
response, response,
isStreamResponse: false, isStreamResponse: false
getEmptyResponseTip
}; };
} catch (error) { } catch (error) {
if (userKey?.baseUrl) { if (userKey?.baseUrl) {

View File

@@ -177,56 +177,50 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
const write = res ? responseWriteController({ res, readStream: stream }) : undefined; const write = res ? responseWriteController({ res, readStream: stream }) : undefined;
const { const { completeMessages, reasoningText, answerText, finish_reason, responseEmptyTip, usage } =
completeMessages, await createLLMResponse({
reasoningText, body: {
answerText, model: modelConstantsData.model,
finish_reason, stream,
getEmptyResponseTip, messages: filterMessages,
usage temperature,
} = await createLLMResponse({ max_tokens,
body: { top_p: aiChatTopP,
model: modelConstantsData.model, stop: aiChatStopSign,
stream, response_format: {
messages: filterMessages, type: aiChatResponseFormat,
temperature, json_schema: aiChatJsonSchema
max_tokens, },
top_p: aiChatTopP, retainDatasetCite,
stop: aiChatStopSign, useVision: aiChatVision,
response_format: { requestOrigin
type: aiChatResponseFormat,
json_schema: aiChatJsonSchema
}, },
retainDatasetCite, userKey: externalProvider.openaiAccount,
useVision: aiChatVision, isAborted: checkIsStopping,
requestOrigin onReasoning({ text }) {
}, if (!aiChatReasoning) return;
userKey: externalProvider.openaiAccount, workflowStreamResponse?.({
isAborted: checkIsStopping, write,
onReasoning({ text }) { event: SseResponseEventEnum.answer,
if (!aiChatReasoning) return; data: textAdaptGptResponse({
workflowStreamResponse?.({ reasoning_content: text
write, })
event: SseResponseEventEnum.answer, });
data: textAdaptGptResponse({ },
reasoning_content: text onStreaming({ text }) {
}) if (!isResponseAnswerText) return;
}); workflowStreamResponse?.({
}, write,
onStreaming({ text }) { event: SseResponseEventEnum.answer,
if (!isResponseAnswerText) return; data: textAdaptGptResponse({
workflowStreamResponse?.({ text
write, })
event: SseResponseEventEnum.answer, });
data: textAdaptGptResponse({ }
text });
})
});
}
});
if (!answerText && !reasoningText) { if (responseEmptyTip) {
return getNodeErrResponse({ error: getEmptyResponseTip() }); return getNodeErrResponse({ error: responseEmptyTip });
} }
const { totalPoints, modelName } = formatModelChars2Points({ const { totalPoints, modelName } = formatModelChars2Points({

View File

@@ -414,7 +414,6 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
return; return;
} }
// Thread avoidance
await surrenderProcess(); await surrenderProcess();
const nodeId = this.activeRunQueue.keys().next().value; const nodeId = this.activeRunQueue.keys().next().value;
const node = nodeId ? this.runtimeNodesMap.get(nodeId) : undefined; const node = nodeId ? this.runtimeNodesMap.get(nodeId) : undefined;
@@ -430,10 +429,6 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
this.processActiveNode(); this.processActiveNode();
}); });
} }
// 兜底,除非极端情况,否则不可能触发
else {
this.processActiveNode();
}
} }
private addSkipNode(node: RuntimeNodeItemType, skippedNodeIdList: Set<string>) { private addSkipNode(node: RuntimeNodeItemType, skippedNodeIdList: Set<string>) {
@@ -446,9 +441,8 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
this.skipNodeQueue.set(node.nodeId, { node, skippedNodeIdList: concatSkippedNodeIdList }); this.skipNodeQueue.set(node.nodeId, { node, skippedNodeIdList: concatSkippedNodeIdList });
} }
private async processSkipNodes() { private async processSkipNodes() {
// Thread avoidance
await surrenderProcess();
// 取一个 node并且从队列里删除 // 取一个 node并且从队列里删除
await surrenderProcess();
const skipItem = this.skipNodeQueue.values().next().value; const skipItem = this.skipNodeQueue.values().next().value;
if (skipItem) { if (skipItem) {
this.skipNodeQueue.delete(skipItem.node.nodeId); this.skipNodeQueue.delete(skipItem.node.nodeId);

View File

@@ -25,7 +25,7 @@ const MyIconButton = ({
...props ...props
}: Props) => { }: Props) => {
return ( return (
<MyTooltip label={tip}> <MyTooltip label={tip} shouldWrapChildren={false}>
<Flex <Flex
position={'relative'} position={'relative'}
p={1} p={1}

View File

@@ -1,3 +1,3 @@
<svg viewBox="0 0 17 16" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 17 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.98608 2.34313C10.5482 0.78103 13.0808 0.78103 14.6429 2.34313C16.205 3.90522 16.205 6.43788 14.6429 7.99998L13.7001 8.94279C13.4398 9.20314 13.0177 9.20314 12.7573 8.94279C12.497 8.68244 12.497 8.26033 12.7573 7.99998L13.7001 7.05717C14.7415 6.01577 14.7415 4.32733 13.7001 3.28594C12.6587 2.24454 10.9703 2.24454 9.92889 3.28594L8.98608 4.22875C8.72573 4.48909 8.30362 4.48909 8.04327 4.22875C7.78292 3.9684 7.78292 3.54629 8.04327 3.28594L8.98608 2.34313ZM11.7908 5.19523C12.0512 5.45558 12.0512 5.87769 11.7908 6.13804L7.12415 10.8047C6.8638 11.0651 6.44169 11.0651 6.18134 10.8047C5.92099 10.5444 5.92099 10.1222 6.18134 9.8619L10.848 5.19523C11.1084 4.93488 11.5305 4.93488 11.7908 5.19523ZM5.21484 7.05717C5.47519 7.31752 5.47519 7.73963 5.21484 7.99998L4.27204 8.94279C3.23064 9.98419 3.23064 11.6726 4.27204 12.714C5.31343 13.7554 7.00187 13.7554 8.04327 12.714L8.98608 11.7712C9.24643 11.5109 9.66854 11.5109 9.92889 11.7712C10.1892 12.0316 10.1892 12.4537 9.92889 12.714L8.98608 13.6568C7.42398 15.2189 4.89132 15.2189 3.32923 13.6568C1.76713 12.0947 1.76713 9.56208 3.32923 7.99998L4.27204 7.05717C4.53239 6.79682 4.9545 6.79682 5.21484 7.05717Z" fill="#485264"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M8.98608 2.34313C10.5482 0.78103 13.0808 0.78103 14.6429 2.34313C16.205 3.90522 16.205 6.43788 14.6429 7.99998L13.7001 8.94279C13.4398 9.20314 13.0177 9.20314 12.7573 8.94279C12.497 8.68244 12.497 8.26033 12.7573 7.99998L13.7001 7.05717C14.7415 6.01577 14.7415 4.32733 13.7001 3.28594C12.6587 2.24454 10.9703 2.24454 9.92889 3.28594L8.98608 4.22875C8.72573 4.48909 8.30362 4.48909 8.04327 4.22875C7.78292 3.9684 7.78292 3.54629 8.04327 3.28594L8.98608 2.34313ZM11.7908 5.19523C12.0512 5.45558 12.0512 5.87769 11.7908 6.13804L7.12415 10.8047C6.8638 11.0651 6.44169 11.0651 6.18134 10.8047C5.92099 10.5444 5.92099 10.1222 6.18134 9.8619L10.848 5.19523C11.1084 4.93488 11.5305 4.93488 11.7908 5.19523ZM5.21484 7.05717C5.47519 7.31752 5.47519 7.73963 5.21484 7.99998L4.27204 8.94279C3.23064 9.98419 3.23064 11.6726 4.27204 12.714C5.31343 13.7554 7.00187 13.7554 8.04327 12.714L8.98608 11.7712C9.24643 11.5109 9.66854 11.5109 9.92889 11.7712C10.1892 12.0316 10.1892 12.4537 9.92889 12.714L8.98608 13.6568C7.42398 15.2189 4.89132 15.2189 3.32923 13.6568C1.76713 12.0947 1.76713 9.56208 3.32923 7.99998L4.27204 7.05717C4.53239 6.79682 4.9545 6.79682 5.21484 7.05717Z" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -136,6 +136,7 @@
"mouse_priority": "Mouse first\n- Press the left button to drag the canvas\n- Hold down shift and left click to select batches", "mouse_priority": "Mouse first\n- Press the left button to drag the canvas\n- Hold down shift and left click to select batches",
"new_context": "New Context", "new_context": "New Context",
"next": "Next", "next": "Next",
"no_edit_permission": "No editing rights",
"no_match_node": "No results", "no_match_node": "No results",
"no_node_found": "No node was not found", "no_node_found": "No node was not found",
"not_contains": "Does Not Contain", "not_contains": "Does Not Contain",
@@ -195,6 +196,7 @@
"text_to_extract": "Text to Extract", "text_to_extract": "Text to Extract",
"these_variables_will_be_input_parameters_for_code_execution": "These variables will be input parameters for code execution", "these_variables_will_be_input_parameters_for_code_execution": "These variables will be input parameters for code execution",
"to_add_node": "to add", "to_add_node": "to add",
"to_app_detail": "Go to edit app",
"to_connect_node": "to connect", "to_connect_node": "to connect",
"tool.tool_result": "Tool operation results", "tool.tool_result": "Tool operation results",
"tool_active_config": "Tool active", "tool_active_config": "Tool active",

View File

@@ -136,6 +136,7 @@
"mouse_priority": "鼠标优先\n- 左键按下后可拖动画布\n- 按住 shift 后左键可批量选择", "mouse_priority": "鼠标优先\n- 左键按下后可拖动画布\n- 按住 shift 后左键可批量选择",
"new_context": "新的上下文", "new_context": "新的上下文",
"next": "下一个", "next": "下一个",
"no_edit_permission": "没有编辑权限",
"no_match_node": "无结果", "no_match_node": "无结果",
"no_node_found": "未搜索到节点", "no_node_found": "未搜索到节点",
"not_contains": "不包含", "not_contains": "不包含",
@@ -195,6 +196,7 @@
"text_to_extract": "需要提取的文本", "text_to_extract": "需要提取的文本",
"these_variables_will_be_input_parameters_for_code_execution": "这些变量会作为代码的运行的输入参数", "these_variables_will_be_input_parameters_for_code_execution": "这些变量会作为代码的运行的输入参数",
"to_add_node": "添加节点", "to_add_node": "添加节点",
"to_app_detail": "前去编辑应用",
"to_connect_node": "连接节点", "to_connect_node": "连接节点",
"tool.tool_result": "工具运行结果", "tool.tool_result": "工具运行结果",
"tool_active_config": "工具激活", "tool_active_config": "工具激活",

View File

@@ -136,6 +136,7 @@
"mouse_priority": "滑鼠優先\n- 按下左鍵拖曳畫布\n- 按住 Shift 鍵並點選左鍵可批次選取", "mouse_priority": "滑鼠優先\n- 按下左鍵拖曳畫布\n- 按住 Shift 鍵並點選左鍵可批次選取",
"new_context": "新的脈絡", "new_context": "新的脈絡",
"next": "下一個", "next": "下一個",
"no_edit_permission": "沒有編輯權限",
"no_match_node": "無結果", "no_match_node": "無結果",
"no_node_found": "未搜索到節點", "no_node_found": "未搜索到節點",
"not_contains": "不包含", "not_contains": "不包含",
@@ -195,6 +196,7 @@
"text_to_extract": "要擷取的文字", "text_to_extract": "要擷取的文字",
"these_variables_will_be_input_parameters_for_code_execution": "這些變數會作為程式碼執行的輸入參數", "these_variables_will_be_input_parameters_for_code_execution": "這些變數會作為程式碼執行的輸入參數",
"to_add_node": "添加節點", "to_add_node": "添加節點",
"to_app_detail": "前去編輯應用",
"to_connect_node": "連接節點", "to_connect_node": "連接節點",
"tool.tool_result": "工具運行結果", "tool.tool_result": "工具運行結果",
"tool_active_config": "工具激活", "tool_active_config": "工具激活",

View File

@@ -55,7 +55,8 @@ import {
PluginStatusMap, PluginStatusMap,
type PluginStatusType type PluginStatusType
} from '@fastgpt/global/core/plugin/type'; } from '@fastgpt/global/core/plugin/type';
import { splitCombineToolId } from '@fastgpt/global/core/app/tool/utils'; import { splitCombineToolId, getToolRawId } from '@fastgpt/global/core/app/tool/utils';
import { getAppPermission } from '@/web/core/app/api';
type Props = FlowNodeItemType & { type Props = FlowNodeItemType & {
children?: React.ReactNode | React.ReactNode[] | string; children?: React.ReactNode | React.ReactNode[] | string;
@@ -309,8 +310,8 @@ const NodeCard = (props: Props) => {
'& .controller-debug': { '& .controller-debug': {
display: 'block' display: 'block'
}, },
'& .controller-rename': { '& .node-hover-controller': {
display: 'block' visibility: 'visible'
} }
}} }}
onMouseEnter={() => setHoverNodeId(nodeId)} onMouseEnter={() => setHoverNodeId(nodeId)}
@@ -346,6 +347,7 @@ const NodeCard = (props: Props) => {
avatar={avatar} avatar={avatar}
name={name} name={name}
searchedText={searchedText} searchedText={searchedText}
appId={pluginId}
/> />
<Box flex={1} mr={1} /> <Box flex={1} mr={1} />
@@ -428,11 +430,14 @@ const NodeTitleSection = React.memo<{
avatar: string; avatar: string;
name: string; name: string;
searchedText?: string; searchedText?: string;
}>(({ nodeId, avatar, name, searchedText }) => { appId?: string;
}>(({ nodeId, avatar, name, searchedText, appId }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const childAppId = useMemo(() => (appId ? getToolRawId(appId) : undefined), [appId]);
// custom title edit // custom title edit
const { onOpenModal: onOpenCustomTitleModal, EditModal: EditTitleModal } = useEditTitle({ const { onOpenModal: onOpenCustomTitleModal, EditModal: EditTitleModal } = useEditTitle({
title: t('common:custom_title'), title: t('common:custom_title'),
@@ -459,8 +464,21 @@ const NodeTitleSection = React.memo<{
}); });
}, [onOpenCustomTitleModal, name, onChangeNode, nodeId, toast, t]); }, [onOpenCustomTitleModal, name, onChangeNode, nodeId, toast, t]);
const { runAsync: onGetPermission } = useRequest2(getAppPermission, {
onSuccess(permission) {
if (permission.hasWritePer) {
window.open(`/app/detail?appId=${childAppId}`, '_blank');
} else {
toast({
title: t('workflow:no_edit_permission'),
status: 'warning'
});
}
}
});
return ( return (
<> <Flex alignItems={'center'}>
<Avatar src={avatar} borderRadius={'sm'} objectFit={'contain'} w={'24px'} h={'24px'} /> <Avatar src={avatar} borderRadius={'sm'} objectFit={'contain'} w={'24px'} h={'24px'} />
<Box ml={2} fontSize={'18px'} fontWeight={'medium'} color={'myGray.900'}> <Box ml={2} fontSize={'18px'} fontWeight={'medium'} color={'myGray.900'}>
<HighlightText <HighlightText
@@ -470,20 +488,22 @@ const NodeTitleSection = React.memo<{
color={'#ffe82d'} color={'#ffe82d'}
/> />
</Box> </Box>
<Button <Box ml={1} visibility={'hidden'}>
display={'none'} <MyIconButton className="node-hover-controller" icon="edit" onClick={handleRenameClick} />
variant={'grayGhost'} </Box>
size={'xs'} {childAppId && (
ml={0.5} <Box ml={1} visibility={'hidden'}>
className="controller-rename" <MyIconButton
cursor={'pointer'} className="node-hover-controller"
onClick={handleRenameClick} icon="common/link"
> tip={t('workflow:to_app_detail')}
<MyIcon name={'edit'} w={'14px'} /> onClick={() => onGetPermission(childAppId)}
</Button> />
</Box>
)}
<EditTitleModal maxLength={100} /> <EditTitleModal maxLength={100} />
</> </Flex>
); );
}); });
NodeTitleSection.displayName = 'NodeTitleSection'; NodeTitleSection.displayName = 'NodeTitleSection';

View File

@@ -205,18 +205,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
}); });
} }
}, },
[ [getNodes, edges, onRemoveError, fitView, toast, t, onUpdateNodeError]
getNodes,
edges,
onRemoveError,
fitView,
getViewport,
setViewport,
toast,
t,
onUpdateNodeError,
onChangeNode
]
); );
// 4. initData - 初始化工作流数据 // 4. initData - 初始化工作流数据

View File

@@ -0,0 +1,45 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { NextAPI } from '@/service/middleware/entry';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import {
GetAppPermissionQuerySchema,
GetAppPermissionResponseSchema,
type GetAppPermissionResponseType
} from '@fastgpt/global/openapi/core/app/common/api';
/* Get app permission */
async function handler(
req: ApiRequestProps,
res: ApiResponseType<any>
): Promise<GetAppPermissionResponseType> {
const { appId } = GetAppPermissionQuerySchema.parse(req.query);
// Auth app permission
try {
const { app } = await authApp({
req,
authToken: true,
appId,
per: ReadPermissionVal
});
return GetAppPermissionResponseSchema.parse({
hasReadPer: app.permission.hasReadPer,
hasWritePer: app.permission.hasWritePer,
hasManagePer: app.permission.hasManagePer,
hasReadChatLogPer: app.permission.hasReadChatLogPer,
isOwner: app.permission.isOwner
});
} catch (error) {
return GetAppPermissionResponseSchema.parse({
hasReadPer: false,
hasWritePer: false,
hasManagePer: false,
hasReadChatLogPer: false,
isOwner: false
});
}
}
export default NextAPI(handler);

View File

@@ -5,6 +5,7 @@ import type { CreateAppBody } from '@/pages/api/core/app/create';
import type { ListAppBody } from '@/pages/api/core/app/list'; import type { ListAppBody } from '@/pages/api/core/app/list';
import type { getBasicInfoResponse } from '@/pages/api/core/app/getBasicInfo'; import type { getBasicInfoResponse } from '@/pages/api/core/app/getBasicInfo';
import type { GetAppPermissionResponseType } from '@fastgpt/global/openapi/core/app/common/api';
/** /**
* 获取应用列表 * 获取应用列表
@@ -36,6 +37,9 @@ export const getAppDetailById = (id: string) => GET<AppDetailType>(`/core/app/de
export const putAppById = (id: string, data: AppUpdateParams) => export const putAppById = (id: string, data: AppUpdateParams) =>
PUT(`/core/app/update?appId=${id}`, data); PUT(`/core/app/update?appId=${id}`, data);
export const getAppPermission = (appId: string) =>
GET<GetAppPermissionResponseType>(`/core/app/getPermission?appId=${appId}`);
/** /**
* Get app basic info by ids * Get app basic info by ids
*/ */

View File

@@ -0,0 +1,120 @@
import * as getPermissionApi from '@/pages/api/core/app/getPermission';
import { AppErrEnum } from '@fastgpt/global/common/error/code/app';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { getFakeUsers } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it } from 'vitest';
import type {
GetAppPermissionQueryType,
GetAppPermissionResponseType
} from '@fastgpt/global/openapi/core/app/common/api';
import { ZodError } from 'zod';
describe('get app permission api', () => {
it('should return permission when user has access', async () => {
const users = await getFakeUsers(1);
const user = users.members[0];
// Create a test app
const app = await MongoApp.create({
name: 'test-app',
type: AppTypeEnum.simple,
modules: [],
edges: [],
teamId: user.teamId,
tmbId: user.tmbId
});
const res = await Call<{}, GetAppPermissionQueryType, GetAppPermissionResponseType>(
getPermissionApi.default,
{
auth: user,
query: {
appId: String(app._id)
}
}
);
expect(res.code).toBe(200);
expect(res.data).toBeDefined();
expect(res.data.isOwner).toBe(true);
expect(res.data.hasReadPer).toBe(true);
expect(res.data.hasWritePer).toBe(true);
expect(res.data.hasManagePer).toBe(true);
});
it('should return error when appId is missing', async () => {
const users = await getFakeUsers(1);
const user = users.members[0];
const res = await Call<{}, GetAppPermissionQueryType, GetAppPermissionResponseType>(
getPermissionApi.default,
{
auth: user,
query: {
appId: ''
}
}
);
console.log(res.error, 232);
expect(res.error instanceof ZodError).toBe(true);
expect(res.code).toBe(500);
});
it('should return error when user does not have access', async () => {
const users = await getFakeUsers(2);
const user1 = users.members[0];
const user2 = users.members[1];
// Create a test app by user1
const app = await MongoApp.create({
name: 'test-app',
type: AppTypeEnum.simple,
modules: [],
edges: [],
teamId: user1.teamId,
tmbId: user1.tmbId
});
// Try to get permission as user2 (different team)
const res = await Call<{}, GetAppPermissionQueryType, GetAppPermissionResponseType>(
getPermissionApi.default,
{
auth: user2,
query: {
appId: String(app._id)
}
}
);
expect(res.data.isOwner).toBe(false);
expect(res.data.hasReadPer).toBe(false);
expect(res.data.hasWritePer).toBe(false);
expect(res.data.hasManagePer).toBe(false);
expect(res.data.hasReadChatLogPer).toBe(false);
expect(res.code).toBe(200);
});
it('should return error when app does not exist', async () => {
const users = await getFakeUsers(1);
const user = users.members[0];
const res = await Call<{}, GetAppPermissionQueryType, GetAppPermissionResponseType>(
getPermissionApi.default,
{
auth: user,
query: {
appId: '507f1f77bcf86cd799439011' // Non-existent appId
}
}
);
expect(res.data.isOwner).toBe(false);
expect(res.data.hasReadPer).toBe(false);
expect(res.data.hasWritePer).toBe(false);
expect(res.data.hasManagePer).toBe(false);
expect(res.data.hasReadChatLogPer).toBe(false);
expect(res.code).toBe(200);
});
});