Commit 34632ba4 by Nixieboluo Committed by GitHub

feat: support multiple questions in agent asking tool (#7408)

* feat: prepare support for ask_user v2 in aux gen

Signed-off-by: Nixieboluo <me@sagirii.me>

* fix: align ask composer UI

* feat: prepare support for ask_user v2 in agent

Signed-off-by: Nixieboluo <me@sagirii.me>

* feat: unified frontend logic for ask_user v2

Signed-off-by: Nixieboluo <me@sagirii.me>

* test: update tests for ask_user v2

Signed-off-by: Nixieboluo <me@sagirii.me>

* refactor: more strict ask_user schema validation

Signed-off-by: Nixieboluo <me@sagirii.me>

* refactor: add transitions and hide answer when retrying

Signed-off-by: Nixieboluo <me@sagirii.me>

* refactor: reorganize ask v2 composer ui

Signed-off-by: Nixieboluo <me@sagirii.me>

* test: update tests

Signed-off-by: Nixieboluo <me@sagirii.me>

* feat: format ask user answers

* refactor: remove compat for legacy agent_ask in aux gen

Signed-off-by: Nixieboluo <me@sagirii.me>

* fix: agent ask composer ui focus handling

Signed-off-by: Nixieboluo <me@sagirii.me>

* refactor: cleanup compatibility fallback for agent ask

Signed-off-by: Nixieboluo <me@sagirii.me>

* feat: legacy ask_user record compatibility

Signed-off-by: Nixieboluo <me@sagirii.me>

* test: fix test mismatches

Signed-off-by: Nixieboluo <me@sagirii.me>

* feat: convert legacy ask to the new one

Signed-off-by: Nixieboluo <me@sagirii.me>

* chore: trigger correct pro build

Signed-off-by: Nixieboluo <me@sagirii.me>

* refactor: remove legacy auxiliary ask fallback

* chore: update pro storage mock fix

Signed-off-by: Archer <545436317@qq.com>

* submodule

---------

Signed-off-by: Nixieboluo <me@sagirii.me>
Signed-off-by: Archer <545436317@qq.com>
Co-authored-by: Archer <545436317@qq.com>
parent 8751665d
import { getNanoid } from '../../../common/string/tools';
import z from 'zod';
export const AgentAskBlockerTypeSchema = z.enum([
'missing_required_input',
'tool_unavailable',
'ambiguous_goal',
'user_choice'
]);
export type AgentAskBlockerType = z.infer<typeof AgentAskBlockerTypeSchema>;
export const AgentAskOptionSchema = z.object({
summary: z.string().trim().min(1),
value: z.string().trim().min(1)
});
export type AgentAskOption = z.infer<typeof AgentAskOptionSchema>;
export const AgentAskQuestionSchema = z.object({
question: z.string().trim().min(1),
options: z.array(AgentAskOptionSchema).min(2).max(4)
});
export type AgentAskQuestion = z.infer<typeof AgentAskQuestionSchema>;
export const AgentAskAnswerPayloadSchema = z.object({
answers: z.array(z.string())
});
export type AgentAskAnswerPayload = z.infer<typeof AgentAskAnswerPayloadSchema>;
export const AgentPlanStatusSchema = z.object({
status: z.enum(['generating', 'updating']).meta({
description: '计划状态:generating 生成计划中,updating 更新计划中'
......
import type { AgentPlanType } from './type';
import { AgentAskAnswerPayloadSchema, type AgentAskQuestion, type AgentPlanType } from './type';
/** 判断 plan 是否仍包含需要跨轮继续处理的步骤。 */
export const hasUnfinishedAgentPlan = (plan: AgentPlanType) =>
plan.steps.some(({ status }) => status !== 'done' && status !== 'skipped');
/** 解析 Composer 提交的 ask_user 回答;格式错误时回退为空答案。 */
export const parseAgentAskAnswers = (value: string) => {
try {
const parsed = AgentAskAnswerPayloadSchema.safeParse(JSON.parse(value));
return parsed.success ? parsed.data.answers : [];
} catch {
return [];
}
};
/** 将多题追问的结构化回答渲染为仅供模型消费的 Markdown tool response。 */
export const formatAgentAskAnswers = ({
questions,
answers
}: {
questions: AgentAskQuestion[];
answers: string[];
}) =>
questions
.map(({ question, options }, index) => {
const answer = answers[index] ?? '';
const option = options.find(({ value }) => value === answer);
const renderedAnswer = (() => {
if (!answer) return '未回答';
if (!option) return answer;
return option.summary === option.value
? option.value
: `${option.summary} - ${option.value}`;
})();
return `## ${question}\n\n回答:${renderedAnswer}`;
})
.join('\n\n');
......@@ -19,6 +19,7 @@ import type {
ChatCompletionToolMessageParam
} from '../ai/llm/type';
import { ChatCompletionRequestMessageRoleEnum } from '../../core/ai/constants';
import { formatAgentAskAnswers } from '../ai/agent/utils';
import { normalizeToolResponseContent } from '../ai/llm/utils';
import { extractDeepestInteractive } from '../workflow/runtime/utils';
......@@ -383,12 +384,21 @@ export const chats2GPTMessages = ({
const finalInteractive = value.interactive
? extractDeepestInteractive(value.interactive)
: undefined;
if (
finalInteractive?.type === 'agentPlanAskQuery' &&
finalInteractive.askId &&
typeof finalInteractive.params.answer === 'string'
) {
agentAskAnswerMap.set(finalInteractive.askId, finalInteractive.params.answer);
// Legacy ask
if (finalInteractive?.type === 'agentPlanAskQuery' && finalInteractive.askId) {
agentAskAnswerMap.set(finalInteractive.askId, finalInteractive.params.answer || '未回答');
}
// New ask_user
if (finalInteractive?.type === 'agentAsk' && finalInteractive.params.submitted) {
agentAskAnswerMap.set(
finalInteractive.askId,
formatAgentAskAnswers({
questions: finalInteractive.params.questions,
answers: finalInteractive.params.questions.map((question) => question.answer)
})
);
}
});
......
......@@ -310,7 +310,11 @@ export const checkInteractiveResponseStatus = ({
interactive: WorkflowInteractiveResponseType;
input: string;
}): 'submit' | 'query' => {
if (extractDeepestInteractive(interactive).type === 'agentPlanAskQuery') {
const finalInteractive = extractDeepestInteractive(interactive);
if (
finalInteractive.type === 'agentPlanAskQuery' ||
(finalInteractive.type === 'agentAsk' && finalInteractive.responseMode !== 'submit')
) {
return 'query';
}
return 'submit';
......
......@@ -26,7 +26,7 @@ export const extractDeepestInteractive = (
let current = interactive;
let depth = 0;
while (depth < MAX_DEPTH && 'childrenResponse' in current.params) {
while (depth < MAX_DEPTH && current?.params && 'childrenResponse' in current.params) {
current = current.params.childrenResponse;
depth++;
}
......@@ -171,6 +171,36 @@ export const getLastInteractiveValue = (
return;
}
// Convert legacy ask_user call to the new one.
if (lastValue.interactive.type === 'agentPlanAskQuery') {
if (lastValue.interactive.params.answer) {
return;
}
return {
...lastValue.interactive,
type: 'agentAsk',
params: {
description: lastValue.interactive.params.reason ?? '',
questions: [
{
question: lastValue.interactive.params.content,
options: lastValue.interactive.params.options.map((option) => ({
summary: option,
value: option
})),
answer: ''
}
]
}
};
}
const finalInteractive = extractDeepestInteractive(lastValue.interactive);
if (finalInteractive.type === 'agentPlanAskQuery') {
return;
}
if (isChildInteractive(lastValue.interactive.type)) {
return lastValue.interactive;
}
......@@ -188,15 +218,11 @@ export const getLastInteractiveValue = (
return lastValue.interactive;
}
if (lastValue.interactive.type === 'paymentPause' && !lastValue.interactive.params.continue) {
if (lastValue.interactive.type === 'agentAsk' && !lastValue.interactive.params.submitted) {
return lastValue.interactive;
}
// Agent plan ask query
if (
lastValue.interactive.type === 'agentPlanAskQuery' &&
!lastValue.interactive.params.answer
) {
if (lastValue.interactive.type === 'paymentPause' && !lastValue.interactive.params.continue) {
return lastValue.interactive;
}
}
......
......@@ -5,6 +5,7 @@ import { AppFileSelectConfigTypeSchema } from '../../../../app/type/config.schem
import { RuntimeEdgeItemTypeSchema } from '../../../type/edge';
import z from 'zod';
import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type';
import { AgentAskQuestionSchema } from '../../../../ai/agent/type';
export const InteractiveBasicTypeSchema = z.object({
entryNodeIds: z.array(z.string()),
......@@ -95,19 +96,32 @@ export type LoopRunInteractive = InteractiveNodeType & {
export const AgentPlanAskOptionSchema = z.string().min(1);
export type AgentPlanAskOption = z.infer<typeof AgentPlanAskOptionSchema>;
export const AgentPlanAskQueryInteractiveSchema = z.object({
type: z.literal('agentPlanAskQuery'),
askId: z.string().min(1),
params: z.object({
content: z.string(),
reason: z.string().optional(),
blockerType: z
.enum(['missing_required_input', 'tool_unavailable', 'ambiguous_goal', 'user_choice'])
.optional(),
options: z.array(AgentPlanAskOptionSchema).min(2).max(5),
answer: z.string().optional()
/**
* Legacy `ask_user` schema.
*
* @deprecated Use `AgentAskInteractiveSchema` (multiple questions).
*/
export const AgentPlanAskQueryInteractiveSchema = z
.object({
type: z.literal('agentPlanAskQuery'),
askId: z.string().min(1),
params: z.object({
content: z.string(),
reason: z.string().optional(),
blockerType: z
.enum(['missing_required_input', 'tool_unavailable', 'ambiguous_goal', 'user_choice'])
.optional(),
options: z.array(AgentPlanAskOptionSchema).min(2).max(5),
answer: z.string().optional()
})
})
});
.meta({
deprecated: true
});
/**
* @deprecated Use `AgentAskInteractiveSchema` (multiple questions).
*/
export type AgentPlanAskQueryInteractive = z.infer<typeof AgentPlanAskQueryInteractiveSchema>;
// User selector
......@@ -154,6 +168,23 @@ export const UserInputInteractiveSchema = z.object({
});
export type UserInputInteractive = z.infer<typeof UserInputInteractiveSchema>;
export const AgentAskQuestionInteractiveSchema = AgentAskQuestionSchema.safeExtend({
answer: z.string()
});
export type AgentAskQuestionInteractive = z.infer<typeof AgentAskQuestionInteractiveSchema>;
export const AgentAskInteractiveSchema = z.object({
type: z.literal('agentAsk'),
askId: z.string().min(1),
responseMode: z.literal('submit').optional(),
params: z.object({
description: z.string(),
questions: z.array(AgentAskQuestionInteractiveSchema).min(1).max(3),
submitted: z.boolean().optional()
})
});
export type AgentAskInteractive = z.infer<typeof AgentAskInteractiveSchema>;
// 欠费暂停交互
export const PaymentPauseInteractiveSchema = z.object({
type: z.literal('paymentPause'),
......@@ -173,7 +204,8 @@ export const InteractiveNodeResponseTypeSchema = z.intersection(
LoopInteractiveSchema,
LoopRunInteractiveSchema,
PaymentPauseInteractiveSchema,
AgentPlanAskQueryInteractiveSchema
AgentPlanAskQueryInteractiveSchema,
AgentAskInteractiveSchema
]),
z.object({
askId: z.string().nullish()
......
......@@ -1798,6 +1798,93 @@ describe('chats2GPTMessages', () => {
expect(result).toHaveLength(0);
});
it('should rebuild an ask_user response from submitted agentAsk form values', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{
agentAsk: {
id: 'call_ask',
askId: 'call_ask',
functionName: 'ask_user',
params: '{"questions":[]}'
}
},
{
interactive: {
type: 'agentAsk',
askId: 'call_ask',
params: {
description: 'Need input',
submitted: true,
questions: [
{
question: 'First?',
options: [
{ summary: 'A', value: 'Answer A' },
{ summary: 'B', value: 'B' }
],
answer: 'Answer A'
},
{
question: 'Second?',
options: [
{ summary: 'C', value: 'C' },
{ summary: 'D', value: 'D' }
],
answer: 'Custom answer'
},
{
question: 'Third?',
options: [
{ summary: 'E', value: 'E' },
{ summary: 'F', value: 'F' }
],
answer: ''
}
]
}
}
} as any
]
}
];
expect(chats2GPTMessages({ messages, reserveId: false, reserveTool: true })).toEqual([
{
dataId: undefined,
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_ask',
type: 'function',
function: {
name: 'ask_user',
arguments: '{"questions":[]}'
}
}
]
},
{
dataId: undefined,
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_ask',
content: `## First?
回答:A - Answer A
## Second?
回答:Custom answer
## Third?
回答:未回答`
}
]);
});
it('should skip plan card when building GPT messages', () => {
const messages: ChatItemMiniType[] = [
{
......
......@@ -582,7 +582,7 @@ describe('getFlatAppResponses', () => {
});
describe('checkInteractiveResponseStatus', () => {
it('should return query for agentPlanAskQuery type', () => {
it('should keep legacy agentPlanAskQuery as a query', () => {
const result = checkInteractiveResponseStatus({
interactive: {
type: 'agentPlanAskQuery',
......@@ -598,7 +598,43 @@ describe('checkInteractiveResponseStatus', () => {
expect(result).toBe('query');
});
it('should return query for an agent ask nested inside child interactive wrappers', () => {
it('should return query for agentAsk by default and submit when configured', () => {
const interactive = {
type: 'agentAsk' as const,
askId: 'call_ask',
params: {
description: 'Need input',
questions: [
{
question: 'Need input?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
};
expect(
checkInteractiveResponseStatus({
interactive,
input: '{"answers":["A"]}'
})
).toBe('query');
expect(
checkInteractiveResponseStatus({
interactive: {
...interactive,
responseMode: 'submit'
},
input: '{"answers":["A"]}'
})
).toBe('submit');
});
it('should keep a nested legacy agent ask as a query', () => {
const result = checkInteractiveResponseStatus({
interactive: {
type: 'toolChildrenInteractive',
......
......@@ -760,6 +760,39 @@ describe('getLastInteractiveValue', () => {
expect(getLastInteractiveValue(histories)).toBeUndefined();
});
it('should return pending agentAsk and skip submitted agentAsk', () => {
const interactive = {
type: 'agentAsk',
askId: 'call_ask',
entryNodeIds: ['node1'],
memoryEdges: [],
nodeOutputs: [],
params: {
description: 'Choose one',
questions: [
{
question: 'Choose one?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType;
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'response' }, interactive }]
}
];
expect(getLastInteractiveValue(histories)).toBe(interactive);
(interactive.params as { submitted?: boolean }).submitted = true;
expect(getLastInteractiveValue(histories)).toBeUndefined();
});
it('should return interactive for paymentPause without continue', () => {
const interactive = {
type: 'paymentPause',
......@@ -801,7 +834,7 @@ describe('getLastInteractiveValue', () => {
expect(getLastInteractiveValue(histories)).toBeUndefined();
});
it('should return interactive for agentPlanAskQuery', () => {
it('should adapt unanswered top-level agentPlanAskQuery to pending agentAsk', () => {
const interactive = {
type: 'agentPlanAskQuery',
askId: 'call_ask',
......@@ -820,7 +853,24 @@ describe('getLastInteractiveValue', () => {
value: [{ text: { content: 'response' }, interactive }]
}
];
expect(getLastInteractiveValue(histories)).toBe(interactive);
expect(getLastInteractiveValue(histories)).toMatchObject({
type: 'agentAsk',
askId: 'call_ask',
params: {
description: '',
questions: [
{
question: 'What do you want?',
options: [
{ summary: 'Use repo', value: 'Use repo' },
{ summary: 'Use docs', value: 'Use docs' },
{ summary: 'Use defaults', value: 'Use defaults' }
],
answer: ''
}
]
}
});
});
it('should return undefined for answered agentPlanAskQuery', () => {
......@@ -846,6 +896,31 @@ describe('getLastInteractiveValue', () => {
expect(getLastInteractiveValue(histories)).toBeUndefined();
});
it('should ignore nested agentPlanAskQuery', () => {
const interactive = {
type: 'childrenInteractive',
params: {
childrenId: 'child_1',
childrenResponse: {
type: 'agentPlanAskQuery',
askId: 'call_ask',
params: {
content: 'What do you want?',
options: ['Use repo', 'Use docs']
}
}
}
} as WorkflowInteractiveResponseType;
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'response' }, interactive }]
}
];
expect(getLastInteractiveValue(histories)).toBeUndefined();
});
});
describe('storeEdges2RuntimeEdges', () => {
......
......@@ -71,8 +71,8 @@ ${
5. 用户目标不明确,需要确认产物类型或成功标准。
调用 ${askToolName} 时必须提供:
- question:一个面向用户的简短标题问题。
- options:2 到 5 个可直接选择的候选答案;每个选项都要是完整答案,不要写成解释或问题
- questions:1 到 3 个面向用户的简短问题。
- 每个问题提供 2 到 4 个候选答案(如无必要则给 3 个)。每个选项必须提供 summary 和 value:summary 是展示给用户的简短选项文本,value 是用户选中后返回给你的完整答案
Skill 要求向用户收集选项信息时,优先遵循 Skill 并调用 ${askToolName},不要自行替用户选择。
不要为了不会影响结果的琐碎细节、可以直接通过工具获得的信息,或只是让计划更完美而追问。
......
import type { ChatCompletionMessageToolCall } from '@fastgpt/global/core/ai/llm/type';
import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants';
import { formatAgentAskAnswers, parseAgentAskAnswers } from '@fastgpt/global/core/ai/agent/utils';
import type {
ChatCompletionMessageParam,
ChatCompletionMessageToolCall
} from '@fastgpt/global/core/ai/llm/type';
import { parseJsonArgs } from '../../../../../utils';
import { AgentAskPayloadSchema, type AgentAskPayload } from './tool';
......@@ -40,3 +45,37 @@ export const parseAgentAskToolCall = (
ask: result.data
};
};
/**
* 将新版 ask_user 的 JSON 回答渲染为模型可读的问答文本。
* 题目只保存在原始 tool call 参数中,所以需要先查找到对应的 tool call,
* 再从 tool call 参数中获取题目,拼接到一起发送给模型。
*/
export const formatAgentAskToolResponse = ({
messages,
askToolCallId,
answer
}: {
messages: ChatCompletionMessageParam[];
askToolCallId: string;
answer: string;
}) => {
const answers = parseAgentAskAnswers(answer);
const askToolCall = messages
.flatMap((message) =>
message.role === ChatCompletionRequestMessageRoleEnum.Assistant
? message.tool_calls || []
: []
)
.find((call) => call.id === askToolCallId);
if (!askToolCall) return answer;
const parsedAsk = parseAgentAskToolCall(askToolCall);
if (!parsedAsk.success) return answer;
return formatAgentAskAnswers({
questions: parsedAsk.ask.questions,
answers
});
};
import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type';
import {
AgentAskBlockerTypeSchema,
AgentAskQuestionSchema
} from '@fastgpt/global/core/ai/agent/type';
import z from 'zod';
export const AgentAskPayloadSchema = z.object({
const AgentAskBaseSchema = z.object({
reason: z.string(),
blockerType: z.enum([
'missing_required_input',
'tool_unavailable',
'ambiguous_goal',
'user_choice'
]),
question: z.string(),
blockerType: AgentAskBlockerTypeSchema
});
const LegacyAgentAskQuestionSchema = z.object({
question: z.string().trim().min(1),
options: z.array(z.string().trim().min(1)).min(2).max(5)
});
export const AgentAskPayloadSchema = z
.union([
AgentAskBaseSchema.extend({
questions: z.array(AgentAskQuestionSchema).min(1).max(3)
}),
AgentAskBaseSchema.extend(LegacyAgentAskQuestionSchema.shape)
])
.transform((payload) =>
'questions' in payload
? payload
: // ? Compatibility fallback for reading legacy payload.
{
reason: payload.reason,
blockerType: payload.blockerType,
questions: [
{
question: payload.question,
options: payload.options.map((option) => ({ summary: option, value: option }))
}
]
}
);
export type AgentAskPayload = z.infer<typeof AgentAskPayloadSchema>;
/**
......@@ -37,22 +61,45 @@ export const createAskAgentTool = (name = 'ask_agent'): ChatCompletionTool => ({
description:
'Use user_choice when asking the user to select a meaningful preference, scope, format, or execution path.'
},
question: {
type: 'string',
description: 'A concise user-facing question shown as the title of the choice card.'
},
options: {
questions: {
type: 'array',
minItems: 2,
maxItems: 5,
description:
'Two to five concise answer choices the user can select directly. Each item must be a complete answer.',
minItems: 1,
maxItems: 3,
description: 'One to three concise user-facing questions to collect together.',
items: {
type: 'string'
type: 'object',
properties: {
question: {
type: 'string',
description: 'A concise user-facing question shown as the title of a choice card.'
},
options: {
type: 'array',
minItems: 2,
maxItems: 4,
description:
'Two to four choices. summary is concise UI text; value is the complete answer returned after selection.',
items: {
type: 'object',
properties: {
summary: {
type: 'string',
description: 'Concise option text shown to the user.'
},
value: {
type: 'string',
description: 'Complete answer returned to the agent after selection.'
}
},
required: ['summary', 'value']
}
}
},
required: ['question', 'options']
}
}
},
required: ['reason', 'blockerType', 'question', 'options']
required: ['reason', 'blockerType', 'questions']
}
}
});
......@@ -8,7 +8,11 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import { parseJsonArgs } from '../../../../../utils';
import { runAgentLoop } from './base';
import { getMainAgentSystemPrompt } from '../../../domain/mainPrompt';
import { parseAgentAskToolCall, type AgentAskPayload } from '../../../domain/systemTool/ask';
import {
formatAgentAskToolResponse,
parseAgentAskToolCall,
type AgentAskPayload
} from '../../../domain/systemTool/ask';
import { applyPlanUpdate, applySetPlan } from '../../../domain/systemTool/plan';
import type { AgentLoopEvent } from './type';
import { normalizeAgentLoopUsages, type AgentLoopUsage } from '../../../domain';
......@@ -205,7 +209,13 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: input.pendingMainContext.askToolCallId,
content: normalizeToolResponseContent(input.userAnswer)
content: normalizeToolResponseContent(
formatAgentAskToolResponse({
messages: input.pendingMainContext.messages,
askToolCallId: input.pendingMainContext.askToolCallId,
answer: input.userAnswer
})
)
} as ChatCompletionMessageParam
]
: buildInitialMessages({ input, hasRuntimeTools, promptMode: runtime.promptMode });
......
......@@ -16,7 +16,11 @@ import { formatModelChars2Points } from '../../../../../../support/wallet/usage/
import { getLLMModel } from '../../../../model';
import { AgentUsageModuleName } from '../../domain/usage';
import { getMainAgentSystemPrompt } from '../../domain/mainPrompt';
import { askUserToolName, type AgentAskPayload } from '../../domain/systemTool/ask';
import {
askUserToolName,
formatAgentAskToolResponse,
type AgentAskPayload
} from '../../domain/systemTool/ask';
import { setPlanToolName, updatePlanToolName } from '../../domain/systemTool/plan';
import {
normalizeAgentLoopUsages,
......@@ -217,7 +221,13 @@ export const runPiAgentLoop = async <TChildrenResponse = unknown>({
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: askResumeId,
content: normalizeToolResponseContent(input.userAnswer)
content: normalizeToolResponseContent(
formatAgentAskToolResponse({
messages: pendingMainContext!.messages,
askToolCallId: askResumeId,
answer: input.userAnswer ?? ''
})
)
} as ChatCompletionMessageParam
]
: undefined;
......
......@@ -33,6 +33,7 @@ import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { encryptSecretValue, anyValueDecrypt } from '../../common/secret/utils';
import type { SecretValueType } from '@fastgpt/global/common/secret/type';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { parseAgentAskAnswers } from '@fastgpt/global/core/ai/agent/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { normalizeChatFileStoreValues } from './fileStoreValue';
import type { NodeResponseWriteSummary } from './nodeResponseStorage';
......@@ -755,11 +756,24 @@ export const updateInteractiveChat = async ({
throw new Error('Prepared chat round is required for interactive query');
}
if (finalInteractive.type === 'agentPlanAskQuery') {
if (finalInteractive.type === 'agentPlanAskQuery' || finalInteractive.type === 'agentAsk') {
if (!finalInteractive.askId) {
throw new Error(`Agent ask interactive askId is required: ${chatId}`);
}
finalInteractive.params.answer = userInteractiveVal;
if (finalInteractive.type === 'agentPlanAskQuery') {
// Legacy ask_user
finalInteractive.params.answer = userInteractiveVal;
} else {
// New (multiple questions)
const answers = parseAgentAskAnswers(userInteractiveVal);
finalInteractive.params.questions = finalInteractive.params.questions.map(
(question, index) => ({
...question,
answer: answers[index] ?? question.answer
})
);
finalInteractive.params.submitted = true;
}
const interactiveChatItem = await MongoChatItem.findOne({
...buildChatSourceQuery(chatSource),
......
......@@ -11,12 +11,14 @@ export const createAgentLoopCoreAskInteractive = ({
askId: string;
ask: AgentAskPayload;
}): InteractiveNodeResponseType => ({
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId,
params: {
content: ask.question,
reason: ask.reason,
blockerType: ask.blockerType,
options: ask.options
description: ask.reason,
questions: ask.questions.map((question) => ({
...question,
// Initialize the initial state
answer: ''
}))
}
});
......@@ -154,7 +154,7 @@ export const createAgentLoopCoreNodeResponseEventCollector = ({
moduleType: node.flowNodeType,
moduleLogo: AgentNodeResponseDisplay.ask.moduleLogo,
runningTime: event.seconds,
textOutput: event.ask.question
textOutput: event.ask.questions.map((question) => question.question).join('\n')
})
);
};
......
......@@ -162,7 +162,7 @@ describe('runFastAgentMainLoop', () => {
expect(mainAgentPrompt).toContain('你是 Work Agent');
expect(mainAgentPrompt).toContain('任务或 Skill 明确需要通过选项向用户收集信息');
expect(mainAgentPrompt).toContain('Skill 要求向用户收集选项信息时');
expect(mainAgentPrompt).toContain('options:2 到 5 个');
expect(mainAgentPrompt).toContain('每个问题提供 2 到 4 个候选答案');
});
it.each([
......@@ -498,11 +498,15 @@ describe('runFastAgentMainLoop', () => {
args: {
reason: 'Need private repository path',
blockerType: 'missing_required_input',
question: 'Which repository should I inspect?',
options: [
'Use the current workspace',
'I will provide a repository path',
'Skip repository inspection'
questions: [
{
question: 'Which repository should I inspect?',
options: [
{ summary: 'Current workspace', value: 'Use the current workspace' },
{ summary: 'Repository path', value: 'I will provide a repository path' },
{ summary: 'Skip inspection', value: 'Skip repository inspection' }
]
}
]
}
})
......@@ -522,13 +526,15 @@ describe('runFastAgentMainLoop', () => {
expect(result.status).toBe('paused');
expect(result.pause?.type).toBe('ask');
expect(result.pause?.type === 'ask' ? result.pause.ask.question : undefined).toBe(
'Which repository should I inspect?'
);
expect(result.pause?.type === 'ask' ? result.pause.ask.options : undefined).toEqual([
'Use the current workspace',
'I will provide a repository path',
'Skip repository inspection'
expect(result.pause?.type === 'ask' ? result.pause.ask.questions : undefined).toEqual([
{
question: 'Which repository should I inspect?',
options: [
{ summary: 'Current workspace', value: 'Use the current workspace' },
{ summary: 'Repository path', value: 'I will provide a repository path' },
{ summary: 'Skip inspection', value: 'Skip repository inspection' }
]
}
]);
expect(result.pendingMainContext?.askToolCallId).toBe('call_ask');
expect(result.pendingMainContext?.messages.at(-1)).toEqual({
......@@ -540,8 +546,7 @@ describe('runFastAgentMainLoop', () => {
type: 'function',
function: {
name: 'ask_user',
arguments:
'{"reason":"Need private repository path","blockerType":"missing_required_input","question":"Which repository should I inspect?","options":["Use the current workspace","I will provide a repository path","Skip repository inspection"]}'
arguments: expect.stringContaining('"summary":"Current workspace"')
}
}
]
......@@ -569,8 +574,19 @@ describe('runFastAgentMainLoop', () => {
arguments: JSON.stringify({
reason: 'Need confirmation before changing data',
blockerType: 'missing_required_input',
question: 'Should I continue with the data change?',
options: ['Continue', 'Cancel', 'Review the proposed change first']
questions: [
{
question: 'Should I continue with the data change?',
options: [
{ summary: 'Continue', value: 'Continue' },
{ summary: 'Cancel', value: 'Cancel' },
{
summary: 'Review proposal',
value: 'Review the proposed change first'
}
]
}
]
})
}
},
......
......@@ -1534,10 +1534,18 @@ describe('runPiAgentLoop', () => {
name: 'ask_user',
callId: 'call_ask_pause',
args: {
question: '请确认目标',
reason: '需要补充范围',
blockerType: 'missing_required_input',
options: ['目标 A', '目标 B', '目标 C']
questions: [
{
question: '请确认目标',
options: [
{ summary: '目标 A', value: '目标 A' },
{ summary: '目标 B', value: '目标 B' },
{ summary: '目标 C', value: '目标 C' }
]
}
]
}
};
......
......@@ -12,7 +12,7 @@ import {
} from '@fastgpt/service/core/ai/llm/agentLoop/domain/systemTool/plan/updateTool';
describe('agent loop system ask tool', () => {
it('parses ask_agent tool call arguments', () => {
it('parses up to three ask_agent questions', () => {
const result = parseAgentAskToolCall({
id: 'call_ask',
type: 'function',
......@@ -21,11 +21,29 @@ describe('agent loop system ask tool', () => {
arguments: JSON.stringify({
reason: 'Need repository path',
blockerType: 'missing_required_input',
question: 'Which repository should I inspect?',
options: [
'/Volumes/code/FastGPT',
'Use the current workspace',
'I will provide another repository path'
questions: [
{
question: 'Which repository should I inspect?',
options: [
{ summary: 'FastGPT repository', value: '/Volumes/code/FastGPT' },
{ summary: 'Current workspace', value: 'Use the current workspace' },
{ summary: 'Another repository', value: 'I will provide another repository path' }
]
},
{
question: 'Which output should I create?',
options: [
{ summary: 'Document', value: 'Document' },
{ summary: 'Spreadsheet', value: 'Spreadsheet' }
]
},
{
question: 'Should I include examples?',
options: [
{ summary: 'Include examples', value: 'Include examples' },
{ summary: 'Skip examples', value: 'Skip examples' }
]
}
]
})
}
......@@ -36,11 +54,29 @@ describe('agent loop system ask tool', () => {
ask: {
reason: 'Need repository path',
blockerType: 'missing_required_input',
question: 'Which repository should I inspect?',
options: [
'/Volumes/code/FastGPT',
'Use the current workspace',
'I will provide another repository path'
questions: [
{
question: 'Which repository should I inspect?',
options: [
{ summary: 'FastGPT repository', value: '/Volumes/code/FastGPT' },
{ summary: 'Current workspace', value: 'Use the current workspace' },
{ summary: 'Another repository', value: 'I will provide another repository path' }
]
},
{
question: 'Which output should I create?',
options: [
{ summary: 'Document', value: 'Document' },
{ summary: 'Spreadsheet', value: 'Spreadsheet' }
]
},
{
question: 'Should I include examples?',
options: [
{ summary: 'Include examples', value: 'Include examples' },
{ summary: 'Skip examples', value: 'Skip examples' }
]
}
]
}
});
......@@ -64,7 +100,7 @@ describe('agent loop system ask tool', () => {
expect(result.error).toContain('options');
});
it('supports a two-option user choice', () => {
it('normalizes the legacy single-question format', () => {
const result = parseAgentAskToolCall({
id: 'call_ask',
type: 'function',
......@@ -84,20 +120,92 @@ describe('agent loop system ask tool', () => {
ask: {
reason: 'Need a choice',
blockerType: 'user_choice',
question: 'Which output should I create?',
options: ['Document', 'Spreadsheet']
questions: [
{
question: 'Which output should I create?',
options: [
{ summary: 'Document', value: 'Document' },
{ summary: 'Spreadsheet', value: 'Spreadsheet' }
]
}
]
}
});
const parameters = createAskAgentTool().function.parameters as any;
expect(parameters.properties.options).toMatchObject({
expect(parameters.properties.questions).toMatchObject({
minItems: 1,
maxItems: 3
});
expect(parameters.properties.questions.items.properties.options).toMatchObject({
minItems: 2,
maxItems: 5
maxItems: 4
});
expect(parameters.properties.questions.items.properties.options.items.required).toEqual([
'summary',
'value'
]);
expect(parameters.properties.blockerType.enum).toContain('user_choice');
expect(createAskAgentTool().function.description).toContain('task or a Skill');
});
it('rejects questions with more than four options', () => {
const result = parseAgentAskToolCall({
id: 'call_ask',
type: 'function',
function: {
name: 'ask_agent',
arguments: JSON.stringify({
reason: 'Need a choice',
blockerType: 'user_choice',
questions: [
{
question: 'Which output should I create?',
options: Array.from({ length: 5 }, (_, index) => ({
summary: `Option ${index + 1}`,
value: `Option ${index + 1}`
}))
}
]
})
}
});
expect(result.success).toBe(false);
});
it('accepts duplicate option summaries and values', () => {
const baseQuestion = {
question: 'Which output should I create?'
};
const createCall = (options: Array<{ summary: string; value: string }>) =>
parseAgentAskToolCall({
id: 'call_ask',
type: 'function',
function: {
name: 'ask_agent',
arguments: JSON.stringify({
reason: 'Need a choice',
blockerType: 'user_choice',
questions: [{ ...baseQuestion, options }]
})
}
});
expect(
createCall([
{ summary: 'Document', value: 'Document' },
{ summary: 'Document', value: 'Spreadsheet' }
]).success
).toBe(true);
expect(
createCall([
{ summary: 'Document', value: 'Document' },
{ summary: 'Spreadsheet', value: 'Document' }
]).success
).toBe(true);
});
it('creates internal tool schemas without workflow dependencies', () => {
expect(createAskAgentTool().function.name).toBe('ask_agent');
expect(createSetPlanTool().function.name).toBe('set_plan');
......
......@@ -1215,7 +1215,7 @@ describe('pushChatRecords', () => {
);
});
it('should persist agentPlanAskQuery answer on previous interactive and finalize prepared records', async () => {
it('should persist multi-question agentAsk form values and finalize prepared records', async () => {
await MongoChatItem.create({
chatId: 'test-chat-id',
teamId: testTeamId,
......@@ -1227,13 +1227,28 @@ describe('pushChatRecords', () => {
value: [
{
interactive: {
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId: 'call_ask_agent',
params: {
content: '请补充目标',
reason: '需要用户明确任务目标',
blockerType: 'missing_required_input',
options: ['继续研究 Rust', '改为研究 Go', '先给出学习路线']
description: '需要用户明确任务目标',
questions: [
{
question: '请选择方向',
options: [
{ summary: 'Rust', value: 'Rust' },
{ summary: 'Go', value: 'Go' }
],
answer: ''
},
{
question: '需要示例吗',
options: [
{ summary: '需要', value: '需要' },
{ summary: '不需要', value: '不需要' }
],
answer: ''
}
]
}
}
}
......@@ -1247,7 +1262,7 @@ describe('pushChatRecords', () => {
dataId: 'prepared-round-data-id',
value: [
{
text: { content: '深入了解 Rust 系统编程方向' }
text: { content: JSON.stringify({ answers: ['Rust', ''] }) }
}
]
},
......@@ -1283,7 +1298,7 @@ describe('pushChatRecords', () => {
dataId: 'prepared-round-data-id',
value: [
{
text: { content: '深入了解 Rust 系统编程方向' }
text: { content: JSON.stringify({ answers: ['Rust', ''] }) }
}
]
},
......@@ -1300,13 +1315,28 @@ describe('pushChatRecords', () => {
]);
const interactive = {
type: 'agentPlanAskQuery' as const,
type: 'agentAsk' as const,
askId: 'call_ask_agent',
params: {
content: '请补充目标',
reason: '需要用户明确任务目标',
blockerType: 'missing_required_input',
options: ['继续研究 Rust', '改为研究 Go', '先给出学习路线']
description: '需要用户明确任务目标',
questions: [
{
question: '请选择方向',
options: [
{ summary: 'Rust', value: 'Rust' },
{ summary: 'Go', value: 'Go' }
],
answer: ''
},
{
question: '需要示例吗',
options: [
{ summary: '需要', value: '需要' },
{ summary: '不需要', value: '不需要' }
],
answer: ''
}
]
},
entryNodeIds: [],
memoryEdges: [],
......@@ -1330,16 +1360,14 @@ describe('pushChatRecords', () => {
throw new Error('previousChatItem does not have AI interactive value');
}
const lastValue = previousChatItem.value[previousChatItem.value.length - 1];
if (lastValue.interactive?.type !== 'agentPlanAskQuery') {
throw new Error('previousChatItem does not have agentPlanAskQuery interactive');
if (lastValue.interactive?.type !== 'agentAsk') {
throw new Error('previousChatItem does not have agentAsk interactive');
}
expect(lastValue.interactive.params.answer).toBe('深入了解 Rust 系统编程方向');
expect(lastValue.interactive.params.reason).toBe('需要用户明确任务目标');
expect(lastValue.interactive.params.options).toEqual([
'继续研究 Rust',
'改为研究 Go',
'先给出学习路线'
expect(lastValue.interactive.params.submitted).toBe(true);
expect(lastValue.interactive.params.questions.map((question) => question.answer)).toEqual([
'Rust',
''
]);
const finalizedAiItem = await MongoChatItem.findOne({
......
......@@ -290,6 +290,63 @@ describe('prepare chat round', () => {
expect(await MongoChatItem.countDocuments({ appId: testAppId, chatId: params.chatId })).toBe(1);
});
it('should reuse the original AI dataId for submit agentAsk', async () => {
const params = createPreChatRoundParams(
{ responseChatItemId: 'client-new-data-id' },
{ appId: testAppId, teamId: testTeamId, tmbId: testTmbId }
);
await MongoChat.create({
appId: testAppId,
chatId: params.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: params.sourceType,
source: params.source
});
await MongoChatItem.create({
teamId: testTeamId,
tmbId: testTmbId,
sourceType: params.sourceType,
appId: testAppId,
chatId: params.chatId,
dataId: 'previous-helper-ai-data-id',
obj: ChatRoleEnum.AI,
value: []
});
const result = await preChatRound({
...params,
interactive: {
type: 'agentAsk',
askId: 'chat-agent-helper-ask',
responseMode: 'submit',
params: {
description: 'Collect requirements',
questions: [
{
question: 'Audience?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
} as any
});
expect(result.responseChatItemId).toBe('previous-helper-ai-data-id');
expect(result.shouldFinalizePreparedRound).toBe(false);
expect(
await MongoChatItem.countDocuments({
appId: testAppId,
chatId: params.chatId,
sourceType: params.sourceType
})
).toBe(1);
});
it('should mark chat as error when interactive continue has no previous AI item', async () => {
const params = createPreChatRoundParams(
{
......
......@@ -1047,13 +1047,21 @@ describe('useUserContext', () => {
value: [
{
interactive: {
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId: 'ask_1',
params: {
content: 'Choose one',
options: ['A', 'B', 'C']
description: 'Choose one',
questions: [
{
question: 'Choose one?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
]
}
]
}
}
} as any
}
]
}
......
......@@ -912,6 +912,74 @@ describe('dispatchRunAgent user context', () => {
expect(result.data.answerText).toBe('continued answer');
});
it('resumes a multi-question agentAsk with answers in form order', async () => {
const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
const props = createProps();
props.lastInteractive = {
type: 'agentAsk',
askId: 'call_ask',
params: {
description: 'Need confirmation',
questions: [
{
question: 'First?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
},
{
question: 'Second?',
options: [
{ summary: 'C', value: 'C' },
{ summary: 'D', value: 'D' }
],
answer: ''
}
]
}
};
props.query = runtimePrompt2ChatsValue({
text: JSON.stringify({ answers: ['A', ''] })
});
props.histories[props.histories.length - 1].memories = {
'agentLoopMemory-agent_node': {
providerState: {
pendingMainContext: {
askToolCallId: 'call_ask',
messages: []
}
}
}
};
runAgentLoopMock.mockResolvedValueOnce({
status: 'done',
completeMessages: [],
assistantMessages: [{ role: 'assistant', content: 'continued answer' }],
requestIds: []
});
let resultPromise: Promise<any>;
runWithContext(
{
mcpClientMemory: {}
},
() => {
resultPromise = dispatchRunAgent(props);
}
);
await resultPromise!;
expect(runAgentLoopMock).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
userAnswer: '{"answers":["A",""]}'
})
})
);
});
it('restores pi providerState from unified memory and resumes ask with user answer', async () => {
const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
serviceEnvMock.AGENT_ENGINE = 'piAgent';
......@@ -959,8 +1027,15 @@ describe('dispatchRunAgent user context', () => {
ask: {
reason: 'Need another confirmation',
blockerType: 'missing_required_input',
question: 'Confirm again?',
options: ['Yes', 'No']
questions: [
{
question: 'Confirm again?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' }
]
}
]
}
},
providerState: {
......
......@@ -136,12 +136,20 @@ describe('appendAgentLoopCoreAssistantResponseFromEvent', () => {
event: {
type: 'ask_start',
id: 'call_ask',
params: '{"question":"Need input?"}',
params:
'{"questions":[{"question":"Need input?","options":[{"summary":"A","value":"A"},{"summary":"B","value":"B"}]}]}',
ask: {
reason: 'Need confirmation',
blockerType: 'ambiguous_goal',
question: 'Need input?',
options: ['A', 'B']
questions: [
{
question: 'Need input?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
]
}
]
}
},
names: {
......@@ -156,7 +164,8 @@ describe('appendAgentLoopCoreAssistantResponseFromEvent', () => {
id: 'call_ask',
askId: 'call_ask',
functionName: 'agent_ask_user',
params: '{"question":"Need input?"}'
params:
'{"questions":[{"question":"Need input?","options":[{"summary":"A","value":"A"},{"summary":"B","value":"B"}]}]}'
}
}
]);
......
......@@ -3,25 +3,56 @@ import { AgentPlanAskQueryInteractiveSchema } from '@fastgpt/global/core/workflo
import { createAgentLoopCoreAskInteractive } from '@fastgpt/service/core/workflow/dispatch/ai/agentLoopCore/adapter/interactive';
describe('agentLoopCore ask interactive', () => {
it('converts ask payload to workflow interactive response', () => {
it('converts ask payload to a multi-question agentAsk response', () => {
expect(
createAgentLoopCoreAskInteractive({
askId: 'call_ask',
ask: {
reason: 'Need input',
blockerType: 'missing_required_input',
question: 'Confirm?',
options: ['Yes', 'No', 'Not sure']
questions: [
{
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
]
},
{
question: 'Include examples?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' }
]
}
]
}
})
).toEqual({
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId: 'call_ask',
params: {
content: 'Confirm?',
reason: 'Need input',
blockerType: 'missing_required_input',
options: ['Yes', 'No', 'Not sure']
description: 'Need input',
questions: [
{
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
],
answer: ''
},
{
question: 'Include examples?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' }
],
answer: ''
}
]
}
});
});
......
......@@ -195,8 +195,22 @@ describe('createAgentLoopCoreNodeResponseEventCollector', () => {
ask: {
reason: 'Need input',
blockerType: 'missing_required_input',
question: 'Confirm?',
options: ['Yes']
questions: [
{
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' }
]
},
{
question: 'Include examples?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' }
]
}
]
}
});
......@@ -210,7 +224,7 @@ describe('createAgentLoopCoreNodeResponseEventCollector', () => {
expect.objectContaining({
id: 'agent_node-ask-call_ask',
moduleName: 'chat:collect_questions',
textOutput: 'Confirm?'
textOutput: 'Confirm?\nInclude examples?'
})
]);
expect(nodeResponses[0].textOutput).toBeUndefined();
......
......@@ -104,8 +104,16 @@ describe('summarizeAgentLoopCoreResult', () => {
const ask = {
reason: 'Need input',
blockerType: 'missing_required_input' as const,
question: 'Confirm?',
options: ['Yes', 'No', 'Not sure']
questions: [
{
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
]
}
]
};
expect(
......@@ -129,13 +137,21 @@ describe('summarizeAgentLoopCoreResult', () => {
status: 'interactive',
providerState,
interactive: {
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId: 'call_ask',
params: {
content: 'Confirm?',
reason: 'Need input',
blockerType: 'missing_required_input',
options: ['Yes', 'No', 'Not sure']
description: 'Need input',
questions: [
{
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
],
answer: ''
}
]
}
}
})
......
......@@ -350,8 +350,16 @@ describe('runAgentLoopCore', () => {
const ask = {
reason: 'Need confirmation',
blockerType: 'missing_required_input' as const,
question: 'Confirm?',
options: ['Yes', 'No', 'Not sure']
questions: [
{
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
]
}
]
};
runAgentLoopMock.mockResolvedValue({
status: 'paused',
......
......@@ -558,13 +558,21 @@ describe('getHistories', () => {
value: [
{
interactive: {
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId: 'ask_1',
params: {
content: 'Choose one',
options: ['A', 'B', 'C']
description: 'Choose one',
questions: [
{
question: 'Choose one?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
]
}
]
}
}
} as any
}
]
}
......
......@@ -292,6 +292,13 @@
"interactive.user_select.collapse_options": "Collapse options",
"interactive.user_select.expand_options": "Expand options",
"interactive.user_select.selected": "Selected: {{answer}}",
"interactive.agent_ask.question_progress": "Question {{current}} of {{total}}",
"interactive.agent_ask.custom_answer": "Tell FastGPT what you have in mind",
"interactive.agent_ask.skip": "Skip",
"interactive.agent_ask.skip_all": "Skip all questions",
"interactive.agent_ask.unanswered": "Unanswered",
"interactive.agent_ask.waiting": "Asking questions",
"interactive.agent_ask.asked_questions": "Asked {{count}} questions",
"view_all_citations": "View all",
"view_citations": "View References"
}
......@@ -292,6 +292,13 @@
"interactive.user_select.collapse_options": "收起选项",
"interactive.user_select.expand_options": "展开选项",
"interactive.user_select.selected": "已选择:{{answer}}",
"interactive.agent_ask.question_progress": "问题 {{current}} / {{total}}",
"interactive.agent_ask.custom_answer": "告诉 FastGPT 你的想法",
"interactive.agent_ask.skip": "跳过",
"interactive.agent_ask.skip_all": "跳过所有题目",
"interactive.agent_ask.unanswered": "未回答",
"interactive.agent_ask.waiting": "正在询问",
"interactive.agent_ask.asked_questions": "已询问 {{count}} 个问题",
"view_all_citations": "查看全部",
"view_citations": "查看引用"
}
......@@ -288,6 +288,13 @@
"interactive.user_select.collapse_options": "收起選項",
"interactive.user_select.expand_options": "展開選項",
"interactive.user_select.selected": "已選擇:{{answer}}",
"interactive.agent_ask.question_progress": "問題 {{current}} / {{total}}",
"interactive.agent_ask.custom_answer": "告訴 FastGPT 你的想法",
"interactive.agent_ask.skip": "跳過",
"interactive.agent_ask.skip_all": "跳過所有題目",
"interactive.agent_ask.unanswered": "未回答",
"interactive.agent_ask.waiting": "正在詢問",
"interactive.agent_ask.asked_questions": "已詢問 {{count}} 個問題",
"view_all_citations": "查看全部",
"view_citations": "檢視引用"
}
Subproject commit e6c7fe1c8036e34d4ba6e8d06189ad2ea9ab3b11
Subproject commit 9a73980b9ae674d91c21b075e7dc518679ae0003
......@@ -34,7 +34,7 @@ export type ChatRecordsListProps = {
| undefined;
questionGuides: string[];
onToggleDeletedGroup: (dataIds: string[]) => void;
onRetry: (dataId?: string) => (() => Promise<void>) | undefined;
onRetry: (dataId?: string, hideInUI?: boolean) => (() => Promise<void>) | undefined;
onEdit: (dataId?: string) => ((input: ChatBoxInputType) => Promise<void>) | undefined;
onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined;
onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined;
......
......@@ -74,7 +74,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps)
*
* 如果没有传入 `dataId`,返回 undefined,让调用方不渲染无效动作。
*/
const retryInput = useMemoizedFn((dataId?: string) => {
const retryInput = useMemoizedFn((dataId?: string, hideInUI = false) => {
if (!dataId) return;
return async () => {
......@@ -88,6 +88,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps)
sendPrompt({
...formatChatValue2InputType(delHistory[0].value),
hideInUI: hideInUI || delHistory[0].hideInUI,
history: chatRecords.slice(0, index)
});
} catch (error) {
......
......@@ -16,13 +16,15 @@ import { postStopV2Chat } from '@/web/core/chat/api';
import type { ChatBoxInputType, StopChatFnResult, ChatGenerateStatusChangeHandler } from './type';
import type { StartChatFnProps } from '../type';
import ChatInput from './Input/ChatInput';
import AgentAskComposer from './Input/AgentAskComposer';
import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import {
ChatGenerateStatusEnum,
ChatRoleEnum,
ChatStatusEnum
} from '@fastgpt/global/core/chat/constants';
import { getInteractiveByHistories } from './utils/interactive';
import { getInteractiveByHistories, isPendingAgentAsk } from './utils/interactive';
import { extractDeepestInteractive } from '@fastgpt/global/core/workflow/runtime/utils';
import {
ChatInputWrapperStyle,
ChatTypeEnum,
......@@ -465,7 +467,12 @@ const ChatBox = ({
finishChatGenerateStatus
});
const canRenderChatInput = onStartChat && chatStarted && active && canSendQuery;
const activeInteractive = lastInteractive
? extractDeepestInteractive(lastInteractive)
: undefined;
const isAgentAskPending = isPendingAgentAsk(lastInteractive);
const canRenderChatInput =
onStartChat && chatStarted && active && (canSendQuery || isAgentAskPending);
const canSendPrompt = canRenderChatInput && !isRoundPending;
const canRenderScrollToBottomButton =
(chatType === ChatTypeEnum.chat ||
......@@ -717,18 +724,39 @@ const ChatBox = ({
onClick={() => scrollToBottom('smooth')}
/>
<ChatInput
onSendMessage={sendPromptWithDisabledGuard}
lastInteractive={lastInteractive}
onStopChat={requestStopChat}
onStopSettled={handleStopSettled}
enableInputGuide={resolvedFeatures.inputGuide}
enableVoiceInput={resolvedFeatures.voice}
disableSend={isRoundPending}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
/>
<Box display={isAgentAskPending ? 'none' : undefined}>
<ChatInput
onSendMessage={sendPromptWithDisabledGuard}
lastInteractive={lastInteractive}
onStopChat={requestStopChat}
onStopSettled={handleStopSettled}
enableInputGuide={resolvedFeatures.inputGuide}
enableVoiceInput={resolvedFeatures.voice}
disableSend={isRoundPending}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
/>
</Box>
{isAgentAskPending && activeInteractive?.type === 'agentAsk' && (
<Box
w={'100%'}
maxW={inputBodyProps?.maxW ?? ['100%', '780px']}
mx={inputBodyProps?.mx ?? inputBodyProps?.margin ?? 'auto'}
pb={inputBodyProps?.pb ?? ['calc(16px + env(safe-area-inset-bottom))', 4]}
>
<AgentAskComposer
questions={activeInteractive.params.questions}
onSubmit={(answers) =>
sendPromptWithDisabledGuard({
text: JSON.stringify({ answers }),
interactive: lastInteractive,
hideInUI: true
})
}
/>
</Box>
)}
</Box>
</Box>
)}
......
......@@ -4,17 +4,56 @@ import {
extractDeepestInteractive,
getLastInteractiveValue
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type {
UserInputInteractive,
WorkflowInteractiveResponseType
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { parseAgentAskAnswers } from '@fastgpt/global/core/ai/agent/utils';
import { checkInteractiveResponseStatus } from '@fastgpt/global/core/chat/utils';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { resolveFormInputFileValues } from '../../../components/FormInputResult';
import type { ChatSiteItemType } from '../type';
/** 判断当前 Agent Ask 是否正在等待多题选择回答。 */
export const isPendingAgentAsk = (interactive?: WorkflowInteractiveResponseType) => {
if (!interactive) return false;
const finalInteractive = extractDeepestInteractive(interactive);
return finalInteractive.type === 'agentAsk' && !finalInteractive.params.submitted;
};
/**
* 判断同条 AI 消息中的 userInput 是否已提交。
*
* 旧聊天记录可能未持久化 `submitted`,但运行结果仍会保留 formInputResult;
* 非最后一条消息也必然已完成,避免历史 ask 误显示为等待状态。
*/
export const isUserInputInteractiveSubmitted = ({
interactive,
responseData,
isLastChild
}: {
interactive: UserInputInteractive;
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
}) => {
if (interactive.params.submitted || !isLastChild) return true;
return !!responseData?.some((item) => {
const formInputResult = item.formInputResult;
if (!formInputResult || typeof formInputResult !== 'object' || Array.isArray(formInputResult)) {
return false;
}
return interactive.params.inputForm.some((input) => input.key in formInputResult);
});
};
/**
* 用户回答 Agent 收集问题后,前端会立即追加 Human/AI 占位消息。
* 这里把答案乐观写回对应的 agentPlanAskQuery,避免旧消息在失去 isLastChild 后丢失选中态。
* 这里把答案乐观写回对应的 Agent Ask,避免旧消息在失去 isLastChild 后丢失选中态。
*/
export const persistAgentPlanAskAnswerToHistories = ({
export const persistAgentAskAnswersToHistories = ({
histories,
interactive,
answer
......@@ -24,14 +63,15 @@ export const persistAgentPlanAskAnswerToHistories = ({
answer: string;
}): ChatSiteItemType[] => {
const sourceInteractive = extractDeepestInteractive(interactive);
if (sourceInteractive.type !== 'agentPlanAskQuery') {
if (sourceInteractive.type !== 'agentAsk') {
return histories;
}
const targetAskId = sourceInteractive.askId;
const submittedAnswers = parseAgentAskAnswers(answer);
let hasUpdated = false;
const nextHistories = histories.map((item) => {
const nextHistories = histories.map<ChatSiteItemType>((item) => {
if (item.obj !== ChatRoleEnum.AI) return item;
let itemUpdated = false;
......@@ -39,10 +79,42 @@ export const persistAgentPlanAskAnswerToHistories = ({
if (!('interactive' in val) || !val.interactive) return val;
const finalInteractive = extractDeepestInteractive(val.interactive);
if (finalInteractive.type !== 'agentPlanAskQuery') return val;
if (finalInteractive.params.answer) return val;
if (finalInteractive.askId !== targetAskId) {
// Convert the legacy ask_user interactive record to the new one.
if (
val.interactive.type === 'agentPlanAskQuery' &&
val.interactive.askId === targetAskId &&
!val.interactive.params.answer
) {
itemUpdated = true;
hasUpdated = true;
return {
...val,
interactive: {
...val.interactive,
type: 'agentAsk' as const,
params: {
description: val.interactive.params.reason ?? '',
questions: [
{
question: val.interactive.params.content,
options: val.interactive.params.options.map((option) => ({
summary: option,
value: option
})),
answer: submittedAnswers[0] ?? ''
}
],
submitted: true
}
}
};
}
if (finalInteractive.type !== 'agentAsk' || finalInteractive.askId !== targetAskId) {
return val;
}
if (finalInteractive.params.submitted) {
return val;
}
......@@ -54,7 +126,11 @@ export const persistAgentPlanAskAnswerToHistories = ({
...finalInteractive,
params: {
...finalInteractive.params,
answer
questions: finalInteractive.params.questions.map((question, index) => ({
...question,
answer: submittedAnswers[index] ?? question.answer
})),
submitted: true
}
}
};
......@@ -205,7 +281,8 @@ export const getInteractiveByHistories = (
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'agentPlanAskQuery') {
} else if (finalInteractive.type === 'agentAsk') {
// New
return {
interactive: finalInteractive,
canSendQuery: true
......@@ -237,10 +314,7 @@ export const resolveInteractiveResponseChatItemId = ({
}) => {
if (!interactive) return responseChatItemId;
const status = checkInteractiveResponseStatus({
interactive,
input: interactiveVal
});
const status = checkInteractiveResponseStatus({ interactive, input: interactiveVal });
if (status === 'query') return responseChatItemId;
const previousAiItem = histories.findLast((item) => item.obj === ChatRoleEnum.AI);
......@@ -256,10 +330,7 @@ export const rewriteHistoriesByInteractiveResponse = ({
interactiveVal: string;
interactive: WorkflowInteractiveResponseType;
}): ChatSiteItemType[] => {
const status = checkInteractiveResponseStatus({
interactive,
input: interactiveVal
});
const status = checkInteractiveResponseStatus({ interactive, input: interactiveVal });
const formatHistories = (() => {
if (status === 'query') {
......@@ -270,7 +341,7 @@ export const rewriteHistoriesByInteractiveResponse = ({
const workingHistories =
status === 'query'
? persistAgentPlanAskAnswerToHistories({
? persistAgentAskAnswersToHistories({
histories: formatHistories,
interactive,
answer: interactiveVal
......@@ -338,6 +409,24 @@ export const rewriteHistoriesByInteractiveResponse = ({
};
}
if (finalInteractive.type === 'agentAsk') {
const answers = parseAgentAskAnswers(interactiveVal);
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
questions: finalInteractive.params.questions.map((question, index) => ({
...question,
answer: answers[index] ?? question.answer
})),
submitted: true
}
}
};
}
if (finalInteractive.type === 'paymentPause') {
return {
...val,
......
......@@ -177,7 +177,7 @@ export const mergeResumeCompletedChatRecords = ({
/**
* 恢复流 replay 交互节点时,判断是否应 append 到 AI record.value。
*
* 核心约束:若 existing 中已有同一身份且已 submitted 的 `userInput`
* 核心约束:若 existing 中已有同一身份且已 submitted 的表单交互
* 则跳过 incoming 的未提交副本,避免空表单覆盖已回填的文件/字段值。
* 不同身份交互,或同身份但 existing 尚未 submitted(例如中间插入了确认文本),仍允许 append。
*/
......@@ -199,7 +199,9 @@ export const shouldAppendResumeInteractive = ({
if (!isSameInteractive) return true;
return !(
existingFinalInteractive.type === 'userInput' && existingFinalInteractive.params.submitted
(existingFinalInteractive.type === 'userInput' ||
existingFinalInteractive.type === 'agentAsk') &&
existingFinalInteractive.params.submitted
);
};
......@@ -225,7 +227,7 @@ const areSameInteractive = (
};
/**
* 将 current 侧已提交的 `userInput` 交互写回 completed 侧同身份交互节点。
* 将 current 侧已提交的表单交互写回 completed 侧同身份交互节点。
* completed record 持久化后 `inputForm.value` 可能为空(尤其 fileSelect URL 数组),
* 而恢复流 replay 期间已在 current record 中 hydrate 过,此处防止覆盖时丢失。
*/
......@@ -242,7 +244,10 @@ const mergeSubmittedInteractiveValues = ({
if (!interactive) return false;
const finalInteractive = extractDeepestInteractive(interactive);
return finalInteractive.type === 'userInput' && !!finalInteractive.params.submitted;
return (
(finalInteractive.type === 'userInput' || finalInteractive.type === 'agentAsk') &&
!!finalInteractive.params.submitted
);
});
if (!currentSubmittedInteractives.length) return completedValues;
......@@ -252,7 +257,7 @@ const mergeSubmittedInteractiveValues = ({
if (!value.interactive) return value;
const finalInteractive = extractDeepestInteractive(value.interactive);
if (finalInteractive.type !== 'userInput') {
if (finalInteractive.type !== 'userInput' && finalInteractive.type !== 'agentAsk') {
return value;
}
......
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex
} from '@chakra-ui/react';
import type { AgentAskInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { useTranslation } from 'next-i18next';
import React from 'react';
/** 显示 Agent Ask 的只读历史。 */
const RenderAgentAskInteractive = ({
interactive,
submitted
}: {
interactive: AgentAskInteractive;
submitted: boolean;
}) => {
const { t } = useTranslation();
const questions = interactive.params.questions.map((question) => ({
question: question.question,
options: question.options,
answer: question.answer
}));
if (!submitted) {
return <Box color={'myGray.600'}>{t('chat:interactive.agent_ask.waiting')}</Box>;
}
return (
<Accordion allowToggle>
<AccordionItem border={'none'}>
<Box w={'full'} pb={1}>
<AccordionButton
w={'fit-content'}
minH={6}
p={0}
color={'myGray.600'}
bg={'transparent'}
_hover={{ color: 'myGray.600', bg: 'transparent' }}
_expanded={{ color: 'myGray.600' }}
>
<Box fontSize={'md'} lineHeight={6}>
{t('chat:interactive.agent_ask.asked_questions', { count: questions.length })}
</Box>
<AccordionIcon ml={1} w={4} h={4} color={'myGray.500'} />
</AccordionButton>
</Box>
<AccordionPanel px={0} pt={2} pb={0}>
<Flex direction={'column'} gap={3}>
{questions.map((question, index) => {
const answer = question.answer;
const answerText =
answer === '' || answer === undefined
? t('chat:interactive.agent_ask.unanswered')
: (question.options.find((option) => option.value === answer)?.summary ?? answer);
return (
<Box key={index}>
<Box whiteSpace={'pre-wrap'} wordBreak={'break-word'} lineHeight={7}>
{question.question}
</Box>
<Box
color={'myGray.600'}
whiteSpace={'pre-wrap'}
wordBreak={'break-word'}
lineHeight={7}
>
{answerText}
</Box>
</Box>
);
})}
</Flex>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default React.memo(RenderAgentAskInteractive);
import { Box, Button, Flex, Textarea } from '@chakra-ui/react';
import type { AgentPlanAskQueryInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import LeftRadio from '@fastgpt/web/components/common/Radio/LeftRadio';
import { useTranslation } from 'next-i18next';
import React, { useCallback, useMemo } from 'react';
import { AGENT_PLAN_ASK_OTHER_OPTION_VALUE } from './constants';
import { onSendPrompt } from './utils';
import {
ChoiceCollapseToggleButton,
SelectedAnswerText,
useInteractiveChoiceCollapse
} from '../Interactive/InteractiveChoiceCollapse';
const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInteractive({
interactive,
isLastChild
}: {
interactive: AgentPlanAskQueryInteractive;
isLastChild: boolean;
}) {
const { t } = useTranslation();
const { content, options = [], answer } = interactive.params;
const [otherAnswer, setOtherAnswer] = React.useState('');
const [isOtherSelected, setIsOtherSelected] = React.useState(false);
const [submittedAnswer, setSubmittedAnswer] = React.useState('');
const normalizedOptions = useMemo(
() => Array.from(new Set(options.map((option) => option.trim()).filter(Boolean))).slice(0, 5),
[options]
);
const effectiveAnswer = answer || submittedAnswer;
const isDisabled = !!effectiveAnswer || !isLastChild;
const selectedOption =
effectiveAnswer && normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : '';
const answeredOther =
effectiveAnswer && !normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : '';
const {
isOptionsExpanded,
selectedAnswerPlacement,
shouldShowOptions,
collapseOptions,
toggleOptionsExpanded
} = useInteractiveChoiceCollapse(effectiveAnswer);
const showOtherInput = !!answeredOther || isOtherSelected;
const radioValue =
answeredOther || isOtherSelected ? AGENT_PLAN_ASK_OTHER_OPTION_VALUE : selectedOption;
const currentOtherAnswer = answeredOther || otherAnswer;
const submitOtherAnswer = useCallback(() => {
const value = otherAnswer.trim();
if (!value || isDisabled) return;
setSubmittedAnswer(value);
collapseOptions();
onSendPrompt(value);
}, [collapseOptions, isDisabled, otherAnswer]);
const radioOptions = useMemo(
() => [
...normalizedOptions.map((option) => ({
title: (
<Box fontSize={'sm'} whiteSpace={'pre-wrap'} wordBreak={'break-word'}>
{option}
</Box>
),
value: option
})),
{
title: (
<Box fontSize={'sm'} whiteSpace={'pre-wrap'} wordBreak={'break-word'}>
{t('common:Other')}
</Box>
),
value: AGENT_PLAN_ASK_OTHER_OPTION_VALUE
}
],
[normalizedOptions, t]
);
return (
<Flex flexDirection={'column'} gap={3} maxW={'520px'}>
<Box fontWeight={'medium'} whiteSpace={'pre-wrap'}>
{content}
</Box>
{normalizedOptions.length > 0 && (
<Box>
{selectedAnswerPlacement === 'above' && (
<Box mb={3}>
<SelectedAnswerText answer={effectiveAnswer} />
</Box>
)}
{shouldShowOptions && (
<Flex w={'360px'} maxW={'100%'} flexDirection={'column'} gap={3} p={'3px'} mx={'-3px'}>
<LeftRadio<string>
px={4}
py={4}
gridGap={2}
align={'center'}
list={radioOptions}
value={radioValue}
defaultBg={'white'}
activeBg={'white'}
onChange={(value) => {
if (!value || isDisabled) return;
if (value === AGENT_PLAN_ASK_OTHER_OPTION_VALUE) {
setIsOtherSelected(true);
return;
}
setIsOtherSelected(false);
setSubmittedAnswer(value);
collapseOptions();
onSendPrompt(value);
}}
isDisabled={isDisabled}
/>
{showOtherInput && (
<Flex flexDirection={'column'} gap={2}>
<Textarea
autoFocus={!isDisabled}
bg={'white'}
rows={3}
resize={'vertical'}
value={currentOtherAnswer}
placeholder={t('common:Other')}
isDisabled={isDisabled}
onChange={(e) => setOtherAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
submitOtherAnswer();
}
}}
/>
<Flex justifyContent={'flex-end'}>
{!isDisabled && (
<Button
flexShrink={0}
isDisabled={!otherAnswer.trim()}
onClick={submitOtherAnswer}
>
{t('common:Submit')}
</Button>
)}
</Flex>
</Flex>
)}
</Flex>
)}
{selectedAnswerPlacement === 'below' && (
<Box mt={3}>
<SelectedAnswerText answer={effectiveAnswer} />
</Box>
)}
<ChoiceCollapseToggleButton
answer={effectiveAnswer}
isOptionsExpanded={isOptionsExpanded}
onToggle={toggleOptionsExpanded}
mt={selectedAnswerPlacement === 'above' && !shouldShowOptions ? 0 : 3}
/>
</Box>
)}
</Flex>
);
});
export default RenderAgentPlanAskInteractive;
......@@ -44,22 +44,24 @@ const getInputFormValueFromResponseData = ({
return (formInputResult as Record<string, unknown>)[inputKey];
};
type RenderUserFormInteractiveProps = {
interactive: UserInputInteractive;
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
};
/**
* 渲染已提交/待提交的 `userInput` 工作流交互表单。
* 渲染标准 `userInput` 工作流交互表单。
*
* fileSelect 始终优先使用 inputForm.value 中持久化的原始文件信息;
* responseData.formInputResult 只为缺少原始值的旧历史兜底。
* 非最后一条子消息时强制 `submitted: true`,禁止重复提交历史表单。
*/
const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive({
const RenderStandardUserFormInteractive = ({
interactive,
responseData,
isLastChild
}: {
interactive: UserInputInteractive;
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
}) {
}: RenderUserFormInteractiveProps) => {
const { t } = useTranslation();
const defaultValues = useMemo(() => {
......@@ -125,6 +127,21 @@ const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive(
</Flex>
</InteractiveCard>
);
};
/** 渲染标准 `userInput` 工作流交互表单。 */
const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive({
interactive,
responseData,
isLastChild
}: RenderUserFormInteractiveProps) {
return (
<RenderStandardUserFormInteractive
interactive={interactive}
responseData={responseData}
isLastChild={isLastChild}
/>
);
});
export default RenderUserFormInteractive;
......@@ -10,7 +10,7 @@ import {
ChatItemContext,
type OnOpenCiteModalProps
} from '@/web/core/chat/context/chatItemContext';
import RenderAgentPlanAskInteractive from './RenderAgentPlanAskInteractive';
import RenderAgentAskInteractive from './RenderAgentAskInteractive';
import RenderPaymentPauseInteractive from './RenderPaymentPauseInteractive';
import RenderPlan from './RenderPlan';
import RenderPlanStatus from './RenderPlanStatus';
......@@ -21,6 +21,7 @@ import RenderText from './RenderText';
import RenderTool from './RenderTool';
import RenderUserFormInteractive from './RenderUserFormInteractive';
import RenderUserSelectInteractive from './RenderUserSelectInteractive';
import { adaptLegacyAgentPlanAskToReadonlyAgentAsk } from './utils';
const AIResponseBox = ({
chatItemDataId,
......@@ -171,15 +172,26 @@ const AIResponseBox = ({
/>
);
}
if (interactive.type === 'agentPlanAskQuery') {
if (interactive.type === 'agentAsk') {
responseBlocks.push(
<RenderAgentPlanAskInteractive
<RenderAgentAskInteractive
key="interactive"
interactive={interactive}
isLastChild={isLastChild}
submitted={interactive.params.submitted || !isLastChild}
/>
);
}
if (interactive.type === 'agentPlanAskQuery') {
// 旧 ask_user 仅适配展示,不能恢复为当前轮可提交的交互。
responseBlocks.push(
<RenderAgentAskInteractive
key="interactive"
interactive={adaptLegacyAgentPlanAskToReadonlyAgentAsk(interactive)}
submitted={true}
/>
);
}
if (interactive.type === 'paymentPause') {
responseBlocks.push(
<RenderPaymentPauseInteractive key="interactive" interactive={interactive} />
......
import type {
AgentAskInteractive,
AgentPlanAskQueryInteractive
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus';
export const onSendPrompt = (text: string) =>
......@@ -5,3 +9,28 @@ export const onSendPrompt = (text: string) =>
text,
focus: true
});
/**
* 把历史单题 ask_user 记录适配成已提交的 Agent Ask,仅用于只读展示。
* 历史交互不会恢复为可提交状态,也不会参与当前会话的交互流程。
*/
export const adaptLegacyAgentPlanAskToReadonlyAgentAsk = (
interactive: AgentPlanAskQueryInteractive
): AgentAskInteractive => ({
type: 'agentAsk',
askId: interactive.askId,
params: {
description: interactive.params.reason ?? '',
questions: [
{
question: interactive.params.content,
options: interactive.params.options.map((option) => ({
summary: option,
value: option
})),
answer: interactive.params.answer ?? ''
}
],
submitted: true
}
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
import { onSendPrompt } from '@/components/core/chat/components/AIResponseBox/utils';
import {
adaptLegacyAgentPlanAskToReadonlyAgentAsk,
onSendPrompt
} from '@/components/core/chat/components/AIResponseBox/utils';
describe('AIResponseBox utils', () => {
beforeEach(() => {
......@@ -18,4 +21,36 @@ describe('AIResponseBox utils', () => {
focus: true
});
});
it('adapts legacy agent plan ask as a submitted readonly agent ask', () => {
expect(
adaptLegacyAgentPlanAskToReadonlyAgentAsk({
type: 'agentPlanAskQuery',
askId: 'ask-1',
params: {
content: 'Which direction?',
reason: 'Need clarification',
options: ['A', 'B'],
answer: 'B'
}
})
).toEqual({
type: 'agentAsk',
askId: 'ask-1',
params: {
description: 'Need clarification',
questions: [
{
question: 'Which direction?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: 'B'
}
],
submitted: true
}
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment