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
+808
-158
No files matched your search
@@ -135,11 +135,12 @@ export function MessageListItem({
|
|||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onMessageDeleted,
|
onEditSave,
|
||||||
turnTime,
|
onMessageDeleted,
|
||||||
}: {
|
turnTime,
|
||||||
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
fitRowToContent?: boolean;
|
fitRowToContent?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -151,10 +152,11 @@ export function MessageListItem({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onMessageDeleted?: (deletedIds: 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
|
* 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
|
* 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.
|
* wall clock at render time and appear to "drift" to now on every reload.
|
||||||
*/
|
*/
|
||||||
turnTime?: string;
|
turnTime?: string;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === 'human';
|
const { t } = useI18n();
|
||||||
const messageMarkdown =
|
const isHuman = message.type === 'human';
|
||||||
extractContentFromMessage(message) ??
|
const messageMarkdown =
|
||||||
extractReasoningContentFromMessage(message) ??
|
extractContentFromMessage(message) ??
|
||||||
'';
|
extractReasoningContentFromMessage(message) ??
|
||||||
return (
|
'';
|
||||||
<AIElementMessage
|
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}
|
id={anchorId}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/conversation-message relative',
|
'group/conversation-message relative',
|
||||||
@@ -187,11 +277,12 @@ export function MessageListItem({
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onMessageDeleted={onMessageDeleted}
|
onEditSave={isLoading ? undefined : onEditSave}
|
||||||
turnTime={turnTime}
|
onMessageDeleted={onMessageDeleted}
|
||||||
/>
|
turnTime={turnTime}
|
||||||
|
/>
|
||||||
{/*
|
{/*
|
||||||
Human messages carry their own inline copy/edit toolbar inside
|
Human messages carry their own inline copy/edit toolbar inside
|
||||||
MessageContent (above the timestamp). Only assistant answers use the
|
MessageContent (above the timestamp). Only assistant answers use the
|
||||||
@@ -206,15 +297,28 @@ export function MessageListItem({
|
|||||||
>
|
>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<CopyButton clipboardData={messageMarkdown} />
|
<CopyButton clipboardData={messageMarkdown} />
|
||||||
{messageMarkdown && (
|
{messageMarkdown && (
|
||||||
<DownloadWordButton
|
<DownloadWordButton
|
||||||
markdown={messageMarkdown}
|
markdown={messageMarkdown}
|
||||||
fileName="message"
|
fileName="message"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
{canSaveAssistantEdit && (
|
||||||
</MessageToolbar>
|
<Tooltip content="编辑消息">
|
||||||
)}
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-6"
|
||||||
|
onClick={() => setAssistantEditing(true)}
|
||||||
|
>
|
||||||
|
<PencilIcon size={11} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MessageToolbar>
|
||||||
|
)}
|
||||||
</AIElementMessage>
|
</AIElementMessage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,26 +332,31 @@ export function MessageListItem({
|
|||||||
* the question; confirming sends the edited text as a brand-new turn (the
|
* the question; confirming sends the edited text as a brand-new turn (the
|
||||||
* original question stays in the thread).
|
* original question stays in the thread).
|
||||||
*/
|
*/
|
||||||
function HumanMessageBlock({
|
function HumanMessageBlock({
|
||||||
text,
|
messageId,
|
||||||
pageContext,
|
text,
|
||||||
filesList,
|
pageContext,
|
||||||
messageTime,
|
filesList,
|
||||||
components,
|
messageTime,
|
||||||
onEditResubmit,
|
components,
|
||||||
deleteButton,
|
onEditResubmit,
|
||||||
}: {
|
onEditSave,
|
||||||
text: string;
|
deleteButton,
|
||||||
pageContext?: EmbeddedPageContext | null;
|
}: {
|
||||||
filesList: React.ReactNode;
|
messageId?: string;
|
||||||
messageTime: { label: string; full: string } | null;
|
text: string;
|
||||||
components: MarkdownContentProps["components"];
|
pageContext?: EmbeddedPageContext | null;
|
||||||
onEditResubmit?: (text: string) => void;
|
filesList: React.ReactNode;
|
||||||
deleteButton?: React.ReactNode;
|
messageTime: { label: string; full: string } | null;
|
||||||
}) {
|
components: MarkdownContentProps["components"];
|
||||||
const { t } = useI18n();
|
onEditResubmit?: (text: string) => void;
|
||||||
const [editing, setEditing] = useState(false);
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
const [draft, setDraft] = useState(text);
|
deleteButton?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(text);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
@@ -255,14 +364,27 @@ function HumanMessageBlock({
|
|||||||
}
|
}
|
||||||
}, [text, editing]);
|
}, [text, editing]);
|
||||||
|
|
||||||
const confirmEdit = useCallback(() => {
|
const confirmEdit = useCallback(async () => {
|
||||||
const value = draft.trim();
|
const value = draft.trim();
|
||||||
if (!value) {
|
if (!value || saving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onEditResubmit?.(value);
|
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
|
||||||
setEditing(false);
|
setSaving(true);
|
||||||
}, [draft, onEditResubmit]);
|
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) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
@@ -275,10 +397,10 @@ function HumanMessageBlock({
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
confirmEdit();
|
void confirmEdit();
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -286,17 +408,22 @@ function HumanMessageBlock({
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setEditing(false)}
|
disabled={saving}
|
||||||
>
|
onClick={() => setEditing(false)}
|
||||||
{t.common.cancel}
|
>
|
||||||
</Button>
|
{t.common.cancel}
|
||||||
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
|
</Button>
|
||||||
{t.common.confirm}
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
</div>
|
onClick={() => void confirmEdit()}
|
||||||
</div>
|
disabled={!draft.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,11 +451,11 @@ function HumanMessageBlock({
|
|||||||
{(text || deleteButton) && (
|
{(text || deleteButton) && (
|
||||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
<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 && <CopyButton clipboardData={text} className="size-6" />}
|
||||||
{text && onEditResubmit && (
|
{text && (onEditResubmit || onEditSave) && (
|
||||||
<Tooltip content={t.inputBox.editQuestion}>
|
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-6"
|
className="size-6"
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
@@ -396,22 +523,24 @@ function MessageContent_({
|
|||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onMessageDeleted,
|
onEditSave,
|
||||||
turnTime,
|
onMessageDeleted,
|
||||||
}: {
|
turnTime,
|
||||||
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
turnTime?: string;
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||||
}) {
|
turnTime?: string;
|
||||||
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
|
|
||||||
// Streamdown splits content into per-block processors; calling the factory
|
// Streamdown splits content into per-block processors; calling the factory
|
||||||
@@ -555,14 +684,16 @@ function MessageContent_({
|
|||||||
const canDelete =
|
const canDelete =
|
||||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||||
return (
|
return (
|
||||||
<HumanMessageBlock
|
<HumanMessageBlock
|
||||||
text={parsedHumanContent.content}
|
messageId={message.id}
|
||||||
pageContext={parsedHumanContent.pageContext}
|
text={parsedHumanContent.content}
|
||||||
filesList={filesList}
|
pageContext={parsedHumanContent.pageContext}
|
||||||
messageTime={messageTime}
|
filesList={filesList}
|
||||||
components={components}
|
messageTime={messageTime}
|
||||||
onEditResubmit={onEditResubmit}
|
components={components}
|
||||||
deleteButton={
|
onEditResubmit={onEditResubmit}
|
||||||
|
onEditSave={onEditSave}
|
||||||
|
deleteButton={
|
||||||
canDelete ? (
|
canDelete ? (
|
||||||
<DeleteMessageButton
|
<DeleteMessageButton
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
|||||||
@@ -97,10 +97,13 @@ export function MessageList({
|
|||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
scrollButtonClassName,
|
scrollButtonClassName,
|
||||||
onClarificationSubmit,
|
onClarificationSubmit,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onSetupAgentApprove,
|
onEditSave,
|
||||||
onSetupAgentReject,
|
messageContentOverrides,
|
||||||
|
externallyHiddenMessageIds,
|
||||||
|
onSetupAgentApprove,
|
||||||
|
onSetupAgentReject,
|
||||||
onSetupWritingApprove,
|
onSetupWritingApprove,
|
||||||
onSetupWritingReject,
|
onSetupWritingReject,
|
||||||
onSetupWritingStart,
|
onSetupWritingStart,
|
||||||
@@ -116,9 +119,12 @@ export function MessageList({
|
|||||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||||
scrollButtonClassName?: string;
|
scrollButtonClassName?: string;
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<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>;
|
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
@@ -157,17 +163,33 @@ export function MessageList({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allMessages = useMemo(
|
const allMessages = useMemo(() => {
|
||||||
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
|
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||||
[thread.messages],
|
return thread.messages
|
||||||
);
|
.filter((m) => !isHiddenFromUIMessage(m))
|
||||||
const messages = useMemo(
|
.map((message) => {
|
||||||
() =>
|
const override =
|
||||||
hiddenMessageIds.size === 0
|
hasOverrides && message.id
|
||||||
? allMessages
|
? messageContentOverrides?.[message.id]
|
||||||
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
: undefined;
|
||||||
[allMessages, hiddenMessageIds],
|
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();
|
const askTimes = useChatAskTimes();
|
||||||
|
|
||||||
@@ -382,10 +404,11 @@ export function MessageList({
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onMessageDeleted={
|
onEditSave={onEditSave}
|
||||||
allowDelete ? handleMessageDeleted : undefined
|
onMessageDeleted={
|
||||||
|
allowDelete ? handleMessageDeleted : undefined
|
||||||
}
|
}
|
||||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1363,8 +1363,8 @@ export function useDeleteThread() {
|
|||||||
* `MessageList`) so the removal shows immediately; a reload then reads the
|
* `MessageList`) so the removal shows immediately; a reload then reads the
|
||||||
* persisted, already-trimmed state.
|
* persisted, already-trimmed state.
|
||||||
*/
|
*/
|
||||||
export function useDeleteMessage() {
|
export function useDeleteMessage() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
threadId,
|
threadId,
|
||||||
messageId,
|
messageId,
|
||||||
@@ -1394,12 +1394,92 @@ export function useDeleteMessage() {
|
|||||||
// Fall back to the requested id so the caller can always hide something.
|
// Fall back to the requested id so the caller can always hide something.
|
||||||
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
|
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge metadata into a conversation's record via the Gateway
|
* Clear all messages from a thread checkpoint. The caller should immediately
|
||||||
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
* 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
|
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
|
||||||
* single user, so these live directly on the thread metadata (no join table).
|
* single user, so these live directly on the thread metadata (no join table).
|
||||||
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
|
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { toast } from "sonner";
|
||||||
|
import { Button as TButton } from "tdesign-react";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { MessageList } from "@/components/workspace/messages";
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
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 type { AgentThreadState } from "@/core/threads/types";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
@@ -228,6 +229,10 @@ export function PositionBuiltinAgentChat({
|
|||||||
useState<PendingBuiltinSubmission | null>(null);
|
useState<PendingBuiltinSubmission | null>(null);
|
||||||
const pendingSubmissionRef = useRef(false);
|
const pendingSubmissionRef = useRef(false);
|
||||||
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
|
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
|
||||||
|
const clearThreadMessages = useClearThreadMessages();
|
||||||
|
const [externallyHiddenMessageIds, setExternallyHiddenMessageIds] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAssistantId(fallbackAgentId);
|
setAssistantId(fallbackAgentId);
|
||||||
@@ -238,6 +243,7 @@ export function PositionBuiltinAgentChat({
|
|||||||
pendingSubmissionRef.current = false;
|
pendingSubmissionRef.current = false;
|
||||||
setPendingSubmission(null);
|
setPendingSubmission(null);
|
||||||
handledAutoStartRequestRef.current = undefined;
|
handledAutoStartRequestRef.current = undefined;
|
||||||
|
setExternallyHiddenMessageIds(new Set());
|
||||||
// Do not depend on `persistedThreadId` here. Preparing the automatic
|
// Do not depend on `persistedThreadId` here. Preparing the automatic
|
||||||
// first question can persist a thread id while the page's initial
|
// first question can persist a thread id while the page's initial
|
||||||
// session-request is still resolving. Treating that update as a new
|
// session-request is still resolving. Treating that update as a new
|
||||||
@@ -477,6 +483,31 @@ export function PositionBuiltinAgentChat({
|
|||||||
[activeThreadId, onPreviewArtifact],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!autoStartRequestKey ||
|
!autoStartRequestKey ||
|
||||||
@@ -532,6 +563,16 @@ export function PositionBuiltinAgentChat({
|
|||||||
onSelect={onSelectBuiltinAgent}
|
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>
|
</header>
|
||||||
|
|
||||||
{persistedArtifacts.length > 0 && (
|
{persistedArtifacts.length > 0 && (
|
||||||
@@ -584,6 +625,7 @@ export function PositionBuiltinAgentChat({
|
|||||||
}
|
}
|
||||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||||
onOpenArtifact={handleOpenMessageArtifact}
|
onOpenArtifact={handleOpenMessageArtifact}
|
||||||
|
externallyHiddenMessageIds={externallyHiddenMessageIds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
|
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
|
||||||
|
|||||||
@@ -11,10 +11,22 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Select as TSelect } from "tdesign-react";
|
||||||
|
|
||||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||||
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
|
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. */
|
/** Keep attachment metadata presentation local to this position-only composer. */
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -49,7 +61,10 @@ export function PositionIntentInput({
|
|||||||
hideEnterRoundtable,
|
hideEnterRoundtable,
|
||||||
onManualSave,
|
onManualSave,
|
||||||
onTriggerToast,
|
onTriggerToast,
|
||||||
}: Step1InputAreaProps) {
|
modelOptions = [],
|
||||||
|
selectedModel,
|
||||||
|
onSelectedModelChange,
|
||||||
|
}: PositionIntentInputProps) {
|
||||||
// Files are owned by Step1Panel. This ref only opens the native picker and
|
// Files are owned by Step1Panel. This ref only opens the native picker and
|
||||||
// forwards its selection through onAddFiles, so replacement layouts do not
|
// forwards its selection through onAddFiles, so replacement layouts do not
|
||||||
// accidentally create a second attachment state.
|
// accidentally create a second attachment state.
|
||||||
@@ -162,6 +177,24 @@ export function PositionIntentInput({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
<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
|
<button
|
||||||
id="send-message-btn"
|
id="send-message-btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { toast } from "sonner";
|
||||||
import { Button as TButton, Select as TSelect } from "tdesign-react";
|
import { Button as TButton, Select as TSelect } from "tdesign-react";
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
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 { MessageList } from "@/components/workspace/messages";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
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 type { AgentThreadState } from "@/core/threads/types";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
@@ -232,6 +232,12 @@ export function PositionNodeChat({
|
|||||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||||
const autoPreviewedArtifactKeyRef = useRef<string | 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 nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
|
||||||
const canWrite =
|
const canWrite =
|
||||||
sessionStatus !== "archived" &&
|
sessionStatus !== "archived" &&
|
||||||
@@ -243,7 +249,9 @@ export function PositionNodeChat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBoundThreadId(node.threadId);
|
setBoundThreadId(node.threadId);
|
||||||
boundThreadIdRef.current = node.threadId;
|
boundThreadIdRef.current = node.threadId;
|
||||||
}, [node.threadId]);
|
setMessageContentOverrides({});
|
||||||
|
setExternallyHiddenMessageIds(new Set());
|
||||||
|
}, [node.nodeKey, node.threadId]);
|
||||||
|
|
||||||
const bindCreatedThread = useCallback(
|
const bindCreatedThread = useCallback(
|
||||||
(threadId: string) => {
|
(threadId: string) => {
|
||||||
@@ -324,6 +332,15 @@ export function PositionNodeChat({
|
|||||||
|
|
||||||
const activeThreadId =
|
const activeThreadId =
|
||||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
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(
|
const handleOpenMessageArtifact = useCallback(
|
||||||
(filepath: string) => {
|
(filepath: string) => {
|
||||||
onPreviewArtifact?.({
|
onPreviewArtifact?.({
|
||||||
@@ -335,6 +352,78 @@ export function PositionNodeChat({
|
|||||||
},
|
},
|
||||||
[activeThreadId, onPreviewArtifact],
|
[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 hasMultipleSwitchOptions = switchOptions.length > 1;
|
||||||
const switchSelectOptions = useMemo(
|
const switchSelectOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -526,6 +615,16 @@ export function PositionNodeChat({
|
|||||||
查看驳回理由
|
查看驳回理由
|
||||||
</TButton>
|
</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 && (
|
{onOpenConfiguration && (
|
||||||
<TButton
|
<TButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -589,6 +688,9 @@ export function PositionNodeChat({
|
|||||||
handleSubmit({ text: answer, files: [] })
|
handleSubmit({ text: answer, files: [] })
|
||||||
}
|
}
|
||||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||||
|
onEditSave={handleSaveMessageEdit}
|
||||||
|
messageContentOverrides={messageContentOverrides}
|
||||||
|
externallyHiddenMessageIds={externallyHiddenMessageIds}
|
||||||
onOpenArtifact={handleOpenMessageArtifact}
|
onOpenArtifact={handleOpenMessageArtifact}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
|
Trash2,
|
||||||
UserRoundCog,
|
UserRoundCog,
|
||||||
XCircle,
|
XCircle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -66,6 +67,7 @@ import { useAgent } from "@/core/agents";
|
|||||||
import { loadArtifactContent } from "@/core/artifacts/loader";
|
import { loadArtifactContent } from "@/core/artifacts/loader";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
import { useClearThreadMessages } from "@/core/threads/hooks";
|
||||||
import type { AgentThreadState } from "@/core/threads/types";
|
import type { AgentThreadState } from "@/core/threads/types";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -616,7 +618,23 @@ export default function PositionRoundtablePage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { models } = useModels();
|
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 triggerToast = useCallback((message: string) => toast.info(message), []);
|
||||||
const intent = useStep1Intent({
|
const intent = useStep1Intent({
|
||||||
// 全岗位共用原会商的对话接口与输入面板;岗位运行编排后续再按角色接入。
|
// 全岗位共用原会商的对话接口与输入面板;岗位运行编排后续再按角色接入。
|
||||||
@@ -633,6 +651,26 @@ export default function PositionRoundtablePage({
|
|||||||
const persistedIntentRef = useRef<string | null>(null);
|
const persistedIntentRef = useRef<string | null>(null);
|
||||||
const isArchivedSession = positionSession?.status === "archived";
|
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(
|
const syncSessionUrl = useCallback(
|
||||||
(sessionId: string, nextTaskId: string) => {
|
(sessionId: string, nextTaskId: string) => {
|
||||||
const next = new URLSearchParams(searchParamsString);
|
const next = new URLSearchParams(searchParamsString);
|
||||||
@@ -1516,6 +1554,18 @@ export default function PositionRoundtablePage({
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
interactiveMessages={intent.interactiveMessages}
|
||||||
intentThreadId={intent.intentThreadId}
|
intentThreadId={intent.intentThreadId}
|
||||||
intentReady={chainStarted ? frozenIntent ?? intent.intentReady : intent.intentReady}
|
intentReady={chainStarted ? frozenIntent ?? intent.intentReady : intent.intentReady}
|
||||||
@@ -1563,7 +1613,14 @@ export default function PositionRoundtablePage({
|
|||||||
</button>
|
</button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
inputArea={(inputProps) => <PositionIntentInput {...inputProps} />}
|
inputArea={(inputProps) => (
|
||||||
|
<PositionIntentInput
|
||||||
|
{...inputProps}
|
||||||
|
modelOptions={intentModelOptions}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onSelectedModelChange={setSelectedModel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
onManualSave={() => undefined}
|
onManualSave={() => undefined}
|
||||||
onTriggerToast={triggerToast}
|
onTriggerToast={triggerToast}
|
||||||
submitIntentClarifications={intent.submitIntentClarifications}
|
submitIntentClarifications={intent.submitIntentClarifications}
|
||||||
@@ -1772,7 +1829,7 @@ export default function PositionRoundtablePage({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLeftPanelCollapsed(false);
|
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" />
|
<img src={expandLogo} alt="" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1917,6 +1974,7 @@ export default function PositionRoundtablePage({
|
|||||||
filepath={sandboxArtifact.path}
|
filepath={sandboxArtifact.path}
|
||||||
threadId={sandboxArtifact.threadId}
|
threadId={sandboxArtifact.threadId}
|
||||||
onClose={closeSandbox}
|
onClose={closeSandbox}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
disableDesensitize
|
disableDesensitize
|
||||||
hideFileName
|
hideFileName
|
||||||
/>
|
/>
|
||||||
@@ -2820,7 +2878,7 @@ function HoverEditButton({
|
|||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
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,
|
revealClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -2859,19 +2917,19 @@ function TaskAnalysisPanel({
|
|||||||
className,
|
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
|
<span
|
||||||
aria-hidden="true"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={collapsed ? "展开任务分析" : "收起任务分析"}
|
title={collapsed ? "展开任务分析" : "收起任务分析"}
|
||||||
aria-label={collapsed ? "展开任务分析" : "收起任务分析"}
|
aria-label={collapsed ? "展开任务分析" : "收起任务分析"}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
onClick={() => onCollapsedChange(!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" />
|
<img src={expandLogo} alt="" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -2892,7 +2950,7 @@ function TaskAnalysisPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("space-y-4 p-4", children && "shrink-0")}>
|
<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 && (
|
{taskEditable && onEditTask && (
|
||||||
<div className="absolute right-2 top-2">
|
<div className="absolute right-2 top-2">
|
||||||
<HoverEditButton
|
<HoverEditButton
|
||||||
@@ -2904,24 +2962,24 @@ function TaskAnalysisPanel({
|
|||||||
)}
|
)}
|
||||||
<h3
|
<h3
|
||||||
title={desensitize(task.title)}
|
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>
|
<span className="line-clamp-1">{desensitize(task.title)}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
title={desensitize(task.description)}
|
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)}
|
{desensitize(task.description)}
|
||||||
</p>
|
</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">
|
<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="shrink-0 text-slate-500 dark:text-[#9ebfe2]">任务来源:</span>
|
||||||
<span className="truncate">{desensitize(task.source)}</span>
|
<span className="truncate">{desensitize(task.source)}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="flex min-w-0 items-center gap-2">
|
<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="shrink-0 text-slate-500 dark:text-[#9ebfe2]">下发时间:</span>
|
||||||
<span className="truncate">{task.issuedAt || "未设置"}</span>
|
<span className="truncate">{task.issuedAt || "未设置"}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -2932,7 +2990,7 @@ function TaskAnalysisPanel({
|
|||||||
<div className="relative flex h-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
|
<div className="relative flex h-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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>
|
<h3 className="text-lg font-[550] tracking-wide text-slate-900 dark:text-white">意图分析</h3>
|
||||||
{intentEditable && onEditIntent && (
|
{intentEditable && onEditIntent && (
|
||||||
@@ -3007,10 +3065,10 @@ function TaskInformationCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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
|
<span
|
||||||
aria-hidden="true"
|
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-white">任务信息</h2>
|
||||||
<div className="relative z-10 ml-auto flex items-center gap-2">
|
<div className="relative z-10 ml-auto flex items-center gap-2">
|
||||||
@@ -3021,7 +3079,7 @@ function TaskInformationCard({
|
|||||||
aria-label={collapsed ? "展开任务信息" : "收起任务信息"}
|
aria-label={collapsed ? "展开任务信息" : "收起任务信息"}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
onClick={() => onCollapsedChange(!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" />
|
<img src={expandLogo} alt="" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -3166,7 +3224,7 @@ function BusinessChainSelector({
|
|||||||
<div className="relative flex h-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
|
<div className="relative flex h-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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>
|
<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]">
|
<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",
|
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
|
<span
|
||||||
aria-hidden="true"
|
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-white">意图理解</h2>
|
||||||
<div className="relative z-10 ml-auto flex items-center gap-2">
|
<div className="relative z-10 ml-auto flex items-center gap-2">
|
||||||
@@ -3362,7 +3420,7 @@ function IntentUnderstandingCard({
|
|||||||
aria-label={collapsed ? "展开意图理解" : "收起意图理解"}
|
aria-label={collapsed ? "展开意图理解" : "收起意图理解"}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
onClick={() => onCollapsedChange(!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" />
|
<img src={expandLogo} alt="" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -3546,7 +3604,7 @@ function BusinessChainFlow({
|
|||||||
{embedded ? (
|
{embedded ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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">
|
<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;
|
hydrateFromDraft: (snapshot: Step1Snapshot) => void;
|
||||||
/** 给「跳过此项」按钮 / 「继续追问」按钮等本地 demo 操作用。 */
|
/** 给「跳过此项」按钮 / 「继续追问」按钮等本地 demo 操作用。 */
|
||||||
appendLocalMessage: (msg: InteractiveMessage) => void;
|
appendLocalMessage: (msg: InteractiveMessage) => void;
|
||||||
|
clearInteractiveMessages: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTENT_WELCOME_TEXT =
|
const INTENT_WELCOME_TEXT =
|
||||||
@@ -662,6 +663,12 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
setInteractiveMessages((prev) => [...prev, msg]);
|
setInteractiveMessages((prev) => [...prev, msg]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearInteractiveMessages = useCallback(() => {
|
||||||
|
setInteractiveMessages([]);
|
||||||
|
setPendingIntentClarificationMsgId(null);
|
||||||
|
setIntentClarificationSelections(new Map());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateIntentReady = useCallback((next: IntentSummary) => {
|
const updateIntentReady = useCallback((next: IntentSummary) => {
|
||||||
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
|
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
|
||||||
setIntentReady(next);
|
setIntentReady(next);
|
||||||
@@ -687,5 +694,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
stopIntentStream,
|
stopIntentStream,
|
||||||
hydrateFromDraft,
|
hydrateFromDraft,
|
||||||
appendLocalMessage,
|
appendLocalMessage,
|
||||||
|
clearInteractiveMessages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -602,7 +602,7 @@
|
|||||||
/* 岗位会商左侧业务链:列表较窄,避免滚动条抢占卡片内容宽度。 */
|
/* 岗位会商左侧业务链:列表较窄,避免滚动条抢占卡片内容宽度。 */
|
||||||
.business-chain-scrollbar {
|
.business-chain-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #2b8ffe transparent;
|
scrollbar-color: rgb(214 36 36 / 0.9) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.business-chain-scrollbar::-webkit-scrollbar {
|
.business-chain-scrollbar::-webkit-scrollbar {
|
||||||
@@ -612,6 +612,15 @@
|
|||||||
|
|
||||||
.business-chain-scrollbar::-webkit-scrollbar-thumb {
|
.business-chain-scrollbar::-webkit-scrollbar-thumb {
|
||||||
border: 0;
|
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))
|
background: linear-gradient(180deg, rgb(85 166 254 / 0.82), rgb(0 98 217 / 0.9))
|
||||||
padding-box;
|
padding-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1064,6 +1064,19 @@ class AppendMessageRequest(BaseModel):
|
|||||||
type: str = Field(default="human", description="Message role: human/ai/system")
|
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:
|
def _flatten_msg_content(content: Any) -> str:
|
||||||
"""把 message.content(str / 结构化 block 列表)拍平成纯文本。"""
|
"""把 message.content(str / 结构化 block 列表)拍平成纯文本。"""
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
@@ -1079,6 +1092,17 @@ def _flatten_msg_content(content: Any) -> str:
|
|||||||
return str(content) if content else ""
|
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:
|
def _extract_last_ai_text(messages: list[Any]) -> str:
|
||||||
"""返回最后一条 AI 消息的纯文本;没有则空串。兼容 message 对象与序列化 dict 两种存储形态。"""
|
"""返回最后一条 AI 消息的纯文本;没有则空串。兼容 message 对象与序列化 dict 两种存储形态。"""
|
||||||
for msg in reversed(messages or []):
|
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}
|
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}")
|
@router.delete("/{thread_id}/messages/{message_id}")
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
@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]:
|
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)}
|
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])
|
@router.post("/{thread_id}/history", response_model=list[HistoryEntry])
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request: Request) -> list[HistoryEntry]:
|
async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request: Request) -> list[HistoryEntry]:
|
||||||
|
|||||||
Reference in new issue
Block a user