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:
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;
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user