智能体对话选 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 { 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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user