mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 22:15:10 +00:00
Fix workflow Preview run blockers and multi-artifact chips
Make the doc-driven Preview flow usable and surface every produced artifact: - A Preview submit with attachments but no text now runs the workflow (opt-in on MessageInput; no filler text is injected), so a doc-driven workflow can be run by attaching its documents. - The builder's global Escape no longer tears down the whole Preview/drawer, and no longer fires from inside text fields. - The Preview drawer has a visible close button, and the upload toast moves off the attach/send controls (to the left) while the Preview is open. - A turn that produces multiple artifacts now renders one chip per artifact (labelled by tool, indexed when several), instead of exposing only the last.
This commit is contained in:
1 parent
60a06081ad
commit
7c91517d62
7 files changed
+169
-99
No files matched your search
@@ -725,13 +725,27 @@ function WorkflowBuilderInner() {
|
||||
handleDeleteNode();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
// While the Preview Sheet is open, leave Escape to its own dismissal
|
||||
// (popovers, the Sheet itself) so a reflexive Escape can't tear down
|
||||
// the node-config panel and lose the run/attachments/typed prompt.
|
||||
if (showPreview) return;
|
||||
// Ignore Escape originating from text fields / editable popovers so it
|
||||
// dismisses the field rather than the whole config panel.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target?.tagName === 'INPUT' ||
|
||||
target?.tagName === 'TEXTAREA' ||
|
||||
target?.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setShowNodeConfig(false);
|
||||
setSelectedNode(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedNode, handleDeleteNode]);
|
||||
}, [selectedNode, handleDeleteNode, showPreview]);
|
||||
|
||||
const handlePanelBackdropClick = useCallback(() => {
|
||||
setShowNodeConfig(false);
|
||||
@@ -2956,7 +2970,6 @@ function WorkflowBuilderInner() {
|
||||
<Sheet open={showPreview} onOpenChange={setShowPreview}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="bg-card w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
|
||||
>
|
||||
<WorkflowPreview
|
||||
|
||||
@@ -25,6 +25,7 @@ import MessageInput from '../../components/MessageInput';
|
||||
import ConversationBubble from '../../conversation/ConversationBubble';
|
||||
import { Query } from '../../conversation/conversationModels';
|
||||
import { AppDispatch } from '../../store';
|
||||
import { selectCompletedAttachments } from '../../upload/uploadSlice';
|
||||
import { WorkflowEdge, WorkflowNode } from '../types/workflow';
|
||||
import WorkflowRunArtifacts from './WorkflowRunArtifacts';
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
resendQuery,
|
||||
resetWorkflowPreview,
|
||||
selectActiveNodeId,
|
||||
setPreviewOpen,
|
||||
selectWorkflowExecutionSteps,
|
||||
selectWorkflowPreviewQueries,
|
||||
selectWorkflowPreviewStatus,
|
||||
@@ -437,6 +439,8 @@ export default function WorkflowPreview({
|
||||
const status = useSelector(selectWorkflowPreviewStatus);
|
||||
const executionSteps = useSelector(selectWorkflowExecutionSteps);
|
||||
const activeNodeId = useSelector(selectActiveNodeId);
|
||||
const completedAttachments = useSelector(selectCompletedAttachments);
|
||||
const hasCompletedAttachment = completedAttachments.length > 0;
|
||||
|
||||
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
|
||||
const [openDetailsIndex, setOpenDetailsIndex] = useState<number | null>(null);
|
||||
@@ -490,7 +494,9 @@ export default function WorkflowPreview({
|
||||
index?: number;
|
||||
}) => {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (trimmedQuestion === '') return;
|
||||
// Doc-driven nodes read ``input_documents`` rather than the query, so an
|
||||
// attachment-only run is allowed to proceed with an empty question.
|
||||
if (trimmedQuestion === '' && !hasCompletedAttachment) return;
|
||||
|
||||
if (index !== undefined) {
|
||||
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
|
||||
@@ -503,7 +509,7 @@ export default function WorkflowPreview({
|
||||
handleFetchAnswer({ question: trimmedQuestion, index: undefined });
|
||||
}
|
||||
},
|
||||
[dispatch, handleFetchAnswer],
|
||||
[dispatch, handleFetchAnswer, hasCompletedAttachment],
|
||||
);
|
||||
|
||||
const handleQuestionSubmission = (
|
||||
@@ -517,8 +523,8 @@ export default function WorkflowPreview({
|
||||
index: indx,
|
||||
isRetry: false,
|
||||
});
|
||||
} else if (question && status !== 'loading') {
|
||||
const currentInput = question.trim();
|
||||
} else if ((question || hasCompletedAttachment) && status !== 'loading') {
|
||||
const currentInput = (question ?? '').trim();
|
||||
if (lastQueryReturnedErr && queries.length > 0) {
|
||||
const lastQueryIndex = queries.length - 1;
|
||||
handleQuestion({
|
||||
@@ -538,10 +544,12 @@ export default function WorkflowPreview({
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(resetWorkflowPreview());
|
||||
dispatch(setPreviewOpen(true));
|
||||
return () => {
|
||||
if (fetchStream.current) fetchStream.current.abort();
|
||||
handleWorkflowPreviewAbort();
|
||||
dispatch(resetWorkflowPreview());
|
||||
dispatch(setPreviewOpen(false));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -708,6 +716,7 @@ export default function WorkflowPreview({
|
||||
showSourceButton={false}
|
||||
showToolButton={false}
|
||||
autoFocus={true}
|
||||
allowSendWithoutText={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Attachment } from '../../upload/uploadSlice';
|
||||
import reducer, {
|
||||
addQuery,
|
||||
collectCompletedAttachmentIds,
|
||||
resetWorkflowPreview,
|
||||
setPreviewOpen,
|
||||
setWorkflowRunId,
|
||||
} from './workflowPreviewSlice';
|
||||
|
||||
@@ -58,3 +60,23 @@ describe('setWorkflowRunId', () => {
|
||||
expect(state.queries[0].workflowRunId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPreviewOpen', () => {
|
||||
it('defaults to closed and toggles the preview-open flag', () => {
|
||||
let state = seedState();
|
||||
expect(state.previewOpen).toBe(false);
|
||||
state = reducer(state, setPreviewOpen(true));
|
||||
expect(state.previewOpen).toBe(true);
|
||||
state = reducer(state, setPreviewOpen(false));
|
||||
expect(state.previewOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a resetWorkflowPreview so the overlay flag stays set', () => {
|
||||
let state = seedState();
|
||||
state = reducer(state, setPreviewOpen(true));
|
||||
state = reducer(state, addQuery({ prompt: 'q' }));
|
||||
state = reducer(state, resetWorkflowPreview());
|
||||
expect(state.queries).toEqual([]);
|
||||
expect(state.previewOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,9 @@ export interface WorkflowPreviewState {
|
||||
status: Status;
|
||||
executionSteps: WorkflowExecutionStep[];
|
||||
activeNodeId: string | null;
|
||||
// True while the Preview drawer is mounted; lets global overlays (the
|
||||
// upload toast) shift off the drawer's right-side input.
|
||||
previewOpen: boolean;
|
||||
}
|
||||
|
||||
const initialState: WorkflowPreviewState = {
|
||||
@@ -44,6 +47,7 @@ const initialState: WorkflowPreviewState = {
|
||||
status: 'idle',
|
||||
executionSteps: [],
|
||||
activeNodeId: null,
|
||||
previewOpen: false,
|
||||
};
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
@@ -438,6 +442,9 @@ export const workflowPreviewSlice = createSlice({
|
||||
state.executionSteps = [];
|
||||
state.activeNodeId = null;
|
||||
},
|
||||
setPreviewOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.previewOpen = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder
|
||||
@@ -475,6 +482,9 @@ export const selectWorkflowExecutionSteps = (
|
||||
) => state.workflowPreview.executionSteps;
|
||||
export const selectActiveNodeId = (state: RootStateWithWorkflowPreview) =>
|
||||
state.workflowPreview.activeNodeId;
|
||||
export const selectWorkflowPreviewOpen = (
|
||||
state: RootStateWithWorkflowPreview,
|
||||
) => state.workflowPreview.previewOpen;
|
||||
|
||||
export const {
|
||||
addQuery,
|
||||
@@ -491,6 +501,7 @@ export const {
|
||||
raiseError,
|
||||
resetWorkflowPreview,
|
||||
clearExecutionSteps,
|
||||
setPreviewOpen,
|
||||
} = workflowPreviewSlice.actions;
|
||||
|
||||
export default workflowPreviewSlice.reducer;
|
||||
@@ -293,6 +293,10 @@ type MessageInputProps = {
|
||||
showSourceButton?: boolean;
|
||||
showToolButton?: boolean;
|
||||
autoFocus?: boolean;
|
||||
// Opt-in: enable send with empty text when there are completed
|
||||
// attachments (used by doc-driven workflow runs). Normal chat leaves this
|
||||
// unset, preserving the text-required behavior.
|
||||
allowSendWithoutText?: boolean;
|
||||
};
|
||||
|
||||
export default function MessageInput({
|
||||
@@ -301,6 +305,7 @@ export default function MessageInput({
|
||||
showSourceButton = true,
|
||||
showToolButton = true,
|
||||
autoFocus = true,
|
||||
allowSendWithoutText = false,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState('');
|
||||
@@ -1467,13 +1472,21 @@ export default function MessageInput({
|
||||
setIsSourcesPopupOpen(false);
|
||||
};
|
||||
|
||||
// When ``allowSendWithoutText`` is set, an attachment-only submit is
|
||||
// permitted as long as at least one attachment has finished processing.
|
||||
const hasCompletedAttachment = attachments.some(
|
||||
(attachment) => attachment.status === 'completed',
|
||||
);
|
||||
const hasSubmittableContent =
|
||||
Boolean(value.trim()) || (allowSendWithoutText && hasCompletedAttachment);
|
||||
const canSubmit =
|
||||
hasSubmittableContent &&
|
||||
!loading &&
|
||||
recordingState !== 'recording' &&
|
||||
recordingState !== 'transcribing';
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (
|
||||
value.trim() &&
|
||||
!loading &&
|
||||
recordingState !== 'recording' &&
|
||||
recordingState !== 'transcribing'
|
||||
) {
|
||||
if (canSubmit) {
|
||||
onSubmit(value);
|
||||
setValue('');
|
||||
if (isTouch) {
|
||||
@@ -1635,19 +1648,11 @@ export default function MessageInput({
|
||||
onClick={handleSubmit}
|
||||
aria-label={t('send')}
|
||||
className={`ml-auto h-7 w-7 shrink-0 rounded-full transition-colors duration-300 ease-in-out sm:h-9 sm:w-9 ${
|
||||
value.trim() &&
|
||||
!loading &&
|
||||
recordingState !== 'recording' &&
|
||||
recordingState !== 'transcribing'
|
||||
canSubmit
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-muted text-muted-foreground dark:bg-accent dark:text-muted-foreground'
|
||||
}`}
|
||||
disabled={
|
||||
!value.trim() ||
|
||||
loading ||
|
||||
recordingState === 'recording' ||
|
||||
recordingState === 'transcribing'
|
||||
}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<SendArrow
|
||||
className="mx-auto my-auto block h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { selectWorkflowPreviewOpen } from '../agents/workflow/workflowPreviewSlice';
|
||||
import CheckCircleFilled from '../assets/check-circle-filled.svg';
|
||||
import ChevronDown from '../assets/chevron-down.svg';
|
||||
import WarnIcon from '../assets/warn.svg';
|
||||
@@ -39,6 +40,10 @@ export default function UploadToast() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const uploadTasks = useSelector(selectUploadTasks);
|
||||
// The workflow Preview drawer occupies the right edge; shift the toast to
|
||||
// the bottom-left while it's open so it stays visible without covering the
|
||||
// drawer's attach/send controls.
|
||||
const previewOpen = useSelector(selectWorkflowPreviewOpen);
|
||||
|
||||
const visibleTasks = uploadTasks.filter((task) => !task.dismissed);
|
||||
if (visibleTasks.length === 0) return null;
|
||||
@@ -60,7 +65,9 @@ export default function UploadToast() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2"
|
||||
className={`fixed bottom-4 z-50 flex max-w-md flex-col gap-2 ${
|
||||
previewOpen ? 'left-4' : 'right-4'
|
||||
}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
|
||||
@@ -127,8 +127,6 @@ const ConversationBubble = forwardRef<
|
||||
const completedArtifactCalls = (toolCalls ?? []).filter(
|
||||
(toolCall) => toolCall.artifact_id && toolCall.status === 'completed',
|
||||
);
|
||||
const primaryArtifactCall =
|
||||
completedArtifactCalls[completedArtifactCalls.length - 1] ?? null;
|
||||
const artifactCount = completedArtifactCalls.length;
|
||||
|
||||
const formatToolName = (toolName: string | undefined): string => {
|
||||
@@ -447,43 +445,45 @@ const ConversationBubble = forwardRef<
|
||||
agentId={agentId}
|
||||
/>
|
||||
)}
|
||||
{!message && primaryArtifactCall?.artifact_id && onOpenArtifact && (
|
||||
<div className="my-2 ml-2 flex justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenArtifact({
|
||||
id: primaryArtifactCall.artifact_id!,
|
||||
toolName: primaryArtifactCall.tool_name,
|
||||
})
|
||||
}
|
||||
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
{!message && onOpenArtifact && completedArtifactCalls.length > 0 && (
|
||||
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
|
||||
{completedArtifactCalls.map((artifactCall, artifactIndex) => (
|
||||
<Button
|
||||
key={artifactCall.call_id ?? artifactIndex}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenArtifact({
|
||||
id: artifactCall.artifact_id!,
|
||||
toolName: artifactCall.tool_name,
|
||||
})
|
||||
}
|
||||
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{primaryArtifactCall.tool_name
|
||||
? formatToolName(primaryArtifactCall.tool_name)
|
||||
: artifactCount > 1
|
||||
? `View artifacts (${artifactCount})`
|
||||
: 'View artifact'}
|
||||
</Button>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{(artifactCall.tool_name
|
||||
? formatToolName(artifactCall.tool_name)
|
||||
: 'Artifact') +
|
||||
(artifactCount > 1 ? ` (${artifactIndex + 1})` : '')}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{thought && (
|
||||
@@ -698,46 +698,49 @@ const ConversationBubble = forwardRef<
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{primaryArtifactCall?.artifact_id && onOpenArtifact && (
|
||||
<div className="relative mr-2 flex items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenArtifact({
|
||||
id: primaryArtifactCall.artifact_id!,
|
||||
toolName: primaryArtifactCall.tool_name,
|
||||
})
|
||||
}
|
||||
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
|
||||
aria-label="View artifacts"
|
||||
{onOpenArtifact &&
|
||||
completedArtifactCalls.map((artifactCall, artifactIndex) => (
|
||||
<div
|
||||
key={artifactCall.call_id ?? artifactIndex}
|
||||
className="relative mr-2 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenArtifact({
|
||||
id: artifactCall.artifact_id!,
|
||||
toolName: artifactCall.tool_name,
|
||||
})
|
||||
}
|
||||
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
|
||||
aria-label="View artifact"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{primaryArtifactCall.tool_name
|
||||
? formatToolName(primaryArtifactCall.tool_name)
|
||||
: artifactCount > 1
|
||||
? `Artifacts (${artifactCount})`
|
||||
: 'Artifact'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{(artifactCall.tool_name
|
||||
? formatToolName(artifactCall.tool_name)
|
||||
: 'Artifact') +
|
||||
(artifactCount > 1 ? ` (${artifactIndex + 1})` : '')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!isStreaming && (
|
||||
<>
|
||||
<div className="relative mr-2 block items-center justify-center">
|
||||
|
||||
Reference in new issue
Block a user