feat(open-webui): full attachment menu with all source types

Replace the single attach popover with an Open WebUI-style multi-level
menu covering every attachment source:

- Upload Files: images stay inline (vision); other files are uploaded and
  grounded via retrieval (`file-{id}` collection).
- Capture: take a photo from the webcam as an inline image.
- Attach Webpage: ingest a URL (existing).
- Attach Files: pick files already stored in Open WebUI.
- Attach Notes: inject the contents of a note as context.
- Attach Knowledge: nested picker with checkboxes, expandable to a base's
  files (replaces the flat checkbox list).
- Reference Chats: bring a previous conversation in as context.

Knowledge bases, web pages and files are grounded by retrieval; notes and
referenced chats are injected verbatim as context blocks. Adds the
supporting integration methods (list files/notes, knowledge files, upload
file), request handlers and tRPC procedures.
This commit is contained in:
Lok3sh-Kumar committed 2026-06-27 14:16:13 +05:30
1 parent 800caf6e5e
commit 78bd66e803
8 files changed
+809 -124

No files matched your search

@@ -38,6 +38,24 @@
max-width: 85%;
}
.menuRowLabel {
flex: 1;
text-align: left;
font-weight: 400;
}
.attachmentBadge {
max-width: 180px;
text-transform: none;
}
.captureVideo {
width: 100%;
max-height: 50vh;
border-radius: var(--mantine-radius-md);
background-color: black;
}
.thumb {
position: relative;
line-height: 0;
+530 -108
View File
@@ -12,6 +12,7 @@ import {
Group,
Image,
Loader,
Modal,
NavLink,
Paper,
Popover,
@@ -27,10 +28,15 @@ import {
IconAlertTriangle,
IconArrowLeft,
IconArrowUp,
IconCamera,
IconChevronDown,
IconChevronRight,
IconDatabase,
IconFile,
IconFileText,
IconHistory,
IconMessage,
IconPaperclip,
IconPhoto,
IconPlayerStopFilled,
IconPlus,
IconRefresh,
@@ -58,12 +64,28 @@ interface ImageAttachment {
name: string;
dataUrl: string;
}
interface WebAttachment {
url: string;
collectionName: string;
title: string;
}
interface FileAttachment {
id: string;
name: string;
}
interface NoteAttachment {
id: string;
title: string;
content: string;
}
interface ChatAttachment {
id: string;
title: string;
transcript: string;
}
// Which sub-view the attach popover is showing.
type AttachView = "root" | "webpage" | "files" | "notes" | "knowledge" | "chats";
// Heuristic fallback for detecting vision-capable models when Open WebUI does
// not declare the capability explicitly.
@@ -81,24 +103,33 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
const [isStreaming, setIsStreaming] = useState(false);
const [pendingMessages, setPendingMessages] = useState<CompletionMessage[]>([]);
const [pendingCollections, setPendingCollections] = useState<string[]>([]);
const [pendingContextTexts, setPendingContextTexts] = useState<string[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [historyOpened, setHistoryOpened] = useState(false);
// Attachments grounding the conversation. Images are per-message; knowledge
// collections and web pages are sticky context kept until removed.
// Attachments. Images are inline per-message (vision); the rest are sticky
// context kept for the conversation until removed.
const [attachments, setAttachments] = useState<ImageAttachment[]>([]);
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([]);
const [webItems, setWebItems] = useState<WebAttachment[]>([]);
const [fileItems, setFileItems] = useState<FileAttachment[]>([]);
const [noteItems, setNoteItems] = useState<NoteAttachment[]>([]);
const [chatItems, setChatItems] = useState<ChatAttachment[]>([]);
const [webInput, setWebInput] = useState("");
const [attachOpened, setAttachOpened] = useState(false);
const [attachView, setAttachView] = useState<AttachView>("root");
const [captureOpened, setCaptureOpened] = useState(false);
const messagesRef = useRef<ChatMessage[]>([]);
const streamingTextRef = useRef("");
const viewportRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const captureStreamRef = useRef<MediaStream | null>(null);
const [composerHeight, setComposerHeight] = useState(72);
const utils = clientApi.useUtils();
@@ -110,12 +141,22 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
const { data: knowledge = [] } = clientApi.widget.openWebUi.getKnowledge.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
{ enabled: Boolean(integrationId) && attachOpened, refetchOnWindowFocus: false, retry: false },
);
const { data: files = [] } = clientApi.widget.openWebUi.getFiles.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId) && attachOpened, refetchOnWindowFocus: false, retry: false },
);
const { data: notes = [] } = clientApi.widget.openWebUi.getNotes.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId) && attachOpened, refetchOnWindowFocus: false, retry: false },
);
const { data: chats = [] } = clientApi.widget.openWebUi.getChats.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId) && options.showHistory, refetchOnWindowFocus: false, retry: false },
{ enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
const { data: loadedChat } = clientApi.widget.openWebUi.getChat.useQuery(
@@ -126,6 +167,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
const createChat = clientApi.widget.openWebUi.createChat.useMutation();
const updateChat = clientApi.widget.openWebUi.updateChat.useMutation();
const processWeb = clientApi.widget.openWebUi.processWeb.useMutation();
const uploadFile = clientApi.widget.openWebUi.uploadFile.useMutation();
// Default to the first available model once the list loads.
useEffect(() => {
@@ -161,12 +203,21 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}, [messages, streamingText, composerHeight]);
// Reset the popover to the root view whenever it closes.
useEffect(() => {
if (!attachOpened) setAttachView("root");
}, [attachOpened]);
// Stop the camera stream on unmount.
useEffect(() => () => stopCaptureStream(), []);
clientApi.widget.openWebUi.sendMessage.useSubscription(
{
integrationId: integrationId ?? "",
model: model ?? "",
messages: pendingMessages,
collections: pendingCollections.length > 0 ? pendingCollections : undefined,
contextTexts: pendingContextTexts.length > 0 ? pendingContextTexts : undefined,
},
{
enabled: isStreaming && pendingMessages.length > 0,
@@ -201,6 +252,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
setIsStreaming(false);
setPendingMessages([]);
setPendingCollections([]);
setPendingContextTexts([]);
};
const persistChat = async (finalMessages: ChatMessage[]) => {
@@ -219,9 +271,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
const chat = await createChat.mutateAsync({ integrationId, ...payload });
setActiveChatId(chat.id);
}
if (options.showHistory) {
await utils.widget.openWebUi.getChats.invalidate({ integrationId });
}
await utils.widget.openWebUi.getChats.invalidate({ integrationId });
} catch {
// Persistence is best-effort; the conversation remains usable in-memory.
}
@@ -238,13 +288,23 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
? [{ role: "system", content: systemPrompt }, ...baseMessages]
: baseMessages;
// Knowledge ids double as their collection name; web pages bring their own.
const collections = [...knowledgeIds, ...webItems.map((item) => item.collectionName)];
// Retrieval collections: knowledge bases (id), web pages and files.
const collections = [
...knowledgeIds,
...webItems.map((item) => item.collectionName),
...fileItems.map((item) => `file-${item.id}`),
];
// Verbatim context: notes and referenced chats.
const contextTexts = [
...noteItems.map((note) => `Note "${note.title}":\n${note.content}`),
...chatItems.map((chat) => `Previous chat "${chat.title}":\n${chat.transcript}`),
];
streamingTextRef.current = "";
setStreamingText("");
setPendingMessages(toCompletionMessages(payloadMessages));
setPendingCollections(collections);
setPendingContextTexts(contextTexts);
setIsStreaming(true);
};
@@ -290,20 +350,41 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
setIsStreaming(false);
setPendingMessages([]);
setPendingCollections([]);
setPendingContextTexts([]);
setAttachments([]);
setKnowledgeIds([]);
setWebItems([]);
setFileItems([]);
setNoteItems([]);
setChatItems([]);
setHistoryOpened(false);
};
// Upload Files: images become inline vision attachments; everything else is
// uploaded to Open WebUI and grounded via retrieval.
const handleFilesSelected = async (fileList: FileList | null) => {
if (!fileList) return;
const images = await Promise.all(
Array.from(fileList)
.filter((file) => file.type.startsWith("image/"))
.map(async (file) => ({ name: file.name, dataUrl: await readFileAsDataUrl(file) })),
);
setAttachments((previous) => [...previous, ...images]);
if (!fileList || !integrationId) return;
for (const file of Array.from(fileList)) {
try {
const dataUrl = await readFileAsDataUrl(file);
if (file.type.startsWith("image/")) {
setAttachments((previous) => [...previous, { name: file.name, dataUrl }]);
} else {
const contentBase64 = dataUrl.split(",")[1] ?? "";
const uploaded = await uploadFile.mutateAsync({
integrationId,
filename: file.name,
contentBase64,
contentType: file.type || "application/octet-stream",
});
setFileItems((previous) =>
previous.some((item) => item.id === uploaded.id) ? previous : [...previous, uploaded],
);
}
} catch {
setError(t("widget.openWebUi.uploadError"));
}
}
};
const addWebUrl = async () => {
@@ -320,22 +401,105 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
? previous
: [...previous, { url, collectionName: document.collectionName, title: document.title }],
);
setAttachOpened(false);
} catch {
setError(t("widget.openWebUi.webError"));
}
};
const toggleFile = (file: FileAttachment) =>
setFileItems((previous) =>
previous.some((item) => item.id === file.id)
? previous.filter((item) => item.id !== file.id)
: [...previous, file],
);
const toggleNote = (note: NoteAttachment) =>
setNoteItems((previous) =>
previous.some((item) => item.id === note.id)
? previous.filter((item) => item.id !== note.id)
: [...previous, note],
);
const toggleKnowledge = (id: string) =>
setKnowledgeIds((previous) =>
previous.includes(id) ? previous.filter((value) => value !== id) : [...previous, id],
);
const toggleChatReference = async (chat: { id: string; title?: string | null }) => {
if (!integrationId) return;
if (chatItems.some((item) => item.id === chat.id)) {
setChatItems((previous) => previous.filter((item) => item.id !== chat.id));
return;
}
try {
const full = await utils.widget.openWebUi.getChat.fetch({ integrationId, chatId: chat.id });
const transcript = extractChatMessages(full.chat)
.map((message) => `${message.role}: ${message.content}`)
.join("\n");
setChatItems((previous) => [...previous, { id: chat.id, title: chat.title ?? chat.id, transcript }]);
} catch {
setError(t("widget.openWebUi.chatError"));
}
};
// ---- Camera capture ----
const stopCaptureStream = () => {
captureStreamRef.current?.getTracks().forEach((track) => track.stop());
captureStreamRef.current = null;
};
const openCapture = async () => {
setAttachOpened(false);
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
captureStreamRef.current = stream;
setCaptureOpened(true);
} catch {
setError(t("widget.openWebUi.captureError"));
}
};
const closeCapture = () => {
stopCaptureStream();
setCaptureOpened(false);
};
useEffect(() => {
if (captureOpened && videoRef.current && captureStreamRef.current) {
videoRef.current.srcObject = captureStreamRef.current;
}
}, [captureOpened]);
const takePhoto = () => {
const video = videoRef.current;
if (!video) return;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
setAttachments((previous) => [
...previous,
{ name: `capture-${Date.now()}.png`, dataUrl: canvas.toDataURL("image/png") },
]);
closeCapture();
};
// Drop the noisy ":latest" tag from the label (common for Ollama models); the
// value keeps the full id so the right model is still sent.
const modelData = useMemo(
() => models.map((m) => ({ value: m.id, label: (m.name ?? m.id).replace(/:latest$/i, "") })),
[models],
);
const knowledgeName = (id: string) => knowledge.find((item) => item.id === id)?.name ?? id;
const hasAttachments = attachments.length > 0 || knowledgeIds.length > 0 || webItems.length > 0;
const hasAttachments =
attachments.length > 0 ||
knowledgeIds.length > 0 ||
webItems.length > 0 ||
fileItems.length > 0 ||
noteItems.length > 0 ||
chatItems.length > 0;
// Whether the selected model can actually see attached images. Prefer Open
// WebUI's declared capability; fall back to a name heuristic when it's absent.
// Whether the selected model can actually see attached images.
const selectedModel = useMemo(() => models.find((item) => item.id === model), [models, model]);
const modelSupportsVision = useMemo(() => {
if (!selectedModel) return true;
@@ -345,6 +509,19 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
}, [selectedModel]);
const showVisionWarning = attachments.length > 0 && !modelSupportsVision;
const attachViewTitle =
attachView === "webpage"
? t("widget.openWebUi.attachWebpage")
: attachView === "files"
? t("widget.openWebUi.attachFiles")
: attachView === "notes"
? t("widget.openWebUi.attachNotes")
: attachView === "knowledge"
? t("widget.openWebUi.attachKnowledge")
: attachView === "chats"
? t("widget.openWebUi.referenceChats")
: "";
return (
<Box className={classes.root} style={isEditMode ? { pointerEvents: "none" } : undefined}>
<ScrollArea className={classes.messages} viewportRef={viewportRef} type="auto">
@@ -393,42 +570,56 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
</Box>
))}
{knowledgeIds.map((id) => (
<Badge
<AttachmentBadge
key={id}
variant="light"
color="grape"
leftSection={<IconDatabase size={12} />}
rightSection={
<CloseButton
size="xs"
onClick={() => setKnowledgeIds((previous) => previous.filter((value) => value !== id))}
aria-label={t("widget.openWebUi.removeAttachment")}
/>
}
>
{knowledgeName(id)}
</Badge>
icon={<IconDatabase size={12} />}
label={knowledge.find((item) => item.id === id)?.name ?? id}
onRemove={() => toggleKnowledge(id)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{webItems.map((item) => (
<Badge
<AttachmentBadge
key={item.collectionName}
variant="light"
color="blue"
leftSection={<IconWorld size={12} />}
rightSection={
<CloseButton
size="xs"
onClick={() =>
setWebItems((previous) =>
previous.filter((value) => value.collectionName !== item.collectionName),
)
}
aria-label={t("widget.openWebUi.removeAttachment")}
/>
icon={<IconWorld size={12} />}
label={hostnameOf(item.url)}
onRemove={() =>
setWebItems((previous) => previous.filter((value) => value.collectionName !== item.collectionName))
}
>
{hostnameOf(item.url)}
</Badge>
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{fileItems.map((item) => (
<AttachmentBadge
key={item.id}
color="teal"
icon={<IconFile size={12} />}
label={item.name}
onRemove={() => toggleFile(item)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{noteItems.map((item) => (
<AttachmentBadge
key={item.id}
color="yellow"
icon={<IconFileText size={12} />}
label={item.title}
onRemove={() => toggleNote(item)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{chatItems.map((item) => (
<AttachmentBadge
key={item.id}
color="indigo"
icon={<IconMessage size={12} />}
label={item.title}
onRemove={() => setChatItems((previous) => previous.filter((value) => value.id !== item.id))}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
</Group>
) : null}
@@ -475,7 +666,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
onChange={setAttachOpened}
position="top-start"
withinPortal={false}
width={260}
width={280}
shadow="md"
>
<Popover.Target>
@@ -483,6 +674,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
<ActionIcon
variant="subtle"
color="gray"
loading={processWeb.isPending || uploadFile.isPending}
onClick={() => setAttachOpened((value) => !value)}
aria-label={t("widget.openWebUi.attach")}
>
@@ -490,68 +682,154 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Button
variant="light"
color="gray"
size="xs"
leftSection={<IconPhoto size={16} />}
justify="flex-start"
onClick={() => fileInputRef.current?.click()}
>
{t("widget.openWebUi.attachImage")}
</Button>
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t("widget.openWebUi.attachKnowledge")}
</Text>
{knowledge.length > 0 ? (
<Checkbox.Group value={knowledgeIds} onChange={setKnowledgeIds}>
<Stack gap={4}>
{knowledge.map((item) => (
<Checkbox key={item.id} value={item.id} label={item.name ?? item.id} size="xs" />
))}
</Stack>
</Checkbox.Group>
) : (
<Text size="xs" c="dimmed">
{t("widget.openWebUi.noKnowledge")}
</Text>
)}
<Popover.Dropdown p={4}>
{attachView === "root" ? (
<Stack gap={2}>
<MenuRow
icon={<IconPaperclip size={16} />}
label={t("widget.openWebUi.uploadFiles")}
onClick={() => fileInputRef.current?.click()}
/>
<MenuRow
icon={<IconCamera size={16} />}
label={t("widget.openWebUi.capture")}
onClick={() => void openCapture()}
/>
<MenuRow
icon={<IconWorld size={16} />}
label={t("widget.openWebUi.attachWebpage")}
hasSub
onClick={() => setAttachView("webpage")}
/>
<MenuRow
icon={<IconFile size={16} />}
label={t("widget.openWebUi.attachFiles")}
hasSub
onClick={() => setAttachView("files")}
/>
<MenuRow
icon={<IconFileText size={16} />}
label={t("widget.openWebUi.attachNotes")}
hasSub
onClick={() => setAttachView("notes")}
/>
<MenuRow
icon={<IconDatabase size={16} />}
label={t("widget.openWebUi.attachKnowledge")}
hasSub
onClick={() => setAttachView("knowledge")}
/>
<MenuRow
icon={<IconMessage size={16} />}
label={t("widget.openWebUi.referenceChats")}
hasSub
onClick={() => setAttachView("chats")}
/>
</Stack>
) : (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t("widget.openWebUi.attachWebpage")}
</Text>
<Group gap={4} wrap="nowrap">
<TextInput
size="xs"
flex={1}
placeholder="https://…"
value={webInput}
onChange={(event) => setWebInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void addWebUrl();
}
}}
/>
<Group gap={6} wrap="nowrap" px={4} pt={2}>
<ActionIcon
variant="light"
variant="subtle"
color="gray"
loading={processWeb.isPending}
onClick={() => void addWebUrl()}
aria-label={t("widget.openWebUi.addUrl")}
size="sm"
onClick={() => setAttachView("root")}
aria-label={t("widget.openWebUi.back")}
>
<IconPlus size={16} />
<IconArrowLeft size={16} />
</ActionIcon>
<Text fw={600} size="sm">
{attachViewTitle}
</Text>
</Group>
{attachView === "webpage" ? (
<Group gap={4} wrap="nowrap" p={4}>
<TextInput
size="xs"
flex={1}
placeholder="https://…"
value={webInput}
onChange={(event) => setWebInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void addWebUrl();
}
}}
/>
<ActionIcon
variant="light"
color="gray"
loading={processWeb.isPending}
onClick={() => void addWebUrl()}
aria-label={t("widget.openWebUi.addUrl")}
>
<IconPlus size={16} />
</ActionIcon>
</Group>
) : null}
{attachView === "files" ? (
<PickerList emptyLabel={t("widget.openWebUi.noFiles")} count={files.length}>
{files.map((file) => (
<Checkbox
key={file.id}
size="xs"
label={file.name}
checked={fileItems.some((item) => item.id === file.id)}
onChange={() => toggleFile(file)}
/>
))}
</PickerList>
) : null}
{attachView === "notes" ? (
<PickerList emptyLabel={t("widget.openWebUi.noNotes")} count={notes.length}>
{notes.map((note) => (
<Checkbox
key={note.id}
size="xs"
label={note.title}
checked={noteItems.some((item) => item.id === note.id)}
onChange={() => toggleNote({ id: note.id, title: note.title, content: note.content })}
/>
))}
</PickerList>
) : null}
{attachView === "knowledge" ? (
<PickerList emptyLabel={t("widget.openWebUi.noKnowledge")} count={knowledge.length}>
{knowledge.map((item) => (
<KnowledgeRow
key={item.id}
integrationId={integrationId ?? ""}
knowledgeId={item.id}
name={item.name ?? item.id}
selected={knowledgeIds.includes(item.id)}
onToggle={() => toggleKnowledge(item.id)}
isFileSelected={(id) => fileItems.some((file) => file.id === id)}
onToggleFile={toggleFile}
/>
))}
</PickerList>
) : null}
{attachView === "chats" ? (
<PickerList emptyLabel={t("widget.openWebUi.noChats")} count={chats.length}>
{chats.map((chat) => (
<Checkbox
key={chat.id}
size="xs"
label={chat.title ?? chat.id}
checked={chatItems.some((item) => item.id === chat.id)}
onChange={() => void toggleChatReference(chat)}
/>
))}
</PickerList>
) : null}
</Stack>
</Stack>
)}
</Popover.Dropdown>
</Popover>
{options.showHistory ? (
@@ -613,10 +891,9 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
hidden
aria-label={t("widget.openWebUi.attachImage")}
aria-label={t("widget.openWebUi.uploadFiles")}
onChange={(event) => {
void handleFilesSelected(event.currentTarget.files);
event.currentTarget.value = "";
@@ -624,6 +901,28 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
/>
</Paper>
<Modal opened={captureOpened} onClose={closeCapture} title={t("widget.openWebUi.capture")} centered>
<Stack gap="sm">
<video
ref={videoRef}
autoPlay
playsInline
aria-label={t("widget.openWebUi.capture")}
className={classes.captureVideo}
>
<track kind="captions" />
</video>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={closeCapture}>
{t("widget.openWebUi.back")}
</Button>
<Button size="xs" leftSection={<IconCamera size={16} />} onClick={takePhoto}>
{t("widget.openWebUi.capture")}
</Button>
</Group>
</Stack>
</Modal>
{options.showHistory && historyOpened ? (
<Paper className={classes.historyOverlay} radius={0}>
<Group gap="xs" p="xs" wrap="nowrap" className={classes.historyHeader}>
@@ -736,6 +1035,129 @@ const extractChatMessages = (chat: RawChat | null | undefined): ChatMessage[] =>
.filter((message): message is ChatMessage => message !== null);
};
function MenuRow({
icon,
label,
hasSub,
onClick,
}: {
icon: React.ReactNode;
label: string;
hasSub?: boolean;
onClick: () => void;
}) {
return (
<Button
variant="subtle"
color="gray"
size="sm"
fullWidth
justify="space-between"
leftSection={icon}
rightSection={hasSub ? <IconChevronRight size={14} /> : <Box w={14} />}
onClick={onClick}
classNames={{ label: classes.menuRowLabel }}
>
{label}
</Button>
);
}
function PickerList({ count, emptyLabel, children }: { count: number; emptyLabel: string; children: React.ReactNode }) {
if (count === 0) {
return (
<Text size="xs" c="dimmed" p="xs">
{emptyLabel}
</Text>
);
}
return (
<ScrollArea.Autosize mah={220} type="auto">
<Stack gap={6} p={4}>
{children}
</Stack>
</ScrollArea.Autosize>
);
}
function KnowledgeRow({
integrationId,
knowledgeId,
name,
selected,
onToggle,
isFileSelected,
onToggleFile,
}: {
integrationId: string;
knowledgeId: string;
name: string;
selected: boolean;
onToggle: () => void;
isFileSelected: (id: string) => boolean;
onToggleFile: (file: FileAttachment) => void;
}) {
const [expanded, setExpanded] = useState(false);
const { data: files = [], isLoading } = clientApi.widget.openWebUi.getKnowledgeFiles.useQuery(
{ integrationId, knowledgeId },
{ enabled: expanded && Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
return (
<Stack gap={4}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Checkbox size="xs" label={name} checked={selected} onChange={onToggle} />
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => setExpanded((value) => !value)}>
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</ActionIcon>
</Group>
{expanded ? (
<Stack gap={4} pl="md">
{isLoading ? (
<Loader size="xs" />
) : (
files.map((file) => (
<Checkbox
key={file.id}
size="xs"
label={file.name}
checked={isFileSelected(file.id)}
onChange={() => onToggleFile(file)}
/>
))
)}
</Stack>
) : null}
</Stack>
);
}
function AttachmentBadge({
color,
icon,
label,
onRemove,
removeLabel,
}: {
color: string;
icon: React.ReactNode;
label: string;
onRemove: () => void;
removeLabel: string;
}) {
return (
<Badge
variant="light"
color={color}
leftSection={icon}
rightSection={<CloseButton size="xs" onClick={onRemove} aria-label={removeLabel} />}
className={classes.attachmentBadge}
>
{label}
</Badge>
);
}
function MessageBubble({ from, content, images }: { from: ChatMessage["role"]; content: string; images?: string[] }) {
const isUser = from === "user";
const avatar = (