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 { getNanoid } from '../../../common/string/tools';
import z from 'zod'; 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({ export const AgentPlanStatusSchema = z.object({
status: z.enum(['generating', 'updating']).meta({ status: z.enum(['generating', 'updating']).meta({
description: '计划状态:generating 生成计划中,updating 更新计划中' description: '计划状态:generating 生成计划中,updating 更新计划中'
......
import type { AgentPlanType } from './type'; import { AgentAskAnswerPayloadSchema, type AgentAskQuestion, type AgentPlanType } from './type';
/** 判断 plan 是否仍包含需要跨轮继续处理的步骤。 */ /** 判断 plan 是否仍包含需要跨轮继续处理的步骤。 */
export const hasUnfinishedAgentPlan = (plan: AgentPlanType) => export const hasUnfinishedAgentPlan = (plan: AgentPlanType) =>
plan.steps.some(({ status }) => status !== 'done' && status !== 'skipped'); 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 { ...@@ -19,6 +19,7 @@ import type {
ChatCompletionToolMessageParam ChatCompletionToolMessageParam
} from '../ai/llm/type'; } from '../ai/llm/type';
import { ChatCompletionRequestMessageRoleEnum } from '../../core/ai/constants'; import { ChatCompletionRequestMessageRoleEnum } from '../../core/ai/constants';
import { formatAgentAskAnswers } from '../ai/agent/utils';
import { normalizeToolResponseContent } from '../ai/llm/utils'; import { normalizeToolResponseContent } from '../ai/llm/utils';
import { extractDeepestInteractive } from '../workflow/runtime/utils'; import { extractDeepestInteractive } from '../workflow/runtime/utils';
...@@ -383,12 +384,21 @@ export const chats2GPTMessages = ({ ...@@ -383,12 +384,21 @@ export const chats2GPTMessages = ({
const finalInteractive = value.interactive const finalInteractive = value.interactive
? extractDeepestInteractive(value.interactive) ? extractDeepestInteractive(value.interactive)
: undefined; : undefined;
if (
finalInteractive?.type === 'agentPlanAskQuery' && // Legacy ask
finalInteractive.askId && if (finalInteractive?.type === 'agentPlanAskQuery' && finalInteractive.askId) {
typeof finalInteractive.params.answer === 'string' agentAskAnswerMap.set(finalInteractive.askId, finalInteractive.params.answer || '未回答');
) { }
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 = ({ ...@@ -310,7 +310,11 @@ export const checkInteractiveResponseStatus = ({
interactive: WorkflowInteractiveResponseType; interactive: WorkflowInteractiveResponseType;
input: string; input: string;
}): 'submit' | 'query' => { }): 'submit' | 'query' => {
if (extractDeepestInteractive(interactive).type === 'agentPlanAskQuery') { const finalInteractive = extractDeepestInteractive(interactive);
if (
finalInteractive.type === 'agentPlanAskQuery' ||
(finalInteractive.type === 'agentAsk' && finalInteractive.responseMode !== 'submit')
) {
return 'query'; return 'query';
} }
return 'submit'; return 'submit';
......
...@@ -26,7 +26,7 @@ export const extractDeepestInteractive = ( ...@@ -26,7 +26,7 @@ export const extractDeepestInteractive = (
let current = interactive; let current = interactive;
let depth = 0; 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; current = current.params.childrenResponse;
depth++; depth++;
} }
...@@ -171,6 +171,36 @@ export const getLastInteractiveValue = ( ...@@ -171,6 +171,36 @@ export const getLastInteractiveValue = (
return; 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)) { if (isChildInteractive(lastValue.interactive.type)) {
return lastValue.interactive; return lastValue.interactive;
} }
...@@ -188,15 +218,11 @@ export const getLastInteractiveValue = ( ...@@ -188,15 +218,11 @@ export const getLastInteractiveValue = (
return lastValue.interactive; return lastValue.interactive;
} }
if (lastValue.interactive.type === 'paymentPause' && !lastValue.interactive.params.continue) { if (lastValue.interactive.type === 'agentAsk' && !lastValue.interactive.params.submitted) {
return lastValue.interactive; return lastValue.interactive;
} }
// Agent plan ask query if (lastValue.interactive.type === 'paymentPause' && !lastValue.interactive.params.continue) {
if (
lastValue.interactive.type === 'agentPlanAskQuery' &&
!lastValue.interactive.params.answer
) {
return lastValue.interactive; return lastValue.interactive;
} }
} }
......
...@@ -5,6 +5,7 @@ import { AppFileSelectConfigTypeSchema } from '../../../../app/type/config.schem ...@@ -5,6 +5,7 @@ import { AppFileSelectConfigTypeSchema } from '../../../../app/type/config.schem
import { RuntimeEdgeItemTypeSchema } from '../../../type/edge'; import { RuntimeEdgeItemTypeSchema } from '../../../type/edge';
import z from 'zod'; import z from 'zod';
import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type'; import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type';
import { AgentAskQuestionSchema } from '../../../../ai/agent/type';
export const InteractiveBasicTypeSchema = z.object({ export const InteractiveBasicTypeSchema = z.object({
entryNodeIds: z.array(z.string()), entryNodeIds: z.array(z.string()),
...@@ -95,19 +96,32 @@ export type LoopRunInteractive = InteractiveNodeType & { ...@@ -95,19 +96,32 @@ export type LoopRunInteractive = InteractiveNodeType & {
export const AgentPlanAskOptionSchema = z.string().min(1); export const AgentPlanAskOptionSchema = z.string().min(1);
export type AgentPlanAskOption = z.infer<typeof AgentPlanAskOptionSchema>; export type AgentPlanAskOption = z.infer<typeof AgentPlanAskOptionSchema>;
export const AgentPlanAskQueryInteractiveSchema = z.object({ /**
type: z.literal('agentPlanAskQuery'), * Legacy `ask_user` schema.
askId: z.string().min(1), *
params: z.object({ * @deprecated Use `AgentAskInteractiveSchema` (multiple questions).
content: z.string(), */
reason: z.string().optional(), export const AgentPlanAskQueryInteractiveSchema = z
blockerType: z .object({
.enum(['missing_required_input', 'tool_unavailable', 'ambiguous_goal', 'user_choice']) type: z.literal('agentPlanAskQuery'),
.optional(), askId: z.string().min(1),
options: z.array(AgentPlanAskOptionSchema).min(2).max(5), params: z.object({
answer: z.string().optional() 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>; export type AgentPlanAskQueryInteractive = z.infer<typeof AgentPlanAskQueryInteractiveSchema>;
// User selector // User selector
...@@ -154,6 +168,23 @@ export const UserInputInteractiveSchema = z.object({ ...@@ -154,6 +168,23 @@ export const UserInputInteractiveSchema = z.object({
}); });
export type UserInputInteractive = z.infer<typeof UserInputInteractiveSchema>; 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({ export const PaymentPauseInteractiveSchema = z.object({
type: z.literal('paymentPause'), type: z.literal('paymentPause'),
...@@ -173,7 +204,8 @@ export const InteractiveNodeResponseTypeSchema = z.intersection( ...@@ -173,7 +204,8 @@ export const InteractiveNodeResponseTypeSchema = z.intersection(
LoopInteractiveSchema, LoopInteractiveSchema,
LoopRunInteractiveSchema, LoopRunInteractiveSchema,
PaymentPauseInteractiveSchema, PaymentPauseInteractiveSchema,
AgentPlanAskQueryInteractiveSchema AgentPlanAskQueryInteractiveSchema,
AgentAskInteractiveSchema
]), ]),
z.object({ z.object({
askId: z.string().nullish() askId: z.string().nullish()
......
...@@ -1798,6 +1798,93 @@ describe('chats2GPTMessages', () => { ...@@ -1798,6 +1798,93 @@ describe('chats2GPTMessages', () => {
expect(result).toHaveLength(0); 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', () => { it('should skip plan card when building GPT messages', () => {
const messages: ChatItemMiniType[] = [ const messages: ChatItemMiniType[] = [
{ {
......
...@@ -582,7 +582,7 @@ describe('getFlatAppResponses', () => { ...@@ -582,7 +582,7 @@ describe('getFlatAppResponses', () => {
}); });
describe('checkInteractiveResponseStatus', () => { describe('checkInteractiveResponseStatus', () => {
it('should return query for agentPlanAskQuery type', () => { it('should keep legacy agentPlanAskQuery as a query', () => {
const result = checkInteractiveResponseStatus({ const result = checkInteractiveResponseStatus({
interactive: { interactive: {
type: 'agentPlanAskQuery', type: 'agentPlanAskQuery',
...@@ -598,7 +598,43 @@ describe('checkInteractiveResponseStatus', () => { ...@@ -598,7 +598,43 @@ describe('checkInteractiveResponseStatus', () => {
expect(result).toBe('query'); 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({ const result = checkInteractiveResponseStatus({
interactive: { interactive: {
type: 'toolChildrenInteractive', type: 'toolChildrenInteractive',
......
...@@ -760,6 +760,39 @@ describe('getLastInteractiveValue', () => { ...@@ -760,6 +760,39 @@ describe('getLastInteractiveValue', () => {
expect(getLastInteractiveValue(histories)).toBeUndefined(); 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', () => { it('should return interactive for paymentPause without continue', () => {
const interactive = { const interactive = {
type: 'paymentPause', type: 'paymentPause',
...@@ -801,7 +834,7 @@ describe('getLastInteractiveValue', () => { ...@@ -801,7 +834,7 @@ describe('getLastInteractiveValue', () => {
expect(getLastInteractiveValue(histories)).toBeUndefined(); expect(getLastInteractiveValue(histories)).toBeUndefined();
}); });
it('should return interactive for agentPlanAskQuery', () => { it('should adapt unanswered top-level agentPlanAskQuery to pending agentAsk', () => {
const interactive = { const interactive = {
type: 'agentPlanAskQuery', type: 'agentPlanAskQuery',
askId: 'call_ask', askId: 'call_ask',
...@@ -820,7 +853,24 @@ describe('getLastInteractiveValue', () => { ...@@ -820,7 +853,24 @@ describe('getLastInteractiveValue', () => {
value: [{ text: { content: 'response' }, interactive }] 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', () => { it('should return undefined for answered agentPlanAskQuery', () => {
...@@ -846,6 +896,31 @@ describe('getLastInteractiveValue', () => { ...@@ -846,6 +896,31 @@ describe('getLastInteractiveValue', () => {
expect(getLastInteractiveValue(histories)).toBeUndefined(); 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', () => { describe('storeEdges2RuntimeEdges', () => {
......
...@@ -71,8 +71,8 @@ ${ ...@@ -71,8 +71,8 @@ ${
5. 用户目标不明确,需要确认产物类型或成功标准。 5. 用户目标不明确,需要确认产物类型或成功标准。
调用 ${askToolName} 时必须提供: 调用 ${askToolName} 时必须提供:
- question:一个面向用户的简短标题问题。 - questions:1 到 3 个面向用户的简短问题。
- options:2 到 5 个可直接选择的候选答案;每个选项都要是完整答案,不要写成解释或问题 - 每个问题提供 2 到 4 个候选答案(如无必要则给 3 个)。每个选项必须提供 summary 和 value:summary 是展示给用户的简短选项文本,value 是用户选中后返回给你的完整答案
Skill 要求向用户收集选项信息时,优先遵循 Skill 并调用 ${askToolName},不要自行替用户选择。 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 { parseJsonArgs } from '../../../../../utils';
import { AgentAskPayloadSchema, type AgentAskPayload } from './tool'; import { AgentAskPayloadSchema, type AgentAskPayload } from './tool';
...@@ -40,3 +45,37 @@ export const parseAgentAskToolCall = ( ...@@ -40,3 +45,37 @@ export const parseAgentAskToolCall = (
ask: result.data 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 type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type';
import {
AgentAskBlockerTypeSchema,
AgentAskQuestionSchema
} from '@fastgpt/global/core/ai/agent/type';
import z from 'zod'; import z from 'zod';
export const AgentAskPayloadSchema = z.object({ const AgentAskBaseSchema = z.object({
reason: z.string(), reason: z.string(),
blockerType: z.enum([ blockerType: AgentAskBlockerTypeSchema
'missing_required_input', });
'tool_unavailable', const LegacyAgentAskQuestionSchema = z.object({
'ambiguous_goal', question: z.string().trim().min(1),
'user_choice'
]),
question: z.string(),
options: z.array(z.string().trim().min(1)).min(2).max(5) 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>; export type AgentAskPayload = z.infer<typeof AgentAskPayloadSchema>;
/** /**
...@@ -37,22 +61,45 @@ export const createAskAgentTool = (name = 'ask_agent'): ChatCompletionTool => ({ ...@@ -37,22 +61,45 @@ export const createAskAgentTool = (name = 'ask_agent'): ChatCompletionTool => ({
description: description:
'Use user_choice when asking the user to select a meaningful preference, scope, format, or execution path.' 'Use user_choice when asking the user to select a meaningful preference, scope, format, or execution path.'
}, },
question: { questions: {
type: 'string',
description: 'A concise user-facing question shown as the title of the choice card.'
},
options: {
type: 'array', type: 'array',
minItems: 2, minItems: 1,
maxItems: 5, maxItems: 3,
description: description: 'One to three concise user-facing questions to collect together.',
'Two to five concise answer choices the user can select directly. Each item must be a complete answer.',
items: { 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'; ...@@ -8,7 +8,11 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import { parseJsonArgs } from '../../../../../utils'; import { parseJsonArgs } from '../../../../../utils';
import { runAgentLoop } from './base'; import { runAgentLoop } from './base';
import { getMainAgentSystemPrompt } from '../../../domain/mainPrompt'; 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 { applyPlanUpdate, applySetPlan } from '../../../domain/systemTool/plan';
import type { AgentLoopEvent } from './type'; import type { AgentLoopEvent } from './type';
import { normalizeAgentLoopUsages, type AgentLoopUsage } from '../../../domain'; import { normalizeAgentLoopUsages, type AgentLoopUsage } from '../../../domain';
...@@ -205,7 +209,13 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({ ...@@ -205,7 +209,13 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({
{ {
role: ChatCompletionRequestMessageRoleEnum.Tool, role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: input.pendingMainContext.askToolCallId, 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 } as ChatCompletionMessageParam
] ]
: buildInitialMessages({ input, hasRuntimeTools, promptMode: runtime.promptMode }); : buildInitialMessages({ input, hasRuntimeTools, promptMode: runtime.promptMode });
......
...@@ -16,7 +16,11 @@ import { formatModelChars2Points } from '../../../../../../support/wallet/usage/ ...@@ -16,7 +16,11 @@ import { formatModelChars2Points } from '../../../../../../support/wallet/usage/
import { getLLMModel } from '../../../../model'; import { getLLMModel } from '../../../../model';
import { AgentUsageModuleName } from '../../domain/usage'; import { AgentUsageModuleName } from '../../domain/usage';
import { getMainAgentSystemPrompt } from '../../domain/mainPrompt'; 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 { setPlanToolName, updatePlanToolName } from '../../domain/systemTool/plan';
import { import {
normalizeAgentLoopUsages, normalizeAgentLoopUsages,
...@@ -217,7 +221,13 @@ export const runPiAgentLoop = async <TChildrenResponse = unknown>({ ...@@ -217,7 +221,13 @@ export const runPiAgentLoop = async <TChildrenResponse = unknown>({
{ {
role: ChatCompletionRequestMessageRoleEnum.Tool, role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: askResumeId, tool_call_id: askResumeId,
content: normalizeToolResponseContent(input.userAnswer) content: normalizeToolResponseContent(
formatAgentAskToolResponse({
messages: pendingMainContext!.messages,
askToolCallId: askResumeId,
answer: input.userAnswer ?? ''
})
)
} as ChatCompletionMessageParam } as ChatCompletionMessageParam
] ]
: undefined; : undefined;
......
...@@ -33,6 +33,7 @@ import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants'; ...@@ -33,6 +33,7 @@ import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { encryptSecretValue, anyValueDecrypt } from '../../common/secret/utils'; import { encryptSecretValue, anyValueDecrypt } from '../../common/secret/utils';
import type { SecretValueType } from '@fastgpt/global/common/secret/type'; import type { SecretValueType } from '@fastgpt/global/common/secret/type';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/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 { getErrText } from '@fastgpt/global/common/error/utils';
import { normalizeChatFileStoreValues } from './fileStoreValue'; import { normalizeChatFileStoreValues } from './fileStoreValue';
import type { NodeResponseWriteSummary } from './nodeResponseStorage'; import type { NodeResponseWriteSummary } from './nodeResponseStorage';
...@@ -755,11 +756,24 @@ export const updateInteractiveChat = async ({ ...@@ -755,11 +756,24 @@ export const updateInteractiveChat = async ({
throw new Error('Prepared chat round is required for interactive query'); 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) { if (!finalInteractive.askId) {
throw new Error(`Agent ask interactive askId is required: ${chatId}`); 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({ const interactiveChatItem = await MongoChatItem.findOne({
...buildChatSourceQuery(chatSource), ...buildChatSourceQuery(chatSource),
......
...@@ -11,12 +11,14 @@ export const createAgentLoopCoreAskInteractive = ({ ...@@ -11,12 +11,14 @@ export const createAgentLoopCoreAskInteractive = ({
askId: string; askId: string;
ask: AgentAskPayload; ask: AgentAskPayload;
}): InteractiveNodeResponseType => ({ }): InteractiveNodeResponseType => ({
type: 'agentPlanAskQuery', type: 'agentAsk',
askId, askId,
params: { params: {
content: ask.question, description: ask.reason,
reason: ask.reason, questions: ask.questions.map((question) => ({
blockerType: ask.blockerType, ...question,
options: ask.options // Initialize the initial state
answer: ''
}))
} }
}); });
...@@ -154,7 +154,7 @@ export const createAgentLoopCoreNodeResponseEventCollector = ({ ...@@ -154,7 +154,7 @@ export const createAgentLoopCoreNodeResponseEventCollector = ({
moduleType: node.flowNodeType, moduleType: node.flowNodeType,
moduleLogo: AgentNodeResponseDisplay.ask.moduleLogo, moduleLogo: AgentNodeResponseDisplay.ask.moduleLogo,
runningTime: event.seconds, runningTime: event.seconds,
textOutput: event.ask.question textOutput: event.ask.questions.map((question) => question.question).join('\n')
}) })
); );
}; };
......
...@@ -162,7 +162,7 @@ describe('runFastAgentMainLoop', () => { ...@@ -162,7 +162,7 @@ describe('runFastAgentMainLoop', () => {
expect(mainAgentPrompt).toContain('你是 Work Agent'); expect(mainAgentPrompt).toContain('你是 Work Agent');
expect(mainAgentPrompt).toContain('任务或 Skill 明确需要通过选项向用户收集信息'); expect(mainAgentPrompt).toContain('任务或 Skill 明确需要通过选项向用户收集信息');
expect(mainAgentPrompt).toContain('Skill 要求向用户收集选项信息时'); expect(mainAgentPrompt).toContain('Skill 要求向用户收集选项信息时');
expect(mainAgentPrompt).toContain('options:2 到 5 个'); expect(mainAgentPrompt).toContain('每个问题提供 2 到 4 个候选答案');
}); });
it.each([ it.each([
...@@ -498,11 +498,15 @@ describe('runFastAgentMainLoop', () => { ...@@ -498,11 +498,15 @@ describe('runFastAgentMainLoop', () => {
args: { args: {
reason: 'Need private repository path', reason: 'Need private repository path',
blockerType: 'missing_required_input', blockerType: 'missing_required_input',
question: 'Which repository should I inspect?', questions: [
options: [ {
'Use the current workspace', question: 'Which repository should I inspect?',
'I will provide a repository path', options: [
'Skip repository inspection' { 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', () => { ...@@ -522,13 +526,15 @@ describe('runFastAgentMainLoop', () => {
expect(result.status).toBe('paused'); expect(result.status).toBe('paused');
expect(result.pause?.type).toBe('ask'); expect(result.pause?.type).toBe('ask');
expect(result.pause?.type === 'ask' ? result.pause.ask.question : undefined).toBe( expect(result.pause?.type === 'ask' ? result.pause.ask.questions : undefined).toEqual([
'Which repository should I inspect?' {
); question: 'Which repository should I inspect?',
expect(result.pause?.type === 'ask' ? result.pause.ask.options : undefined).toEqual([ options: [
'Use the current workspace', { summary: 'Current workspace', value: 'Use the current workspace' },
'I will provide a repository path', { summary: 'Repository path', value: 'I will provide a repository path' },
'Skip repository inspection' { summary: 'Skip inspection', value: 'Skip repository inspection' }
]
}
]); ]);
expect(result.pendingMainContext?.askToolCallId).toBe('call_ask'); expect(result.pendingMainContext?.askToolCallId).toBe('call_ask');
expect(result.pendingMainContext?.messages.at(-1)).toEqual({ expect(result.pendingMainContext?.messages.at(-1)).toEqual({
...@@ -540,8 +546,7 @@ describe('runFastAgentMainLoop', () => { ...@@ -540,8 +546,7 @@ describe('runFastAgentMainLoop', () => {
type: 'function', type: 'function',
function: { function: {
name: 'ask_user', name: 'ask_user',
arguments: arguments: expect.stringContaining('"summary":"Current workspace"')
'{"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"]}'
} }
} }
] ]
...@@ -569,8 +574,19 @@ describe('runFastAgentMainLoop', () => { ...@@ -569,8 +574,19 @@ describe('runFastAgentMainLoop', () => {
arguments: JSON.stringify({ arguments: JSON.stringify({
reason: 'Need confirmation before changing data', reason: 'Need confirmation before changing data',
blockerType: 'missing_required_input', blockerType: 'missing_required_input',
question: 'Should I continue with the data change?', questions: [
options: ['Continue', 'Cancel', 'Review the proposed change first'] {
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', () => { ...@@ -1534,10 +1534,18 @@ describe('runPiAgentLoop', () => {
name: 'ask_user', name: 'ask_user',
callId: 'call_ask_pause', callId: 'call_ask_pause',
args: { args: {
question: '请确认目标',
reason: '需要补充范围', reason: '需要补充范围',
blockerType: 'missing_required_input', 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 { ...@@ -12,7 +12,7 @@ import {
} from '@fastgpt/service/core/ai/llm/agentLoop/domain/systemTool/plan/updateTool'; } from '@fastgpt/service/core/ai/llm/agentLoop/domain/systemTool/plan/updateTool';
describe('agent loop system ask tool', () => { describe('agent loop system ask tool', () => {
it('parses ask_agent tool call arguments', () => { it('parses up to three ask_agent questions', () => {
const result = parseAgentAskToolCall({ const result = parseAgentAskToolCall({
id: 'call_ask', id: 'call_ask',
type: 'function', type: 'function',
...@@ -21,11 +21,29 @@ describe('agent loop system ask tool', () => { ...@@ -21,11 +21,29 @@ describe('agent loop system ask tool', () => {
arguments: JSON.stringify({ arguments: JSON.stringify({
reason: 'Need repository path', reason: 'Need repository path',
blockerType: 'missing_required_input', blockerType: 'missing_required_input',
question: 'Which repository should I inspect?', questions: [
options: [ {
'/Volumes/code/FastGPT', question: 'Which repository should I inspect?',
'Use the current workspace', options: [
'I will provide another repository path' { 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', () => { ...@@ -36,11 +54,29 @@ describe('agent loop system ask tool', () => {
ask: { ask: {
reason: 'Need repository path', reason: 'Need repository path',
blockerType: 'missing_required_input', blockerType: 'missing_required_input',
question: 'Which repository should I inspect?', questions: [
options: [ {
'/Volumes/code/FastGPT', question: 'Which repository should I inspect?',
'Use the current workspace', options: [
'I will provide another repository path' { 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', () => { ...@@ -64,7 +100,7 @@ describe('agent loop system ask tool', () => {
expect(result.error).toContain('options'); expect(result.error).toContain('options');
}); });
it('supports a two-option user choice', () => { it('normalizes the legacy single-question format', () => {
const result = parseAgentAskToolCall({ const result = parseAgentAskToolCall({
id: 'call_ask', id: 'call_ask',
type: 'function', type: 'function',
...@@ -84,20 +120,92 @@ describe('agent loop system ask tool', () => { ...@@ -84,20 +120,92 @@ describe('agent loop system ask tool', () => {
ask: { ask: {
reason: 'Need a choice', reason: 'Need a choice',
blockerType: 'user_choice', blockerType: 'user_choice',
question: 'Which output should I create?', questions: [
options: ['Document', 'Spreadsheet'] {
question: 'Which output should I create?',
options: [
{ summary: 'Document', value: 'Document' },
{ summary: 'Spreadsheet', value: 'Spreadsheet' }
]
}
]
} }
}); });
const parameters = createAskAgentTool().function.parameters as any; 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, 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(parameters.properties.blockerType.enum).toContain('user_choice');
expect(createAskAgentTool().function.description).toContain('task or a Skill'); 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', () => { it('creates internal tool schemas without workflow dependencies', () => {
expect(createAskAgentTool().function.name).toBe('ask_agent'); expect(createAskAgentTool().function.name).toBe('ask_agent');
expect(createSetPlanTool().function.name).toBe('set_plan'); expect(createSetPlanTool().function.name).toBe('set_plan');
......
...@@ -1215,7 +1215,7 @@ describe('pushChatRecords', () => { ...@@ -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({ await MongoChatItem.create({
chatId: 'test-chat-id', chatId: 'test-chat-id',
teamId: testTeamId, teamId: testTeamId,
...@@ -1227,13 +1227,28 @@ describe('pushChatRecords', () => { ...@@ -1227,13 +1227,28 @@ describe('pushChatRecords', () => {
value: [ value: [
{ {
interactive: { interactive: {
type: 'agentPlanAskQuery', type: 'agentAsk',
askId: 'call_ask_agent', askId: 'call_ask_agent',
params: { params: {
content: '请补充目标', description: '需要用户明确任务目标',
reason: '需要用户明确任务目标', questions: [
blockerType: 'missing_required_input', {
options: ['继续研究 Rust', '改为研究 Go', '先给出学习路线'] question: '请选择方向',
options: [
{ summary: 'Rust', value: 'Rust' },
{ summary: 'Go', value: 'Go' }
],
answer: ''
},
{
question: '需要示例吗',
options: [
{ summary: '需要', value: '需要' },
{ summary: '不需要', value: '不需要' }
],
answer: ''
}
]
} }
} }
} }
...@@ -1247,7 +1262,7 @@ describe('pushChatRecords', () => { ...@@ -1247,7 +1262,7 @@ describe('pushChatRecords', () => {
dataId: 'prepared-round-data-id', dataId: 'prepared-round-data-id',
value: [ value: [
{ {
text: { content: '深入了解 Rust 系统编程方向' } text: { content: JSON.stringify({ answers: ['Rust', ''] }) }
} }
] ]
}, },
...@@ -1283,7 +1298,7 @@ describe('pushChatRecords', () => { ...@@ -1283,7 +1298,7 @@ describe('pushChatRecords', () => {
dataId: 'prepared-round-data-id', dataId: 'prepared-round-data-id',
value: [ value: [
{ {
text: { content: '深入了解 Rust 系统编程方向' } text: { content: JSON.stringify({ answers: ['Rust', ''] }) }
} }
] ]
}, },
...@@ -1300,13 +1315,28 @@ describe('pushChatRecords', () => { ...@@ -1300,13 +1315,28 @@ describe('pushChatRecords', () => {
]); ]);
const interactive = { const interactive = {
type: 'agentPlanAskQuery' as const, type: 'agentAsk' as const,
askId: 'call_ask_agent', askId: 'call_ask_agent',
params: { params: {
content: '请补充目标', description: '需要用户明确任务目标',
reason: '需要用户明确任务目标', questions: [
blockerType: 'missing_required_input', {
options: ['继续研究 Rust', '改为研究 Go', '先给出学习路线'] question: '请选择方向',
options: [
{ summary: 'Rust', value: 'Rust' },
{ summary: 'Go', value: 'Go' }
],
answer: ''
},
{
question: '需要示例吗',
options: [
{ summary: '需要', value: '需要' },
{ summary: '不需要', value: '不需要' }
],
answer: ''
}
]
}, },
entryNodeIds: [], entryNodeIds: [],
memoryEdges: [], memoryEdges: [],
...@@ -1330,16 +1360,14 @@ describe('pushChatRecords', () => { ...@@ -1330,16 +1360,14 @@ describe('pushChatRecords', () => {
throw new Error('previousChatItem does not have AI interactive value'); throw new Error('previousChatItem does not have AI interactive value');
} }
const lastValue = previousChatItem.value[previousChatItem.value.length - 1]; const lastValue = previousChatItem.value[previousChatItem.value.length - 1];
if (lastValue.interactive?.type !== 'agentPlanAskQuery') { if (lastValue.interactive?.type !== 'agentAsk') {
throw new Error('previousChatItem does not have agentPlanAskQuery interactive'); throw new Error('previousChatItem does not have agentAsk interactive');
} }
expect(lastValue.interactive.params.answer).toBe('深入了解 Rust 系统编程方向'); expect(lastValue.interactive.params.submitted).toBe(true);
expect(lastValue.interactive.params.reason).toBe('需要用户明确任务目标'); expect(lastValue.interactive.params.questions.map((question) => question.answer)).toEqual([
expect(lastValue.interactive.params.options).toEqual([ 'Rust',
'继续研究 Rust', ''
'改为研究 Go',
'先给出学习路线'
]); ]);
const finalizedAiItem = await MongoChatItem.findOne({ const finalizedAiItem = await MongoChatItem.findOne({
......
...@@ -290,6 +290,63 @@ describe('prepare chat round', () => { ...@@ -290,6 +290,63 @@ describe('prepare chat round', () => {
expect(await MongoChatItem.countDocuments({ appId: testAppId, chatId: params.chatId })).toBe(1); 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 () => { it('should mark chat as error when interactive continue has no previous AI item', async () => {
const params = createPreChatRoundParams( const params = createPreChatRoundParams(
{ {
......
...@@ -1047,13 +1047,21 @@ describe('useUserContext', () => { ...@@ -1047,13 +1047,21 @@ describe('useUserContext', () => {
value: [ value: [
{ {
interactive: { interactive: {
type: 'agentPlanAskQuery', type: 'agentAsk',
askId: 'ask_1', askId: 'ask_1',
params: { params: {
content: 'Choose one', description: 'Choose one',
options: ['A', 'B', 'C'] questions: [
{
question: 'Choose one?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
]
}
]
} }
} } as any
} }
] ]
} }
......
...@@ -912,6 +912,74 @@ describe('dispatchRunAgent user context', () => { ...@@ -912,6 +912,74 @@ describe('dispatchRunAgent user context', () => {
expect(result.data.answerText).toBe('continued answer'); 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 () => { 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'); const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
serviceEnvMock.AGENT_ENGINE = 'piAgent'; serviceEnvMock.AGENT_ENGINE = 'piAgent';
...@@ -959,8 +1027,15 @@ describe('dispatchRunAgent user context', () => { ...@@ -959,8 +1027,15 @@ describe('dispatchRunAgent user context', () => {
ask: { ask: {
reason: 'Need another confirmation', reason: 'Need another confirmation',
blockerType: 'missing_required_input', blockerType: 'missing_required_input',
question: 'Confirm again?', questions: [
options: ['Yes', 'No'] {
question: 'Confirm again?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' }
]
}
]
} }
}, },
providerState: { providerState: {
......
...@@ -136,12 +136,20 @@ describe('appendAgentLoopCoreAssistantResponseFromEvent', () => { ...@@ -136,12 +136,20 @@ describe('appendAgentLoopCoreAssistantResponseFromEvent', () => {
event: { event: {
type: 'ask_start', type: 'ask_start',
id: 'call_ask', id: 'call_ask',
params: '{"question":"Need input?"}', params:
'{"questions":[{"question":"Need input?","options":[{"summary":"A","value":"A"},{"summary":"B","value":"B"}]}]}',
ask: { ask: {
reason: 'Need confirmation', reason: 'Need confirmation',
blockerType: 'ambiguous_goal', blockerType: 'ambiguous_goal',
question: 'Need input?', questions: [
options: ['A', 'B'] {
question: 'Need input?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
]
}
]
} }
}, },
names: { names: {
...@@ -156,7 +164,8 @@ describe('appendAgentLoopCoreAssistantResponseFromEvent', () => { ...@@ -156,7 +164,8 @@ describe('appendAgentLoopCoreAssistantResponseFromEvent', () => {
id: 'call_ask', id: 'call_ask',
askId: 'call_ask', askId: 'call_ask',
functionName: 'agent_ask_user', 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 ...@@ -3,25 +3,56 @@ import { AgentPlanAskQueryInteractiveSchema } from '@fastgpt/global/core/workflo
import { createAgentLoopCoreAskInteractive } from '@fastgpt/service/core/workflow/dispatch/ai/agentLoopCore/adapter/interactive'; import { createAgentLoopCoreAskInteractive } from '@fastgpt/service/core/workflow/dispatch/ai/agentLoopCore/adapter/interactive';
describe('agentLoopCore ask interactive', () => { describe('agentLoopCore ask interactive', () => {
it('converts ask payload to workflow interactive response', () => { it('converts ask payload to a multi-question agentAsk response', () => {
expect( expect(
createAgentLoopCoreAskInteractive({ createAgentLoopCoreAskInteractive({
askId: 'call_ask', askId: 'call_ask',
ask: { ask: {
reason: 'Need input', reason: 'Need input',
blockerType: 'missing_required_input', blockerType: 'missing_required_input',
question: 'Confirm?', questions: [
options: ['Yes', 'No', 'Not sure'] {
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({ ).toEqual({
type: 'agentPlanAskQuery', type: 'agentAsk',
askId: 'call_ask', askId: 'call_ask',
params: { params: {
content: 'Confirm?', description: 'Need input',
reason: 'Need input', questions: [
blockerType: 'missing_required_input', {
options: ['Yes', 'No', 'Not sure'] 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', () => { ...@@ -195,8 +195,22 @@ describe('createAgentLoopCoreNodeResponseEventCollector', () => {
ask: { ask: {
reason: 'Need input', reason: 'Need input',
blockerType: 'missing_required_input', blockerType: 'missing_required_input',
question: 'Confirm?', questions: [
options: ['Yes'] {
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', () => { ...@@ -210,7 +224,7 @@ describe('createAgentLoopCoreNodeResponseEventCollector', () => {
expect.objectContaining({ expect.objectContaining({
id: 'agent_node-ask-call_ask', id: 'agent_node-ask-call_ask',
moduleName: 'chat:collect_questions', moduleName: 'chat:collect_questions',
textOutput: 'Confirm?' textOutput: 'Confirm?\nInclude examples?'
}) })
]); ]);
expect(nodeResponses[0].textOutput).toBeUndefined(); expect(nodeResponses[0].textOutput).toBeUndefined();
......
...@@ -104,8 +104,16 @@ describe('summarizeAgentLoopCoreResult', () => { ...@@ -104,8 +104,16 @@ describe('summarizeAgentLoopCoreResult', () => {
const ask = { const ask = {
reason: 'Need input', reason: 'Need input',
blockerType: 'missing_required_input' as const, blockerType: 'missing_required_input' as const,
question: 'Confirm?', questions: [
options: ['Yes', 'No', 'Not sure'] {
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
]
}
]
}; };
expect( expect(
...@@ -129,13 +137,21 @@ describe('summarizeAgentLoopCoreResult', () => { ...@@ -129,13 +137,21 @@ describe('summarizeAgentLoopCoreResult', () => {
status: 'interactive', status: 'interactive',
providerState, providerState,
interactive: { interactive: {
type: 'agentPlanAskQuery', type: 'agentAsk',
askId: 'call_ask', askId: 'call_ask',
params: { params: {
content: 'Confirm?', description: 'Need input',
reason: 'Need input', questions: [
blockerType: 'missing_required_input', {
options: ['Yes', 'No', 'Not sure'] question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
],
answer: ''
}
]
} }
} }
}) })
......
...@@ -350,8 +350,16 @@ describe('runAgentLoopCore', () => { ...@@ -350,8 +350,16 @@ describe('runAgentLoopCore', () => {
const ask = { const ask = {
reason: 'Need confirmation', reason: 'Need confirmation',
blockerType: 'missing_required_input' as const, blockerType: 'missing_required_input' as const,
question: 'Confirm?', questions: [
options: ['Yes', 'No', 'Not sure'] {
question: 'Confirm?',
options: [
{ summary: 'Yes', value: 'Yes' },
{ summary: 'No', value: 'No' },
{ summary: 'Not sure', value: 'Not sure' }
]
}
]
}; };
runAgentLoopMock.mockResolvedValue({ runAgentLoopMock.mockResolvedValue({
status: 'paused', status: 'paused',
......
...@@ -558,13 +558,21 @@ describe('getHistories', () => { ...@@ -558,13 +558,21 @@ describe('getHistories', () => {
value: [ value: [
{ {
interactive: { interactive: {
type: 'agentPlanAskQuery', type: 'agentAsk',
askId: 'ask_1', askId: 'ask_1',
params: { params: {
content: 'Choose one', description: 'Choose one',
options: ['A', 'B', 'C'] questions: [
{
question: 'Choose one?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
]
}
]
} }
} } as any
} }
] ]
} }
......
...@@ -292,6 +292,13 @@ ...@@ -292,6 +292,13 @@
"interactive.user_select.collapse_options": "Collapse options", "interactive.user_select.collapse_options": "Collapse options",
"interactive.user_select.expand_options": "Expand options", "interactive.user_select.expand_options": "Expand options",
"interactive.user_select.selected": "Selected: {{answer}}", "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_all_citations": "View all",
"view_citations": "View References" "view_citations": "View References"
} }
...@@ -292,6 +292,13 @@ ...@@ -292,6 +292,13 @@
"interactive.user_select.collapse_options": "收起选项", "interactive.user_select.collapse_options": "收起选项",
"interactive.user_select.expand_options": "展开选项", "interactive.user_select.expand_options": "展开选项",
"interactive.user_select.selected": "已选择:{{answer}}", "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_all_citations": "查看全部",
"view_citations": "查看引用" "view_citations": "查看引用"
} }
...@@ -288,6 +288,13 @@ ...@@ -288,6 +288,13 @@
"interactive.user_select.collapse_options": "收起選項", "interactive.user_select.collapse_options": "收起選項",
"interactive.user_select.expand_options": "展開選項", "interactive.user_select.expand_options": "展開選項",
"interactive.user_select.selected": "已選擇:{{answer}}", "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_all_citations": "查看全部",
"view_citations": "檢視引用" "view_citations": "檢視引用"
} }
Subproject commit e6c7fe1c8036e34d4ba6e8d06189ad2ea9ab3b11 Subproject commit 9a73980b9ae674d91c21b075e7dc518679ae0003
...@@ -34,7 +34,7 @@ export type ChatRecordsListProps = { ...@@ -34,7 +34,7 @@ export type ChatRecordsListProps = {
| undefined; | undefined;
questionGuides: string[]; questionGuides: string[];
onToggleDeletedGroup: (dataIds: string[]) => void; 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; onEdit: (dataId?: string) => ((input: ChatBoxInputType) => Promise<void>) | undefined;
onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined; onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined;
onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined; onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined;
......
...@@ -74,7 +74,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps) ...@@ -74,7 +74,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps)
* *
* 如果没有传入 `dataId`,返回 undefined,让调用方不渲染无效动作。 * 如果没有传入 `dataId`,返回 undefined,让调用方不渲染无效动作。
*/ */
const retryInput = useMemoizedFn((dataId?: string) => { const retryInput = useMemoizedFn((dataId?: string, hideInUI = false) => {
if (!dataId) return; if (!dataId) return;
return async () => { return async () => {
...@@ -88,6 +88,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps) ...@@ -88,6 +88,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps)
sendPrompt({ sendPrompt({
...formatChatValue2InputType(delHistory[0].value), ...formatChatValue2InputType(delHistory[0].value),
hideInUI: hideInUI || delHistory[0].hideInUI,
history: chatRecords.slice(0, index) history: chatRecords.slice(0, index)
}); });
} catch (error) { } catch (error) {
......
...@@ -16,13 +16,15 @@ import { postStopV2Chat } from '@/web/core/chat/api'; ...@@ -16,13 +16,15 @@ import { postStopV2Chat } from '@/web/core/chat/api';
import type { ChatBoxInputType, StopChatFnResult, ChatGenerateStatusChangeHandler } from './type'; import type { ChatBoxInputType, StopChatFnResult, ChatGenerateStatusChangeHandler } from './type';
import type { StartChatFnProps } from '../type'; import type { StartChatFnProps } from '../type';
import ChatInput from './Input/ChatInput'; import ChatInput from './Input/ChatInput';
import AgentAskComposer from './Input/AgentAskComposer';
import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { import {
ChatGenerateStatusEnum, ChatGenerateStatusEnum,
ChatRoleEnum, ChatRoleEnum,
ChatStatusEnum ChatStatusEnum
} from '@fastgpt/global/core/chat/constants'; } 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 { import {
ChatInputWrapperStyle, ChatInputWrapperStyle,
ChatTypeEnum, ChatTypeEnum,
...@@ -465,7 +467,12 @@ const ChatBox = ({ ...@@ -465,7 +467,12 @@ const ChatBox = ({
finishChatGenerateStatus 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 canSendPrompt = canRenderChatInput && !isRoundPending;
const canRenderScrollToBottomButton = const canRenderScrollToBottomButton =
(chatType === ChatTypeEnum.chat || (chatType === ChatTypeEnum.chat ||
...@@ -717,18 +724,39 @@ const ChatBox = ({ ...@@ -717,18 +724,39 @@ const ChatBox = ({
onClick={() => scrollToBottom('smooth')} onClick={() => scrollToBottom('smooth')}
/> />
<ChatInput <Box display={isAgentAskPending ? 'none' : undefined}>
onSendMessage={sendPromptWithDisabledGuard} <ChatInput
lastInteractive={lastInteractive} onSendMessage={sendPromptWithDisabledGuard}
onStopChat={requestStopChat} lastInteractive={lastInteractive}
onStopSettled={handleStopSettled} onStopChat={requestStopChat}
enableInputGuide={resolvedFeatures.inputGuide} onStopSettled={handleStopSettled}
enableVoiceInput={resolvedFeatures.voice} enableInputGuide={resolvedFeatures.inputGuide}
disableSend={isRoundPending} enableVoiceInput={resolvedFeatures.voice}
TextareaDom={TextareaDom} disableSend={isRoundPending}
resetInputVal={resetInputVal} TextareaDom={TextareaDom}
chatForm={chatForm} 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>
</Box> </Box>
)} )}
......
...@@ -4,17 +4,56 @@ import { ...@@ -4,17 +4,56 @@ import {
extractDeepestInteractive, extractDeepestInteractive,
getLastInteractiveValue getLastInteractiveValue
} from '@fastgpt/global/core/workflow/runtime/utils'; } 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 { checkInteractiveResponseStatus } from '@fastgpt/global/core/chat/utils';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { resolveFormInputFileValues } from '../../../components/FormInputResult'; import { resolveFormInputFileValues } from '../../../components/FormInputResult';
import type { ChatSiteItemType } from '../type'; 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 占位消息。 * 用户回答 Agent 收集问题后,前端会立即追加 Human/AI 占位消息。
* 这里把答案乐观写回对应的 agentPlanAskQuery,避免旧消息在失去 isLastChild 后丢失选中态。 * 这里把答案乐观写回对应的 Agent Ask,避免旧消息在失去 isLastChild 后丢失选中态。
*/ */
export const persistAgentPlanAskAnswerToHistories = ({ export const persistAgentAskAnswersToHistories = ({
histories, histories,
interactive, interactive,
answer answer
...@@ -24,14 +63,15 @@ export const persistAgentPlanAskAnswerToHistories = ({ ...@@ -24,14 +63,15 @@ export const persistAgentPlanAskAnswerToHistories = ({
answer: string; answer: string;
}): ChatSiteItemType[] => { }): ChatSiteItemType[] => {
const sourceInteractive = extractDeepestInteractive(interactive); const sourceInteractive = extractDeepestInteractive(interactive);
if (sourceInteractive.type !== 'agentPlanAskQuery') { if (sourceInteractive.type !== 'agentAsk') {
return histories; return histories;
} }
const targetAskId = sourceInteractive.askId; const targetAskId = sourceInteractive.askId;
const submittedAnswers = parseAgentAskAnswers(answer);
let hasUpdated = false; let hasUpdated = false;
const nextHistories = histories.map((item) => { const nextHistories = histories.map<ChatSiteItemType>((item) => {
if (item.obj !== ChatRoleEnum.AI) return item; if (item.obj !== ChatRoleEnum.AI) return item;
let itemUpdated = false; let itemUpdated = false;
...@@ -39,10 +79,42 @@ export const persistAgentPlanAskAnswerToHistories = ({ ...@@ -39,10 +79,42 @@ export const persistAgentPlanAskAnswerToHistories = ({
if (!('interactive' in val) || !val.interactive) return val; if (!('interactive' in val) || !val.interactive) return val;
const finalInteractive = extractDeepestInteractive(val.interactive); 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; return val;
} }
...@@ -54,7 +126,11 @@ export const persistAgentPlanAskAnswerToHistories = ({ ...@@ -54,7 +126,11 @@ export const persistAgentPlanAskAnswerToHistories = ({
...finalInteractive, ...finalInteractive,
params: { params: {
...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 = ( ...@@ -205,7 +281,8 @@ export const getInteractiveByHistories = (
interactive: finalInteractive, interactive: finalInteractive,
canSendQuery: false canSendQuery: false
}; };
} else if (finalInteractive.type === 'agentPlanAskQuery') { } else if (finalInteractive.type === 'agentAsk') {
// New
return { return {
interactive: finalInteractive, interactive: finalInteractive,
canSendQuery: true canSendQuery: true
...@@ -237,10 +314,7 @@ export const resolveInteractiveResponseChatItemId = ({ ...@@ -237,10 +314,7 @@ export const resolveInteractiveResponseChatItemId = ({
}) => { }) => {
if (!interactive) return responseChatItemId; if (!interactive) return responseChatItemId;
const status = checkInteractiveResponseStatus({ const status = checkInteractiveResponseStatus({ interactive, input: interactiveVal });
interactive,
input: interactiveVal
});
if (status === 'query') return responseChatItemId; if (status === 'query') return responseChatItemId;
const previousAiItem = histories.findLast((item) => item.obj === ChatRoleEnum.AI); const previousAiItem = histories.findLast((item) => item.obj === ChatRoleEnum.AI);
...@@ -256,10 +330,7 @@ export const rewriteHistoriesByInteractiveResponse = ({ ...@@ -256,10 +330,7 @@ export const rewriteHistoriesByInteractiveResponse = ({
interactiveVal: string; interactiveVal: string;
interactive: WorkflowInteractiveResponseType; interactive: WorkflowInteractiveResponseType;
}): ChatSiteItemType[] => { }): ChatSiteItemType[] => {
const status = checkInteractiveResponseStatus({ const status = checkInteractiveResponseStatus({ interactive, input: interactiveVal });
interactive,
input: interactiveVal
});
const formatHistories = (() => { const formatHistories = (() => {
if (status === 'query') { if (status === 'query') {
...@@ -270,7 +341,7 @@ export const rewriteHistoriesByInteractiveResponse = ({ ...@@ -270,7 +341,7 @@ export const rewriteHistoriesByInteractiveResponse = ({
const workingHistories = const workingHistories =
status === 'query' status === 'query'
? persistAgentPlanAskAnswerToHistories({ ? persistAgentAskAnswersToHistories({
histories: formatHistories, histories: formatHistories,
interactive, interactive,
answer: interactiveVal answer: interactiveVal
...@@ -338,6 +409,24 @@ export const rewriteHistoriesByInteractiveResponse = ({ ...@@ -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') { if (finalInteractive.type === 'paymentPause') {
return { return {
...val, ...val,
......
...@@ -177,7 +177,7 @@ export const mergeResumeCompletedChatRecords = ({ ...@@ -177,7 +177,7 @@ export const mergeResumeCompletedChatRecords = ({
/** /**
* 恢复流 replay 交互节点时,判断是否应 append 到 AI record.value。 * 恢复流 replay 交互节点时,判断是否应 append 到 AI record.value。
* *
* 核心约束:若 existing 中已有同一身份且已 submitted 的 `userInput` * 核心约束:若 existing 中已有同一身份且已 submitted 的表单交互
* 则跳过 incoming 的未提交副本,避免空表单覆盖已回填的文件/字段值。 * 则跳过 incoming 的未提交副本,避免空表单覆盖已回填的文件/字段值。
* 不同身份交互,或同身份但 existing 尚未 submitted(例如中间插入了确认文本),仍允许 append。 * 不同身份交互,或同身份但 existing 尚未 submitted(例如中间插入了确认文本),仍允许 append。
*/ */
...@@ -199,7 +199,9 @@ export const shouldAppendResumeInteractive = ({ ...@@ -199,7 +199,9 @@ export const shouldAppendResumeInteractive = ({
if (!isSameInteractive) return true; if (!isSameInteractive) return true;
return !( return !(
existingFinalInteractive.type === 'userInput' && existingFinalInteractive.params.submitted (existingFinalInteractive.type === 'userInput' ||
existingFinalInteractive.type === 'agentAsk') &&
existingFinalInteractive.params.submitted
); );
}; };
...@@ -225,7 +227,7 @@ const areSameInteractive = ( ...@@ -225,7 +227,7 @@ const areSameInteractive = (
}; };
/** /**
* 将 current 侧已提交的 `userInput` 交互写回 completed 侧同身份交互节点。 * 将 current 侧已提交的表单交互写回 completed 侧同身份交互节点。
* completed record 持久化后 `inputForm.value` 可能为空(尤其 fileSelect URL 数组), * completed record 持久化后 `inputForm.value` 可能为空(尤其 fileSelect URL 数组),
* 而恢复流 replay 期间已在 current record 中 hydrate 过,此处防止覆盖时丢失。 * 而恢复流 replay 期间已在 current record 中 hydrate 过,此处防止覆盖时丢失。
*/ */
...@@ -242,7 +244,10 @@ const mergeSubmittedInteractiveValues = ({ ...@@ -242,7 +244,10 @@ const mergeSubmittedInteractiveValues = ({
if (!interactive) return false; if (!interactive) return false;
const finalInteractive = extractDeepestInteractive(interactive); 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; if (!currentSubmittedInteractives.length) return completedValues;
...@@ -252,7 +257,7 @@ const mergeSubmittedInteractiveValues = ({ ...@@ -252,7 +257,7 @@ const mergeSubmittedInteractiveValues = ({
if (!value.interactive) return value; if (!value.interactive) return value;
const finalInteractive = extractDeepestInteractive(value.interactive); const finalInteractive = extractDeepestInteractive(value.interactive);
if (finalInteractive.type !== 'userInput') { if (finalInteractive.type !== 'userInput' && finalInteractive.type !== 'agentAsk') {
return value; 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 = ({ ...@@ -44,22 +44,24 @@ const getInputFormValueFromResponseData = ({
return (formInputResult as Record<string, unknown>)[inputKey]; return (formInputResult as Record<string, unknown>)[inputKey];
}; };
type RenderUserFormInteractiveProps = {
interactive: UserInputInteractive;
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
};
/** /**
* 渲染已提交/待提交的 `userInput` 工作流交互表单。 * 渲染标准 `userInput` 工作流交互表单。
* *
* fileSelect 始终优先使用 inputForm.value 中持久化的原始文件信息; * fileSelect 始终优先使用 inputForm.value 中持久化的原始文件信息;
* responseData.formInputResult 只为缺少原始值的旧历史兜底。 * responseData.formInputResult 只为缺少原始值的旧历史兜底。
* 非最后一条子消息时强制 `submitted: true`,禁止重复提交历史表单。 * 非最后一条子消息时强制 `submitted: true`,禁止重复提交历史表单。
*/ */
const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive({ const RenderStandardUserFormInteractive = ({
interactive, interactive,
responseData, responseData,
isLastChild isLastChild
}: { }: RenderUserFormInteractiveProps) => {
interactive: UserInputInteractive;
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const defaultValues = useMemo(() => { const defaultValues = useMemo(() => {
...@@ -125,6 +127,21 @@ const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive( ...@@ -125,6 +127,21 @@ const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive(
</Flex> </Flex>
</InteractiveCard> </InteractiveCard>
); );
};
/** 渲染标准 `userInput` 工作流交互表单。 */
const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive({
interactive,
responseData,
isLastChild
}: RenderUserFormInteractiveProps) {
return (
<RenderStandardUserFormInteractive
interactive={interactive}
responseData={responseData}
isLastChild={isLastChild}
/>
);
}); });
export default RenderUserFormInteractive; export default RenderUserFormInteractive;
...@@ -10,7 +10,7 @@ import { ...@@ -10,7 +10,7 @@ import {
ChatItemContext, ChatItemContext,
type OnOpenCiteModalProps type OnOpenCiteModalProps
} from '@/web/core/chat/context/chatItemContext'; } from '@/web/core/chat/context/chatItemContext';
import RenderAgentPlanAskInteractive from './RenderAgentPlanAskInteractive'; import RenderAgentAskInteractive from './RenderAgentAskInteractive';
import RenderPaymentPauseInteractive from './RenderPaymentPauseInteractive'; import RenderPaymentPauseInteractive from './RenderPaymentPauseInteractive';
import RenderPlan from './RenderPlan'; import RenderPlan from './RenderPlan';
import RenderPlanStatus from './RenderPlanStatus'; import RenderPlanStatus from './RenderPlanStatus';
...@@ -21,6 +21,7 @@ import RenderText from './RenderText'; ...@@ -21,6 +21,7 @@ import RenderText from './RenderText';
import RenderTool from './RenderTool'; import RenderTool from './RenderTool';
import RenderUserFormInteractive from './RenderUserFormInteractive'; import RenderUserFormInteractive from './RenderUserFormInteractive';
import RenderUserSelectInteractive from './RenderUserSelectInteractive'; import RenderUserSelectInteractive from './RenderUserSelectInteractive';
import { adaptLegacyAgentPlanAskToReadonlyAgentAsk } from './utils';
const AIResponseBox = ({ const AIResponseBox = ({
chatItemDataId, chatItemDataId,
...@@ -171,15 +172,26 @@ const AIResponseBox = ({ ...@@ -171,15 +172,26 @@ const AIResponseBox = ({
/> />
); );
} }
if (interactive.type === 'agentPlanAskQuery') { if (interactive.type === 'agentAsk') {
responseBlocks.push( responseBlocks.push(
<RenderAgentPlanAskInteractive <RenderAgentAskInteractive
key="interactive" key="interactive"
interactive={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') { if (interactive.type === 'paymentPause') {
responseBlocks.push( responseBlocks.push(
<RenderPaymentPauseInteractive key="interactive" interactive={interactive} /> <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'; import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus';
export const onSendPrompt = (text: string) => export const onSendPrompt = (text: string) =>
...@@ -5,3 +9,28 @@ export const onSendPrompt = (text: string) => ...@@ -5,3 +9,28 @@ export const onSendPrompt = (text: string) =>
text, text,
focus: true 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus'; 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', () => { describe('AIResponseBox utils', () => {
beforeEach(() => { beforeEach(() => {
...@@ -18,4 +21,36 @@ describe('AIResponseBox utils', () => { ...@@ -18,4 +21,36 @@ describe('AIResponseBox utils', () => {
focus: true 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