代码提交

This commit is contained in:
fengxiangdebaozi committed 2026-07-23 15:07:23 +08:00
1 parent b60d9ed09e
commit 91c5d43c48
10 files changed
+808 -158

No files matched your search

@@ -135,11 +135,12 @@ export function MessageListItem({
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
turnTime,
}: {
extraRehypePlugins,
onEditResubmit,
onEditSave,
onMessageDeleted,
turnTime,
}: {
className?: string;
fitRowToContent?: boolean;
/**
@@ -151,10 +152,11 @@ export function MessageListItem({
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
onMessageDeleted?: (deletedIds: string[]) => void;
/**
* Durable ask time of the turn this message belongs to (the preceding
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
@@ -162,14 +164,102 @@ export function MessageListItem({
* wall clock at render time and appear to "drift" to now on every reload.
*/
turnTime?: string;
}) {
const isHuman = message.type === 'human';
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
'';
return (
<AIElementMessage
}) {
const { t } = useI18n();
const isHuman = message.type === 'human';
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
'';
const [assistantEditing, setAssistantEditing] = useState(false);
const [assistantDraft, setAssistantDraft] = useState(messageMarkdown);
const [assistantSaving, setAssistantSaving] = useState(false);
useEffect(() => {
if (!assistantEditing) {
setAssistantDraft(messageMarkdown);
}
}, [assistantEditing, messageMarkdown]);
const canSaveAssistantEdit =
!isLoading &&
!isHuman &&
!!onEditSave &&
!!message.id &&
!message.id.startsWith('opt-') &&
!!messageMarkdown;
const confirmAssistantEdit = useCallback(async () => {
const value = assistantDraft.trim();
if (!value || !message.id || !onEditSave || assistantSaving) {
return;
}
setAssistantSaving(true);
try {
await onEditSave(message.id, value);
setAssistantEditing(false);
} catch {
// The page-level save callback owns user-facing error toasts. Keep the
// editor open so the user can adjust and retry.
} finally {
setAssistantSaving(false);
}
}, [assistantDraft, assistantSaving, message.id, onEditSave]);
if (!isHuman && assistantEditing) {
return (
<AIElementMessage
id={anchorId}
className={cn(
'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
anchorId && 'scroll-mt-20',
className,
)}
from="assistant"
>
<AIElementMessageContent className="w-full">
<div className="flex w-full flex-col gap-2 rounded-2xl border border-primary/20 bg-primary/5 p-3">
<Textarea
value={assistantDraft}
autoFocus
onChange={(event) => setAssistantDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
void confirmAssistantEdit();
}
if (event.key === "Escape") {
event.preventDefault();
setAssistantEditing(false);
}
}}
className="min-h-32 resize-y text-[15px] leading-7"
/>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={assistantSaving}
onClick={() => setAssistantEditing(false)}
>
{t.common.cancel}
</Button>
<Button
size="sm"
disabled={!assistantDraft.trim() || assistantSaving}
onClick={() => void confirmAssistantEdit()}
>
{assistantSaving ? "保存中..." : t.common.save}
</Button>
</div>
</div>
</AIElementMessageContent>
</AIElementMessage>
);
}
return (
<AIElementMessage
id={anchorId}
className={cn(
'group/conversation-message relative',
@@ -187,11 +277,12 @@ export function MessageListItem({
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={onMessageDeleted}
turnTime={turnTime}
/>
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={isLoading ? undefined : onEditSave}
onMessageDeleted={onMessageDeleted}
turnTime={turnTime}
/>
{/*
Human messages carry their own inline copy/edit toolbar inside
MessageContent (above the timestamp). Only assistant answers use the
@@ -206,15 +297,28 @@ export function MessageListItem({
>
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
)}
</div>
</MessageToolbar>
)}
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
)}
{canSaveAssistantEdit && (
<Tooltip content="编辑消息">
<Button
size="icon-sm"
type="button"
variant="ghost"
className="size-6"
onClick={() => setAssistantEditing(true)}
>
<PencilIcon size={11} />
</Button>
</Tooltip>
)}
</div>
</MessageToolbar>
)}
</AIElementMessage>
);
}
@@ -228,26 +332,31 @@ export function MessageListItem({
* the question; confirming sends the edited text as a brand-new turn (the
* original question stays in the thread).
*/
function HumanMessageBlock({
text,
pageContext,
filesList,
messageTime,
components,
onEditResubmit,
deleteButton,
}: {
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
deleteButton?: React.ReactNode;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(text);
function HumanMessageBlock({
messageId,
text,
pageContext,
filesList,
messageTime,
components,
onEditResubmit,
onEditSave,
deleteButton,
}: {
messageId?: string;
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
deleteButton?: React.ReactNode;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(text);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!editing) {
@@ -255,14 +364,27 @@ function HumanMessageBlock({
}
}, [text, editing]);
const confirmEdit = useCallback(() => {
const value = draft.trim();
if (!value) {
return;
}
onEditResubmit?.(value);
setEditing(false);
}, [draft, onEditResubmit]);
const confirmEdit = useCallback(async () => {
const value = draft.trim();
if (!value || saving) {
return;
}
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
setSaving(true);
try {
await onEditSave(messageId, value);
setEditing(false);
} catch {
// The page-level save callback owns user-facing error toasts. Keep the
// editor open so the user can adjust and retry.
} finally {
setSaving(false);
}
return;
}
onEditResubmit?.(value);
setEditing(false);
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
if (editing) {
return (
@@ -275,10 +397,10 @@ function HumanMessageBlock({
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
confirmEdit();
}
if (event.key === "Escape") {
event.preventDefault();
void confirmEdit();
}
if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
@@ -286,17 +408,22 @@ function HumanMessageBlock({
/>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing(false)}
>
{t.common.cancel}
</Button>
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
{t.common.confirm}
</Button>
</div>
</div>
size="sm"
variant="ghost"
disabled={saving}
onClick={() => setEditing(false)}
>
{t.common.cancel}
</Button>
<Button
size="sm"
onClick={() => void confirmEdit()}
disabled={!draft.trim() || saving}
>
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
</Button>
</div>
</div>
);
}
@@ -324,11 +451,11 @@ function HumanMessageBlock({
{(text || deleteButton) && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
{text && <CopyButton clipboardData={text} className="size-6" />}
{text && onEditResubmit && (
<Tooltip content={t.inputBox.editQuestion}>
<Button
size="icon-sm"
type="button"
{text && (onEditResubmit || onEditSave) && (
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
<Button
size="icon-sm"
type="button"
variant="ghost"
className="size-6"
onClick={() => setEditing(true)}
@@ -396,22 +523,24 @@ function MessageContent_({
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
turnTime,
}: {
extraRehypePlugins,
onEditResubmit,
onEditSave,
onMessageDeleted,
turnTime,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
turnTime?: string;
}) {
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
onMessageDeleted?: (deletedIds: string[]) => void;
turnTime?: string;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
// Streamdown splits content into per-block processors; calling the factory
@@ -555,14 +684,16 @@ function MessageContent_({
const canDelete =
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
return (
<HumanMessageBlock
text={parsedHumanContent.content}
pageContext={parsedHumanContent.pageContext}
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
deleteButton={
<HumanMessageBlock
messageId={message.id}
text={parsedHumanContent.content}
pageContext={parsedHumanContent.pageContext}
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
deleteButton={
canDelete ? (
<DeleteMessageButton
threadId={threadId}
@@ -97,10 +97,13 @@ export function MessageList({
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit,
onEditResubmit,
onSetupAgentApprove,
onSetupAgentReject,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
externallyHiddenMessageIds,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
@@ -116,9 +119,12 @@ export function MessageList({
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
messageContentOverrides?: Record<string, string>;
externallyHiddenMessageIds?: Set<string>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
@@ -157,17 +163,33 @@ export function MessageList({
});
}, []);
const allMessages = useMemo(
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
[thread.messages],
);
const messages = useMemo(
() =>
hiddenMessageIds.size === 0
? allMessages
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
[allMessages, hiddenMessageIds],
);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
? allMessages
: allMessages.filter(
(m) =>
!m.id ||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const askTimes = useChatAskTimes();
@@ -382,10 +404,11 @@ export function MessageList({
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
+88 -8
View File
@@ -1363,8 +1363,8 @@ export function useDeleteThread() {
* `MessageList`) so the removal shows immediately; a reload then reads the
* persisted, already-trimmed state.
*/
export function useDeleteMessage() {
return useMutation({
export function useDeleteMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
@@ -1394,12 +1394,92 @@ export function useDeleteMessage() {
// Fall back to the requested id so the caller can always hide something.
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
});
}
/**
* Clear all messages from a thread checkpoint. The caller should immediately
* hide the returned ids locally because the active stream cache may still hold
* the old message objects until a reload/refetch reads the trimmed checkpoint.
*/
export function useClearThreadMessages() {
return useMutation({
mutationFn: async ({
threadId,
}: {
threadId: string;
}): Promise<string[]> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages`,
{
method: "DELETE",
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "清空对话失败。" }));
throw new Error(error.detail ?? "清空对话失败。");
}
const body = (await response.json().catch(() => ({}))) as {
deleted_ids?: string[];
};
return body.deleted_ids ?? [];
},
});
}
/**
* Persist an in-place message edit in the latest thread checkpoint.
*
* This is opt-in UI behaviour: most chat pages keep their existing
* "edit question and resubmit" affordance, while position roundtable can pass
* this mutation down to save the original message content itself.
*/
export function useEditMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
content,
}: {
threadId: string;
messageId: string;
content: string;
}): Promise<Message> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages/${encodeURIComponent(messageId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "消息保存失败。" }));
throw new Error(error.detail ?? "消息保存失败。");
}
const body = (await response.json().catch(() => ({}))) as {
message?: Message;
};
return body.message ?? ({ id: messageId, type: "human", content } as Message);
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
* single user, so these live directly on the thread metadata (no join table).
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
@@ -1,14 +1,15 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target } from "lucide-react";
import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton } from "tdesign-react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { useClearThreadMessages, useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
@@ -234,6 +235,10 @@ export function PositionBuiltinAgentChat({
useState<PendingBuiltinSubmission | null>(null);
const pendingSubmissionRef = useRef(false);
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
const clearThreadMessages = useClearThreadMessages();
const [externallyHiddenMessageIds, setExternallyHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
setAssistantId(fallbackAgentId);
@@ -244,6 +249,7 @@ export function PositionBuiltinAgentChat({
pendingSubmissionRef.current = false;
setPendingSubmission(null);
handledAutoStartRequestRef.current = undefined;
setExternallyHiddenMessageIds(new Set());
}, [fallbackAgentId, kind, persistedThreadId, session?.id]);
const canWrite =
@@ -479,6 +485,31 @@ export function PositionBuiltinAgentChat({
[activeThreadId, onPreviewArtifact],
);
const handleClearConversation = useCallback(async () => {
const threadId = boundThreadIdRef.current;
if (!threadId) {
toast.info("当前智能体还没有可清空的对话");
return;
}
if (thread.isLoading || isSubmitting) {
toast.info("智能体正在生成,完成后再清空对话");
return;
}
const confirmed = window.confirm("确定清空当前智能体对话吗?清空后不可恢复。");
if (!confirmed) return;
const fallbackIds = thread.messages
.map((message) => message.id)
.filter((id): id is string => Boolean(id));
try {
const deletedIds = await clearThreadMessages.mutateAsync({ threadId });
setExternallyHiddenMessageIds(new Set(deletedIds.length ? deletedIds : fallbackIds));
toast.success("对话已清空");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清空对话失败");
}
}, [clearThreadMessages, isSubmitting, thread.isLoading, thread.messages]);
useEffect(() => {
if (
!autoStartRequestKey ||
@@ -534,6 +565,16 @@ export function PositionBuiltinAgentChat({
onSelect={onSelectBuiltinAgent}
/>
)}
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Trash2 className="!size-4 !shrink-0" />}
disabled={!boundThreadId || thread.isLoading || isSubmitting || clearThreadMessages.isPending}
onClick={() => void handleClearConversation()}
title="清空当前智能体对话"
/>
</header>
{persistedArtifacts.length > 0 && (
@@ -580,6 +621,7 @@ export function PositionBuiltinAgentChat({
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
externallyHiddenMessageIds={externallyHiddenMessageIds}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
@@ -11,10 +11,22 @@ import {
Sparkles,
X,
} from "lucide-react";
import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
interface PositionIntentModelOption {
label: string;
value: string;
}
interface PositionIntentInputProps extends Step1InputAreaProps {
modelOptions?: PositionIntentModelOption[];
selectedModel?: string;
onSelectedModelChange?: (model: string) => void;
}
/** Keep attachment metadata presentation local to this position-only composer. */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -49,7 +61,10 @@ export function PositionIntentInput({
hideEnterRoundtable,
onManualSave,
onTriggerToast,
}: Step1InputAreaProps) {
modelOptions = [],
selectedModel,
onSelectedModelChange,
}: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not
// accidentally create a second attachment state.
@@ -162,6 +177,24 @@ export function PositionIntentInput({
</button>
</div>
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{modelOptions.length > 0 && (
<div
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white pl-2 text-xs font-bold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7]"
title="切换意图理解模型"
>
<Sparkles className="size-3.5 shrink-0 text-primary dark:text-[#73c8ff]" />
<TSelect
size="small"
aria-label="切换意图理解模型"
value={selectedModel}
options={modelOptions}
disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))}
className="w-[150px] shrink-0 [&_.t-input]:!h-8 [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!shadow-none dark:[&_.t-input]:!text-[#dceaff]"
popupProps={{ overlayClassName: "z-[80]" }}
/>
</div>
)}
<button
id="send-message-btn"
type="button"
@@ -1,6 +1,6 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, FileText, LockKeyhole, Settings } from "lucide-react";
import { Bot, FileText, LockKeyhole, Settings, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton, Select as TSelect } from "tdesign-react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -8,7 +8,7 @@ import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { useClearThreadMessages, useEditMessage, useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
@@ -221,6 +221,12 @@ export function PositionNodeChat({
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const clearThreadMessages = useClearThreadMessages();
const editMessage = useEditMessage();
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
const [externallyHiddenMessageIds, setExternallyHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite =
sessionStatus !== "archived" &&
@@ -232,7 +238,9 @@ export function PositionNodeChat({
useEffect(() => {
setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId;
}, [node.threadId]);
setMessageContentOverrides({});
setExternallyHiddenMessageIds(new Set());
}, [node.nodeKey, node.threadId]);
const bindCreatedThread = useCallback(
(threadId: string) => {
@@ -313,6 +321,15 @@ export function PositionNodeChat({
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const latestAiMessageId = useMemo(() => {
for (let index = thread.messages.length - 1; index >= 0; index -= 1) {
const message = thread.messages[index];
if (message?.type === "ai" && message.id && (textOfMessage(message) ?? "").trim()) {
return message.id;
}
}
return null;
}, [thread.messages]);
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
@@ -324,6 +341,78 @@ export function PositionNodeChat({
},
[activeThreadId, onPreviewArtifact],
);
const handleSaveMessageEdit = useCallback(
async (messageId: string, text: string) => {
const threadId = boundThreadIdRef.current;
const content = text.trim();
if (!threadId || !content) return;
try {
const updatedMessage = await editMessage.mutateAsync({
threadId,
messageId,
content,
});
setMessageContentOverrides((prev) => ({
...prev,
[messageId]: textOfMessage(updatedMessage) ?? content,
}));
if (updatedMessage.type === "ai" && messageId === latestAiMessageId) {
try {
const updatedNode = await completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer: content,
artifactManifest: node.artifactManifest,
upstreamRevision: node.upstreamRevision,
});
onNodeUpdate(updatedNode);
} catch (syncError) {
toast.warning(
`消息已保存,但节点交接摘要同步失败:${
syncError instanceof Error ? syncError.message : String(syncError)
}`,
);
}
}
toast.success("消息已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "消息保存失败");
throw error;
}
},
[
editMessage,
latestAiMessageId,
node.artifactManifest,
node.nodeKey,
node.upstreamRevision,
onNodeUpdate,
sessionId,
],
);
const handleClearConversation = useCallback(async () => {
const threadId = boundThreadIdRef.current;
if (!threadId) {
toast.info("当前智能体还没有可清空的对话");
return;
}
if (thread.isLoading) {
toast.info("智能体正在生成,完成后再清空对话");
return;
}
const confirmed = window.confirm("确定清空当前智能体对话吗?清空后不可恢复。");
if (!confirmed) return;
const fallbackIds = thread.messages
.map((message) => message.id)
.filter((id): id is string => Boolean(id));
try {
const deletedIds = await clearThreadMessages.mutateAsync({ threadId });
setExternallyHiddenMessageIds(new Set(deletedIds.length ? deletedIds : fallbackIds));
setMessageContentOverrides({});
toast.success("对话已清空");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清空对话失败");
}
}, [clearThreadMessages, thread.isLoading, thread.messages]);
const hasMultipleSwitchOptions = switchOptions.length > 1;
const switchSelectOptions = useMemo(
() =>
@@ -504,6 +593,16 @@ export function PositionNodeChat({
onSelect={onSelectBuiltinAgent}
/>
)}
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Trash2 className="!size-4 !shrink-0" />}
disabled={!boundThreadId || thread.isLoading || clearThreadMessages.isPending}
onClick={() => void handleClearConversation()}
title="清空当前智能体对话"
/>
{onOpenConfiguration && (
<TButton
size="small"
@@ -560,6 +659,9 @@ export function PositionNodeChat({
paddingBottom={24}
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onEditSave={handleSaveMessageEdit}
messageContentOverrides={messageContentOverrides}
externallyHiddenMessageIds={externallyHiddenMessageIds}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
@@ -26,6 +26,7 @@ import {
Settings,
Sparkles,
Target,
Trash2,
UserRoundCog,
XCircle,
type LucideIcon,
@@ -66,6 +67,7 @@ import { useAgent } from "@/core/agents";
import { loadArtifactContent } from "@/core/artifacts/loader";
import type { Agent } from "@/core/agents";
import { useModels } from "@/core/models/hooks";
import { useClearThreadMessages } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
@@ -616,7 +618,23 @@ export default function PositionRoundtablePage({
);
const { models } = useModels();
const selectedModel = models[0]?.name ?? "";
const [selectedModel, setSelectedModel] = useState("");
useEffect(() => {
if (models.length === 0) return;
const stillExists = models.some((model) => model.name === selectedModel);
if (!selectedModel || !stillExists) {
setSelectedModel(models[0]?.name ?? "");
}
}, [models, selectedModel]);
const intentModelOptions = useMemo(
() =>
models.map((model) => ({
label: model.display_name || model.name,
value: model.name,
})),
[models],
);
const clearIntentThreadMessages = useClearThreadMessages();
const triggerToast = useCallback((message: string) => toast.info(message), []);
const intent = useStep1Intent({
// 全岗位共用原会商的对话接口与输入面板;岗位运行编排后续再按角色接入。
@@ -633,6 +651,26 @@ export default function PositionRoundtablePage({
const persistedIntentRef = useRef<string | null>(null);
const isArchivedSession = positionSession?.status === "archived";
const handleClearIntentConversation = useCallback(async () => {
if (intent.isIntentSending) {
toast.info("意图理解正在生成,完成后再清空对话");
return;
}
const confirmed = window.confirm("确定清空意图理解对话吗?清空后不可恢复。");
if (!confirmed) return;
try {
if (intent.intentThreadId) {
await clearIntentThreadMessages.mutateAsync({
threadId: intent.intentThreadId,
});
}
intent.clearInteractiveMessages();
toast.success("意图理解对话已清空");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清空意图理解对话失败");
}
}, [clearIntentThreadMessages, intent]);
const syncSessionUrl = useCallback(
(sessionId: string, nextTaskId: string) => {
const next = new URLSearchParams(searchParamsString);
@@ -1491,6 +1529,18 @@ export default function PositionRoundtablePage({
</div>
</div>
}
headerRightSlot={
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Trash2 className="!size-4 !shrink-0" />}
disabled={intent.isIntentSending || clearIntentThreadMessages.isPending}
onClick={() => void handleClearIntentConversation()}
title="清空意图理解对话"
/>
}
interactiveMessages={intent.interactiveMessages}
intentThreadId={intent.intentThreadId}
intentReady={chainStarted ? frozenIntent ?? intent.intentReady : intent.intentReady}
@@ -1538,7 +1588,14 @@ export default function PositionRoundtablePage({
</button>
) : undefined
}
inputArea={(inputProps) => <PositionIntentInput {...inputProps} />}
inputArea={(inputProps) => (
<PositionIntentInput
{...inputProps}
modelOptions={intentModelOptions}
selectedModel={selectedModel}
onSelectedModelChange={setSelectedModel}
/>
)}
onManualSave={() => undefined}
onTriggerToast={triggerToast}
submitIntentClarifications={intent.submitIntentClarifications}
@@ -1747,7 +1804,7 @@ export default function PositionRoundtablePage({
onClick={() => {
setLeftPanelCollapsed(false);
}}
className="absolute left-3 top-3 z-20 flex h-10 w-[30px] items-center justify-center rounded-r-[4px] border border-l-0 border-[#2b8ffe]/60 bg-[#032650]/95 shadow-[4px_0_12px_rgba(0,0,0,0.18)] transition-colors hover:bg-[#0b386d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#45c3f3]/70"
className="absolute left-3 top-3 z-20 flex h-10 w-[30px] items-center justify-center rounded-r-[4px] border border-l-0 border-primary/25 bg-white/95 text-primary shadow-[4px_0_12px_rgba(15,23,42,0.08)] transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/25 dark:border-[#2b8ffe]/60 dark:bg-[#032650]/95 dark:shadow-[4px_0_12px_rgba(0,0,0,0.18)] dark:hover:bg-[#0b386d] dark:focus-visible:ring-[#45c3f3]/70"
>
<img src={expandLogo} alt="" className="size-4" />
</button>
@@ -1892,6 +1949,7 @@ export default function PositionRoundtablePage({
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
onClose={closeSandbox}
allowVirtualMarkdownEdit
disableDesensitize
hideFileName
/>
@@ -2795,7 +2853,7 @@ function HoverEditButton({
aria-label={title}
onClick={onClick}
className={cn(
"pointer-events-none flex size-7 shrink-0 items-center justify-center rounded-[4px] text-[#45c3f3] opacity-0 transition-[opacity,color,background-color] duration-150 hover:bg-[#45c3f3]/10 hover:text-[#a2e1f9] focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#45c3f3]/65 motion-reduce:transition-none",
"pointer-events-none flex size-7 shrink-0 items-center justify-center rounded-[4px] text-primary opacity-0 transition-[opacity,color,background-color] duration-150 hover:bg-primary/10 hover:text-primary focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/25 motion-reduce:transition-none dark:text-[#45c3f3] dark:hover:bg-[#45c3f3]/10 dark:hover:text-[#a2e1f9] dark:focus-visible:ring-[#45c3f3]/65",
revealClassName,
)}
>
@@ -2834,19 +2892,19 @@ function TaskAnalysisPanel({
className,
)}
>
<div className="relative flex h-10 items-center border-b border-slate-200 !bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] px-4 dark:border-b-[#2b8ffe]/60 dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<div className="relative flex h-10 items-center border-b border-rose-100 bg-[linear-gradient(89.65deg,rgba(245,34,45,0.10)_0.03%,rgba(245,34,45,0)_99.97%)] px-4 dark:!bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] dark:border-b-[#2b8ffe]/60 dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<span
aria-hidden="true"
className="mr-3 h-5 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
className="mr-3 h-5 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<h2 className="text-base font-[550] tracking-wide text-white">任务分析</h2>
<h2 className="text-base font-[550] tracking-wide text-slate-900 dark:text-white">任务分析</h2>
<button
type="button"
title={collapsed ? "展开任务分析" : "收起任务分析"}
aria-label={collapsed ? "展开任务分析" : "收起任务分析"}
aria-expanded={!collapsed}
onClick={() => onCollapsedChange(!collapsed)}
className="relative z-10 ml-auto flex size-7 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-[#45c3f3]/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#45c3f3]/65"
className="relative z-10 ml-auto flex size-7 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/25 dark:hover:bg-[#45c3f3]/10 dark:focus-visible:ring-[#45c3f3]/65"
>
<img src={expandLogo} alt="" className="size-4" />
</button>
@@ -2867,7 +2925,7 @@ function TaskAnalysisPanel({
)}
>
<div className={cn("space-y-4 p-4", children && "shrink-0")}>
<article className="group/task-details relative overflow-hidden rounded-[4px] bg-[rgba(0,98,217,0.1)] px-4 py-5">
<article className="group/task-details relative overflow-hidden rounded-[4px] border border-rose-100 bg-rose-50/70 px-4 py-5 dark:border-transparent dark:bg-[rgba(0,98,217,0.1)]">
{taskEditable && onEditTask && (
<div className="absolute right-2 top-2">
<HoverEditButton
@@ -2879,24 +2937,24 @@ function TaskAnalysisPanel({
)}
<h3
title={desensitize(task.title)}
className="pr-8 text-xl font-[550] leading-8 text-white"
className="pr-8 text-xl font-[550] leading-8 text-slate-900 dark:text-white"
>
<span className="line-clamp-1">{desensitize(task.title)}</span>
</h3>
<p
title={desensitize(task.description)}
className="mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-white"
className="mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-slate-700 dark:text-white"
>
{desensitize(task.description)}
</p>
<div className="mt-4 space-y-2 border-t border-[rgba(85,166,254,0.16)] pt-3 text-sm text-slate-600 dark:text-[#c5d7ee]">
<div className="mt-4 space-y-2 border-t border-rose-100 pt-3 text-sm text-slate-600 dark:border-[rgba(85,166,254,0.16)] dark:text-[#c5d7ee]">
<p className="flex min-w-0 items-center gap-2">
<FileText className="size-4 shrink-0 text-[#45c3f3]" strokeWidth={1.8} />
<FileText className="size-4 shrink-0 text-primary dark:text-[#45c3f3]" strokeWidth={1.8} />
<span className="shrink-0 text-slate-500 dark:text-[#9ebfe2]">任务来源:</span>
<span className="truncate">{desensitize(task.source)}</span>
</p>
<p className="flex min-w-0 items-center gap-2">
<Clock3 className="size-4 shrink-0 text-[#45c3f3]" strokeWidth={1.8} />
<Clock3 className="size-4 shrink-0 text-primary dark:text-[#45c3f3]" strokeWidth={1.8} />
<span className="shrink-0 text-slate-500 dark:text-[#9ebfe2]">下发时间:</span>
<span className="truncate">{task.issuedAt || "未设置"}</span>
</p>
@@ -2907,7 +2965,7 @@ function TaskAnalysisPanel({
<div className="relative flex h-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
<span
aria-hidden="true"
className="mr-3 h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
className="mr-3 h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<h3 className="text-lg font-[550] tracking-wide text-slate-900 dark:text-white">意图分析</h3>
{intentEditable && onEditIntent && (
@@ -2982,10 +3040,10 @@ function TaskInformationCard({
}) {
return (
<section className="group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(43,143,254,0.6)] dark:bg-[#032650] dark:shadow-none">
<div className="relative flex h-10 items-center border-b border-slate-200 !bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] px-4 dark:border-b-[#2b8ffe]/60 dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<div className="relative flex h-10 items-center border-b border-rose-100 bg-[linear-gradient(89.65deg,rgba(245,34,45,0.10)_0.03%,rgba(245,34,45,0)_99.97%)] px-4 [&_h2]:!text-slate-900 dark:!bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] dark:border-b-[#2b8ffe]/60 dark:[&_h2]:!text-white dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<span
aria-hidden="true"
className="mr-3 h-5 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
className="mr-3 h-5 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<h2 className="text-base font-[550] tracking-wide text-white">任务信息</h2>
<div className="relative z-10 ml-auto flex items-center gap-2">
@@ -2996,7 +3054,7 @@ function TaskInformationCard({
aria-label={collapsed ? "展开任务信息" : "收起任务信息"}
aria-expanded={!collapsed}
onClick={() => onCollapsedChange(!collapsed)}
className="flex size-7 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-[#45c3f3]/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#45c3f3]/65"
className="flex size-7 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/25 dark:hover:bg-[#45c3f3]/10 dark:focus-visible:ring-[#45c3f3]/65"
>
<img src={expandLogo} alt="" className="size-4" />
</button>
@@ -3141,7 +3199,7 @@ function BusinessChainSelector({
<div className="relative flex h-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
<span
aria-hidden="true"
className="mr-3 h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
className="mr-3 h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<h3 className="text-lg font-[550] tracking-wide text-slate-900 dark:text-white">业务链</h3>
<span className="ml-auto flex size-6 items-center justify-center rounded-[4px] bg-slate-100 text-xs font-semibold tabular-nums text-slate-500 dark:bg-[#0b386d] dark:text-[#83c1ff]">
@@ -3323,10 +3381,10 @@ function IntentUnderstandingCard({
collapsed ? "flex-none" : "flex-1",
)}
>
<div className="relative flex h-10 shrink-0 items-center border-b border-slate-200 !bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] px-4 dark:border-b-[#2b8ffe]/60 dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<div className="relative flex h-10 shrink-0 items-center border-b border-rose-100 bg-[linear-gradient(89.65deg,rgba(245,34,45,0.10)_0.03%,rgba(245,34,45,0)_99.97%)] px-4 [&_h2]:!text-slate-900 dark:!bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] dark:border-b-[#2b8ffe]/60 dark:[&_h2]:!text-white dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<span
aria-hidden="true"
className="mr-3 h-5 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
className="mr-3 h-5 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<h2 className="text-base font-[550] tracking-wide text-white">意图理解</h2>
<div className="relative z-10 ml-auto flex items-center gap-2">
@@ -3337,7 +3395,7 @@ function IntentUnderstandingCard({
aria-label={collapsed ? "展开意图理解" : "收起意图理解"}
aria-expanded={!collapsed}
onClick={() => onCollapsedChange(!collapsed)}
className="flex size-7 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-[#45c3f3]/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#45c3f3]/65"
className="flex size-7 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/25 dark:hover:bg-[#45c3f3]/10 dark:focus-visible:ring-[#45c3f3]/65"
>
<img src={expandLogo} alt="" className="size-4" />
</button>
@@ -3521,7 +3579,7 @@ function BusinessChainFlow({
{embedded ? (
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
@@ -149,6 +149,7 @@ export interface UseStep1IntentReturn {
hydrateFromDraft: (snapshot: Step1Snapshot) => void;
/** 给「跳过此项」按钮 / 「继续追问」按钮等本地 demo 操作用。 */
appendLocalMessage: (msg: InteractiveMessage) => void;
clearInteractiveMessages: () => void;
}
const INTENT_WELCOME_TEXT =
@@ -662,6 +663,12 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setInteractiveMessages((prev) => [...prev, msg]);
}, []);
const clearInteractiveMessages = useCallback(() => {
setInteractiveMessages([]);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
}, []);
const updateIntentReady = useCallback((next: IntentSummary) => {
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
setIntentReady(next);
@@ -687,5 +694,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
stopIntentStream,
hydrateFromDraft,
appendLocalMessage,
clearInteractiveMessages,
};
}
+10 -1
View File
@@ -602,7 +602,7 @@
/* 岗位会商左侧业务链:列表较窄,避免滚动条抢占卡片内容宽度。 */
.business-chain-scrollbar {
scrollbar-width: thin;
scrollbar-color: #2b8ffe transparent;
scrollbar-color: rgb(214 36 36 / 0.9) transparent;
}
.business-chain-scrollbar::-webkit-scrollbar {
@@ -612,6 +612,15 @@
.business-chain-scrollbar::-webkit-scrollbar-thumb {
border: 0;
background: linear-gradient(180deg, rgb(241 89 89 / 0.82), rgb(214 36 36 / 0.9))
padding-box;
}
.dark .business-chain-scrollbar {
scrollbar-color: #2b8ffe transparent;
}
.dark .business-chain-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgb(85 166 254 / 0.82), rgb(0 98 217 / 0.9))
padding-box;
}
@@ -1064,6 +1064,19 @@ class AppendMessageRequest(BaseModel):
type: str = Field(default="human", description="Message role: human/ai/system")
class EditMessageRequest(BaseModel):
"""Request body for editing one persisted chat message."""
content: str = Field(..., description="Replacement message text")
@field_validator("content")
@classmethod
def _content_not_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("Message content cannot be empty")
return value
def _flatten_msg_content(content: Any) -> str:
"""把 message.content(str / 结构化 block 列表)拍平成纯文本。"""
if isinstance(content, str):
@@ -1079,6 +1092,17 @@ def _flatten_msg_content(content: Any) -> str:
return str(content) if content else ""
def _set_message_content(message: Any, content: str) -> None:
"""Replace a message's visible content while preserving id/metadata/files."""
if isinstance(message, dict):
message["content"] = content
return
try:
setattr(message, "content", content)
except Exception as exc:
raise TypeError("Message content cannot be edited") from exc
def _extract_last_ai_text(messages: list[Any]) -> str:
"""返回最后一条 AI 消息的纯文本;没有则空串。兼容 message 对象与序列化 dict 两种存储形态。"""
for msg in reversed(messages or []):
@@ -1176,6 +1200,64 @@ async def get_thread_last_ai_message(thread_id: str, request: Request, max_chars
return {"delivered": full_len > 0, "content": text, "length": full_len}
@router.delete("/{thread_id}/messages")
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def clear_thread_messages(thread_id: str, request: Request) -> dict[str, Any]:
"""Clear all messages from a thread's latest checkpoint."""
checkpointer = get_checkpointer(request)
config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to get state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread state")
if checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {})
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}))
messages = channel_values.get("messages")
if not isinstance(messages, list):
messages = []
deleted_ids = [mid for m in messages if (mid := _message_id(m))]
channel_values["messages"] = []
checkpoint["channel_values"] = channel_values
checkpoint["id"] = str(uuid6(clock_seq=-2))
checkpoint["ts"] = now_iso()
versions = dict(checkpoint.get("channel_versions", {}))
new_ver = checkpointer.get_next_version(versions.get("messages"), None)
versions["messages"] = new_ver
checkpoint["channel_versions"] = versions
metadata["updated_at"] = now_iso()
try:
await checkpointer.aput(config, checkpoint, metadata, {"messages": new_ver})
except Exception:
logger.exception("Failed to clear messages from thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to clear thread messages")
logger.info(
"Cleared %d message(s) from thread %s",
len(messages),
sanitize_log_param(thread_id),
)
return {
"ok": True,
"deleted_ids": deleted_ids,
"deleted_count": len(messages),
"remaining": 0,
}
@router.delete("/{thread_id}/messages/{message_id}")
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def delete_thread_message(thread_id: str, message_id: str, request: Request) -> dict[str, Any]:
@@ -1266,6 +1348,88 @@ async def delete_thread_message(thread_id: str, message_id: str, request: Reques
return {"ok": True, "deleted_ids": deleted_ids, "remaining": len(filtered)}
@router.patch("/{thread_id}/messages/{message_id}")
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def edit_thread_message(
thread_id: str,
message_id: str,
body: EditMessageRequest,
request: Request,
) -> dict[str, Any]:
"""Edit one message in a thread's latest checkpoint.
This is intentionally narrower than ``POST /state``: callers send only the
target id and replacement text, while the server preserves all other
message fields (attachments, timestamps, tool metadata, ids) and bumps the
``messages`` channel version so the edited checkpoint is durable.
"""
checkpointer = get_checkpointer(request)
config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to get state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread state")
if checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {})
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}))
messages = list(channel_values.get("messages") or [])
if not messages:
raise HTTPException(status_code=404, detail="Thread has no messages")
target_index = next(
(i for i, m in enumerate(messages) if _message_id(m) == message_id),
None,
)
if target_index is None:
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
try:
_set_message_content(messages[target_index], body.content)
except TypeError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
channel_values["messages"] = messages
checkpoint["channel_values"] = channel_values
checkpoint["id"] = str(uuid6(clock_seq=-2))
checkpoint["ts"] = now_iso()
versions = dict(checkpoint.get("channel_versions", {}))
new_ver = checkpointer.get_next_version(versions.get("messages"), None)
versions["messages"] = new_ver
checkpoint["channel_versions"] = versions
metadata["updated_at"] = now_iso()
try:
await checkpointer.aput(config, checkpoint, metadata, {"messages": new_ver})
except Exception:
logger.exception(
"Failed to edit message %s from thread %s",
sanitize_log_param(message_id),
sanitize_log_param(thread_id),
)
raise HTTPException(status_code=500, detail="Failed to edit message")
serialized_messages = serialize_channel_values({"messages": [messages[target_index]]}).get("messages", [])
logger.info(
"Edited message %s in thread %s",
sanitize_log_param(message_id),
sanitize_log_param(thread_id),
)
return {
"ok": True,
"message": serialized_messages[0] if serialized_messages else {"id": message_id, "content": body.content},
}
@router.post("/{thread_id}/history", response_model=list[HistoryEntry])
@require_permission("threads", "read", owner_check=True)
async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request: Request) -> list[HistoryEntry]: