Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1
# Conflicts: # frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx # frontend-web/src/position-roundtable/components/PositionNodeChat.tsx
This commit is contained in:
commit
7d249a81ce
10 files changed
+696
-46
No files matched your search
@@ -137,6 +137,7 @@ export function MessageListItem({
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
@@ -154,6 +155,7 @@ export function MessageListItem({
|
||||
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
|
||||
@@ -163,11 +165,99 @@ export function MessageListItem({
|
||||
*/
|
||||
turnTime?: string;
|
||||
}) {
|
||||
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}
|
||||
@@ -189,6 +279,7 @@ export function MessageListItem({
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={isLoading ? undefined : onEditSave}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
turnTime={turnTime}
|
||||
/>
|
||||
@@ -212,6 +303,19 @@ export function MessageListItem({
|
||||
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>
|
||||
)}
|
||||
@@ -229,25 +333,30 @@ export function MessageListItem({
|
||||
* original question stays in the thread).
|
||||
*/
|
||||
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 confirmEdit = useCallback(async () => {
|
||||
const value = draft.trim();
|
||||
if (!value) {
|
||||
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, onEditResubmit]);
|
||||
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
@@ -275,7 +397,7 @@ function HumanMessageBlock({
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
confirmEdit();
|
||||
void confirmEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -288,12 +410,17 @@ function HumanMessageBlock({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
|
||||
{t.common.confirm}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void confirmEdit()}
|
||||
disabled={!draft.trim() || saving}
|
||||
>
|
||||
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,8 +451,8 @@ 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}>
|
||||
{text && (onEditResubmit || onEditSave) && (
|
||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
@@ -398,6 +525,7 @@ function MessageContent_({
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
@@ -409,6 +537,7 @@ function MessageContent_({
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
turnTime?: string;
|
||||
}) {
|
||||
@@ -556,12 +685,14 @@ function MessageContent_({
|
||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||
return (
|
||||
<HumanMessageBlock
|
||||
messageId={message.id}
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
deleteButton={
|
||||
canDelete ? (
|
||||
<DeleteMessageButton
|
||||
|
||||
@@ -99,6 +99,9 @@ export function MessageList({
|
||||
scrollButtonClassName,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
messageContentOverrides,
|
||||
externallyHiddenMessageIds,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
@@ -118,6 +121,9 @@ export function MessageList({
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
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>;
|
||||
@@ -157,16 +163,32 @@ export function MessageList({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allMessages = useMemo(
|
||||
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
|
||||
[thread.messages],
|
||||
);
|
||||
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(
|
||||
() =>
|
||||
hiddenMessageIds.size === 0
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
? allMessages
|
||||
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
||||
[allMessages, hiddenMessageIds],
|
||||
: allMessages.filter(
|
||||
(m) =>
|
||||
!m.id ||
|
||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
|
||||
const askTimes = useChatAskTimes();
|
||||
@@ -384,6 +406,7 @@ export function MessageList({
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
|
||||
@@ -1397,6 +1397,86 @@ export function useDeleteMessage() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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";
|
||||
@@ -228,6 +229,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);
|
||||
@@ -238,6 +243,7 @@ export function PositionBuiltinAgentChat({
|
||||
pendingSubmissionRef.current = false;
|
||||
setPendingSubmission(null);
|
||||
handledAutoStartRequestRef.current = undefined;
|
||||
setExternallyHiddenMessageIds(new Set());
|
||||
// Do not depend on `persistedThreadId` here. Preparing the automatic
|
||||
// first question can persist a thread id while the page's initial
|
||||
// session-request is still resolving. Treating that update as a new
|
||||
@@ -477,6 +483,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 ||
|
||||
@@ -532,6 +563,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 && (
|
||||
@@ -584,6 +625,7 @@ export function PositionBuiltinAgentChat({
|
||||
}
|
||||
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";
|
||||
@@ -15,7 +15,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";
|
||||
@@ -232,6 +232,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" &&
|
||||
@@ -243,7 +249,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) => {
|
||||
@@ -324,6 +332,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?.({
|
||||
@@ -335,6 +352,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(
|
||||
() =>
|
||||
@@ -526,6 +615,16 @@ export function PositionNodeChat({
|
||||
查看驳回理由
|
||||
</TButton>
|
||||
)}
|
||||
<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"
|
||||
@@ -589,6 +688,9 @@ export function PositionNodeChat({
|
||||
handleSubmit({ text: answer, files: [] })
|
||||
}
|
||||
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);
|
||||
@@ -1516,6 +1554,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}
|
||||
@@ -1563,7 +1613,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}
|
||||
@@ -1772,7 +1829,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>
|
||||
@@ -1917,6 +1974,7 @@ export default function PositionRoundtablePage({
|
||||
filepath={sandboxArtifact.path}
|
||||
threadId={sandboxArtifact.threadId}
|
||||
onClose={closeSandbox}
|
||||
allowVirtualMarkdownEdit
|
||||
disableDesensitize
|
||||
hideFileName
|
||||
/>
|
||||
@@ -2820,7 +2878,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,
|
||||
)}
|
||||
>
|
||||
@@ -2859,19 +2917,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>
|
||||
@@ -2892,7 +2950,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
|
||||
@@ -2904,24 +2962,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>
|
||||
@@ -2932,7 +2990,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 && (
|
||||
@@ -3007,10 +3065,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">
|
||||
@@ -3021,7 +3079,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>
|
||||
@@ -3166,7 +3224,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]">
|
||||
@@ -3348,10 +3406,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">
|
||||
@@ -3362,7 +3420,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>
|
||||
@@ -3546,7 +3604,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,
|
||||
};
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
Reference in new issue
Block a user