KnowledgeBasePage 加载后自动跳转至 /knowledge/:noteId,原精确路径匹配 导致顶栏和侧边栏选中态丢失。将 hasActiveChild / isItemActive / menuItemContainsPath 等 8 处判断统一改为前缀匹配 (pathname === path || pathname.startsWith(path + "/")), 覆盖浅色侧边栏、深色侧边栏、顶部导航栏三套组件。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1131 lines
40 KiB
TypeScript
1131 lines
40 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||
|
||
import {
|
||
Conversation,
|
||
ConversationContent,
|
||
} from "@/components/ai-elements/conversation";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { rememberAskTimes } from "@/core/chat-timestamps";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import {
|
||
extractContentFromMessage,
|
||
extractPresentFilesFromMessage,
|
||
extractTextFromMessage,
|
||
groupMessages,
|
||
hasContent,
|
||
hasPresentFiles,
|
||
hasReasoning,
|
||
hasToolCalls,
|
||
} from "@/core/messages/utils";
|
||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||
import { useSkills } from "@/core/skills/hooks";
|
||
import {
|
||
buildSkillDisplayMap,
|
||
collectRuntimeDisplaySources,
|
||
} from "@/core/skill-display/runtime";
|
||
import type { Subtask } from "@/core/tasks";
|
||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||
import type { AgentThreadState } from "@/core/threads";
|
||
import { cn } from "@/lib/utils";
|
||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||
|
||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||
import { getQuestionAnchorId } from "../question-navigator";
|
||
import { StreamingIndicator } from "../streaming-indicator";
|
||
|
||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||
import { MarkdownContent } from "./markdown-content";
|
||
import { MessageGroup } from "./message-group";
|
||
import { MessageListItem } from "./message-list-item";
|
||
import {
|
||
MessageMetricsProvider,
|
||
MessageTokenUsageList,
|
||
} from "./message-token-usage";
|
||
import { MessageListSkeleton } from "./skeleton";
|
||
import { SubtaskCard } from "./subtask-card";
|
||
|
||
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
|
||
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||
|
||
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
|
||
function ScrollToBottomOnUserMessage({
|
||
threadId,
|
||
messages,
|
||
}: {
|
||
threadId: string;
|
||
messages: Message[];
|
||
}) {
|
||
const { scrollToBottom } = useStickToBottomContext();
|
||
const prevHumanCountRef = useRef<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
prevHumanCountRef.current = null;
|
||
}, [threadId]);
|
||
|
||
useEffect(() => {
|
||
const humanCount = messages.reduce(
|
||
(count, message) => count + (message.type === "human" ? 1 : 0),
|
||
0,
|
||
);
|
||
const prev = prevHumanCountRef.current;
|
||
prevHumanCountRef.current = humanCount;
|
||
if (prev !== null && humanCount > prev) {
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => scrollToBottom());
|
||
});
|
||
}
|
||
}, [messages, scrollToBottom]);
|
||
|
||
return null;
|
||
}
|
||
|
||
export function MessageList({
|
||
className,
|
||
contentClassName,
|
||
fitMessageRowsToContent = false,
|
||
threadId,
|
||
thread,
|
||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||
tokenUsageEnabled = false,
|
||
extraRehypePlugins,
|
||
onClarificationSubmit,
|
||
onEditResubmit,
|
||
onSetupAgentApprove,
|
||
onSetupAgentReject,
|
||
onSetupWritingApprove,
|
||
onSetupWritingReject,
|
||
}: {
|
||
className?: string;
|
||
contentClassName?: string;
|
||
fitMessageRowsToContent?: boolean;
|
||
threadId: string;
|
||
thread: BaseStream<AgentThreadState>;
|
||
paddingBottom?: number;
|
||
tokenUsageEnabled?: boolean;
|
||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||
onEditResubmit?: (text: string) => void;
|
||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||
}) {
|
||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||
const rehypePlugins = useMemo(
|
||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||
[baseRehypePlugins, extraRehypePlugins],
|
||
);
|
||
const updateSubtask = useUpdateSubtask();
|
||
const { t } = useI18n();
|
||
const threadContext = useContext(ThreadContext);
|
||
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||
const allowDelete = !threadContext?.publicShareCode && !threadContext?.isMock;
|
||
|
||
// Locally hide messages the user just deleted. The server-side checkpoint is
|
||
// trimmed immediately, but the live `useStream` cache still holds the message
|
||
// until the next refetch — filtering by id here makes the removal instant and
|
||
// keeps it hidden for the rest of the session (a reload reads the trimmed
|
||
// state). Reset whenever the active thread changes.
|
||
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
||
() => new Set(),
|
||
);
|
||
useEffect(() => {
|
||
setHiddenMessageIds(new Set());
|
||
}, [threadId]);
|
||
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
||
if (deletedIds.length === 0) return;
|
||
setHiddenMessageIds((prev) => {
|
||
const next = new Set(prev);
|
||
for (const id of deletedIds) next.add(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const allMessages = thread.messages;
|
||
const messages = useMemo(
|
||
() =>
|
||
hiddenMessageIds.size === 0
|
||
? allMessages
|
||
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
||
[allMessages, hiddenMessageIds],
|
||
);
|
||
|
||
// Stamp a wall-clock time onto every question/answer the first time it is
|
||
// seen, in a single batched commit. Questions sent this session already carry
|
||
// an accurate time (see useThreadStream); this also covers assistant answers
|
||
// and messages loaded from history. First-write-wins keeps the earliest time.
|
||
useEffect(() => {
|
||
const now = new Date().toISOString();
|
||
const entries: Record<string, string> = {};
|
||
for (const message of messages) {
|
||
if (message.type !== "human" && message.type !== "ai") continue;
|
||
if (!message.id || message.id.startsWith("opt-")) continue;
|
||
if (typeof message.additional_kwargs?.client_ts === "string") continue;
|
||
entries[message.id] = now;
|
||
}
|
||
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
|
||
}, [messages]);
|
||
|
||
const [settings] = useLocalSettings();
|
||
const ragModeEnabled =
|
||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||
const { skills } = useSkills();
|
||
const skillDisplayMap = useMemo(
|
||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||
[ragModeEnabled, skills],
|
||
);
|
||
// Same per-turn reference batches the right-side panel renders (backend
|
||
// batches preferred). Inline [n] tokens must resolve against these so badge
|
||
// numbering matches the panel, and so citations still render when the
|
||
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
||
const referenceBatches = useContext(ReferenceBatchesContext);
|
||
|
||
// 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 (
|
||
selectionOverrides?: Map<string, ClarificationSelection>,
|
||
) => {
|
||
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 = selectionOverrides?.get(id) ?? 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 />;
|
||
}
|
||
return (
|
||
<Conversation
|
||
className={cn("flex size-full flex-col justify-center", className)}
|
||
>
|
||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||
<ConversationContent
|
||
className={cn(
|
||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||
contentClassName,
|
||
)}
|
||
>
|
||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||
{groupMessages(messages, (group) => {
|
||
if (group.type === "human" || group.type === "assistant") {
|
||
return group.messages.map((msg) => {
|
||
const isAssistantMessage =
|
||
ragModeEnabled &&
|
||
group.type === "assistant" &&
|
||
msg.type === "ai";
|
||
const batchSources = isAssistantMessage
|
||
? referenceBatches.find(
|
||
(batch) => batch.assistant_message_id === msg.id,
|
||
)?.sources ?? []
|
||
: [];
|
||
const displaySources = isAssistantMessage
|
||
? batchSources.length > 0
|
||
? batchSources
|
||
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||
beforeMessageId: msg.id,
|
||
includeBeforeMessage: true,
|
||
})
|
||
: [];
|
||
return (
|
||
<MessageListItem
|
||
key={`${group.id}/${msg.id}`}
|
||
fitRowToContent={fitMessageRowsToContent}
|
||
anchorId={
|
||
msg.type === "human"
|
||
? getQuestionAnchorId(msg, messages.indexOf(msg))
|
||
: undefined
|
||
}
|
||
message={msg}
|
||
isLoading={thread.isLoading}
|
||
threadId={threadId}
|
||
tokenUsageEnabled={tokenUsageEnabled}
|
||
displaySources={displaySources}
|
||
extraRehypePlugins={extraRehypePlugins}
|
||
onEditResubmit={onEditResubmit}
|
||
onMessageDeleted={
|
||
allowDelete ? handleMessageDeleted : undefined
|
||
}
|
||
/>
|
||
);
|
||
});
|
||
} 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}
|
||
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={(override) => {
|
||
const overrides = override
|
||
? new Map([[messageId, override]])
|
||
: undefined;
|
||
void handleSubmitAllClarifications(overrides);
|
||
}}
|
||
/>
|
||
<MessageTokenUsageList
|
||
enabled={tokenUsageEnabled}
|
||
isLoading={thread.isLoading}
|
||
messages={group.messages}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
} else if (group.type === "assistant:setup-agent-approval") {
|
||
const message = group.messages[0];
|
||
return message ? (
|
||
<div key={group.id} className="w-full">
|
||
<SetupAgentApprovalCard
|
||
disabled={thread.isLoading}
|
||
message={message}
|
||
onApprove={onSetupAgentApprove}
|
||
onReject={onSetupAgentReject}
|
||
/>
|
||
<MessageTokenUsageList
|
||
enabled={tokenUsageEnabled}
|
||
isLoading={thread.isLoading}
|
||
messages={group.messages}
|
||
/>
|
||
</div>
|
||
) : null;
|
||
} else if (group.type === "assistant:setup-writing-approval") {
|
||
const message = group.messages[0];
|
||
return message ? (
|
||
<div key={group.id} className="w-full">
|
||
<SetupWritingApprovalCard
|
||
disabled={thread.isLoading}
|
||
message={message}
|
||
onApprove={onSetupWritingApprove}
|
||
onReject={onSetupWritingReject}
|
||
/>
|
||
<MessageTokenUsageList
|
||
enabled={tokenUsageEnabled}
|
||
isLoading={thread.isLoading}
|
||
messages={group.messages}
|
||
/>
|
||
</div>
|
||
) : null;
|
||
} else if (group.type === "assistant:present-files") {
|
||
const files: string[] = [];
|
||
for (const message of group.messages) {
|
||
if (hasPresentFiles(message)) {
|
||
const presentFiles = extractPresentFilesFromMessage(message);
|
||
files.push(...presentFiles);
|
||
}
|
||
}
|
||
return (
|
||
<div className="w-full" key={group.id}>
|
||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||
<MarkdownContent
|
||
content={extractContentFromMessage(group.messages[0])}
|
||
isLoading={thread.isLoading}
|
||
rehypePlugins={rehypePlugins}
|
||
className="mb-4"
|
||
/>
|
||
)}
|
||
<ArtifactFileList files={files} threadId={threadId} />
|
||
<MessageTokenUsageList
|
||
enabled={tokenUsageEnabled}
|
||
isLoading={thread.isLoading}
|
||
messages={group.messages}
|
||
/>
|
||
</div>
|
||
);
|
||
} else if (group.type === "assistant:subagent") {
|
||
const tasks = new Set<Subtask>();
|
||
for (const message of group.messages) {
|
||
if (message.type === "ai") {
|
||
for (const toolCall of message.tool_calls ?? []) {
|
||
if (toolCall.name === "task") {
|
||
const task: Subtask = {
|
||
id: toolCall.id!,
|
||
subagent_type: toolCall.args.subagent_type,
|
||
description: toolCall.args.description,
|
||
prompt: toolCall.args.prompt,
|
||
status: "in_progress",
|
||
};
|
||
updateSubtask(task);
|
||
tasks.add(task);
|
||
}
|
||
}
|
||
} else if (message.type === "tool") {
|
||
const taskId = message.tool_call_id;
|
||
if (taskId) {
|
||
const result = extractTextFromMessage(message);
|
||
if (result.startsWith("Task Succeeded. Result:")) {
|
||
updateSubtask({
|
||
id: taskId,
|
||
status: "completed",
|
||
result: result
|
||
.split("Task Succeeded. Result:")[1]
|
||
?.trim(),
|
||
});
|
||
} else if (result.startsWith("Task failed.")) {
|
||
updateSubtask({
|
||
id: taskId,
|
||
status: "failed",
|
||
error: result.split("Task failed.")[1]?.trim(),
|
||
});
|
||
} else if (result.startsWith("Task timed out")) {
|
||
updateSubtask({
|
||
id: taskId,
|
||
status: "failed",
|
||
error: result,
|
||
});
|
||
} else {
|
||
updateSubtask({
|
||
id: taskId,
|
||
status: "in_progress",
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const results: React.ReactNode[] = [];
|
||
for (const message of group.messages.filter(
|
||
(message) => message.type === "ai",
|
||
)) {
|
||
if (hasReasoning(message)) {
|
||
results.push(
|
||
<MessageGroup
|
||
key={"thinking-group-" + message.id}
|
||
messages={[message]}
|
||
isLoading={thread.isLoading}
|
||
/>,
|
||
);
|
||
}
|
||
results.push(
|
||
<div
|
||
key="subtask-count"
|
||
className="text-muted-foreground pt-2 text-sm font-normal"
|
||
>
|
||
{t.subtasks.executing(tasks.size)}
|
||
</div>,
|
||
);
|
||
const taskIds = message.tool_calls
|
||
?.filter((toolCall) => toolCall.name === "task")
|
||
.map((toolCall) => toolCall.id);
|
||
for (const taskId of taskIds ?? []) {
|
||
results.push(
|
||
<SubtaskCard
|
||
key={"task-group-" + taskId}
|
||
taskId={taskId!}
|
||
isLoading={thread.isLoading}
|
||
/>,
|
||
);
|
||
}
|
||
}
|
||
return (
|
||
<div
|
||
key={"subtask-group-" + group.id}
|
||
className="relative z-1 flex flex-col gap-2"
|
||
>
|
||
{results}
|
||
<MessageTokenUsageList
|
||
enabled={tokenUsageEnabled}
|
||
isLoading={thread.isLoading}
|
||
messages={group.messages}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
const tokenUsageMessages = group.messages.filter(
|
||
(message) =>
|
||
message.type === "ai" &&
|
||
(hasToolCalls(message) ? true : !hasContent(message)),
|
||
);
|
||
return (
|
||
<div key={"group-" + group.id} className="w-full">
|
||
<MessageGroup
|
||
messages={group.messages}
|
||
isLoading={thread.isLoading}
|
||
/>
|
||
<MessageTokenUsageList
|
||
enabled={tokenUsageEnabled}
|
||
isLoading={thread.isLoading}
|
||
messages={tokenUsageMessages}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||
<div style={{ height: `${paddingBottom}px` }} />
|
||
</MessageMetricsProvider>
|
||
</ConversationContent>
|
||
</Conversation>
|
||
);
|
||
}
|
||
|
||
type ClarificationOption = {
|
||
id: string;
|
||
label: string;
|
||
description?: string;
|
||
};
|
||
|
||
type ClarificationPayload = {
|
||
question?: string;
|
||
context?: string | null;
|
||
clarification_type?: string;
|
||
options?: ClarificationOption[];
|
||
allow_custom?: boolean;
|
||
allow_multiple?: boolean;
|
||
};
|
||
|
||
type ClarificationSelection = {
|
||
selectedIds: string[];
|
||
custom: string;
|
||
};
|
||
|
||
export type SetupAgentApprovalPayload = {
|
||
name?: string;
|
||
description?: string;
|
||
soul?: string;
|
||
skills?: string[];
|
||
};
|
||
|
||
export type SetupWritingApprovalPayload = {
|
||
user_intent?: string;
|
||
article_type?: string;
|
||
word_count_target?: number | null;
|
||
target_audience?: string;
|
||
writing_mode?: string;
|
||
material_source?: string;
|
||
outline?: string;
|
||
};
|
||
|
||
function findPreviousHumanMessageId(
|
||
messages: Message[],
|
||
beforeMessageId?: string,
|
||
): string | undefined {
|
||
const beforeIndex =
|
||
beforeMessageId === undefined
|
||
? messages.length
|
||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||
for (let index = end - 1; index >= 0; index--) {
|
||
const message = messages[index];
|
||
if (message?.type === "human") {
|
||
return message.id;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function getSetupAgentApprovalPayload(
|
||
message: unknown,
|
||
): SetupAgentApprovalPayload | null {
|
||
if (typeof message !== "object" || message === null) {
|
||
return null;
|
||
}
|
||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||
return null;
|
||
}
|
||
const approval = Reflect.get(additionalKwargs, "setup_agent_approval");
|
||
if (typeof approval !== "object" || approval === null) {
|
||
return null;
|
||
}
|
||
return approval as SetupAgentApprovalPayload;
|
||
}
|
||
|
||
function SetupAgentApprovalCard({
|
||
disabled,
|
||
message,
|
||
onApprove,
|
||
onReject,
|
||
}: {
|
||
disabled: boolean;
|
||
message: unknown;
|
||
onApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||
onReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||
}) {
|
||
const payload = getSetupAgentApprovalPayload(message);
|
||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
|
||
null,
|
||
);
|
||
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
|
||
const approvalPayload = payload;
|
||
const busy = disabled || submitting !== null;
|
||
const skills = approvalPayload.skills ?? [];
|
||
|
||
async function run(action: "approve" | "reject") {
|
||
if (busy) {
|
||
return;
|
||
}
|
||
setSubmitting(action);
|
||
try {
|
||
if (action === "approve") {
|
||
await onApprove?.(approvalPayload);
|
||
} else {
|
||
await onReject?.(approvalPayload);
|
||
}
|
||
} finally {
|
||
setSubmitting(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||
<div className="space-y-3">
|
||
<div>
|
||
<div className="text-muted-foreground text-xs font-medium">
|
||
创建智能体审批
|
||
</div>
|
||
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
|
||
</div>
|
||
|
||
{approvalPayload.name ? (
|
||
<div className="space-y-1">
|
||
<div className="text-muted-foreground text-xs">名称</div>
|
||
<p className="text-sm font-medium">{approvalPayload.name}</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{approvalPayload.description ? (
|
||
<div className="space-y-1">
|
||
<div className="text-muted-foreground text-xs">描述</div>
|
||
<p className="text-sm leading-6">{approvalPayload.description}</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{skills.length > 0 ? (
|
||
<div className="space-y-1">
|
||
<div className="text-muted-foreground text-xs">技能</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{skills.map((skill) => (
|
||
<span
|
||
key={skill}
|
||
className="bg-secondary text-secondary-foreground rounded-md px-2 py-1 text-xs"
|
||
>
|
||
{skill}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{approvalPayload.soul ? (
|
||
<div className="space-y-1">
|
||
<div className="text-muted-foreground text-xs">SOUL.md</div>
|
||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||
{approvalPayload.soul}
|
||
</pre>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={busy || !onReject}
|
||
onClick={() => void run("reject")}
|
||
>
|
||
<XIcon className="h-4 w-4" />
|
||
拒绝
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
disabled={busy || !onApprove}
|
||
onClick={() => void run("approve")}
|
||
>
|
||
<CheckIcon className="h-4 w-4" />
|
||
批准创建
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getSetupWritingApprovalPayload(
|
||
message: unknown,
|
||
): SetupWritingApprovalPayload | null {
|
||
if (typeof message !== "object" || message === null) {
|
||
return null;
|
||
}
|
||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||
return null;
|
||
}
|
||
const approval = Reflect.get(additionalKwargs, "setup_writing_approval");
|
||
if (typeof approval !== "object" || approval === null) {
|
||
return null;
|
||
}
|
||
return approval as SetupWritingApprovalPayload;
|
||
}
|
||
|
||
const WRITING_MODE_LABELS: Record<string, string> = {
|
||
strict: "严格依据素材",
|
||
loose: "可结合通用知识",
|
||
};
|
||
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
|
||
general: "通用检索",
|
||
notebook: "我的空间",
|
||
// 旧值(历史会话回显兼容)
|
||
knowledge_base: "知识库",
|
||
skill: "技能检索",
|
||
};
|
||
|
||
function SetupWritingApprovalCard({
|
||
disabled,
|
||
message,
|
||
onApprove,
|
||
onReject,
|
||
}: {
|
||
disabled: boolean;
|
||
message: unknown;
|
||
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||
}) {
|
||
const payload = getSetupWritingApprovalPayload(message);
|
||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
|
||
null,
|
||
);
|
||
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
|
||
const approvalPayload = payload;
|
||
const busy = disabled || submitting !== null;
|
||
|
||
async function run(action: "approve" | "reject") {
|
||
if (busy) {
|
||
return;
|
||
}
|
||
setSubmitting(action);
|
||
try {
|
||
if (action === "approve") {
|
||
await onApprove?.(approvalPayload);
|
||
} else {
|
||
await onReject?.(approvalPayload);
|
||
}
|
||
} finally {
|
||
setSubmitting(null);
|
||
}
|
||
}
|
||
|
||
const rows: Array<{ label: string; value: string }> = [];
|
||
if (approvalPayload.article_type) {
|
||
rows.push({ label: "文章类型", value: approvalPayload.article_type });
|
||
}
|
||
if (typeof approvalPayload.word_count_target === "number") {
|
||
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
|
||
}
|
||
if (approvalPayload.target_audience) {
|
||
rows.push({ label: "目标读者", value: approvalPayload.target_audience });
|
||
}
|
||
if (approvalPayload.writing_mode) {
|
||
rows.push({
|
||
label: "写作模式",
|
||
value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode,
|
||
});
|
||
}
|
||
if (approvalPayload.material_source) {
|
||
rows.push({
|
||
label: "素材来源",
|
||
value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||
<div className="space-y-3">
|
||
<div>
|
||
<div className="text-muted-foreground text-xs font-medium">写作配置建议</div>
|
||
<div className="text-sm font-medium">确认后将应用到左侧写作表单。</div>
|
||
</div>
|
||
|
||
{approvalPayload.user_intent ? (
|
||
<div className="space-y-1">
|
||
<div className="text-muted-foreground text-xs">写作意图</div>
|
||
<p className="text-sm leading-6">{approvalPayload.user_intent}</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{rows.length > 0 ? (
|
||
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
|
||
{rows.map((row) => (
|
||
<div key={row.label} className="space-y-0.5">
|
||
<div className="text-muted-foreground text-xs">{row.label}</div>
|
||
<p className="text-sm font-medium">{row.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{approvalPayload.outline ? (
|
||
<div className="space-y-1">
|
||
<div className="text-muted-foreground text-xs">已有大纲</div>
|
||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||
{approvalPayload.outline}
|
||
</pre>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={busy || !onReject}
|
||
onClick={() => void run("reject")}
|
||
>
|
||
<XIcon className="h-4 w-4" />
|
||
忽略
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
disabled={busy || !onApprove}
|
||
onClick={() => void run("approve")}
|
||
>
|
||
<CheckIcon className="h-4 w-4" />
|
||
应用到表单
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||
if (typeof message !== "object" || message === null) {
|
||
return null;
|
||
}
|
||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||
return null;
|
||
}
|
||
const clarification = Reflect.get(additionalKwargs, "clarification");
|
||
if (typeof clarification !== "object" || clarification === null) {
|
||
return null;
|
||
}
|
||
return clarification as ClarificationPayload;
|
||
}
|
||
|
||
function ClarificationCard({
|
||
fallbackContent,
|
||
isLoading,
|
||
message,
|
||
rehypePlugins,
|
||
isPending,
|
||
pendingIndex,
|
||
pendingCount,
|
||
isLastPending,
|
||
selection,
|
||
onSelectionChange,
|
||
submitting,
|
||
canSubmit,
|
||
onSubmitAll,
|
||
}: {
|
||
fallbackContent: string;
|
||
isLoading: boolean;
|
||
message: unknown;
|
||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||
isPending: boolean;
|
||
pendingIndex?: number;
|
||
pendingCount?: number;
|
||
isLastPending: boolean;
|
||
selection: ClarificationSelection;
|
||
onSelectionChange: (next: ClarificationSelection) => void;
|
||
submitting: boolean;
|
||
canSubmit: boolean;
|
||
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
|
||
}) {
|
||
const payload = getClarificationPayload(message);
|
||
|
||
const options = useMemo(
|
||
() =>
|
||
(payload?.options ?? []).filter(
|
||
(option) => typeof option.label === "string" && option.label.trim(),
|
||
),
|
||
[payload?.options],
|
||
);
|
||
|
||
if (!payload) {
|
||
return (
|
||
<MarkdownContent
|
||
content={fallbackContent}
|
||
isLoading={isLoading}
|
||
rehypePlugins={rehypePlugins}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const disabled = !isPending || submitting;
|
||
const multiPending = (pendingCount ?? 0) > 1;
|
||
const allowMultiple = resolveAllowMultiple(
|
||
payload.allow_multiple,
|
||
payload.clarification_type,
|
||
);
|
||
|
||
function handleOptionClick(optionId: string) {
|
||
if (disabled) {
|
||
return;
|
||
}
|
||
if (!allowMultiple) {
|
||
// 单选:仅当「只有这一张待答澄清卡」时才点击即发送。多张澄清卡同时出现时,
|
||
// 点击只记录本卡选择,待最后一张的「全部发送」统一提交——否则点了第一张就立刻
|
||
// 提交整批,后面的卡片再也无法选择(见 bug:选了第一个,第二个不让选)。
|
||
const next = { selectedIds: [optionId], custom: selection.custom };
|
||
onSelectionChange(next);
|
||
if (!multiPending) {
|
||
void onSubmitAll(next);
|
||
}
|
||
return;
|
||
}
|
||
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 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">
|
||
{payload.context}
|
||
</p>
|
||
) : null}
|
||
<div className="text-sm leading-6 font-medium">
|
||
{payload.question || fallbackContent}
|
||
</div>
|
||
</div>
|
||
|
||
{options.length > 0 ? (
|
||
<div className="mt-4 grid gap-2">
|
||
<div className="text-muted-foreground text-xs">
|
||
{allowMultiple
|
||
? "可多选(再次点击取消选择)"
|
||
: multiPending
|
||
? "请选择一项"
|
||
: "请选择一项(点击即发送)"}
|
||
</div>
|
||
{options.map((option) => {
|
||
const optionId = option.id || option.label;
|
||
const isSelected = selection.selectedIds.includes(optionId);
|
||
return (
|
||
<Button
|
||
key={optionId}
|
||
type="button"
|
||
variant={isSelected && (allowMultiple || multiPending) ? "default" : "outline"}
|
||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||
disabled={disabled}
|
||
onClick={() => handleOptionClick(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 && (allowMultiple || multiPending) ? (
|
||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||
) : null}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
{payload.allow_custom !== false ? (
|
||
<div className="mt-4 flex flex-col gap-2">
|
||
<Textarea
|
||
value={selection.custom}
|
||
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||
placeholder={
|
||
options.length > 0
|
||
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||
}
|
||
disabled={disabled}
|
||
className="min-h-20 resize-none"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{isPending && (allowMultiple || multiPending || payload.allow_custom !== false) ? (
|
||
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={!canSubmit || submitting}
|
||
onClick={() => void onSubmitAll()}
|
||
>
|
||
<SendIcon className="h-4 w-4" />
|
||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||
</Button>
|
||
</div>
|
||
) : allowMultiple || multiPending ? (
|
||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||
选择后等待最后一题统一提交
|
||
</div>
|
||
) : null
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|