智能体对话选 Agent 直达、澄清问题批量提交、LLM 错误中文化等
- 前端:新会话选 Agent 后直接跳转并自动发送,智能体页输入框宽度与首页对齐 - 前端:澄清卡片支持多选+多问题合并提交,消除单题逐一发送的割裂感 - 前端:定时任务弹窗 Agent 下拉支持长描述截断,避免撑破布局 - 后端:ask_clarification 支持同回合多次调用以提多个独立问题 - 后端:LLM 错误处理与定时任务失败提示统一改为中文 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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 type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -64,6 +64,111 @@ export function MessageList({
|
|||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
const messages = thread.messages;
|
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) {
|
if (thread.isThreadLoading && messages.length === 0) {
|
||||||
return <MessageListSkeleton />;
|
return <MessageListSkeleton />;
|
||||||
}
|
}
|
||||||
@@ -89,14 +194,34 @@ export function MessageList({
|
|||||||
} else if (group.type === "assistant:clarification") {
|
} else if (group.type === "assistant:clarification") {
|
||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
|
const messageId = message.id ?? "";
|
||||||
|
const pendingIndex = pendingClarificationIds.indexOf(messageId);
|
||||||
|
const isPending = pendingIndex >= 0;
|
||||||
|
const isLastPending =
|
||||||
|
isPending && pendingIndex === totalPending - 1;
|
||||||
return (
|
return (
|
||||||
<div key={group.id} className="w-full">
|
<div key={group.id} className="w-full">
|
||||||
<ClarificationCard
|
<ClarificationCard
|
||||||
fallbackContent={extractContentFromMessage(message)}
|
fallbackContent={extractContentFromMessage(message)}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
message={message}
|
message={message}
|
||||||
onSubmit={onClarificationSubmit}
|
|
||||||
rehypePlugins={rehypePlugins}
|
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
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -289,6 +414,11 @@ type ClarificationPayload = {
|
|||||||
allow_custom?: boolean;
|
allow_custom?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClarificationSelection = {
|
||||||
|
selectedIds: string[];
|
||||||
|
custom: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SetupAgentApprovalPayload = {
|
export type SetupAgentApprovalPayload = {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -447,19 +577,32 @@ function ClarificationCard({
|
|||||||
fallbackContent,
|
fallbackContent,
|
||||||
isLoading,
|
isLoading,
|
||||||
message,
|
message,
|
||||||
onSubmit,
|
|
||||||
rehypePlugins,
|
rehypePlugins,
|
||||||
|
isPending,
|
||||||
|
pendingIndex,
|
||||||
|
pendingCount,
|
||||||
|
isLastPending,
|
||||||
|
selection,
|
||||||
|
onSelectionChange,
|
||||||
|
submitting,
|
||||||
|
canSubmit,
|
||||||
|
onSubmitAll,
|
||||||
}: {
|
}: {
|
||||||
fallbackContent: string;
|
fallbackContent: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
message: unknown;
|
message: unknown;
|
||||||
onSubmit?: (answer: string) => void | Promise<void>;
|
|
||||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
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 payload = getClarificationPayload(message);
|
||||||
const [customAnswer, setCustomAnswer] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
|
|
||||||
const options = useMemo(
|
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) {
|
function toggleOption(optionId: string) {
|
||||||
const trimmed = answer.trim();
|
if (disabled) {
|
||||||
if (!trimmed) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
const next = selection.selectedIds.includes(optionId)
|
||||||
try {
|
? selection.selectedIds.filter((id) => id !== optionId)
|
||||||
await onSubmit?.(trimmed);
|
: [...selection.selectedIds, optionId];
|
||||||
setSubmitted(true);
|
onSelectionChange({ ...selection, selectedIds: next });
|
||||||
} finally {
|
}
|
||||||
setSubmitting(false);
|
|
||||||
|
function updateCustom(value: string) {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
onSelectionChange({ ...selection, custom: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
<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="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>
|
</div>
|
||||||
{payload.context ? (
|
{payload.context ? (
|
||||||
<p className="text-muted-foreground text-sm leading-6">
|
<p className="text-muted-foreground text-sm leading-6">
|
||||||
@@ -513,49 +665,84 @@ function ClarificationCard({
|
|||||||
|
|
||||||
{options.length > 0 ? (
|
{options.length > 0 ? (
|
||||||
<div className="mt-4 grid gap-2">
|
<div className="mt-4 grid gap-2">
|
||||||
{options.map((option) => (
|
<div className="text-muted-foreground text-xs">
|
||||||
<Button
|
可多选(再次点击取消选择)
|
||||||
key={option.id || option.label}
|
</div>
|
||||||
type="button"
|
{options.map((option) => {
|
||||||
variant="outline"
|
const optionId = option.id || option.label;
|
||||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
const isSelected = selection.selectedIds.includes(optionId);
|
||||||
disabled={disabled}
|
return (
|
||||||
onClick={() => void submitAnswer(`我选择:${option.label}`)}
|
<Button
|
||||||
>
|
key={optionId}
|
||||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
type="button"
|
||||||
<span>{option.label}</span>
|
variant={isSelected ? "default" : "outline"}
|
||||||
{option.description ? (
|
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
disabled={disabled}
|
||||||
{option.description}
|
onClick={() => toggleOption(optionId)}
|
||||||
</span>
|
>
|
||||||
|
<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}
|
) : null}
|
||||||
</span>
|
</Button>
|
||||||
</Button>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{payload.allow_custom !== false ? (
|
{payload.allow_custom !== false ? (
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={customAnswer}
|
value={selection.custom}
|
||||||
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
|
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
placeholder={
|
||||||
|
options.length > 0
|
||||||
|
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||||
|
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||||
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="min-h-20 resize-none"
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled || !customAnswer.trim()}
|
disabled={!canSubmit || submitting}
|
||||||
onClick={() => void submitAnswer(customAnswer)}
|
onClick={() => void onSubmitAll()}
|
||||||
>
|
>
|
||||||
<SendIcon className="h-4 w-4" />
|
<SendIcon className="h-4 w-4" />
|
||||||
发送回答
|
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||||
|
选择后等待最后一题统一提交
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { BotIcon, PlusSquare } from "lucide-react";
|
import { BotIcon, PlusSquare } from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
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 type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -36,13 +36,30 @@ export default function AgentChatPage() {
|
|||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [showFollowups, setShowFollowups] = useState(false);
|
const [showFollowups, setShowFollowups] = useState(false);
|
||||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { agent_id } = useParams<{
|
const { agent_id } = useParams<{
|
||||||
agent_id: string;
|
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 { agent } = useAgent(agent_id);
|
||||||
|
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||||
@@ -52,9 +69,10 @@ export default function AgentChatPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
setHasStartedConversation(false);
|
setHasStartedConversation(hasPendingForThisAgent());
|
||||||
}
|
}
|
||||||
}, [isNewThread, threadId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isNewThread, threadId, agent_id]);
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
@@ -100,6 +118,26 @@ export default function AgentChatPage() {
|
|||||||
[sendMessage, threadId, agent_id],
|
[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(
|
const handleClarificationSubmit = useCallback(
|
||||||
(answer: string) => {
|
(answer: string) => {
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
@@ -180,8 +218,8 @@ export default function AgentChatPage() {
|
|||||||
"relative w-full",
|
"relative w-full",
|
||||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||||
isLandingState
|
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">
|
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||||
|
|||||||
@@ -758,11 +758,11 @@ export default function ScheduledTasksPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader className="min-w-0">
|
||||||
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
<Input
|
<Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(event) =>
|
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="选择执行智能体" />
|
<SelectValue placeholder="选择执行智能体" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent
|
||||||
|
position="popper"
|
||||||
|
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||||
|
>
|
||||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<SelectItem key={agent.id} value={agent.id}>
|
<SelectItem key={agent.id} value={agent.id}>
|
||||||
{agent.name}
|
<span className="block truncate">
|
||||||
{agent.description ? ` — ${agent.description}` : ""}
|
{agent.name}
|
||||||
|
{agent.description ? ` — ${agent.description}` : ""}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
+11
@@ -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 skip clarification for "efficiency" - accuracy matters more than speed
|
||||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
||||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
- ❌ 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
|
- ✅ 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
|
- ✅ 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
|
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||||
- ✅ Wait for user response - do NOT continue with assumptions
|
- ✅ 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:**
|
**How to Use:**
|
||||||
```python
|
```python
|
||||||
ask_clarification(
|
ask_clarification(
|
||||||
|
|||||||
+7
-7
@@ -171,21 +171,21 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
|
|
||||||
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||||
seconds = max(1, round(wait_ms / 1000))
|
seconds = max(1, round(wait_ms / 1000))
|
||||||
reason_text = "provider is busy" if reason == "busy" else "provider request failed temporarily"
|
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
|
||||||
return f"LLM request retry {attempt}/{self.retry_max_attempts}: {reason_text}. Retrying in {seconds}s."
|
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
|
||||||
|
|
||||||
def _build_circuit_breaker_message(self) -> str:
|
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:
|
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||||
detail = _extract_error_detail(exc)
|
detail = _extract_error_detail(exc)
|
||||||
if reason == "quota":
|
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":
|
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"}:
|
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 "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
|
||||||
return f"LLM request failed: {detail}"
|
return f"大模型因「{detail}」原因调用失败。"
|
||||||
|
|
||||||
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+1
-1
@@ -713,7 +713,7 @@ class ScheduledTaskService:
|
|||||||
retry_count: int = 0,
|
retry_count: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark a run failed and reflect the outcome on the parent task."""
|
"""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(
|
await self.store.update_run(
|
||||||
run_id,
|
run_id,
|
||||||
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
|
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
|
||||||
|
|||||||
+20
-7
@@ -36,18 +36,31 @@ def ask_clarification_tool(
|
|||||||
- You're about to perform a potentially dangerous operation
|
- You're about to perform a potentially dangerous operation
|
||||||
- You have a recommendation but need user approval
|
- 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:
|
Best practices:
|
||||||
- Ask ONE clarification at a time for clarity
|
- One question per ask_clarification call — keep each `question` focused on a single decision.
|
||||||
- Be specific and clear in your question
|
- Be specific and clear in your question.
|
||||||
- Don't make assumptions when clarification is needed
|
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
|
||||||
- For risky operations, ALWAYS ask for confirmation
|
this lets the user multi-select in the UI instead of typing free text.
|
||||||
- After calling this tool, execution will be interrupted automatically
|
- 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:
|
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).
|
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.
|
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
|
# This is a placeholder implementation
|
||||||
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
|
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
|
||||||
|
|||||||
Reference in new issue
Block a user