智能体对话选 Agent 直达、澄清问题批量提交、LLM 错误中文化等

- 前端:新会话选 Agent 后直接跳转并自动发送,智能体页输入框宽度与首页对齐
- 前端:澄清卡片支持多选+多问题合并提交,消除单题逐一发送的割裂感
- 前端:定时任务弹窗 Agent 下拉支持长描述截断,避免撑破布局
- 后端:ask_clarification 支持同回合多次调用以提多个独立问题
- 后端:LLM 错误处理与定时任务失败提示统一改为中文

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-22 14:24:29 +08:00
1 parent c74be2b789
commit ba2bc46042
7 files changed
+327 -73

No files matched your search

@@ -1,6 +1,6 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
Conversation,
@@ -64,6 +64,111 @@ export function MessageList({
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
const pendingClarificationIds = useMemo(() => {
let lastHumanIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.type === "human") {
lastHumanIndex = i;
break;
}
}
const ids: string[] = [];
for (let i = lastHumanIndex + 1; i < messages.length; i++) {
const msg = messages[i];
if (msg && msg.id && msg.type === "tool" && msg.name === "ask_clarification") {
ids.push(msg.id);
}
}
return ids;
}, [messages]);
const [clarificationSelections, setClarificationSelections] = useState<
Map<string, ClarificationSelection>
>(new Map());
const [submittingClarification, setSubmittingClarification] = useState(false);
const handleClarificationSelectionChange = useCallback(
(id: string, next: ClarificationSelection) => {
setClarificationSelections((prev) => {
const map = new Map(prev);
map.set(id, next);
return map;
});
},
[],
);
const handleSubmitAllClarifications = useCallback(async () => {
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
return;
}
const parts: string[] = [];
for (let index = 0; index < pendingClarificationIds.length; index++) {
const id = pendingClarificationIds[index]!;
const message = messages.find((msg) => msg.id === id);
const payload = message ? getClarificationPayload(message) : null;
const selection = clarificationSelections.get(id) ?? {
selectedIds: [],
custom: "",
};
const options = (payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
);
const selectedLabels = selection.selectedIds
.map(
(sid) =>
options.find((option) => (option.id || option.label) === sid)?.label,
)
.filter((label): label is string => Boolean(label && label.trim()));
const trimmedCustom = selection.custom.trim();
let part = "";
if (selectedLabels.length > 0 && trimmedCustom) {
part = `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
} else if (selectedLabels.length > 0) {
part = `我选择:${selectedLabels.join("、")}`;
} else if (trimmedCustom) {
part = trimmedCustom;
} else {
continue;
}
if (pendingClarificationIds.length > 1) {
const question = payload?.question?.trim();
parts.push(
question
? `问题${index + 1}「${question}」:${part}`
: `问题${index + 1}:${part}`,
);
} else {
parts.push(part);
}
}
if (parts.length === 0) {
return;
}
const combined =
pendingClarificationIds.length > 1
? `针对你提出的多个问题,依次回答:\n${parts.map((p) => `- ${p}`).join("\n")}`
: parts[0]!;
setSubmittingClarification(true);
try {
await onClarificationSubmit(combined);
} finally {
setSubmittingClarification(false);
}
}, [pendingClarificationIds, messages, clarificationSelections, onClarificationSubmit]);
const totalPending = pendingClarificationIds.length;
const hasAnyClarificationAnswer = useMemo(() => {
return pendingClarificationIds.some((id) => {
const sel = clarificationSelections.get(id);
return (sel?.selectedIds.length ?? 0) > 0 || (sel?.custom.trim() ?? "").length > 0;
});
}, [pendingClarificationIds, clarificationSelections]);
if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />;
}
@@ -89,14 +194,34 @@ export function MessageList({
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
const messageId = message.id ?? "";
const pendingIndex = pendingClarificationIds.indexOf(messageId);
const isPending = pendingIndex >= 0;
const isLastPending =
isPending && pendingIndex === totalPending - 1;
return (
<div key={group.id} className="w-full">
<ClarificationCard
fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading}
message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins}
isPending={isPending}
pendingIndex={isPending ? pendingIndex : undefined}
pendingCount={isPending ? totalPending : undefined}
isLastPending={isLastPending}
selection={
clarificationSelections.get(messageId) ?? {
selectedIds: [],
custom: "",
}
}
onSelectionChange={(next) =>
handleClarificationSelectionChange(messageId, next)
}
submitting={submittingClarification}
canSubmit={hasAnyClarificationAnswer}
onSubmitAll={handleSubmitAllClarifications}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -289,6 +414,11 @@ type ClarificationPayload = {
allow_custom?: boolean;
};
type ClarificationSelection = {
selectedIds: string[];
custom: string;
};
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
@@ -447,19 +577,32 @@ function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
isPending,
pendingIndex,
pendingCount,
isLastPending,
selection,
onSelectionChange,
submitting,
canSubmit,
onSubmitAll,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
isPending: boolean;
pendingIndex?: number;
pendingCount?: number;
isLastPending: boolean;
selection: ClarificationSelection;
onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean;
canSubmit: boolean;
onSubmitAll: () => void | Promise<void>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const options = useMemo(
() =>
@@ -479,27 +622,36 @@ function ClarificationCard({
);
}
const disabled = false;
const disabled = !isPending || submitting;
const multiPending = (pendingCount ?? 0) > 1;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed) {
function toggleOption(optionId: string) {
if (disabled) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
setSubmitted(true);
} finally {
setSubmitting(false);
const next = selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId];
onSelectionChange({ ...selection, selectedIds: next });
}
function updateCustom(value: string) {
if (disabled) {
return;
}
onSelectionChange({ ...selection, custom: value });
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
<span>需要你的确认</span>
{multiPending && pendingIndex !== undefined && pendingCount ? (
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]">
{pendingIndex + 1} / {pendingCount}
</span>
) : null}
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
@@ -513,49 +665,84 @@ function ClarificationCard({
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
<div className="text-muted-foreground text-xs">
可多选(再次点击取消选择)
</div>
{options.map((option) => {
const optionId = option.id || option.label;
const isSelected = selection.selectedIds.includes(optionId);
return (
<Button
key={optionId}
type="button"
variant={isSelected ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => toggleOption(optionId)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span
className={cn(
"text-xs font-normal leading-5",
isSelected
? "text-primary-foreground/80"
: "text-muted-foreground",
)}
>
{option.description}
</span>
) : null}
</span>
{isSelected ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null}
</span>
</Button>
))}
</Button>
);
})}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
value={selection.custom}
onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder={
options.length > 0
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
</div>
) : null}
{isPending ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
) : null}
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
disabled={!canSubmit || submitting}
onClick={() => void onSubmitAll()}
>
<SendIcon className="h-4 w-4" />
发送回答
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
</Button>
</div>
</div>
) : (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
)
) : null}
</div>
);
+44 -6
View File
@@ -3,7 +3,7 @@
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -36,13 +36,30 @@ export default function AgentChatPage() {
const routeBase = useRouteBase();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
// If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI
// before the auto-sent message takes over.
const hasPendingForThisAgent = () => {
if (typeof window === "undefined" || !agent_id) return false;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return false;
const parsed = JSON.parse(raw);
return parsed?.agentId === agent_id && !!parsed?.text;
} catch {
return false;
}
};
const [hasStartedConversation, setHasStartedConversation] = useState(
hasPendingForThisAgent,
);
const { agent } = useAgent(agent_id);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
@@ -52,9 +69,10 @@ export default function AgentChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setHasStartedConversation(hasPendingForThisAgent());
}
}, [isNewThread, threadId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewThread, threadId, agent_id]);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
@@ -100,6 +118,26 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id],
);
// ChatPage stashes the opening message in sessionStorage when the user picks
// an agent on the landing screen, then redirects here. Replay it once so the
// user doesn't have to re-type the question on the agent chat page.
const pendingMessageHandled = useRef(false);
useEffect(() => {
if (pendingMessageHandled.current) return;
if (!agent_id || !isNewThread || hasStartedConversation) return;
let pending: { agentId?: string; text?: string } | null = null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (raw) pending = JSON.parse(raw);
} catch {
pending = null;
}
if (!pending || pending.agentId !== agent_id || !pending.text) return;
pendingMessageHandled.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: pending.text, files: [] });
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
@@ -180,8 +218,8 @@ export default function AgentChatPage() {
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
+12 -7
View File
@@ -758,11 +758,11 @@ export default function ScheduledTasksPage() {
</Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogContent className="max-w-2xl overflow-hidden">
<DialogHeader className="min-w-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex min-w-0 flex-col gap-3">
<Input
value={form.name}
onChange={(event) =>
@@ -798,15 +798,20 @@ export default function ScheduledTasksPage() {
}))
}
>
<SelectTrigger>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" />
</SelectTrigger>
<SelectContent>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
{agent.description ? ` — ${agent.description}` : ""}
<span className="block truncate">
{agent.name}
{agent.description ? ` — ${agent.description}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
@@ -464,11 +464,22 @@ You are {agent_name}, an internal super agent.
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
- ❌ DO NOT cram several unrelated questions into one `question` string — split them into separate tool calls
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
- ✅ After calling ask_clarification, execution will be interrupted automatically
- ✅ Wait for user response - do NOT continue with assumptions
**Batching multiple independent questions:**
- When the request leaves **several independent aspects** unclear (e.g. project type AND tech stack
AND deployment target), call `ask_clarification` multiple times in the same turn — one call per
aspect, each with its own focused `question` and `options`.
- The frontend groups all clarification cards from one turn into a single submission, so the user
answers them together in one reply. You will then receive ONE combined user message addressing
every question in order — read it carefully and match answers back to the questions you asked.
- If the next question only makes sense after the previous answer (sequential dependency), ask
only the first one this turn and wait for the reply before asking the next.
**How to Use:**
```python
ask_clarification(
@@ -171,21 +171,21 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
seconds = max(1, round(wait_ms / 1000))
reason_text = "provider is busy" if reason == "busy" else "provider request failed temporarily"
return f"LLM request retry {attempt}/{self.retry_max_attempts}: {reason_text}. Retrying in {seconds}s."
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
def _build_circuit_breaker_message(self) -> str:
return "The configured LLM provider is currently unavailable due to continuous failures. Circuit breaker is engaged to protect the system. Please wait a moment before trying again."
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
def _build_user_message(self, exc: BaseException, reason: str) -> str:
detail = _extract_error_detail(exc)
if reason == "quota":
return "The configured LLM provider rejected the request because the account is out of quota, billing is unavailable, or usage is restricted. Please fix the provider account and try again."
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
if reason == "auth":
return "The configured LLM provider rejected the request because authentication or access is invalid. Please check the provider credentials and try again."
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
if reason in {"busy", "transient"}:
return "The configured LLM provider is temporarily unavailable after multiple retries. Please wait a moment and continue the conversation."
return f"LLM request failed: {detail}"
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
return f"大模型因「{detail}」原因调用失败。"
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
try:
@@ -713,7 +713,7 @@ class ScheduledTaskService:
retry_count: int = 0,
) -> None:
"""Mark a run failed and reflect the outcome on the parent task."""
result = _build_result(task, scheduled_for=scheduled_for, content=f"Scheduled task failed: {error}")
result = _build_result(task, scheduled_for=scheduled_for, content=f"定时任务执行失败:{error}")
await self.store.update_run(
run_id,
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
@@ -36,18 +36,31 @@ def ask_clarification_tool(
- You're about to perform a potentially dangerous operation
- You have a recommendation but need user approval
Batching multiple independent questions:
- **You CAN and SHOULD call ask_clarification multiple times in the same turn** when the user's
request leaves several **independent** aspects unclear (e.g. project type AND tech stack AND
deployment target). Emit one tool call per aspect — do NOT cram several unrelated questions
into one `question` string.
- The frontend automatically groups all clarification cards in one turn into a single submission,
so the user answers them together in one reply. You will then receive one combined user message
addressing every question in order.
- When the questions DEPEND on each other (the second only makes sense after the first is answered),
ask only the first one and wait for the reply before asking the next.
Best practices:
- Ask ONE clarification at a time for clarity
- Be specific and clear in your question
- Don't make assumptions when clarification is needed
- For risky operations, ALWAYS ask for confirmation
- After calling this tool, execution will be interrupted automatically
- One question per ask_clarification call — keep each `question` focused on a single decision.
- Be specific and clear in your question.
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
this lets the user multi-select in the UI instead of typing free text.
- Don't make assumptions when clarification is needed.
- For risky operations, ALWAYS ask for confirmation.
- After calling this tool (or several in one turn), execution will be interrupted automatically.
Args:
question: The clarification question to ask the user. Be specific and clear.
question: The clarification question to ask the user. Focused on a single decision; be specific and clear.
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from.
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from; the UI supports multi-select.
"""
# This is a placeholder implementation
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call