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
import { Box, Button, Flex, IconButton, Textarea } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { AgentAskQuestionInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { useTranslation } from 'next-i18next';
import {
type KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
type Answers = Record<string, string>;
type AgentAskOption = AgentAskQuestionInteractive['options'][number];
type AgentAskNavigationProps = {
questionIndex: number;
questionCount: number;
isPreviousDisabled: boolean;
isNextDisabled: boolean;
isDisabled: boolean;
onPrevious: () => void;
onNext: () => void;
onSkipAll: () => void;
};
const AgentAskNavigation = ({
questionIndex,
questionCount,
isPreviousDisabled,
isNextDisabled,
isDisabled,
onPrevious,
onNext,
onSkipAll
}: AgentAskNavigationProps) => {
const { t } = useTranslation();
return (
<Flex flexShrink={0} alignItems={'center'} gap={3}>
<Flex alignItems={'center'} gap={2}>
<IconButton
variant={'unstyled'}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'full'}
w={6}
h={6}
minW={6}
maxW={6}
minH={6}
maxH={6}
flexShrink={0}
p={0}
bg={'myGray.150'}
isDisabled={isPreviousDisabled}
_hover={isPreviousDisabled ? undefined : { bg: 'myGray.200' }}
_focusVisible={{ bg: 'myGray.200', boxShadow: 'none', outline: 'none' }}
_active={{ transform: 'none' }}
onClick={onPrevious}
aria-label={t('chat:Previous')}
icon={<MyIcon name={'common/leftArrowLight'} w={'6px'} color={'myGray.900'} />}
/>
<Box color={'myGray.500'} fontSize={'sm'} fontWeight={500} whiteSpace={'nowrap'}>
{questionIndex + 1}
<Box as={'span'} mx={1}>
/
</Box>
<Box as={'span'} color={'myGray.900'}>
{questionCount}
</Box>
</Box>
<IconButton
variant={'unstyled'}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'full'}
w={6}
h={6}
minW={6}
maxW={6}
minH={6}
maxH={6}
flexShrink={0}
p={0}
bg={'myGray.150'}
isDisabled={isNextDisabled}
_hover={isNextDisabled ? undefined : { bg: 'myGray.200' }}
_focusVisible={{ bg: 'myGray.200', boxShadow: 'none', outline: 'none' }}
_active={{ transform: 'none' }}
onClick={onNext}
aria-label={t('chat:Next')}
icon={<MyIcon name={'common/rightArrow'} w={'6px'} color={'myGray.900'} />}
/>
</Flex>
<Button
variant={'unstyled'}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
w={6}
h={6}
minW={0}
p={0}
borderRadius={'full'}
isDisabled={isDisabled}
_hover={isDisabled ? undefined : { bg: 'myGray.50' }}
_focusVisible={{ bg: 'myGray.50', boxShadow: 'none', outline: 'none' }}
_active={{ transform: 'none' }}
onClick={onSkipAll}
aria-label={t('chat:interactive.agent_ask.skip_all')}
>
<MyIcon name={'common/closeLight'} w={4} h={4} />
</Button>
</Flex>
);
};
type AgentAskAdvanceButtonProps = {
label: string;
isLoading: boolean;
isDisabled: boolean;
onClick: () => void;
};
const AgentAskAdvanceButton = ({
label,
isLoading,
isDisabled,
onClick
}: AgentAskAdvanceButtonProps) => (
<Button
variant={'whiteBase'}
size={'xs'}
minW={'auto'}
h={'32px'}
flexShrink={0}
px={3.5}
borderRadius={'full'}
fontSize={'xs'}
isLoading={isLoading}
isDisabled={isDisabled}
_focusVisible={{ bg: 'myGray.50', boxShadow: 'none', outline: 'none' }}
_active={{ transform: 'none' }}
onClick={onClick}
>
{label}
</Button>
);
type AgentAskOptionButtonProps = {
option: AgentAskOption;
index: number;
isSelected: boolean;
isDisabled: boolean;
optionRef: (element: HTMLButtonElement | null) => void;
onSelect: () => void;
onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
};
const AgentAskOptionButton = ({
option,
index,
isSelected,
isDisabled,
optionRef,
onSelect,
onKeyDown
}: AgentAskOptionButtonProps) => (
<Box role={'group'}>
<Button
ref={optionRef}
variant={'unstyled'}
display={'flex'}
flexDirection={'row'}
flexWrap={'nowrap'}
alignItems={'center'}
minH={'40px'}
h={'auto'}
w={'100%'}
justifyContent={'space-between'}
gap={3}
p={2}
border={'1px solid'}
borderColor={isSelected ? 'primary.300' : 'transparent'}
borderRadius={'21px'}
bg={isSelected ? 'primary.50' : 'transparent'}
textAlign={'left'}
_hover={
isSelected ? { bg: 'primary.50', borderColor: 'primary.300' } : { bg: 'blackAlpha.50' }
}
_focusVisible={{
...(isSelected
? { bg: 'primary.50', borderColor: 'primary.300' }
: { bg: 'blackAlpha.50' }),
boxShadow: 'none',
outline: 'none',
'& > svg': { opacity: 1 }
}}
_active={{ transform: 'none' }}
isDisabled={isDisabled}
_disabled={isSelected ? { opacity: 1 } : undefined}
onKeyDown={onKeyDown}
onClick={onSelect}
aria-pressed={isSelected}
>
<Flex minW={0} alignItems={'center'} gap={2}>
<Flex
flexShrink={0}
alignItems={'center'}
justifyContent={'center'}
w={6}
h={6}
borderRadius={'full'}
bg={isSelected ? 'primary.600' : 'myGray.50'}
color={isSelected ? 'myGray.100' : 'myGray.600'}
fontSize={'sm'}
fontWeight={500}
>
{index + 1}
</Flex>
<Box minW={0} whiteSpace={'pre-wrap'} wordBreak={'break-word'}>
<Box as={'span'} fontWeight={500} color={'myGray.900'}>
{option.summary}
</Box>
{option.summary !== option.value && (
<Box as={'span'} color={'myGray.600'}>
{` ${option.value}`}
</Box>
)}
</Box>
</Flex>
<MyIcon
name={'common/arrowRight'}
alignSelf={'center'}
flexShrink={0}
w={4}
h={4}
color={'myGray.400'}
opacity={isSelected ? 1 : 0}
_groupHover={{ opacity: 1 }}
/>
</Button>
</Box>
);
/** 渲染统一的 Agent Ask 问题,并按题目顺序提交回答值。 */
const AgentAskComposer = ({
questions,
onSubmit
}: {
questions: AgentAskQuestionInteractive[];
onSubmit: (answers: string[]) => void;
}) => {
const { t } = useTranslation();
const [questionIndex, setQuestionIndex] = useState(0);
const [answers, setAnswers] = useState<Answers>({});
const [selectedOptionIndexes, setSelectedOptionIndexes] = useState<Record<string, number>>({});
const [customValues, setCustomValues] = useState<Answers>({});
const [editingQuestionIndex, setEditingQuestionIndex] = useState<number>();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isQuestionVisible, setIsQuestionVisible] = useState(true);
const [isQuestionTransitioning, setIsQuestionTransitioning] = useState(false);
const [contentHeight, setContentHeight] = useState<number>();
const contentRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const questionTransitionTimer = useRef<ReturnType<typeof setTimeout>>();
const updateContentHeight = useCallback(() => {
const content = contentRef.current;
if (!content) return;
const previousHeight = content.style.height;
content.style.height = 'auto';
const nextHeight = content.offsetHeight;
content.style.height = previousHeight;
if (nextHeight) setContentHeight(nextHeight);
}, []);
// Ask 覆盖普通输入区时,焦点始终从当前题的第一个选项开始。
useEffect(() => {
optionRefs.current[0]?.focus();
}, [questionIndex]);
useEffect(() => {
return () => {
if (questionTransitionTimer.current) clearTimeout(questionTransitionTimer.current);
};
}, []);
useLayoutEffect(() => {
updateContentHeight();
}, [editingQuestionIndex, questionIndex, updateContentHeight]);
const question = questions[questionIndex];
if (!question) return null;
const questionKey = String(questionIndex);
const answer = answers[questionKey] ?? '';
const selectedOptionIndex = selectedOptionIndexes[questionKey];
const customValue = customValues[questionKey] ?? '';
const isCustom = !!answer && selectedOptionIndex === undefined;
const isEditingCustom = editingQuestionIndex === questionIndex;
const isAnswerValid = !!answer;
const isLastQuestion = questionIndex === questions.length - 1;
const changeQuestion = (nextQuestionIndex: number, delay = false) => {
if (isQuestionTransitioning || nextQuestionIndex === questionIndex) return;
setIsQuestionTransitioning(true);
const startTransition = () => {
setIsQuestionVisible(false);
questionTransitionTimer.current = setTimeout(() => {
setQuestionIndex(nextQuestionIndex);
setIsQuestionVisible(true);
setIsQuestionTransitioning(false);
}, 200);
};
if (delay) {
questionTransitionTimer.current = setTimeout(startTransition, 200);
return;
}
startTransition();
};
const submit = (nextAnswers = answers) => {
if (isSubmitting) return;
setIsSubmitting(true);
onSubmit(questions.map((_, index) => nextAnswers[String(index)] ?? ''));
};
const goNext = (
nextAnswer: string,
delayTransition = false,
nextSelectedOptionIndex?: number
) => {
const nextAnswers = { ...answers, [questionKey]: nextAnswer };
setAnswers(nextAnswers);
setSelectedOptionIndexes((indexes) => {
const nextIndexes = { ...indexes };
if (nextSelectedOptionIndex === undefined) {
delete nextIndexes[questionKey];
} else {
nextIndexes[questionKey] = nextSelectedOptionIndex;
}
return nextIndexes;
});
setEditingQuestionIndex(undefined);
if (isLastQuestion) {
submit(nextAnswers);
return;
}
changeQuestion(questionIndex + 1, delayTransition);
};
const skipOrAdvance = () => goNext(isCustom && customValue.trim() ? customValue.trim() : '');
const skipAll = () => submit({});
const selectCustom = () => {
setEditingQuestionIndex(questionIndex);
setAnswers((answers) => ({ ...answers, [questionKey]: customValue }));
setSelectedOptionIndexes((indexes) => {
const nextIndexes = { ...indexes };
delete nextIndexes[questionKey];
return nextIndexes;
});
};
const resizeCustomTextarea = (textarea: HTMLTextAreaElement | null) => {
if (!textarea) return;
textarea.style.height = '40px';
textarea.style.height = `${Math.min(textarea.scrollHeight, 116)}px`;
textarea.style.overflowY = textarea.scrollHeight > 116 ? 'auto' : 'hidden';
updateContentHeight();
};
const actionLabel =
isCustom && customValue.trim()
? isLastQuestion
? t('common:Submit')
: t('common:next_step')
: t('chat:interactive.agent_ask.skip');
const isInputDisabled = isSubmitting || isQuestionTransitioning;
const advanceButton = (
<AgentAskAdvanceButton
label={actionLabel}
isLoading={isSubmitting}
isDisabled={isInputDisabled}
onClick={skipOrAdvance}
/>
);
const isPreviousDisabled = questionIndex === 0 || isSubmitting || isQuestionTransitioning;
const isNextDisabled =
isSubmitting || isQuestionTransitioning || (isLastQuestion && !isAnswerValid);
return (
<Box
w={'100%'}
maxW={'780px'}
mx={'auto'}
border={'1px solid'}
borderColor={'myGray.250'}
borderRadius={'20px'}
bg={'white'}
boxShadow={'0px 5px 10px rgba(19, 51, 107, 0.13)'}
p={4}
ref={contentRef}
h={contentHeight ? `${contentHeight}px` : undefined}
overflow={'hidden'}
transition={'height 0.2s ease'}
>
<Flex alignItems={'center'} justifyContent={'space-between'} gap={4} px={1} mb={4}>
<Box
minW={0}
color={'myGray.900'}
fontSize={'md'}
fontWeight={500}
lineHeight={6}
opacity={isQuestionVisible ? 1 : 0}
transition={'opacity 0.2s ease'}
>
{question.question}
</Box>
<AgentAskNavigation
questionIndex={questionIndex}
questionCount={questions.length}
isPreviousDisabled={isPreviousDisabled}
isNextDisabled={isNextDisabled}
isDisabled={isInputDisabled}
onPrevious={() => {
setEditingQuestionIndex(undefined);
changeQuestion(questionIndex - 1);
}}
onNext={() => {
setEditingQuestionIndex(undefined);
if (isLastQuestion) {
goNext(answer);
return;
}
changeQuestion(questionIndex + 1);
}}
onSkipAll={skipAll}
/>
</Flex>
<Flex
direction={'column'}
gap={1}
opacity={isQuestionVisible ? 1 : 0}
transition={'opacity 0.2s ease'}
>
{question.options.map((option, index) => {
const isSelected = selectedOptionIndex === index;
return (
<AgentAskOptionButton
key={index}
option={option}
index={index}
isSelected={isSelected}
isDisabled={isInputDisabled}
optionRef={(element) => {
optionRefs.current[index] = element;
}}
onSelect={() => goNext(option.value, true, index)}
onKeyDown={(event) => {
const lastOptionIndex = question.options.length - 1;
const nextIndex = (() => {
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
return Math.min(index + 1, lastOptionIndex);
}
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
return Math.max(index - 1, 0);
}
if (event.key === 'Home') return 0;
if (event.key === 'End') return lastOptionIndex;
})();
if (nextIndex === undefined || nextIndex === index) return;
event.preventDefault();
optionRefs.current[nextIndex]?.focus();
}}
/>
);
})}
{isEditingCustom ? (
<Flex
alignItems={'center'}
flexWrap={'nowrap'}
gap={2}
pl={2}
minW={0}
minH={'42px'}
borderRadius={'21px'}
>
<Flex
flexShrink={0}
alignItems={'center'}
justifyContent={'center'}
w={6}
h={6}
borderRadius={'full'}
bg={'primary.600'}
color={'myGray.100'}
>
<MyIcon name={'common/edit'} w={'14px'} h={'14px'} />
</Flex>
<Textarea
autoFocus
ref={resizeCustomTextarea}
flex={'1 0 0'}
minW={0}
minH={'40px'}
maxH={'116px'}
h={'40px'}
py={2}
px={3}
resize={'none'}
borderColor={'primary.600'}
boxShadow={'0 0 0 2.4px rgba(51, 112, 255, 0.15)'}
color={'myGray.900'}
fontSize={'sm'}
lineHeight={5}
value={customValue}
aria-label={t('chat:interactive.agent_ask.custom_answer')}
onChange={(event) => {
resizeCustomTextarea(event.currentTarget);
const value = event.currentTarget.value;
setCustomValues((values) => ({ ...values, [questionKey]: value }));
setAnswers((answers) => ({ ...answers, [questionKey]: value }));
setSelectedOptionIndexes((indexes) => {
const nextIndexes = { ...indexes };
delete nextIndexes[questionKey];
return nextIndexes;
});
}}
/>
{advanceButton}
</Flex>
) : (
<Flex alignItems={'center'} gap={2} minW={0} minH={'42px'}>
<Button
variant={'unstyled'}
display={'flex'}
flex={'1 0 0'}
flexDirection={'row'}
flexWrap={'nowrap'}
alignItems={'center'}
minW={0}
h={'auto'}
minH={'40px'}
justifyContent={'flex-start'}
gap={2}
p={2}
borderRadius={'21px'}
bg={isCustom ? 'primary.50' : 'transparent'}
border={'1px solid'}
borderColor={isCustom ? 'primary.300' : 'transparent'}
textAlign={'left'}
_hover={
isCustom
? { bg: 'primary.50', borderColor: 'primary.300' }
: { bg: 'blackAlpha.50' }
}
_focusVisible={{
bg: isCustom ? 'primary.50' : 'blackAlpha.50',
borderColor: isCustom ? 'primary.300' : 'transparent',
boxShadow: 'none',
outline: 'none'
}}
_active={{ transform: 'none' }}
isDisabled={isSubmitting || isQuestionTransitioning}
onClick={selectCustom}
>
<Flex
flexShrink={0}
alignItems={'center'}
justifyContent={'center'}
w={6}
h={6}
borderRadius={'full'}
bg={isCustom ? 'primary.600' : 'myGray.50'}
color={isCustom ? 'myGray.100' : 'myGray.600'}
>
<MyIcon name={'common/edit'} w={'14px'} h={'14px'} />
</Flex>
<Box
minW={0}
color={isCustom ? 'myGray.900' : 'myGray.600'}
whiteSpace={'pre-wrap'}
wordBreak={'break-word'}
>
{customValue || t('chat:interactive.agent_ask.custom_answer')}
</Box>
</Button>
{advanceButton}
</Flex>
)}
</Flex>
</Box>
);
};
export default AgentAskComposer;
......@@ -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
}
});
......@@ -6,7 +6,9 @@ import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workf
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import {
getInteractiveByHistories,
persistAgentPlanAskAnswerToHistories,
isPendingAgentAsk,
isUserInputInteractiveSubmitted,
persistAgentAskAnswersToHistories,
resolveInteractiveResponseChatItemId,
rewriteHistoriesByInteractiveResponse
} from '@/components/core/chat/ChatContainer/ChatBox/utils/interactive';
......@@ -92,7 +94,9 @@ const createUserSelectInteractive = (userSelectedVal?: string): WorkflowInteract
}
}) as WorkflowInteractiveResponseType;
const createUserInputInteractive = (submitted = false): WorkflowInteractiveResponseType =>
const createUserInputInteractive = ({
submitted = false
}: { submitted?: boolean } = {}): WorkflowInteractiveResponseType =>
({
...baseInteractive,
type: 'userInput',
......@@ -136,7 +140,7 @@ describe('getInteractiveByHistories', () => {
});
});
it('allows sending a query while preserving agent plan ask interactive', () => {
it('adapts unanswered legacy agent plan ask interactive to a pending agentAsk', () => {
const interactive = {
...baseInteractive,
type: 'agentPlanAskQuery',
......@@ -147,10 +151,74 @@ describe('getInteractiveByHistories', () => {
}
} as WorkflowInteractiveResponseType;
expect(getInteractiveByHistories([createAiRecord(interactive)])).toEqual({
interactive,
const result = getInteractiveByHistories([createAiRecord(interactive)]);
expect(result).toMatchObject({
interactive: {
type: 'agentAsk',
askId: 'ask-1',
params: {
description: '',
questions: [
{
question: 'Need more detail',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' },
{ summary: 'C', value: 'C' }
],
answer: ''
}
]
}
},
canSendQuery: true
});
expect(isPendingAgentAsk(result.interactive)).toBe(true);
});
});
describe('isPendingAgentAsk', () => {
it('matches only pending agentAsk records', () => {
expect(
isPendingAgentAsk({
...baseInteractive,
type: 'agentAsk',
askId: 'ask-1',
params: {
description: 'Need input',
questions: [
{
question: 'Need input?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType)
).toBe(true);
expect(isPendingAgentAsk(createUserInputInteractive())).toBe(false);
expect(isPendingAgentAsk()).toBe(false);
});
});
describe('isUserInputInteractiveSubmitted', () => {
it('treats persisted formInputResult as a submitted historical form', () => {
const interactive = createUserInputInteractive() as Extract<
WorkflowInteractiveResponseType,
{ type: 'userInput' }
>;
expect(
isUserInputInteractiveSubmitted({
interactive,
isLastChild: true,
responseData: [{ formInputResult: { name: 'FastGPT' } } as any]
})
).toBe(true);
});
});
......@@ -183,6 +251,48 @@ describe('rewriteHistoriesByInteractiveResponse', () => {
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toBe('FastGPT');
});
it('writes auxiliary agentAsk answers into the original AI record', () => {
const interactive = {
...baseInteractive,
type: 'agentAsk',
askId: 'chat-agent-helper-ask',
responseMode: 'submit',
params: {
description: 'Collect requirements',
questions: [
{
question: 'Audience?',
options: [
{ summary: 'Developers', value: 'Developers' },
{ summary: 'Designers', value: 'Designers' }
],
answer: ''
},
{
question: 'Format?',
options: [
{ summary: 'Course', value: 'Course' },
{ summary: 'Workshop', value: 'Workshop' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType;
const result = rewriteHistoriesByInteractiveResponse({
histories: [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()],
interactive,
interactiveVal: JSON.stringify({ answers: ['Developers', 'Workshop'] })
});
const submittedInteractive = (result[0].value[0] as any).interactive;
expect(submittedInteractive.params.questions.map((question: any) => question.answer)).toEqual([
'Developers',
'Workshop'
]);
expect(submittedInteractive.params.submitted).toBe(true);
});
it('marks paymentPause as continued and removes temporary round records', () => {
const interactive = {
...baseInteractive,
......@@ -228,7 +338,7 @@ describe('rewriteHistoriesByInteractiveResponse', () => {
});
});
it('persists agentPlanAskQuery answer on the previous AI message for query responses', () => {
it('keeps legacy agentPlanAskQuery read-only', () => {
const interactive = {
...baseInteractive,
type: 'agentPlanAskQuery',
......@@ -246,7 +356,54 @@ describe('rewriteHistoriesByInteractiveResponse', () => {
interactiveVal: 'B'
});
expect((result[0].value[0] as any).interactive.params.answer).toBe('B');
expect(result).toHaveLength(3);
expect((result[0].value[0] as any).interactive.params.answer).toBeUndefined();
expect(result[2]).toEqual({
...histories[2],
status: ChatStatusEnum.loading
});
});
it('persists multi-question agentAsk values for query responses', () => {
const interactive = {
...baseInteractive,
type: 'agentAsk',
askId: 'ask-1',
params: {
description: 'Need input',
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: ''
}
]
}
} as WorkflowInteractiveResponseType;
const histories = [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()];
const result = rewriteHistoriesByInteractiveResponse({
histories,
interactive,
interactiveVal: JSON.stringify({ answers: ['A', ''] })
});
const submittedInteractive = (result[0].value[0] as any).interactive;
expect(submittedInteractive.params.submitted).toBe(true);
expect(submittedInteractive.params.questions.map((question: any) => question.answer)).toEqual([
'A',
''
]);
expect(result[2]).toEqual({
...histories[2],
status: ChatStatusEnum.loading
......@@ -256,31 +413,116 @@ describe('rewriteHistoriesByInteractiveResponse', () => {
it('only persists an agent ask answer to the matching askId', () => {
const firstAsk = {
...baseInteractive,
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId: 'ask-1',
params: {
content: 'First question',
options: ['A', 'B', 'C']
description: 'Need input',
questions: [
{
question: 'First question',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType;
const secondAsk = {
...baseInteractive,
type: 'agentPlanAskQuery',
type: 'agentAsk',
askId: 'ask-2',
params: {
content: 'Second question',
options: ['A', 'B', 'C']
description: 'Need input',
questions: [
{
question: 'Second question',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType;
const unrelatedInteractive = {
...createUserSelectInteractive(),
askId: 'ask-2'
} as WorkflowInteractiveResponseType;
const result = persistAgentPlanAskAnswerToHistories({
histories: [createAiRecord(firstAsk), createAiRecord(secondAsk, { id: 'ai-2' })],
const result = persistAgentAskAnswersToHistories({
histories: [
createAiRecord(firstAsk),
createAiRecord(secondAsk, { id: 'ai-2' }),
createAiRecord(unrelatedInteractive, { id: 'ai-3' })
],
interactive: secondAsk,
answer: 'B'
answer: JSON.stringify({ answers: ['B'] })
});
expect((result[0].value[0] as any).interactive.params.answer).toBeUndefined();
expect((result[1].value[0] as any).interactive.params.answer).toBe('B');
expect((result[0].value[0] as any).interactive.params.questions[0].answer).toBe('');
expect((result[1].value[0] as any).interactive.params.questions[0].answer).toBe('B');
expect((result[2].value[0] as any).interactive.params.answer).toBeUndefined();
});
it('replaces an unanswered legacy agent ask with submitted agentAsk', () => {
const legacyAsk = {
...baseInteractive,
type: 'agentPlanAskQuery',
askId: 'ask-1',
params: {
content: 'Need more detail',
reason: 'Choose one',
options: ['A', 'B', 'C']
}
} as WorkflowInteractiveResponseType;
const pendingAsk = {
...baseInteractive,
type: 'agentAsk',
askId: 'ask-1',
params: {
description: 'Choose one',
questions: [
{
question: 'Need more detail',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' },
{ summary: 'C', value: 'C' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType;
const result = persistAgentAskAnswersToHistories({
histories: [createAiRecord(legacyAsk)],
interactive: pendingAsk,
answer: JSON.stringify({ answers: ['B'] })
});
expect((result[0].value[0] as any).interactive).toMatchObject({
type: 'agentAsk',
askId: 'ask-1',
params: {
description: 'Choose one',
questions: [
{
question: 'Need more detail',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' },
{ summary: 'C', value: 'C' }
],
answer: 'B'
}
],
submitted: true
}
});
});
});
......@@ -323,6 +565,67 @@ describe('resolveInteractiveResponseChatItemId', () => {
).toBe('new-response-id');
});
it('keeps the new responseChatItemId for agentAsk', () => {
const interactive = {
...baseInteractive,
type: 'agentAsk',
askId: 'ask-1',
params: {
description: 'Need input',
questions: [
{
question: 'Need input?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType;
expect(
resolveInteractiveResponseChatItemId({
histories: [createAiRecord(interactive, { dataId: 'old-ai-1' })],
interactive,
interactiveVal: JSON.stringify({ answers: ['A'] }),
responseChatItemId: 'new-response-id'
})
).toBe('new-response-id');
});
it('uses the original AI dataId for submit agentAsk', () => {
const interactive = {
...baseInteractive,
type: 'agentAsk',
askId: 'chat-agent-helper-ask',
responseMode: 'submit',
params: {
description: 'Need input',
questions: [
{
question: 'Need input?',
options: [
{ summary: 'A', value: 'A' },
{ summary: 'B', value: 'B' }
],
answer: ''
}
]
}
} as WorkflowInteractiveResponseType;
expect(
resolveInteractiveResponseChatItemId({
histories: [createAiRecord(interactive, { dataId: 'old-ai-1' })],
interactive,
interactiveVal: JSON.stringify({ answers: ['A'] }),
responseChatItemId: 'new-response-id'
})
).toBe('old-ai-1');
});
it('falls back to the new responseChatItemId when no previous AI item exists', () => {
const interactive = createUserInputInteractive();
......
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