mirror of
https://github.com/ikawrakow/ik_llama.cpp.git
synced 2026-01-26 17:20:01 +00:00
webui: add system message in export conversation, support upload conversation with system message Webui: show upload only when in new conversation Webui: Add model name webui: increase height of chat message window when clicking editing Webui: autoclose settings dialog dropdown and maximze screen width when zoom in webui: fix date issues and add more dates webui: change error to toast.error. server: add n_past and slot_id in props_simple webui: add cache tokens, context and prompt speed in chat webui: modernize ui webui: change welcome message webui: change speed display webui: change run python icon webui: add config to use server defaults for sampler webui: put speed on left and context on right webui: recognize AsciiDoc files as valid text files (#16850) * webui: recognize AsciiDoc files as valid text files * webui: add an updated static webui build * webui: add the updated dependency list * webui: re-add an updated static webui build Add a setting to display message generation statistics (#16901) * feat: Add setting to display message generation statistics * chore: build static webui output webui: add HTML/JS preview support to MarkdownContent with sandboxed iframe (#16757) * webui: add HTML/JS preview support to MarkdownContent with sandboxed iframe dialog Extended MarkdownContent to flag previewable code languages, add a preview button alongside copy controls, manage preview dialog state, and share styling for the new button group Introduced CodePreviewDialog.svelte, a sandboxed iframe modal for rendering HTML/JS previews with consistent dialog controls * webui: fullscreen HTML preview dialog using bits-ui * Update tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: pedantic style tweak for CodePreviewDialog close button * webui: remove overengineered preview language logic * chore: update webui static build --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> webui: auto-refresh /props on inference start to resync model metadata (#16784) * webui: auto-refresh /props on inference start to resync model metadata - Add no-cache headers to /props and /slots - Throttle slot checks to 30s - Prevent concurrent fetches with promise guard - Trigger refresh from chat streaming for legacy and ModelSelector - Show dynamic serverWarning when using cached data * fix: restore proper legacy behavior in webui by using unified /props refresh Updated assistant message bubbles to show each message's stored model when available, falling back to the current server model only when the per-message value is missing When the model selector is disabled, now fetches /props and prioritizes that model name over chunk metadata, then persists it with the streamed message so legacy mode properly reflects the backend configuration * fix: detect first valid SSE chunk and refresh server props once * fix: removed the slots availability throttle constant and state * webui: purge ai-generated cruft * chore: update webui static build feat(webui): improve LaTeX rendering with currency detection (#16508) * webui : Revised LaTeX formula recognition * webui : Further examples containg amounts * webui : vitest for maskInlineLaTeX * webui: Moved preprocessLaTeX to lib/utils * webui: LaTeX in table-cells * chore: update webui build output (use theirs) * webui: backslash in LaTeX-preprocessing * chore: update webui build output * webui: look-behind backslash-check * chore: update webui build output * Apply suggestions from code review Code maintenance (variable names, code formatting, string handling) Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: Moved constants to lib/constants. * webui: package woff2 inside base64 data * webui: LaTeX-line-break in display formula * chore: update webui build output * webui: Bugfix (font embedding) * webui: Bugfix (font embedding) * webui: vite embeds assets * webui: don't suppress 404 (fonts) * refactor: KaTeX integration with SCSS Moves KaTeX styling to SCSS for better customization and font embedding. This change includes: - Adding `sass` as a dev dependency. - Introducing a custom SCSS file to override KaTeX variables and disable TTF/WOFF fonts, relying solely on WOFF2 for embedding. - Adjusting the Vite configuration to resolve `katex-fonts` alias and inject SCSS variables. * fix: LaTeX processing within blockquotes * webui: update webui build output --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> server : add props.model_alias (#16943) * server : add props.model_alias webui: fix keyboard shortcuts for new chat & edit chat title (#17007) Better UX for handling multiple attachments in WebUI (#17246) webui: add OAI-Compat Harmony tool-call streaming visualization and persistence in chat UI (#16618) * webui: add OAI-Compat Harmony tool-call live streaming visualization and persistence in chat UI - Purely visual and diagnostic change, no effect on model context, prompt construction, or inference behavior - Captured assistant tool call payloads during streaming and non-streaming completions, and persisted them in chat state and storage for downstream use - Exposed parsed tool call labels beneath the assistant's model info line with graceful fallback when parsing fails - Added tool call badges beneath assistant responses that expose JSON tooltips and copy their payloads when clicked, matching the existing model badge styling - Added a user-facing setting to toggle tool call visibility to the Developer settings section directly under the model selector option * webui: remove scroll listener causing unnecessary layout updates (model selector) * Update tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * chore: npm run format & update webui build output * chore: update webui build output --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> webui: Fix clickability around chat processing statistics UI (#17278) * fix: Better pointer events handling in chat processing info elements * chore: update webui build output Fix merge error webui: Add a "Continue" Action for Assistant Message (#16971) * feat: Add "Continue" action for assistant messages * feat: Continuation logic & prompt improvements * chore: update webui build output * feat: Improve logic for continuing the assistant message * chore: update webui build output * chore: Linting * chore: update webui build output * fix: Remove synthetic prompt logic, use the prefill feature by sending the conversation payload ending with assistant message * chore: update webui build output * feat: Enable "Continue" button based on config & non-reasoning model type * chore: update webui build output * chore: Update packages with `npm audit fix` * fix: Remove redundant error * chore: update webui build output * chore: Update `.gitignore` * fix: Add missing change * feat: Add auto-resizing for Edit Assistant/User Message textareas * chore: update webui build output Improved file naming & structure for UI components (#17405) * refactor: Component iles naming & structure * chore: update webui build output * refactor: Dialog titles + components namig * chore: update webui build output * refactor: Imports * chore: update webui build output webui: hide border of button webui: update webui: update webui: update add vision webui: minor settings reorganization and add disable autoscroll option (#17452) * webui: added a dedicated 'Display' settings section that groups visualization options * webui: added a Display setting to toggle automatic chat scrolling * chore: update webui build output Co-authored-by: firecoperana <firecoperana>
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import toast from 'react-hot-toast';
|
|
import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
|
|
import ChatMessage from './ChatMessage';
|
|
import { CanvasType, Message, PendingMessage } from '../utils/types';
|
|
import { classNames, cleanCurrentUrl } from '../utils/misc';
|
|
import CanvasPyInterpreter from './CanvasPyInterpreter';
|
|
import StorageUtils from '../utils/storage';
|
|
import { useVSCodeContext } from '../utils/llama-vscode';
|
|
import { useChatTextarea, ChatTextareaApi } from './useChatTextarea.ts';
|
|
import { scrollToBottom, useChatScroll } from './useChatScroll.tsx';
|
|
import {
|
|
ArrowUpIcon,
|
|
StopIcon,
|
|
PaperClipIcon,
|
|
} from '@heroicons/react/24/solid';
|
|
import {
|
|
ChatExtraContextApi,
|
|
useChatExtraContext,
|
|
} from './useChatExtraContext.tsx';
|
|
import Dropzone from 'react-dropzone';
|
|
import ChatInputExtraContextItem from './ChatInputExtraContextItem.tsx';
|
|
/**
|
|
* A message display is a message node with additional information for rendering.
|
|
* For example, siblings of the message node are stored as their last node (aka leaf node).
|
|
*/
|
|
export interface MessageDisplay {
|
|
msg: Message | PendingMessage;
|
|
siblingLeafNodeIds: Message['id'][];
|
|
siblingCurrIdx: number;
|
|
isPending?: boolean;
|
|
}
|
|
|
|
/**
|
|
* If the current URL contains "?m=...", prefill the message input with the value.
|
|
* If the current URL contains "?q=...", prefill and SEND the message.
|
|
*/
|
|
const prefilledMsg = {
|
|
content() {
|
|
const url = new URL(window.location.href);
|
|
return url.searchParams.get('m') ?? url.searchParams.get('q') ?? '';
|
|
},
|
|
shouldSend() {
|
|
const url = new URL(window.location.href);
|
|
return url.searchParams.has('q');
|
|
},
|
|
clear() {
|
|
cleanCurrentUrl(['m', 'q']);
|
|
},
|
|
};
|
|
|
|
function getListMessageDisplay(
|
|
msgs: Readonly<Message[]>,
|
|
leafNodeId: Message['id']
|
|
): MessageDisplay[] {
|
|
const currNodes = StorageUtils.filterByLeafNodeId(msgs, leafNodeId, true);
|
|
const res: MessageDisplay[] = [];
|
|
const nodeMap = new Map<Message['id'], Message>();
|
|
for (const msg of msgs) {
|
|
nodeMap.set(msg.id, msg);
|
|
}
|
|
// find leaf node from a message node
|
|
const findLeafNode = (msgId: Message['id']): Message['id'] => {
|
|
let currNode: Message | undefined = nodeMap.get(msgId);
|
|
while (currNode) {
|
|
if (currNode.children.length === 0) break;
|
|
currNode = nodeMap.get(currNode.children.at(-1) ?? -1);
|
|
}
|
|
return currNode?.id ?? -1;
|
|
};
|
|
// traverse the current nodes
|
|
for (const msg of currNodes) {
|
|
const parentNode = nodeMap.get(msg.parent ?? -1);
|
|
if (!parentNode) continue;
|
|
const siblings = parentNode.children;
|
|
if (msg.type !== 'root') {
|
|
res.push({
|
|
msg,
|
|
siblingLeafNodeIds: siblings.map(findLeafNode),
|
|
siblingCurrIdx: siblings.indexOf(msg.id),
|
|
});
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
|
|
|
|
export default function ChatScreen() {
|
|
const {
|
|
viewingChat,
|
|
sendMessage,
|
|
isGenerating,
|
|
stopGenerating,
|
|
pendingMessages,
|
|
canvasData,
|
|
replaceMessageAndGenerate,
|
|
continueMessageAndGenerate,
|
|
} = useAppContext();
|
|
|
|
const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content());
|
|
|
|
const extraContext = useChatExtraContext();
|
|
useVSCodeContext(textarea, extraContext);
|
|
|
|
const msgListRef = useRef<HTMLDivElement>(null);
|
|
useChatScroll(msgListRef);
|
|
// TODO: improve this when we have "upload file" feature
|
|
// keep track of leaf node for rendering
|
|
const [currNodeId, setCurrNodeId] = useState<number>(-1);
|
|
const messages: MessageDisplay[] = useMemo(() => {
|
|
if (!viewingChat) return [];
|
|
else return getListMessageDisplay(viewingChat.messages, currNodeId);
|
|
}, [currNodeId, viewingChat]);
|
|
|
|
const currConvId = viewingChat?.conv.id ?? null;
|
|
const pendingMsg: PendingMessage | undefined =
|
|
pendingMessages[currConvId ?? ''];
|
|
|
|
useEffect(() => {
|
|
// reset to latest node when conversation changes
|
|
setCurrNodeId(-1);
|
|
// scroll to bottom when conversation changes
|
|
scrollToBottom(false, 1);
|
|
}, [currConvId]);
|
|
|
|
const onChunk: CallbackGeneratedChunk = (currLeafNodeId?: Message['id']) => {
|
|
if (currLeafNodeId) {
|
|
setCurrNodeId(currLeafNodeId);
|
|
}
|
|
//useChatScroll will handle the auto scroll
|
|
};
|
|
|
|
const sendNewMessage = async () => {
|
|
|
|
const lastInpMsg = textarea.value();
|
|
try {
|
|
const generate = isGenerating(currConvId ?? '');
|
|
console.log('IsGenerating', generate);
|
|
if (lastInpMsg.trim().length === 0 || generate)
|
|
return;
|
|
|
|
textarea.setValue('');
|
|
scrollToBottom(false);
|
|
setCurrNodeId(-1);
|
|
// get the last message node
|
|
const lastMsgNodeId = messages.at(-1)?.msg.id ?? null;
|
|
const successSendMsg=await sendMessage(
|
|
currConvId,
|
|
lastMsgNodeId,
|
|
lastInpMsg,
|
|
extraContext.items,
|
|
onChunk
|
|
);
|
|
console.log('Send msg success:', successSendMsg);
|
|
if (!successSendMsg)
|
|
{
|
|
// restore the input message if failed
|
|
textarea.setValue(lastInpMsg);
|
|
}
|
|
// OK
|
|
extraContext.clearItems();
|
|
}
|
|
catch (err) {
|
|
//console.error('Error sending message:', error);
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
textarea.setValue(lastInpMsg); // Restore input on error
|
|
}
|
|
};
|
|
|
|
const handleEditMessage = async (msg: Message, content: string) => {
|
|
if (!viewingChat) return;
|
|
setCurrNodeId(msg.id);
|
|
scrollToBottom(false);
|
|
await replaceMessageAndGenerate(
|
|
viewingChat.conv.id,
|
|
msg.parent,
|
|
content,
|
|
msg.extra,
|
|
onChunk
|
|
);
|
|
setCurrNodeId(-1);
|
|
scrollToBottom(false);
|
|
|
|
};
|
|
|
|
const handleRegenerateMessage = async (msg: Message) => {
|
|
if (!viewingChat) return;
|
|
setCurrNodeId(msg.parent);
|
|
scrollToBottom(false);
|
|
await replaceMessageAndGenerate(
|
|
viewingChat.conv.id,
|
|
msg.parent,
|
|
null,
|
|
msg.extra,
|
|
onChunk
|
|
);
|
|
setCurrNodeId(-1);
|
|
scrollToBottom(false);
|
|
|
|
};
|
|
|
|
const handleContinueMessage = async (msg: Message, content: string) => {
|
|
if (!viewingChat || !continueMessageAndGenerate) return;
|
|
setCurrNodeId(msg.id);
|
|
scrollToBottom(false);
|
|
await continueMessageAndGenerate(
|
|
viewingChat.conv.id,
|
|
msg.id,
|
|
content,
|
|
onChunk
|
|
);
|
|
setCurrNodeId(-1);
|
|
scrollToBottom(false);
|
|
|
|
};
|
|
|
|
const hasCanvas = !!canvasData;
|
|
|
|
useEffect(() => {
|
|
if (prefilledMsg.shouldSend()) {
|
|
// send the prefilled message if needed
|
|
sendNewMessage();
|
|
} else {
|
|
// otherwise, focus on the input
|
|
textarea.focus();
|
|
}
|
|
prefilledMsg.clear();
|
|
// no need to keep track of sendNewMessage
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [textarea.ref]);
|
|
|
|
// due to some timing issues of StorageUtils.appendMsg(), we need to make sure the pendingMsg is not duplicated upon rendering (i.e. appears once in the saved conversation and once in the pendingMsg)
|
|
const pendingMsgDisplay: MessageDisplay[] =
|
|
pendingMsg && !messages.some((m) => m.msg.id === pendingMsg.id) // Only show if pendingMsg is not an existing message being continued
|
|
? [
|
|
{
|
|
msg: pendingMsg,
|
|
siblingLeafNodeIds: [],
|
|
siblingCurrIdx: 0,
|
|
isPending: true,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
return (
|
|
<div
|
|
className={classNames({
|
|
'grid lg:gap-8 grow transition-[300ms]': true,
|
|
'grid-cols-[1fr_0fr] lg:grid-cols-[1fr_1fr]': hasCanvas, // adapted for mobile
|
|
'grid-cols-[1fr_0fr]': !hasCanvas,
|
|
})}
|
|
>
|
|
<div
|
|
className={classNames({
|
|
'flex flex-col w-[75vw] mx-auto': true,
|
|
'hidden lg:flex': hasCanvas, // adapted for mobile
|
|
flex: !hasCanvas,
|
|
})}
|
|
>
|
|
<div className="flex items-center justify-center">
|
|
{viewingChat?.conv.model_name}
|
|
</div>
|
|
{/* chat messages */}
|
|
<div id="messages-list" className="grow" ref={msgListRef}>
|
|
<div className="mt-auto flex justify-center">
|
|
{/* placeholder to shift the message to the bottom */}
|
|
<div>
|
|
{viewingChat ? '' : ''}
|
|
</div>
|
|
{viewingChat==null && (
|
|
<div className="w-full max-w-2xl px-4">
|
|
<div className="mb-8 text-center" >
|
|
<p className="text-1xl text-muted-foreground">How can I help you today?</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
{[...messages, ...pendingMsgDisplay].map((msgDisplay) => {
|
|
const actualMsgObject = msgDisplay.msg;
|
|
// Check if the current message from the list is the one actively being generated/continued
|
|
const isThisMessageTheActivePendingOne =
|
|
pendingMsg?.id === actualMsgObject.id;
|
|
|
|
return (
|
|
<ChatMessage
|
|
key={actualMsgObject.id}
|
|
// If this message is the active pending one, use the live object from pendingMsg state (which has streamed content).
|
|
// Otherwise, use the version from the messages array (from storage).
|
|
msg={
|
|
isThisMessageTheActivePendingOne
|
|
? pendingMsg
|
|
: actualMsgObject
|
|
}
|
|
siblingLeafNodeIds={msgDisplay.siblingLeafNodeIds}
|
|
siblingCurrIdx={msgDisplay.siblingCurrIdx}
|
|
onRegenerateMessage={handleRegenerateMessage}
|
|
onEditMessage={handleEditMessage}
|
|
onChangeSibling={setCurrNodeId}
|
|
// A message is pending if it's the actively streaming one OR if it came from pendingMsgDisplay (for new messages)
|
|
isPending={
|
|
isThisMessageTheActivePendingOne || msgDisplay.isPending
|
|
}
|
|
onContinueMessage={handleContinueMessage}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
{/* chat input */}
|
|
<ChatInput
|
|
textarea={textarea}
|
|
extraContext={extraContext}
|
|
onSend={sendNewMessage}
|
|
onStop={() => stopGenerating(currConvId ?? '')}
|
|
isGenerating={isGenerating(currConvId ?? '')}
|
|
/>
|
|
</div>
|
|
<div className="w-full sticky top-[7em] h-[calc(100vh-9em)]">
|
|
{canvasData?.type === CanvasType.PY_INTERPRETER && (
|
|
<CanvasPyInterpreter />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function ChatInput({
|
|
textarea,
|
|
extraContext,
|
|
onSend,
|
|
onStop,
|
|
isGenerating,
|
|
}: {
|
|
textarea: ChatTextareaApi;
|
|
extraContext: ChatExtraContextApi;
|
|
onSend: () => void;
|
|
onStop: () => void;
|
|
isGenerating: boolean;
|
|
}) {
|
|
const { config } = useAppContext();
|
|
const [isDrag, setIsDrag] = useState(false);
|
|
|
|
return (
|
|
<div
|
|
role="group"
|
|
aria-label="Chat input"
|
|
className={classNames({
|
|
'flex items-end pt-8 pb-6 sticky bottom-0 bg-base-100': true,
|
|
'opacity-50': isDrag, // simply visual feedback to inform user that the file will be accepted
|
|
})}
|
|
>
|
|
<Dropzone
|
|
noClick
|
|
onDrop={(files: File[]) => {
|
|
setIsDrag(false);
|
|
extraContext.onFileAdded(files);
|
|
}}
|
|
onDragEnter={() => setIsDrag(true)}
|
|
onDragLeave={() => setIsDrag(false)}
|
|
multiple={true}
|
|
>
|
|
{({ getRootProps, getInputProps }) => (
|
|
<div
|
|
className="flex flex-col rounded-xl border-1 border-base-content/30 p-3 w-full"
|
|
// when a file is pasted to the input, we handle it here
|
|
// if a text is pasted, and if it is long text, we will convert it to a file
|
|
onPasteCapture={(e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
const text = e.clipboardData.getData('text/plain');
|
|
if (
|
|
text.length > 0 &&
|
|
config.pasteLongTextToFileLen > 0 &&
|
|
text.length > config.pasteLongTextToFileLen
|
|
) {
|
|
// if the text is too long, we will convert it to a file
|
|
extraContext.addItems([
|
|
{
|
|
type: 'context',
|
|
name: 'Pasted Content',
|
|
content: text,
|
|
},
|
|
]);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// if a file is pasted, we will handle it here
|
|
const files = Array.from(e.clipboardData.items)
|
|
.filter((item) => item.kind === 'file')
|
|
.map((item) => item.getAsFile())
|
|
.filter((file) => file !== null);
|
|
|
|
if (files.length > 0) {
|
|
e.preventDefault();
|
|
extraContext.onFileAdded(files);
|
|
}
|
|
}}
|
|
{...getRootProps()}
|
|
>
|
|
{!isGenerating && (
|
|
<ChatInputExtraContextItem
|
|
items={extraContext.items}
|
|
removeItem={extraContext.removeItem}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex flex-row w-full">
|
|
<textarea
|
|
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed
|
|
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit
|
|
className="text-md outline-none border-none w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto" // Adjust lg:max-h-48 as needed (e.g., lg:max-h-60)
|
|
placeholder="Type a message..."
|
|
ref={textarea.ref}
|
|
onInput={textarea.onInput} // Hook's input handler (will only resize height on lg+ screens)
|
|
onKeyDown={(e) => {
|
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
onSend();
|
|
}
|
|
}}
|
|
id="msg-input"
|
|
dir="auto"
|
|
// Set a base height of 2 rows for mobile views
|
|
// On lg+ screens, the hook will calculate and set the initial height anyway
|
|
rows={2}
|
|
></textarea>
|
|
|
|
{/* buttons area */}
|
|
<div className="flex flex-row gap-2 ml-2">
|
|
<label
|
|
htmlFor="file-upload"
|
|
className={classNames({
|
|
'btn w-8 h-8 p-0 rounded-full': true,
|
|
'btn-disabled': isGenerating,
|
|
})}
|
|
aria-label="Upload file"
|
|
tabIndex={0}
|
|
role="button"
|
|
>
|
|
<PaperClipIcon className="h-5 w-5" />
|
|
</label>
|
|
<input
|
|
id="file-upload"
|
|
type="file"
|
|
disabled={isGenerating}
|
|
{...getInputProps()}
|
|
hidden
|
|
/>
|
|
{isGenerating ? (
|
|
<button
|
|
className="btn btn-neutral w-8 h-8 p-0 rounded-full"
|
|
onClick={onStop}
|
|
>
|
|
<StopIcon className="h-5 w-5" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn btn-primary w-8 h-8 p-0 rounded-full"
|
|
onClick={onSend}
|
|
aria-label="Send message"
|
|
>
|
|
<ArrowUpIcon className="h-5 w-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Dropzone>
|
|
</div>
|
|
);
|
|
} |