Skip to content

Commit 1bffca0

Browse files
committed
✨ ask_user 工具支持结构化选项(单选/多选)
- tool 定义增加 options 和 multiple 参数 - ChatStreamEvent ask_user 事件传递选项字段 - UI 支持 Radio 单选(点击即提交)和 Checkbox 多选(确认提交) - 所有模式下保留文本输入框供自定义回答 - 新增 2 个单元测试覆盖选项传递
1 parent fa47c20 commit 1bffca0

6 files changed

Lines changed: 160 additions & 10 deletions

File tree

src/app/service/agent/tools/ask_user.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,53 @@ describe("ask_user", () => {
7777
}
7878
await Promise.all([p1, p2]);
7979
});
80+
81+
it("should send options in ask_user event", async () => {
82+
const events: ChatStreamEvent[] = [];
83+
const sendEvent = (event: ChatStreamEvent) => events.push(event);
84+
const resolvers = new Map<string, (answer: string) => void>();
85+
86+
const { executor } = createAskUserTool(sendEvent, resolvers);
87+
88+
const resultPromise = executor.execute({
89+
question: "Pick a color",
90+
options: ["Red", "Blue", "Green"],
91+
});
92+
93+
expect(events).toHaveLength(1);
94+
const askEvent = events[0] as Extract<ChatStreamEvent, { type: "ask_user" }>;
95+
expect(askEvent.options).toEqual(["Red", "Blue", "Green"]);
96+
expect(askEvent.multiple).toBeUndefined();
97+
98+
// Resolve
99+
const [_id, resolve] = Array.from(resolvers.entries())[0];
100+
resolve("Blue");
101+
const result = JSON.parse((await resultPromise) as string);
102+
expect(result).toEqual({ answer: "Blue" });
103+
});
104+
105+
it("should send multiple flag in ask_user event", async () => {
106+
const events: ChatStreamEvent[] = [];
107+
const sendEvent = (event: ChatStreamEvent) => events.push(event);
108+
const resolvers = new Map<string, (answer: string) => void>();
109+
110+
const { executor } = createAskUserTool(sendEvent, resolvers);
111+
112+
const resultPromise = executor.execute({
113+
question: "Select languages",
114+
options: ["JavaScript", "Python", "Rust"],
115+
multiple: true,
116+
});
117+
118+
expect(events).toHaveLength(1);
119+
const askEvent = events[0] as Extract<ChatStreamEvent, { type: "ask_user" }>;
120+
expect(askEvent.options).toEqual(["JavaScript", "Python", "Rust"]);
121+
expect(askEvent.multiple).toBe(true);
122+
123+
// Resolve with multiple selections
124+
const [_id, resolve] = Array.from(resolvers.entries())[0];
125+
resolve(JSON.stringify(["JavaScript", "Rust"]));
126+
const result = JSON.parse((await resultPromise) as string);
127+
expect(result).toEqual({ answer: '["JavaScript","Rust"]' });
128+
});
80129
});

src/app/service/agent/tools/ask_user.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@ import type { ToolExecutor } from "@App/app/service/agent/tool_registry";
44
export const ASK_USER_DEFINITION: ToolDefinition = {
55
name: "ask_user",
66
description:
7-
"Ask the user a question and wait for their response. Use this when you need clarification, a decision, or user input before proceeding. The user will see the question in the chat UI and can type a response.",
7+
"Ask the user a question and wait for their response (text only, no image support). " +
8+
"Use options for structured choices (single/multi-select). Times out after 5 minutes.",
89
parameters: {
910
type: "object",
1011
properties: {
1112
question: { type: "string", description: "The question to ask the user" },
13+
options: {
14+
type: "array",
15+
items: { type: "string" },
16+
description:
17+
"Optional list of choices for the user. If provided, user selects from these instead of free text input.",
18+
},
19+
multiple: {
20+
type: "boolean",
21+
description: "Allow selecting multiple options (default: false, single-select).",
22+
},
1223
},
1324
required: ["question"],
1425
},
@@ -30,10 +41,13 @@ export function createAskUserTool(
3041
throw new Error("question is required");
3142
}
3243

44+
const options = args.options as string[] | undefined;
45+
const multiple = args.multiple as boolean | undefined;
46+
3347
const askId = `ask_${Date.now()}_${++askCounter}`;
3448

3549
// 通知 UI 显示提问
36-
sendEvent({ type: "ask_user", id: askId, question });
50+
sendEvent({ type: "ask_user", id: askId, question, options, multiple });
3751

3852
// 等待用户回复
3953
return new Promise<string>((resolve) => {

src/app/service/agent/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export type ChatStreamEvent =
9191
| { type: "tool_call_complete"; id: string; result: string; attachments?: Attachment[] }
9292
| { type: "content_block_start"; block: Omit<ImageBlock | FileBlock | AudioBlock, "attachmentId"> }
9393
| { type: "content_block_complete"; block: ImageBlock | FileBlock | AudioBlock; data?: string }
94-
| { type: "ask_user"; id: string; question: string }
94+
| { type: "ask_user"; id: string; question: string; options?: string[]; multiple?: boolean }
9595
| { type: "sub_agent_event"; agentId: string; description: string; event: ChatStreamEvent }
9696
| { type: "new_message" }
9797
| {

src/pages/options/routes/AgentChat/AskUserBlock.tsx

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { useState, useRef, useEffect } from "react";
2-
import { Button, Input } from "@arco-design/web-react";
2+
import { Button, Input, Radio, Checkbox } from "@arco-design/web-react";
33
import { IconSend } from "@arco-design/web-react/icon";
44

55
export default function AskUserBlock({
66
id,
77
question,
8+
options,
9+
multiple,
810
onRespond,
911
}: {
1012
id: string;
1113
question: string;
14+
options?: string[];
15+
multiple?: boolean;
1216
onRespond: (id: string, answer: string) => void;
1317
}) {
1418
const [answer, setAnswer] = useState("");
19+
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
1520
const [submitted, setSubmitted] = useState(false);
1621
const inputRef = useRef<any>(null);
1722

@@ -20,33 +25,104 @@ export default function AskUserBlock({
2025
}, []);
2126

2227
const handleSubmit = () => {
23-
if (!answer.trim() || submitted) return;
28+
if (submitted) return;
29+
if (!answer.trim()) return;
2430
setSubmitted(true);
2531
onRespond(id, answer.trim());
2632
};
2733

34+
// 单选:点击选项直接提交
35+
const handleSingleSelect = (value: string) => {
36+
if (submitted) return;
37+
setSelectedOptions([value]);
38+
setAnswer(value);
39+
setSubmitted(true);
40+
onRespond(id, value);
41+
};
42+
43+
// 多选:确认提交
44+
const handleMultiSubmit = () => {
45+
if (submitted || selectedOptions.length === 0) return;
46+
const result = JSON.stringify(selectedOptions);
47+
setAnswer(result);
48+
setSubmitted(true);
49+
onRespond(id, result);
50+
};
51+
52+
const displayAnswer = (() => {
53+
if (!answer) return "";
54+
// 多选时尝试解析为数组展示
55+
if (multiple) {
56+
try {
57+
const arr = JSON.parse(answer);
58+
if (Array.isArray(arr)) return arr.join(", ");
59+
} catch {
60+
// 用户自行输入的文本
61+
}
62+
}
63+
return answer;
64+
})();
65+
2866
if (submitted) {
2967
return (
3068
<div className="tw-my-3 tw-px-4 tw-py-3 tw-rounded-lg tw-bg-[var(--color-fill-1)] tw-border tw-border-solid tw-border-[var(--color-border-1)]">
3169
<div className="tw-text-xs tw-text-[var(--color-text-3)] tw-mb-1">Agent asked:</div>
3270
<div className="tw-text-sm tw-text-[var(--color-text-2)] tw-mb-2">{question}</div>
3371
<div className="tw-text-xs tw-text-[var(--color-text-3)] tw-mb-1">Your answer:</div>
34-
<div className="tw-text-sm tw-text-[var(--color-text-1)]">{answer}</div>
72+
<div className="tw-text-sm tw-text-[var(--color-text-1)]">{displayAnswer}</div>
3573
</div>
3674
);
3775
}
3876

77+
const hasOptions = options && options.length > 0;
78+
3979
return (
4080
<div className="tw-my-3 tw-px-4 tw-py-3 tw-rounded-lg tw-bg-[var(--color-fill-1)] tw-border tw-border-solid tw-border-[rgb(var(--arcoblue-3))]">
4181
<div className="tw-text-xs tw-text-[var(--color-text-3)] tw-mb-2">Agent is asking:</div>
4282
<div className="tw-text-sm tw-text-[var(--color-text-1)] tw-mb-3">{question}</div>
83+
84+
{/* 选项区域 */}
85+
{hasOptions && !multiple && (
86+
<div className="tw-mb-3">
87+
<Radio.Group direction="vertical">
88+
{options.map((opt) => (
89+
<Radio key={opt} value={opt} onClick={() => handleSingleSelect(opt)}>
90+
{opt}
91+
</Radio>
92+
))}
93+
</Radio.Group>
94+
</div>
95+
)}
96+
97+
{hasOptions && multiple && (
98+
<div className="tw-mb-3">
99+
<Checkbox.Group
100+
direction="vertical"
101+
value={selectedOptions}
102+
onChange={(values) => setSelectedOptions(values as string[])}
103+
>
104+
{options.map((opt) => (
105+
<Checkbox key={opt} value={opt}>
106+
{opt}
107+
</Checkbox>
108+
))}
109+
</Checkbox.Group>
110+
<div className="tw-mt-2">
111+
<Button type="primary" size="small" onClick={handleMultiSubmit} disabled={selectedOptions.length === 0}>
112+
Confirm
113+
</Button>
114+
</div>
115+
</div>
116+
)}
117+
118+
{/* 文本输入框(始终显示) */}
43119
<div className="tw-flex tw-gap-2">
44120
<Input
45121
ref={inputRef}
46122
value={answer}
47123
onChange={setAnswer}
48124
onPressEnter={handleSubmit}
49-
placeholder="Type your response..."
125+
placeholder={hasOptions ? "Or type a custom response..." : "Type your response..."}
50126
size="small"
51127
/>
52128
<Button type="primary" size="small" icon={<IconSend />} onClick={handleSubmit} disabled={!answer.trim()}>

src/pages/options/routes/AgentChat/ChatArea.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,13 @@ export default function ChatArea({
442442
)
443443
)}
444444
{askUserPending && (
445-
<AskUserBlock id={askUserPending.id} question={askUserPending.question} onRespond={respondToAskUser} />
445+
<AskUserBlock
446+
id={askUserPending.id}
447+
question={askUserPending.question}
448+
options={askUserPending.options}
449+
multiple={askUserPending.multiple}
450+
onRespond={respondToAskUser}
451+
/>
446452
)}
447453
<div ref={messagesEndRef} />
448454
</div>

src/pages/options/routes/AgentChat/hooks.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export function useMessages(conversationId: string) {
132132
}
133133

134134
// ask_user 待回复状态
135-
export type AskUserPending = { id: string; question: string };
135+
export type AskUserPending = { id: string; question: string; options?: string[]; multiple?: boolean };
136136

137137
// 流式聊天 hook
138138
export function useStreamingChat() {
@@ -191,7 +191,12 @@ export function useStreamingChat() {
191191
const event = msg.data as ChatStreamEvent;
192192
// 处理 ask_user 事件
193193
if (event.type === "ask_user") {
194-
setAskUserPending({ id: event.id, question: event.question });
194+
setAskUserPending({
195+
id: event.id,
196+
question: event.question,
197+
options: event.options,
198+
multiple: event.multiple,
199+
});
195200
}
196201
onEvent(event);
197202
if (event.type === "done" || event.type === "error") {

0 commit comments

Comments
 (0)