mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
import {
|
|
ActionBarPrimitive,
|
|
AuiIf,
|
|
MessagePrimitive,
|
|
useAuiState,
|
|
useMessagePartText,
|
|
} from "@assistant-ui/react";
|
|
import { useAtomValue, useSetAtom } from "jotai";
|
|
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react";
|
|
import Image from "next/image";
|
|
import { useParams } from "next/navigation";
|
|
import { type FC, useCallback, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
|
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
|
import { MentionChip } from "@/components/assistant-ui/mention-chip";
|
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
|
import { parseMentionSegments } from "@/lib/chat/parse-mention-segments";
|
|
|
|
interface AuthorMetadata {
|
|
displayName: string | null;
|
|
avatarUrl: string | null;
|
|
}
|
|
|
|
const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
|
|
const [hasError, setHasError] = useState(false);
|
|
|
|
const initials = displayName
|
|
? displayName
|
|
.split(" ")
|
|
.map((n) => n[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2)
|
|
: "U";
|
|
|
|
if (avatarUrl && !hasError) {
|
|
return (
|
|
<Image
|
|
src={avatarUrl}
|
|
alt={displayName || "User"}
|
|
width={32}
|
|
height={32}
|
|
className="size-8 rounded-full object-cover"
|
|
referrerPolicy="no-referrer"
|
|
onError={() => setHasError(true)}
|
|
unoptimized
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary select-none">
|
|
{initials}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UserTextPart: FC = () => {
|
|
const messageId = useAuiState(({ message }) => message?.id);
|
|
const part = useMessagePartText();
|
|
const text = (part as { text?: string }).text ?? "";
|
|
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
|
const mentionedDocs = (messageId ? messageDocumentsMap[messageId] : undefined) ?? [];
|
|
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
|
const params = useParams();
|
|
const searchSpaceIdParam = params?.search_space_id;
|
|
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
|
? Number(searchSpaceIdParam[0])
|
|
: Number(searchSpaceIdParam);
|
|
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
|
? parsedSearchSpaceId
|
|
: undefined;
|
|
|
|
const handleOpenDoc = useCallback(
|
|
(docId: number, title: string) => {
|
|
if (!resolvedSearchSpaceId) {
|
|
toast.error("Cannot open document outside a search space.");
|
|
return;
|
|
}
|
|
openEditorPanel({
|
|
kind: "document",
|
|
documentId: docId,
|
|
searchSpaceId: resolvedSearchSpaceId,
|
|
title,
|
|
});
|
|
},
|
|
[openEditorPanel, resolvedSearchSpaceId]
|
|
);
|
|
|
|
const segments = parseMentionSegments(text, mentionedDocs);
|
|
|
|
return (
|
|
<p style={{ whiteSpace: "pre-line" }} className="wrap-break-word">
|
|
{segments.map((segment) => {
|
|
if (segment.type === "text") {
|
|
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
|
|
}
|
|
const isFolder = segment.doc.kind === "folder";
|
|
const icon = isFolder ? (
|
|
<FolderIcon className="size-3.5" />
|
|
) : (
|
|
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
|
|
);
|
|
return (
|
|
<MentionChip
|
|
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
|
|
icon={icon}
|
|
label={segment.doc.title}
|
|
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
|
|
onClick={
|
|
isFolder
|
|
? undefined
|
|
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
|
|
}
|
|
className="mx-0.5"
|
|
/>
|
|
);
|
|
})}
|
|
</p>
|
|
);
|
|
};
|
|
|
|
const userMessageParts = { Text: UserTextPart };
|
|
|
|
export const UserMessage: FC = () => {
|
|
const metadata = useAuiState(({ message }) => message?.metadata);
|
|
const author = metadata?.custom?.author as AuthorMetadata | undefined;
|
|
const isSharedChat = useAtomValue(currentThreadAtom).visibility === "SEARCH_SPACE";
|
|
const showAvatar = isSharedChat && !!author;
|
|
|
|
return (
|
|
<MessagePrimitive.Root
|
|
className="group/user-msg aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 pt-3 pb-8 duration-150 [&:where(>*)]:col-start-2"
|
|
data-role="user"
|
|
>
|
|
<div className="col-start-2 min-w-0">
|
|
<div className="aui-user-message-content-wrapper flex items-end gap-2">
|
|
<div className="relative flex-1 min-w-0">
|
|
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
|
<MessagePrimitive.Parts components={userMessageParts} />
|
|
</div>
|
|
<div className="absolute right-0 top-full mt-1 z-10 opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:transition-opacity md:duration-200 md:delay-300 md:group-hover/user-msg:opacity-100 md:group-hover/user-msg:delay-0 md:group-hover/user-msg:pointer-events-auto">
|
|
<UserActionBar />
|
|
</div>
|
|
</div>
|
|
{showAvatar && (
|
|
<div className="shrink-0 mb-1.5">
|
|
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</MessagePrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const UserActionBar: FC = () => {
|
|
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
|
|
|
// Get current message ID
|
|
const currentMessageId = useAuiState(({ message }) => message?.id);
|
|
|
|
// Find the last user message ID in the thread (computed once, memoized by selector)
|
|
const lastUserMessageId = useAuiState(({ thread }) => {
|
|
const messages = thread.messages;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (messages[i].role === "user") {
|
|
return messages[i].id;
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// Simple comparison - no iteration needed per message
|
|
const isLastUserMessage = currentMessageId === lastUserMessageId;
|
|
|
|
// Show edit button only on the last user message and when thread is not running
|
|
const canEdit = isLastUserMessage && !isThreadRunning;
|
|
|
|
return (
|
|
<ActionBarPrimitive.Root
|
|
hideWhenRunning
|
|
className="aui-user-action-bar-root flex items-center justify-end gap-1 text-muted-foreground"
|
|
>
|
|
<ActionBarPrimitive.Copy asChild>
|
|
<TooltipIconButton tooltip="Copy">
|
|
<AuiIf condition={({ message }) => message.isCopied}>
|
|
<CheckIcon />
|
|
</AuiIf>
|
|
<AuiIf condition={({ message }) => !message.isCopied}>
|
|
<CopyIcon />
|
|
</AuiIf>
|
|
</TooltipIconButton>
|
|
</ActionBarPrimitive.Copy>
|
|
{canEdit && (
|
|
<ActionBarPrimitive.Edit asChild>
|
|
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
|
|
<Pencil />
|
|
</TooltipIconButton>
|
|
</ActionBarPrimitive.Edit>
|
|
)}
|
|
</ActionBarPrimitive.Root>
|
|
);
|
|
};
|