Implement new home page redesign (#4931)

* remove legacy home page components, update home page to new layout

* update PromptInput component styles to match new designs, make quick action buttons functional

* home page chat creates new thread in last used workspace

* fix slash commands and agent popup on home page

* disable llm workspace selector action in home page

* add drag and drop file support to home page

* fix behavior of drag and drop on home page

* handle pasting attachments in home page

* update empty state of workspace chat to use new ui

* update empty workspace ui to match home page design, fix flickering loading states

* convert quick action buttons to component, add to empty state ws chat

* fix hover state light mode in quick actions

* add suggested messages subcomponent to empty ws/thread

* adjust width, rounded edges of prompt input

* only show quick actions for admin/manager role

* fix hover states for quick actions and suggested messages component

* make upload document quick action trigger parsed document upload

* fix mic behavior in homepage, ws chat, ws thread chat

* fix margin between prompt input and quick actions

* Simplify message presets by removing heading input (#4915)

* Remove heading input from message presets, merge legacy headings on edit

* filter out empty messages from state after saving

* mark form as dirty on input change

* styling

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* convert SuggestedMessages to component, render SuggestedMessages in home page to target ws

* fix broken handleMessageChange reference

* add translations for QuickActions

* lint

* fix home page chat submission broken by PromptInput onChange removal

* fix prompt input remount race condition, home page suggested message flicker

* remove unused handleSendSuggestedMessage from ChatHistory

* add greeting text to main-page translations, remove defaults

* fix file deletion in parsed files menu on home page

* add virtual thread sidebar state and workspace indicator on home page

* show workspace llm selector on home page when workspace exists

* show home page for all user roles with rbac quick actions

* fix positioning of agent and slash command popups

* remove workspace indicator from home page, match empty state spacing

* Normalize translations for home page redesign (#4986)

* normalize translations

* update translations with DMR

* accidentally changed es translation

* normalize translations for main-page.greeting

* update translations with DMR

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* update translations

* create new workspace in native language
Cleanup workspace page from empty state handling

* update quick action show logic

* fix send button

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2026-02-19 12:40:36 -08:00 committed by GitHub
parent 907bd09faf
commit d325b07182
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 861 additions and 1235 deletions

View File

@ -24,12 +24,15 @@ export default function ThreadItem({
hasNext,
ctrlPressed = false,
}) {
const { slug, threadSlug = null } = useParams();
const { slug: urlSlug, threadSlug = null } = useParams();
const workspaceSlug = workspace?.slug ?? urlSlug;
const optionsContainer = useRef(null);
const [showOptions, setShowOptions] = useState(false);
const linkTo = !thread.slug
? paths.workspace.chat(slug)
: paths.workspace.thread(slug, thread.slug);
const linkTo = thread.virtual
? "/"
: !thread.slug
? paths.workspace.chat(workspaceSlug)
: paths.workspace.thread(workspaceSlug, thread.slug);
const { ref } = useScrollActiveItemIntoView({
isActive,
@ -114,7 +117,7 @@ export default function ThreadItem({
</p>
</a>
)}
{!!thread.slug && !thread.deleted && (
{!!thread.slug && !thread.deleted && !thread.virtual && (
<div ref={optionsContainer} className="flex items-center">
{" "}
{/* Added flex and items-center */}

View File

@ -7,7 +7,10 @@ import ThreadItem from "./ThreadItem";
import { useParams } from "react-router-dom";
export const THREAD_RENAME_EVENT = "renameThread";
export default function ThreadContainer({ workspace }) {
export default function ThreadContainer({
workspace,
isVirtualThread = false,
}) {
const { threadSlug = null } = useParams();
const [threads, setThreads] = useState([]);
const [loading, setLoading] = useState(true);
@ -109,6 +112,12 @@ export default function ThreadContainer({ workspace }) {
}, 500);
}
function getActiveThreadIdx() {
if (isVirtualThread) return threads.length + 1;
const idx = threads.findIndex((t) => t?.slug === threadSlug);
return idx >= 0 ? idx + 1 : 0;
}
if (loading) {
return (
<div className="flex flex-col bg-pulse w-full h-10 items-center justify-center">
@ -117,11 +126,7 @@ export default function ThreadContainer({ workspace }) {
);
}
const activeThreadIdx = !!threads.find(
(thread) => thread?.slug === threadSlug
)
? threads.findIndex((thread) => thread?.slug === threadSlug) + 1
: 0;
const activeThreadIdx = getActiveThreadIdx();
return (
<div className="flex flex-col" role="list" aria-label="Threads">
@ -129,8 +134,9 @@ export default function ThreadContainer({ workspace }) {
idx={0}
activeIdx={activeThreadIdx}
isActive={activeThreadIdx === 0}
workspace={workspace}
thread={{ slug: null, name: "default" }}
hasNext={threads.length > 0}
hasNext={threads.length > 0 || isVirtualThread}
/>
{threads.map((thread, i) => (
<ThreadItem
@ -143,9 +149,19 @@ export default function ThreadContainer({ workspace }) {
workspace={workspace}
onRemove={removeThread}
thread={thread}
hasNext={i !== threads.length - 1}
hasNext={i !== threads.length - 1 || isVirtualThread}
/>
))}
{isVirtualThread && (
<ThreadItem
idx={activeThreadIdx}
activeIdx={activeThreadIdx}
isActive={true}
workspace={workspace}
thread={{ slug: null, name: "*New Thread", virtual: true }}
hasNext={false}
/>
)}
<DeleteAllThreadButton
ctrlPressed={ctrlPressed}
threads={threads}

View File

@ -6,13 +6,14 @@ import ManageWorkspace, {
useManageWorkspaceModal,
} from "../../Modals/ManageWorkspace";
import paths from "@/utils/paths";
import { useParams, useNavigate } from "react-router-dom";
import { useParams, useNavigate, useMatch } from "react-router-dom";
import { GearSix, UploadSimple, DotsSixVertical } from "@phosphor-icons/react";
import useUser from "@/hooks/useUser";
import ThreadContainer from "./ThreadContainer";
import { useMatch } from "react-router-dom";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
import showToast from "@/utils/toast";
import { LAST_VISITED_WORKSPACE } from "@/utils/constants";
import { safeJsonParse } from "@/utils/request";
export default function ActiveWorkspaces() {
const navigate = useNavigate();
@ -23,6 +24,7 @@ export default function ActiveWorkspaces() {
const { showing, showModal, hideModal } = useManageWorkspaceModal();
const { user } = useUser();
const isInWorkspaceSettings = !!useMatch("/workspace/:slug/settings/:tab");
const isHomePage = !!useMatch("/");
useEffect(() => {
async function getWorkspaces() {
@ -71,6 +73,20 @@ export default function ActiveWorkspaces() {
reorderWorkspaces(result.source.index, result.destination.index);
};
// When on the home page, resolve which workspace should be virtually active
const virtualActiveSlug = (() => {
if (!isHomePage || workspaces.length === 0) return null;
const lastVisited = safeJsonParse(
localStorage.getItem(LAST_VISITED_WORKSPACE)
);
if (
lastVisited?.slug &&
workspaces.some((ws) => ws.slug === lastVisited.slug)
)
return lastVisited.slug;
return workspaces[0]?.slug ?? null;
})();
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="workspaces">
@ -83,7 +99,8 @@ export default function ActiveWorkspaces() {
{...provided.droppableProps}
>
{workspaces.map((workspace, index) => {
const isActive = workspace.slug === slug;
const isVirtuallyActive = workspace.slug === virtualActiveSlug;
const isActive = workspace.slug === slug || isVirtuallyActive;
return (
<Draggable
key={workspace.id}
@ -191,6 +208,7 @@ export default function ActiveWorkspaces() {
<ThreadContainer
workspace={workspace}
isActive={isActive}
isVirtualThread={isVirtuallyActive}
/>
)}
</div>

View File

@ -13,7 +13,6 @@ import { useManageWorkspaceModal } from "../../../Modals/ManageWorkspace";
import ManageWorkspace from "../../../Modals/ManageWorkspace";
import { ArrowDown } from "@phosphor-icons/react";
import debounce from "lodash.debounce";
import useUser from "@/hooks/useUser";
import Chartable from "./Chartable";
import Workspace from "@/models/workspace";
import { useParams } from "react-router-dom";
@ -21,7 +20,6 @@ import paths from "@/utils/paths";
import Appearance from "@/models/appearance";
import useTextSize from "@/hooks/useTextSize";
import useChatHistoryScrollHandle from "@/hooks/useChatHistoryScrollHandle";
import { useTranslation } from "react-i18next";
import { useChatMessageAlignment } from "@/hooks/useChatMessageAlignment";
import { ThoughtExpansionProvider } from "./ThoughtContainer";
@ -32,16 +30,13 @@ export default forwardRef(function (
sendCommand,
updateHistory,
regenerateAssistantMessage,
hasAttachments = false,
},
ref
) {
const { t } = useTranslation();
const lastScrollTopRef = useRef(0);
const chatHistoryRef = useRef(null);
const { user } = useUser();
const { threadSlug = null } = useParams();
const { showing, showModal, hideModal } = useManageWorkspaceModal();
const { showing, hideModal } = useManageWorkspaceModal();
const [isAtBottom, setIsAtBottom] = useState(true);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const isStreaming = history[history.length - 1]?.animate;
@ -98,10 +93,6 @@ export default forwardRef(function (
scrollToBottom,
});
const handleSendSuggestedMessage = (heading, message) => {
sendCommand({ text: `${heading} ${message}`, autoSubmit: true });
};
const saveEditedMessage = async ({
editedMessage,
chatId,
@ -197,46 +188,6 @@ export default forwardRef(function (
[compiledHistory.length, lastMessageInfo]
);
if (history.length === 0 && !hasAttachments) {
return (
<div className="flex flex-col h-full md:mt-0 pb-44 md:pb-40 w-full justify-end items-center">
<div className="flex flex-col items-center md:items-start md:max-w-[600px] w-full px-4">
<p className="text-white/60 text-lg font-base py-4">
{t("chat_window.welcome")}
</p>
{!user || user.role !== "default" ? (
<p className="w-full items-center text-white/60 text-lg font-base flex flex-col md:flex-row gap-x-1">
{t("chat_window.get_started")}
<span
className="underline font-medium cursor-pointer"
onClick={showModal}
>
{t("chat_window.upload")}
</span>
{t("chat_window.or")}{" "}
<b className="font-medium italic">{t("chat_window.send_chat")}</b>
</p>
) : (
<p className="w-full items-center text-white/60 text-lg font-base flex flex-col md:flex-row gap-x-1">
{t("chat_window.get_started_default")}{" "}
<b className="font-medium italic">{t("chat_window.send_chat")}</b>
</p>
)}
<WorkspaceChatSuggestions
suggestions={workspace?.suggestedMessages ?? []}
sendSuggestion={handleSendSuggestedMessage}
/>
</div>
{showing && (
<ManageWorkspace
hideModal={hideModal}
providedSlug={workspace.slug}
/>
)}
</div>
);
}
return (
<ThoughtExpansionProvider>
<div
@ -282,24 +233,6 @@ const getLastMessageInfo = (history) => {
};
};
function WorkspaceChatSuggestions({ suggestions = [], sendSuggestion }) {
if (suggestions.length === 0) return null;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-theme-text-primary text-xs mt-10 w-full justify-center">
{suggestions.map((suggestion, index) => (
<button
key={index}
className="text-left p-2.5 rounded-xl bg-theme-sidebar-footer-icon hover:bg-theme-sidebar-footer-icon-hover border border-theme-border"
onClick={() => sendSuggestion(suggestion.heading, suggestion.message)}
>
<p className="font-semibold">{suggestion.heading}</p>
<p>{suggestion.message}</p>
</button>
))}
</div>
);
}
/**
* Builds the history of messages for the chat.
* This is mostly useful for rendering the history in a way that is easy to understand.

View File

@ -16,13 +16,13 @@ export default function AvailableAgentsButton({ showing, setShowAgents }) {
data-tooltip-content={t("chat_window.agents")}
aria-label={t("chat_window.agents")}
onClick={() => setShowAgents(!showing)}
className={`flex justify-center items-center cursor-pointer ${
className={`flex justify-center items-center cursor-pointer opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 ${
showing ? "!opacity-100" : ""
}`}
>
<At
color="var(--theme-sidebar-footer-icon-fill)"
className={`w-[22px] h-[22px] pointer-events-none text-theme-text-primary opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60`}
className="w-[20px] h-[20px] pointer-events-none text-theme-text-primary"
/>
<Tooltip
id="tooltip-agent-list-btn"
@ -47,6 +47,7 @@ export function AvailableAgents({
setShowing,
sendCommand,
promptRef,
centered = false,
}) {
const formRef = useRef(null);
const agentSessionActive = useIsAgentSessionActive();
@ -88,10 +89,16 @@ export function AvailableAgents({
return (
<>
<div hidden={!showing}>
<div className="w-full flex justify-center absolute bottom-[130px] md:bottom-[150px] left-0 z-10 px-4">
<div
className={
centered
? "w-full flex justify-center md:justify-start absolute top-full mt-2 left-0 z-10 px-4 md:px-0 md:pl-[57px]"
: "flex justify-center md:justify-start absolute bottom-[130px] md:bottom-[150px] left-0 right-0 z-10 max-w-[750px] mx-auto px-4 md:px-0 md:pl-[57px]"
}
>
<div
ref={formRef}
className="w-[600px] p-2 bg-theme-action-menu-bg rounded-2xl shadow flex-col justify-center items-start gap-2.5 inline-flex"
className="w-[600px] p-2 bg-theme-action-menu-bg rounded-2xl shadow flex-col justify-center items-start gap-2.5 inline-flex overflow-y-auto max-h-[200px] no-scroll"
>
<button
onClick={handleAgentClick}

View File

@ -1,7 +1,6 @@
import { useState } from "react";
import { X, CircleNotch, Warning } from "@phosphor-icons/react";
import Workspace from "@/models/workspace";
import { useParams } from "react-router-dom";
import { nFormatter } from "@/utils/numbers";
import showToast from "@/utils/toast";
import pluralize from "pluralize";
@ -17,13 +16,14 @@ export default function ParsedFilesMenu({
setCurrentTokens,
contextWindow,
isLoading,
workspaceSlug,
threadSlug = null,
}) {
const { user } = useUser();
const canEmbed = !user || user.role !== "default";
const initialContextWindowLimitExceeded =
contextWindow &&
currentTokens >= contextWindow * Workspace.maxContextWindowLimit;
const { slug, threadSlug = null } = useParams();
const [isEmbedding, setIsEmbedding] = useState(false);
const [embedProgress, setEmbedProgress] = useState(1);
const [contextWindowLimitExceeded, setContextWindowLimitExceeded] = useState(
@ -35,7 +35,7 @@ export default function ParsedFilesMenu({
e.stopPropagation();
if (!file?.id) return;
const success = await Workspace.deleteParsedFiles(slug, [file.id]);
const success = await Workspace.deleteParsedFiles(workspaceSlug, [file.id]);
if (!success) return;
// Update the local files list and current tokens
@ -48,7 +48,7 @@ export default function ParsedFilesMenu({
})
);
const { currentContextTokenCount } = await Workspace.getParsedFiles(
slug,
workspaceSlug,
threadSlug
);
const newContextWindowLimitExceeded =
@ -73,7 +73,7 @@ export default function ParsedFilesMenu({
let completed = 0;
await Promise.all(
files.map((file) =>
Workspace.embedParsedFile(slug, file.id).then(() => {
Workspace.embedParsedFile(workspaceSlug, file.id).then(() => {
completed++;
setEmbedProgress(completed + 1);
})
@ -81,7 +81,7 @@ export default function ParsedFilesMenu({
);
setFiles([]);
const { currentContextTokenCount } = await Workspace.getParsedFiles(
slug,
workspaceSlug,
threadSlug
);
setCurrentTokens(currentContextTokenCount);

View File

@ -15,10 +15,15 @@ import ParsedFilesMenu from "./ParsedFilesMenu";
* This is a simple proxy component that clicks on the DnD file uploader for the user.
* @returns
*/
export default function AttachItem() {
export default function AttachItem({
workspaceSlug = null,
workspaceThreadSlug = null,
}) {
const { t } = useTranslation();
const { theme } = useTheme();
const { slug, threadSlug = null } = useParams();
const params = useParams();
const slug = workspaceSlug || params.slug;
const threadSlug = workspaceThreadSlug ?? params.threadSlug ?? null;
const tooltipRef = useRef(null);
const [isEmbedding, setIsEmbedding] = useState(false);
const [files, setFiles] = useState([]);
@ -93,7 +98,7 @@ export default function AttachItem() {
<div className="relative">
<PaperclipHorizontal
color="var(--theme-sidebar-footer-icon-fill)"
className="w-[22px] h-[22px] pointer-events-none text-white rotate-90 -scale-y-100"
className="w-[20px] h-[20px] pointer-events-none text-white rotate-90 -scale-y-100"
/>
{files.length > 0 && (
<div className="absolute -top-2 right-[1%] bg-white text-black light:invert text-[8px] rounded-full px-1 flex items-center justify-center">
@ -127,6 +132,8 @@ export default function AttachItem() {
currentTokens={currentTokens}
setCurrentTokens={setCurrentTokens}
contextWindow={contextWindow}
workspaceSlug={slug}
threadSlug={threadSlug}
/>
</Tooltip>
)}

View File

@ -3,6 +3,7 @@ import { Brain, CheckCircle } from "@phosphor-icons/react";
import LLMSelectorModal from "./index";
import { useTheme } from "@/hooks/useTheme";
import { useRef, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import useUser from "@/hooks/useUser";
import { useModal } from "@/hooks/useModal";
import SetupProvider from "./SetupProvider";
@ -11,7 +12,9 @@ export const TOGGLE_LLM_SELECTOR_EVENT = "toggle_llm_selector";
export const SAVE_LLM_SELECTOR_EVENT = "save_llm_selector";
export const PROVIDER_SETUP_EVENT = "provider_setup_requested";
export default function LLMSelectorAction() {
export default function LLMSelectorAction({ workspaceSlug = null }) {
const { slug: urlSlug } = useParams();
const slug = urlSlug ?? workspaceSlug;
const tooltipRef = useRef(null);
const { theme } = useTheme();
const { user } = useUser();
@ -87,6 +90,7 @@ export default function LLMSelectorAction() {
// This feature is disabled for multi-user instances where the user is not an admin
// This is because of the limitations of model selection currently and other nuances in controls.
if (!!user && user.role !== "admin") return null;
if (!slug) return null;
return (
<>
@ -97,9 +101,9 @@ export default function LLMSelectorAction() {
className={`border-none relative flex justify-center items-center opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 cursor-pointer`}
>
{saved ? (
<CheckCircle className="w-[22px] h-[22px] pointer-events-none text-green-400" />
<CheckCircle className="w-[20px] h-[20px] pointer-events-none text-green-400" />
) : (
<Brain className="w-[22px] h-[22px] pointer-events-none text-[var(--theme-sidebar-footer-icon-fill)]" />
<Brain className="w-[20px] h-[20px] pointer-events-none text-[var(--theme-sidebar-footer-icon-fill)]" />
)}
</div>
<Tooltip
@ -117,7 +121,7 @@ export default function LLMSelectorAction() {
}
className="z-99 !w-[500px] !bg-theme-bg-primary !px-[5px] !rounded-lg !pointer-events-auto light:border-2 light:border-theme-modal-border"
>
<LLMSelectorModal tooltipRef={tooltipRef} />
<LLMSelectorModal tooltipRef={tooltipRef} workspaceSlug={slug} />
</Tooltip>
<SetupProvider
isOpen={isSetupProviderOpen}

View File

@ -16,8 +16,9 @@ import showToast from "@/utils/toast";
import Workspace from "@/models/workspace";
import System from "@/models/system";
export default function LLMSelectorModal() {
const { slug } = useParams();
export default function LLMSelectorModal({ workspaceSlug = null }) {
const { slug: urlSlug } = useParams();
const slug = urlSlug ?? workspaceSlug;
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [settings, setSettings] = useState(null);

View File

@ -44,6 +44,7 @@ export function validatedModelSelection(model) {
}
export function hasMissingCredentials(settings, provider) {
if (!settings) return false;
const providerEntry = AVAILABLE_LLM_PROVIDERS.find(
(p) => p.value === provider
);

View File

@ -15,13 +15,13 @@ export default function SlashCommandsButton({ showing, setShowSlashCommand }) {
data-tooltip-id="tooltip-slash-cmd-btn"
data-tooltip-content={t("chat_window.slash")}
onClick={() => setShowSlashCommand(!showing)}
className={`flex justify-center items-center cursor-pointer ${
className={`flex justify-center items-center cursor-pointer opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 ${
showing ? "!opacity-100" : ""
}`}
>
<SlashCommandIcon
color="var(--theme-sidebar-footer-icon-fill)"
className={`w-[20px] h-[20px] pointer-events-none opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60`}
className="w-[18px] h-[18px] pointer-events-none"
/>
<Tooltip
id="tooltip-slash-cmd-btn"
@ -33,7 +33,13 @@ export default function SlashCommandsButton({ showing, setShowSlashCommand }) {
);
}
export function SlashCommands({ showing, setShowing, sendCommand, promptRef }) {
export function SlashCommands({
showing,
setShowing,
sendCommand,
promptRef,
centered = false,
}) {
const cmdRef = useRef(null);
useSlashCommandKeyboardNavigation({ showing });
@ -54,10 +60,16 @@ export function SlashCommands({ showing, setShowing, sendCommand, promptRef }) {
return (
<div hidden={!showing}>
<div className="w-full flex justify-center absolute bottom-[130px] md:bottom-[150px] left-0 z-10 px-4">
<div
className={
centered
? "w-full flex justify-center md:justify-start absolute top-full mt-2 left-0 z-10 px-4 md:px-0 md:pl-[31px]"
: "flex justify-center md:justify-start absolute bottom-[130px] md:bottom-[150px] left-0 right-0 z-10 max-w-[750px] mx-auto px-4 md:px-0 md:pl-[31px]"
}
>
<div
ref={cmdRef}
className="w-[600px] bg-theme-action-menu-bg rounded-2xl flex shadow flex-col justify-start items-start gap-2.5 p-2 overflow-y-auto max-h-[300px] no-scroll"
className="w-[600px] bg-theme-action-menu-bg rounded-2xl flex shadow flex-col justify-start items-start gap-2.5 p-2 overflow-y-auto max-h-[200px] no-scroll"
>
<ResetCommand sendCommand={sendCommand} setShowing={setShowing} />
<EndAgentSession sendCommand={sendCommand} setShowing={setShowing} />

View File

@ -130,9 +130,9 @@ export default function SpeechToText({ sendCommand }) {
}`}
>
<Microphone
weight="fill"
weight="regular"
color="var(--theme-sidebar-footer-icon-fill)"
className={`w-[22px] h-[22px] pointer-events-none text-theme-text-primary ${
className={`w-[20px] h-[20px] pointer-events-none text-theme-text-primary ${
listening ? "animate-pulse-glow" : ""
}`}
/>

View File

@ -1,4 +1,5 @@
import { ABORT_STREAM_EVENT } from "@/utils/chat";
import { Stop } from "@phosphor-icons/react";
import { Tooltip } from "react-tooltip";
export default function StopGenerationButton() {
@ -13,40 +14,19 @@ export default function StopGenerationButton() {
onClick={emitHaltEvent}
data-tooltip-id="stop-generation-button"
data-tooltip-content="Stop generating response"
className="border-none text-white/60 cursor-pointer group -mr-1.5 mt-1.5"
className="border-none inline-flex justify-center items-center rounded-full cursor-pointer w-[20px] h-[20px] light:bg-slate-800 bg-white hover:opacity-80 transition-opacity"
aria-label="Stop generating"
>
<svg
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ transform: "scale(1.3)" }}
className="opacity-60 group-hover:opacity-100 light:opacity-100 light:group-hover:opacity-60"
>
<circle
cx="10"
cy="10.562"
r="9"
strokeWidth="2"
className="group-hover:stroke-primary-button stroke-white light:stroke-theme-text-secondary"
/>
<rect
x="6.3999"
y="6.96204"
width="7.2"
height="7.2"
rx="2"
className="group-hover:fill-primary-button fill-white light:fill-theme-text-secondary"
/>
</svg>
<Stop
className="w-[12px] h-[12px] light:text-white text-black"
weight="fill"
/>
</button>
<Tooltip
id="stop-generation-button"
place="bottom"
delayShow={300}
className="tooltip !text-xs z-99 -ml-1"
className="tooltip !text-xs z-99"
/>
</>
);

View File

@ -28,7 +28,7 @@ export default function TextSizeButton() {
<TextT
color="var(--theme-sidebar-footer-icon-fill)"
weight="fill"
className="w-[22px] h-[22px] pointer-events-none text-white"
className="w-[20px] h-[20px] pointer-events-none text-white"
/>
</div>
<Tooltip

View File

@ -4,7 +4,7 @@ import SlashCommandsButton, {
useSlashCommands,
} from "./SlashCommands";
import debounce from "lodash.debounce";
import { PaperPlaneRight } from "@phosphor-icons/react";
import { ArrowUp } from "@phosphor-icons/react";
import StopGenerationButton from "./StopGenerationButton";
import AvailableAgentsButton, {
AvailableAgents,
@ -30,11 +30,23 @@ export const PROMPT_INPUT_ID = "primary-prompt-input";
export const PROMPT_INPUT_EVENT = "set_prompt_input";
const MAX_EDIT_STACK_SIZE = 100;
/**
* @param {function} props.submit - form submit handler
* @param {boolean} props.isStreaming - disables input while streaming response
* @param {function} props.sendCommand - handler for slash commands and agent mentions
* @param {Array} [props.attachments] - file attachments array
* @param {boolean} [props.centered] - renders in centered layout mode (for home page)
* @param {string} [props.workspaceSlug] - workspace slug for home page context
* @param {string} [props.threadSlug] - thread slug for home page context
*/
export default function PromptInput({
submit,
isStreaming,
sendCommand,
attachments = [],
centered = false,
workspaceSlug = null,
threadSlug = null,
}) {
const { t } = useTranslation();
const { isDisabled } = useIsDisabled();
@ -247,27 +259,41 @@ export default function PromptInput({
}
return (
<div className="w-full fixed md:absolute bottom-0 left-0 z-10 md:z-0 flex justify-center items-center pwa:pb-5">
<div
className={
centered
? "w-full relative flex justify-center items-center"
: "w-full fixed md:absolute bottom-0 left-0 z-10 md:z-0 flex justify-center items-center pwa:pb-5"
}
>
<SlashCommands
showing={showSlashCommand}
setShowing={setShowSlashCommand}
sendCommand={sendCommand}
promptRef={textareaRef}
centered={centered}
/>
<AvailableAgents
showing={showAgents}
setShowing={setShowAgents}
sendCommand={sendCommand}
promptRef={textareaRef}
centered={centered}
/>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-y-1 rounded-t-lg md:w-3/4 w-full mx-auto max-w-xl items-center"
className={
centered
? "flex flex-col gap-y-1 rounded-t-lg w-full items-center"
: "flex flex-col gap-y-1 rounded-t-lg md:w-full w-full mx-auto max-w-[750px] items-center"
}
>
<div className="flex items-center rounded-lg md:mb-4 md:w-full">
<div className="w-[95vw] md:w-[635px] bg-theme-bg-chat-input light:bg-white light:border-solid light:border-[1px] light:border-theme-chat-input-border shadow-sm rounded-2xl pwa:rounded-3xl flex flex-col px-2 overflow-hidden">
<div
className={`flex items-center rounded-lg md:w-full ${centered ? "mb-0" : "mb-4"}`}
>
<div className="w-[95vw] md:w-[750px] bg-theme-bg-chat-input light:bg-white light:border-solid light:border-[1px] light:border-theme-chat-input-border shadow-sm rounded-[20px] pwa:rounded-3xl flex flex-col px-2 overflow-hidden">
<AttachmentManager attachments={attachments} />
<div className="flex items-center border-b border-theme-chat-input-border mx-3">
<div className="flex items-center mx-[7px]">
<textarea
id={PROMPT_INPUT_ID}
ref={textareaRef}
@ -288,42 +314,13 @@ export default function PromptInput({
className={`border-none cursor-text max-h-[50vh] md:max-h-[350px] md:min-h-[40px] mx-2 md:mx-0 pt-[12px] w-full leading-5 text-white bg-transparent placeholder:text-white/60 light:placeholder:text-theme-text-primary resize-none active:outline-none focus:outline-none flex-grow mb-1 pwa:!text-[16px] ${textSizeClass}`}
placeholder={t("chat_window.send_message")}
/>
{isStreaming ? (
<StopGenerationButton />
) : (
<>
<button
ref={formRef}
type="submit"
disabled={isDisabled}
className="border-none inline-flex justify-center rounded-2xl cursor-pointer opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 ml-4 disabled:cursor-not-allowed group"
data-tooltip-id="send-prompt"
data-tooltip-content={
isDisabled
? t("chat_window.attachments_processing")
: t("chat_window.send")
}
aria-label={t("chat_window.send")}
>
<PaperPlaneRight
color="var(--theme-sidebar-footer-icon-fill)"
className="w-[22px] h-[22px] pointer-events-none text-theme-text-primary group-disabled:opacity-[25%]"
weight="fill"
/>
<span className="sr-only">Send message</span>
</button>
<Tooltip
id="send-prompt"
place="bottom"
delayShow={300}
className="tooltip !text-xs z-99"
/>
</>
)}
</div>
<div className="flex justify-between py-3.5 mx-3 mb-1">
<div className="flex gap-x-2">
<AttachItem />
<div className="flex justify-between items-center pt-3.5 pb-3 mx-[7px]">
<div className="flex gap-x-2 items-center h-5 -ml-[4.5px]">
<AttachItem
workspaceSlug={workspaceSlug}
workspaceThreadSlug={threadSlug}
/>
<SlashCommandsButton
showing={showSlashCommand}
setShowSlashCommand={setShowSlashCommand}
@ -333,10 +330,41 @@ export default function PromptInput({
setShowAgents={setShowAgents}
/>
<TextSizeButton />
<LLMSelectorAction />
<LLMSelectorAction workspaceSlug={workspaceSlug} />
</div>
<div className="flex gap-x-2">
<div className="flex gap-x-2 items-center h-5">
<SpeechToText sendCommand={sendCommand} />
{isStreaming ? (
<StopGenerationButton />
) : (
<>
<button
ref={formRef}
type="submit"
disabled={isDisabled}
className="border-none inline-flex justify-center items-center rounded-full cursor-pointer w-[20px] h-[20px] light:bg-slate-800 bg-white disabled:cursor-not-allowed disabled:opacity-50 hover:opacity-80 transition-opacity"
data-tooltip-id="send-prompt"
data-tooltip-content={
isDisabled
? t("chat_window.attachments_processing")
: t("chat_window.send")
}
aria-label={t("chat_window.send")}
>
<ArrowUp
className="w-[12px] h-[12px] pointer-events-none light:text-white text-black"
weight="bold"
/>
<span className="sr-only">Send message</span>
</button>
<Tooltip
id="send-prompt"
place="bottom"
delayShow={300}
className="tooltip !text-xs z-99"
/>
</>
)}
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useContext } from "react";
import { useState, useEffect, useContext, useRef } from "react";
import ChatHistory from "./ChatHistory";
import { CLEAR_ATTACHMENTS_EVENT, DndUploaderContext } from "./DnDWrapper";
import PromptInput, {
@ -9,7 +9,7 @@ import Workspace from "@/models/workspace";
import handleChat, { ABORT_STREAM_EVENT } from "@/utils/chat";
import { isMobile } from "react-device-detect";
import { SidebarMobileHeader } from "../../Sidebar";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import { v4 } from "uuid";
import handleSocketResponse, {
websocketURI,
@ -23,8 +23,16 @@ import SpeechRecognition, {
import { ChatTooltips } from "./ChatTooltips";
import { MetricsProvider } from "./ChatHistory/HistoricalMessage/Actions/RenderMetrics";
import useChatContainerQuickScroll from "@/hooks/useChatContainerQuickScroll";
import { PENDING_HOME_MESSAGE } from "@/utils/constants";
import { safeJsonParse } from "@/utils/request";
import { useTranslation } from "react-i18next";
import paths from "@/utils/paths";
import QuickActions from "@/components/lib/QuickActions";
import SuggestedMessages from "@/components/lib/SuggestedMessages";
export default function ChatContainer({ workspace, knownHistory = [] }) {
const navigate = useNavigate();
const { t } = useTranslation();
const { threadSlug = null } = useParams();
const [loadingResponse, setLoadingResponse] = useState(false);
const [chatHistory, setChatHistory] = useState(knownHistory);
@ -32,6 +40,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
const [websocket, setWebsocket] = useState(null);
const { files, parseAttachments } = useContext(DndUploaderContext);
const { chatHistoryRef } = useChatContainerQuickScroll();
const pendingMessageChecked = useRef(false);
const { listening, resetTranscript } = useSpeechRecognition({
clearTranscriptOnListen: true,
@ -164,6 +173,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
role: "assistant",
pending: true,
userMessage: text,
attachments,
animate: true,
},
];
@ -174,6 +184,23 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
setLoadingResponse(true);
};
useEffect(() => {
if (pendingMessageChecked.current || !workspace?.slug) return;
pendingMessageChecked.current = true;
const pending = safeJsonParse(sessionStorage.getItem(PENDING_HOME_MESSAGE));
if (pending?.message) {
setTimeout(() => {
sessionStorage.removeItem(PENDING_HOME_MESSAGE);
sendCommand({
text: pending.message,
attachments: pending.attachments || [],
autoSubmit: true,
});
}, 100);
}
}, [workspace?.slug]);
useEffect(() => {
async function fetchReply() {
const promptMessage =
@ -294,6 +321,53 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
handleWSS();
}, [socketId]);
const isEmpty =
chatHistory.length === 0 && !sessionStorage.getItem(PENDING_HOME_MESSAGE);
if (isEmpty) {
return (
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-hidden"
>
{isMobile && <SidebarMobileHeader />}
<DnDFileUploaderWrapper>
<div className="flex flex-col h-full w-full items-center justify-center">
<div className="flex flex-col items-center w-full max-w-[750px]">
<h1 className="text-white text-xl md:text-2xl mb-11 text-center">
{t("main-page.greeting")}
</h1>
<PromptInput
submit={handleSubmit}
isStreaming={loadingResponse}
sendCommand={sendCommand}
attachments={files}
centered={true}
/>
<QuickActions
hasAvailableWorkspace={!!workspace}
onCreateAgent={() => navigate(paths.settings.agentSkills())}
onEditWorkspace={() =>
navigate(
paths.workspace.settings.generalAppearance(workspace.slug)
)
}
onUploadDocument={() =>
document.getElementById("dnd-chat-file-uploader")?.click()
}
/>
</div>
<SuggestedMessages
suggestedMessages={workspace?.suggestedMessages}
sendCommand={sendCommand}
/>
</div>
</DnDFileUploaderWrapper>
<ChatTooltips />
</div>
);
}
return (
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
@ -301,23 +375,27 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
>
{isMobile && <SidebarMobileHeader />}
<DnDFileUploaderWrapper>
<MetricsProvider>
<ChatHistory
ref={chatHistoryRef}
history={chatHistory}
workspace={workspace}
sendCommand={sendCommand}
updateHistory={setChatHistory}
regenerateAssistantMessage={regenerateAssistantMessage}
hasAttachments={files.length > 0}
/>
</MetricsProvider>
<PromptInput
submit={handleSubmit}
isStreaming={loadingResponse}
sendCommand={sendCommand}
attachments={files}
/>
<div className="flex flex-col h-full w-full">
<div className="contents">
<MetricsProvider>
<ChatHistory
ref={chatHistoryRef}
history={chatHistory}
workspace={workspace}
sendCommand={sendCommand}
updateHistory={setChatHistory}
regenerateAssistantMessage={regenerateAssistantMessage}
/>
</MetricsProvider>
<PromptInput
submit={handleSubmit}
isStreaming={loadingResponse}
sendCommand={sendCommand}
attachments={files}
centered={false}
/>
</div>
</div>
</DnDFileUploaderWrapper>
<ChatTooltips />
</div>

View File

@ -11,6 +11,7 @@ import {
TTSProvider,
useWatchForAutoPlayAssistantTTSResponse,
} from "../contexts/TTSProvider";
import { PENDING_HOME_MESSAGE } from "@/utils/constants";
export default function WorkspaceChat({ loading, workspace }) {
useWatchForAutoPlayAssistantTTSResponse();
@ -36,7 +37,15 @@ export default function WorkspaceChat({ loading, workspace }) {
getHistory();
}, [workspace, loading]);
if (loadingHistory) return <LoadingChat />;
const hasPendingMessage = !!sessionStorage.getItem(PENDING_HOME_MESSAGE);
if (loadingHistory) {
if (hasPendingMessage) {
return (
<div className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full" />
);
}
return <LoadingChat />;
}
if (!loading && !loadingHistory && !workspace) {
return (
<>

View File

@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import useUser from "@/hooks/useUser";
/**
* Quick action buttons for home and empty workspace states.
* @param {Object} props
* @param {boolean} props.hasAvailableWorkspace - Whether the user has a workspace they can use
* @param {Function} props.onCreateAgent - Handler for "Create an Agent" action
* @param {Function} props.onEditWorkspace - Handler for "Edit Workspace" action
* @param {Function} props.onUploadDocument - Handler for "Upload a Document" action
*/
export default function QuickActions({
hasAvailableWorkspace,
onCreateAgent,
onEditWorkspace,
onUploadDocument,
}) {
const { t } = useTranslation();
const { user } = useUser();
return (
<div className="flex flex-wrap justify-center gap-2 mt-6">
<QuickActionButton
label={t("main-page.quickActions.createAgent")}
onClick={onCreateAgent}
show={!user || ["admin"].includes(user?.role)}
/>
<QuickActionButton
label={t("main-page.quickActions.editWorkspace")}
onClick={onEditWorkspace}
show={
hasAvailableWorkspace &&
(!user || ["admin", "manager"].includes(user?.role))
}
/>
<QuickActionButton
label={t("main-page.quickActions.uploadDocument")}
onClick={onUploadDocument}
// Any user can upload documents.
show={true}
/>
</div>
);
}
function QuickActionButton({ label, onClick, show = true }) {
if (!show) return null;
return (
<button
type="button"
onClick={onClick}
className="px-4 py-2 rounded-full bg-theme-bg-chat-input text-white/80 text-sm font-normal leading-5 hover:bg-zinc-700 light:hover:bg-black/20 transition-colors light:text-theme-text-primary"
>
{label}
</button>
);
}

View File

@ -0,0 +1,32 @@
export default function SuggestedMessages({
suggestedMessages = [],
sendCommand,
}) {
if (!suggestedMessages?.length) return null;
return (
<div className="flex flex-col w-full max-w-[650px] mt-6 px-4">
{suggestedMessages.map((msg, index) => {
const text = msg.heading?.trim()
? `${msg.heading.trim()} ${msg.message?.trim() || ""}`
: msg.message?.trim() || "";
if (!text) return null;
return (
<div key={index}>
{index > 0 && (
<div className="border-t border-zinc-800 light:border-theme-chat-input-border" />
)}
<button
type="button"
onClick={() => sendCommand({ text, autoSubmit: true })}
className="w-full text-left py-3 px-3 text-white/80 text-sm font-normal leading-5 hover:text-white transition-colors light:text-theme-text-primary light:hover:text-theme-text-primary/80 hover:bg-zinc-800 light:hover:bg-black/20 rounded-lg"
>
{text}
</button>
</div>
);
})}
</div>
);
}

View File

@ -917,6 +917,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "اختصارات لوحة المفاتيح",
},
quickActions: {
createAgent: "إنشاء وكيل",
editWorkspace: "تعديل مساحة العمل",
uploadDocument: "تحميل مستند",
},
greeting: "كيف يمكنني مساعدتك اليوم؟",
},
"keyboard-shortcuts": {
title: "اختصارات لوحة المفاتيح",

View File

@ -215,6 +215,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Klávesové zkratky",
},
quickActions: {
createAgent: "Vytvořte agenta",
editWorkspace: "Upravit pracovní prostor",
uploadDocument: "Nahrajte dokument",
},
greeting: "Jak vám mohu dnes pomoci?",
},
"new-workspace": {
title: "Nový pracovní prostor",

View File

@ -942,6 +942,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Tastaturgenveje",
},
quickActions: {
createAgent: "Opret en agent",
editWorkspace: "Rediger arbejdsområdet",
uploadDocument: "Upload en fil",
},
greeting: "Hvordan kan jeg hjælpe dig i dag?",
},
"keyboard-shortcuts": {
title: "Tastaturgenveje",

View File

@ -210,6 +210,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Tastaturkürzel",
},
quickActions: {
createAgent: "Erstelle einen Agenten",
editWorkspace: "Arbeitsbereich bearbeiten",
uploadDocument: "Ein Dokument hochladen",
},
greeting: "Wie kann ich Ihnen heute helfen?",
},
"new-workspace": {
title: "Neuer Workspace",

View File

@ -139,6 +139,7 @@ const TRANSLATIONS = {
},
"main-page": {
greeting: "How can I help you today?",
noWorkspaceError: "Please create a workspace before starting a chat.",
checklist: {
title: "Getting Started",
@ -178,6 +179,11 @@ const TRANSLATIONS = {
},
},
},
quickActions: {
createAgent: "Create an Agent",
editWorkspace: "Edit Workspace",
uploadDocument: "Upload a Document",
},
quickLinks: {
title: "Quick Links",
sendChat: "Send Chat",

View File

@ -210,6 +210,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Atajos de teclado",
},
quickActions: {
createAgent: "Crear un agente",
editWorkspace: "Editar espacio de trabajo",
uploadDocument: "Cargar un documento",
},
greeting: "¿Cómo puedo ayudarte hoy?",
},
"new-workspace": {
title: "Nuevo espacio de trabajo",

View File

@ -203,6 +203,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Klaviatuuri otseteed",
},
quickActions: {
createAgent: "Loo agent",
editWorkspace: "Redige tööruum",
uploadDocument: "Lae fail üles",
},
greeting: "Kuidas saan teid täna aidata?",
},
"new-workspace": {
title: "Uus tööruum",

View File

@ -932,6 +932,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "کلیدهای میانبر",
},
quickActions: {
createAgent: "ایجاد یک عامل",
editWorkspace: "ویرایش فضای کاری",
uploadDocument: "بارگذاری یک سند",
},
greeting: "امروز چگونه می‌توانم به شما کمک کنم؟",
},
"keyboard-shortcuts": {
title: "کلیدهای میانبر",

View File

@ -942,6 +942,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Raccourcis clavier",
},
quickActions: {
createAgent: "Créer un agent",
editWorkspace: "Modifier l'espace de travail",
uploadDocument: "Télécharger un document",
},
greeting: "Comment puis-je vous aider aujourd'hui ?",
},
"keyboard-shortcuts": {
title: "Raccourcis clavier",

View File

@ -203,6 +203,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "קיצורי מקלדת",
},
quickActions: {
createAgent: "צור סוכן",
editWorkspace: "ערוך את סביבת העבודה",
uploadDocument: "העלה מסמך",
},
greeting: "במה אוכל לעזור לך היום?",
},
"new-workspace": {
title: "סביבת עבודה חדשה",

View File

@ -959,6 +959,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Combinazioni di tasti",
},
quickActions: {
createAgent: "Creare un agente",
editWorkspace: "Modifica l'area di lavoro",
uploadDocument: "Caricare un documento",
},
greeting: "Come posso aiutarti oggi?",
},
"keyboard-shortcuts": {
title: "Combinazioni di tasti",

View File

@ -928,6 +928,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "キーボードショートカット",
},
quickActions: {
createAgent: "エージェントを作成する",
editWorkspace: "ワークスペースの編集",
uploadDocument: "ドキュメントをアップロードする",
},
greeting: "今日はどのようにお手伝いできますか?",
},
"keyboard-shortcuts": {
title: "キーボードショートカット",

View File

@ -204,6 +204,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "단축키 안내",
},
quickActions: {
createAgent: "에이전트 생성",
editWorkspace: "워크스페이스 편집",
uploadDocument: "문서 업로드",
},
greeting: "오늘 어떻게 도와드릴까요?",
},
"new-workspace": {
title: "새 워크스페이스",

View File

@ -207,6 +207,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Taustiņu atvieglojumi",
},
quickActions: {
createAgent: "Izveidot aģentu",
editWorkspace: "Rediģēt darba telpu",
uploadDocument: "August failu",
},
greeting: "Kā es varu jums šodien palīdzēt?",
},
"new-workspace": {
title: "Jauna darba telpa",

View File

@ -937,6 +937,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Sneltoetsen",
},
quickActions: {
createAgent: "Maak een agent",
editWorkspace: "Werkruimte bewerken",
uploadDocument: "Upload een document",
},
greeting: "Hoe kan ik u vandaag helpen?",
},
"keyboard-shortcuts": {
title: "Sneltoetsen",

View File

@ -209,6 +209,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Skróty klawiaturowe",
},
quickActions: {
createAgent: "Utwórz agenta",
editWorkspace: "Edytuj przestrzeń roboczą",
uploadDocument: "Załaduj dokument",
},
greeting: "W czym mogę Ci dzisiaj pomóc?",
},
"new-workspace": {
title: "Nowy obszar roboczy",

View File

@ -205,6 +205,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Atalhos de Teclado",
},
quickActions: {
createAgent: "Criar um Agente",
editWorkspace: "Editar o Espaço de Trabalho",
uploadDocument: "Enviar um documento",
},
greeting: "Como posso ajudá-lo hoje?",
},
"new-workspace": {
title: "Novo Workspace",

View File

@ -209,6 +209,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Scurtături de tastatură",
},
quickActions: {
createAgent: "Creați un agent",
editWorkspace: "Modifică spațiul de lucru",
uploadDocument: "Încărcați un document",
},
greeting: "Cu ce vă pot ajuta astăzi?",
},
"new-workspace": {
title: "Spațiu de lucru nou",

View File

@ -946,6 +946,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Сочетания клавиш",
},
quickActions: {
createAgent: "Создать агента",
editWorkspace: "Редактировать рабочее пространство",
uploadDocument: "Загрузить документ",
},
greeting: "Чем я могу вам помочь сегодня?",
},
"keyboard-shortcuts": {
title: "Сочетания клавиш",

View File

@ -934,6 +934,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Klavye Kısayolları",
},
quickActions: {
createAgent: "Bir temsilci oluşturun",
editWorkspace: "Çalışma Alanını Düzenle",
uploadDocument: "Bir belge yükleyin",
},
greeting: "Bugün size nasıl yardımcı olabilirim?",
},
"keyboard-shortcuts": {
title: "Klavye Kısayolları",

View File

@ -930,6 +930,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "Phím tắt",
},
quickActions: {
createAgent: "Tạo một đại lý",
editWorkspace: "Chỉnh sửa không gian làm việc",
uploadDocument: "Tải lên một tài liệu",
},
greeting: "Hôm nay tôi có thể giúp gì cho bạn?",
},
"keyboard-shortcuts": {
title: "Phím tắt",

View File

@ -199,6 +199,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "键盘快捷键",
},
quickActions: {
createAgent: "创建代理",
editWorkspace: "编辑工作区",
uploadDocument: "上传文件",
},
greeting: "今天我能帮您什么?",
},
"new-workspace": {
title: "新工作区",

View File

@ -874,6 +874,12 @@ const TRANSLATIONS = {
},
keyboardShortcuts: "鍵盤快捷鍵",
},
quickActions: {
createAgent: "建立一個代理",
editWorkspace: "編輯工作區",
uploadDocument: "上傳文件",
},
greeting: "今天我能幫您什麼?",
},
"keyboard-shortcuts": {
title: "鍵盤快捷鍵",

View File

@ -1,28 +0,0 @@
import React from "react";
export default function SlashCommandIcon({ className }) {
return (
<svg
width={16}
height={16}
viewBox="0 0 14 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect
x="0.5"
y="1"
width="13"
height="13"
rx="3.5"
stroke="currentColor"
/>
<path
d="M4.18103 10.7974L9.8189 4.20508"
stroke="currentColor"
strokeLinecap="round"
/>
</svg>
);
}

View File

@ -1,81 +0,0 @@
import { useState } from "react";
import { CHECKLIST_STORAGE_KEY, CHECKLIST_UPDATED_EVENT } from "../constants";
import { Check } from "@phosphor-icons/react";
import { safeJsonParse } from "@/utils/request";
export function ChecklistItem({ id, title, action, onAction, icon: Icon }) {
const [isCompleted, setIsCompleted] = useState(() => {
const stored = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
if (!stored) return false;
const completedItems = safeJsonParse(stored, {});
return completedItems[id] || false;
});
const handleClick = async (e) => {
e.preventDefault();
if (!isCompleted) {
const shouldComplete = await onAction();
if (shouldComplete) {
const stored = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
const completedItems = safeJsonParse(stored, {});
completedItems[id] = true;
window.localStorage.setItem(
CHECKLIST_STORAGE_KEY,
JSON.stringify(completedItems)
);
setIsCompleted(true);
window.dispatchEvent(new CustomEvent(CHECKLIST_UPDATED_EVENT));
}
} else {
await onAction();
}
};
return (
<div
className={`flex items-center gap-x-4 transition-colors cursor-pointer rounded-lg p-3 group hover:bg-theme-checklist-item-bg-hover ${
isCompleted
? "bg-theme-checklist-item-completed-bg"
: "bg-theme-checklist-item-bg"
}`}
onClick={handleClick}
>
{Icon && (
<div className="flex-shrink-0">
<Icon
size={18}
className={
isCompleted
? "text-theme-checklist-item-completed-text"
: "text-theme-checklist-item-text"
}
/>
</div>
)}
<div className="flex-1">
<h3
className={`text-sm font-medium transition-colors duration-200 ${
isCompleted
? "text-theme-checklist-item-completed-text line-through"
: "text-theme-checklist-item-text"
}`}
>
{title}
</h3>
</div>
{isCompleted ? (
<div className="w-5 h-5 rounded-full bg-theme-checklist-checkbox-fill flex items-center justify-center">
<Check
size={14}
weight="bold"
className="text-theme-checklist-checkbox-text"
/>
</div>
) : (
<button className="w-[64px] h-[24px] rounded-md bg-white/10 light:bg-white/70 text-theme-checklist-item-text font-semibold text-xs transition-all duration-200 flex items-center justify-center hover:bg-white/20 light:hover:bg-white/60">
{action}
</button>
)}
</div>
);
}

View File

@ -1,168 +0,0 @@
import {
SquaresFour,
ChatDots,
Files,
ChatCenteredText,
UsersThree,
} from "@phosphor-icons/react";
import SlashCommandIcon from "./ChecklistItem/icons/SlashCommand";
import paths from "@/utils/paths";
import { t } from "i18next";
const noop = () => {};
export const CHECKLIST_UPDATED_EVENT = "anythingllm_checklist_updated";
export const CHECKLIST_STORAGE_KEY = "anythingllm_checklist_completed";
export const CHECKLIST_HIDDEN = "anythingllm_checklist_dismissed";
/**
* @typedef {Object} ChecklistItemHandlerParams
* @property {Object[]} workspaces - Array of workspaces
* @property {Function} navigate - Function to navigate to a path
* @property {Function} setSelectedWorkspace - Function to set the selected workspace
* @property {Function} showManageWsModal - Function to show the manage workspace modal
* @property {Function} showToast - Function to show a toast
* @property {Function} showNewWsModal - Function to show the new workspace modal
*/
/**
* @typedef {Object} ChecklistItem
* @property {string} id
* @property {string} title
* @property {string} description
* @property {string} action
* @property {(params: ChecklistItemHandlerParams) => boolean} handler
* @property {string} icon
* @property {boolean} completed
*/
/**
* Function to generate the checklist items
* @returns {ChecklistItem[]}
*/
export const CHECKLIST_ITEMS = () => [
{
id: "create_workspace",
title: t("main-page.checklist.tasks.create_workspace.title"),
description: t("main-page.checklist.tasks.create_workspace.description"),
action: t("main-page.checklist.tasks.create_workspace.action"),
handler: ({ showNewWsModal = noop }) => {
showNewWsModal();
return true;
},
icon: SquaresFour,
},
{
id: "send_chat",
title: t("main-page.checklist.tasks.send_chat.title"),
description: t("main-page.checklist.tasks.send_chat.description"),
action: t("main-page.checklist.tasks.send_chat.action"),
handler: ({
workspaces = [],
navigate = noop,
showToast = noop,
showNewWsModal = noop,
}) => {
if (workspaces.length === 0) {
showToast(t("main-page.noWorkspaceError"), "warning", {
clear: true,
});
showNewWsModal();
return false;
}
navigate(paths.workspace.chat(workspaces[0].slug));
return true;
},
icon: ChatDots,
},
{
id: "embed_document",
title: t("main-page.checklist.tasks.embed_document.title"),
description: t("main-page.checklist.tasks.embed_document.description"),
action: t("main-page.checklist.tasks.embed_document.action"),
handler: ({
workspaces = [],
setSelectedWorkspace = noop,
showManageWsModal = noop,
showToast = noop,
showNewWsModal = noop,
}) => {
if (workspaces.length === 0) {
showToast(t("main-page.noWorkspaceError"), "warning", {
clear: true,
});
showNewWsModal();
return false;
}
setSelectedWorkspace(workspaces[0]);
showManageWsModal();
return true;
},
icon: Files,
},
{
id: "setup_system_prompt",
title: t("main-page.checklist.tasks.setup_system_prompt.title"),
description: t("main-page.checklist.tasks.setup_system_prompt.description"),
action: t("main-page.checklist.tasks.setup_system_prompt.action"),
handler: ({
workspaces = [],
navigate = noop,
showNewWsModal = noop,
showToast = noop,
}) => {
if (workspaces.length === 0) {
showToast(t("main-page.noWorkspaceError"), "warning", {
clear: true,
});
showNewWsModal();
return false;
}
navigate(
paths.workspace.settings.chatSettings(workspaces[0].slug, {
search: { action: "focus-system-prompt" },
})
);
return true;
},
icon: ChatCenteredText,
},
{
id: "define_slash_command",
title: t("main-page.checklist.tasks.define_slash_command.title"),
description: t(
"main-page.checklist.tasks.define_slash_command.description"
),
action: t("main-page.checklist.tasks.define_slash_command.action"),
handler: ({
workspaces = [],
navigate = noop,
showNewWsModal = noop,
showToast = noop,
}) => {
if (workspaces.length === 0) {
showToast(t("main-page.noWorkspaceError"), "warning", { clear: true });
showNewWsModal();
return false;
}
navigate(
paths.workspace.chat(workspaces[0].slug, {
search: { action: "open-new-slash-command-modal" },
})
);
return true;
},
icon: SlashCommandIcon,
},
{
id: "visit_community",
title: t("main-page.checklist.tasks.visit_community.title"),
description: t("main-page.checklist.tasks.visit_community.description"),
action: t("main-page.checklist.tasks.visit_community.action"),
handler: () => {
window.open(paths.communityHub.website(), "_blank");
return true;
},
icon: UsersThree,
},
];

View File

@ -1,216 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import ManageWorkspace, {
useManageWorkspaceModal,
} from "@/components/Modals/ManageWorkspace";
import NewWorkspaceModal, {
useNewWorkspaceModal,
} from "@/components/Modals/NewWorkspace";
import Workspace from "@/models/workspace";
import { useNavigate } from "react-router-dom";
import { ChecklistItem } from "./ChecklistItem";
import showToast from "@/utils/toast";
import {
CHECKLIST_HIDDEN,
CHECKLIST_STORAGE_KEY,
CHECKLIST_ITEMS,
CHECKLIST_UPDATED_EVENT,
} from "./constants";
import ConfettiExplosion from "react-confetti-explosion";
import { safeJsonParse } from "@/utils/request";
import { useTranslation } from "react-i18next";
const MemoizedChecklistItem = React.memo(ChecklistItem);
export default function Checklist() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [isHidden, setIsHidden] = useState(false);
const [completedCount, setCompletedCount] = useState(0);
const [isCompleted, setIsCompleted] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState(null);
const [workspaces, setWorkspaces] = useState([]);
const navigate = useNavigate();
const containerRef = useRef(null);
const {
showModal: showNewWsModal,
hideModal: hideNewWsModal,
showing: showingNewWsModal,
} = useNewWorkspaceModal();
const { showModal: showManageWsModal, hideModal: hideManageWsModal } =
useManageWorkspaceModal();
const createItemHandler = useCallback(
(item) => {
return () =>
item.handler({
workspaces,
navigate,
setSelectedWorkspace,
showManageWsModal,
showToast,
showNewWsModal,
});
},
[
workspaces,
navigate,
setSelectedWorkspace,
showManageWsModal,
showToast,
showNewWsModal,
]
);
useEffect(() => {
async function initialize() {
try {
const hidden = window.localStorage.getItem(CHECKLIST_HIDDEN);
setIsHidden(!!hidden);
// If the checklist is hidden, don't bother evaluating it.
if (hidden) return;
// If the checklist is completed then dont continue and just show the completed state.
const checklist = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
const existingChecklist = checklist ? safeJsonParse(checklist, {}) : {};
const isCompleted =
Object.keys(existingChecklist).length === CHECKLIST_ITEMS().length;
setIsCompleted(isCompleted);
if (isCompleted) return;
// Otherwise, we can fetch workspaces for our checklist tasks as well
// as determine if the create_workspace task is completed for pre-checking.
const workspaces = await Workspace.all();
setWorkspaces(workspaces);
if (workspaces.length > 0) {
existingChecklist["create_workspace"] = true;
window.localStorage.setItem(
CHECKLIST_STORAGE_KEY,
JSON.stringify(existingChecklist)
);
}
evaluateChecklist(); // Evaluate checklist on mount.
window.addEventListener(CHECKLIST_UPDATED_EVENT, evaluateChecklist);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
initialize();
return () => {
window.removeEventListener(CHECKLIST_UPDATED_EVENT, evaluateChecklist);
};
}, []);
useEffect(() => {
const fetchWorkspaces = async () => {
const workspaces = await Workspace.all();
setWorkspaces(workspaces);
};
fetchWorkspaces();
}, []);
useEffect(() => {
if (isCompleted) {
setTimeout(() => {
handleClose();
}, 5_000);
}
}, [isCompleted]);
const evaluateChecklist = useCallback(() => {
try {
const checklist = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
if (!checklist) return;
const completedItems = safeJsonParse(checklist, {});
setCompletedCount(Object.keys(completedItems).length);
setIsCompleted(
Object.keys(completedItems).length === CHECKLIST_ITEMS().length
);
} catch (error) {
console.error(error);
}
}, []);
const handleClose = useCallback(() => {
window.localStorage.setItem(CHECKLIST_HIDDEN, "true");
if (containerRef?.current) containerRef.current.style.height = "0px";
}, []);
if (isHidden || loading) return null;
return (
<div
ref={containerRef}
className="transition-height duration-300 h-[100%] overflow-y-hidden relative"
>
<div
className={`${isCompleted ? "checklist-completed" : "hidden"} absolute top-0 left-0 w-full h-full p-2 z-10 transition-all duration-300`}
>
{isCompleted && (
<div className="flex w-full items-center justify-center">
<ConfettiExplosion force={0.25} duration={3000} />
</div>
)}
<div
style={{}}
className="bg-[rgba(54,70,61,0.5)] light:bg-[rgba(216,243,234,0.5)] w-full h-full flex items-center justify-center bg-theme-checklist-item-completed-bg/50 rounded-lg"
>
<p className="text-theme-checklist-item-completed-text text-lg font-bold">
{t("main-page.checklist.completed")}
</p>
</div>
</div>
<div
className={`rounded-lg p-4 lg:p-6 bg-theme-home-bg-card relative ${isCompleted ? "blur-sm" : ""}`}
>
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-x-3">
<h1 className="text-theme-home-text uppercase text-sm font-semibold">
{t("main-page.checklist.title")}
</h1>
{CHECKLIST_ITEMS().length - completedCount > 0 && (
<p className="text-theme-home-text-secondary text-xs">
{CHECKLIST_ITEMS().length - completedCount}{" "}
{t("main-page.checklist.tasksLeft")}
</p>
)}
</div>
<div className="flex items-center gap-x-2">
<button
onClick={handleClose}
className="text-theme-home-text-secondary bg-theme-home-bg-button px-3 py-1 rounded-xl hover:bg-white/10 transition-colors text-xs light:bg-black-100"
>
{t("main-page.checklist.dismiss")}
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CHECKLIST_ITEMS().map((item) => (
<MemoizedChecklistItem
key={item.id}
id={item.id}
title={item.title}
action={item.action}
icon={item.icon}
completed={item.completed}
onAction={createItemHandler(item)}
/>
))}
</div>
</div>
{showingNewWsModal && <NewWorkspaceModal hideModal={hideNewWsModal} />}
{selectedWorkspace && (
<ManageWorkspace
providedSlug={selectedWorkspace.slug}
hideModal={() => {
setSelectedWorkspace(null);
hideManageWsModal();
}}
/>
)}
</div>
);
}

View File

@ -1,151 +0,0 @@
import { useNavigate } from "react-router-dom";
import paths from "@/utils/paths";
import Workspace from "@/models/workspace";
import { useTranslation } from "react-i18next";
export default function ExploreFeatures() {
const { t } = useTranslation();
const navigate = useNavigate();
const chatWithAgent = async () => {
const workspaces = await Workspace.all();
if (workspaces.length > 0) {
const firstWorkspace = workspaces[0];
navigate(
paths.workspace.chat(firstWorkspace.slug, {
search: { action: "set-agent-chat" },
})
);
}
};
const buildAgentFlow = () => navigate(paths.agents.builder());
const setSlashCommand = async () => {
const workspaces = await Workspace.all();
if (workspaces.length > 0) {
const firstWorkspace = workspaces[0];
navigate(
paths.workspace.chat(firstWorkspace.slug, {
search: { action: "open-new-slash-command-modal" },
})
);
}
};
const exploreSlashCommands = () => {
window.open(paths.communityHub.viewMoreOfType("slash-commands"), "_blank");
};
const setSystemPrompt = async () => {
const workspaces = await Workspace.all();
if (workspaces.length > 0) {
const firstWorkspace = workspaces[0];
navigate(
paths.workspace.settings.chatSettings(firstWorkspace.slug, {
search: { action: "focus-system-prompt" },
})
);
}
};
const managePromptVariables = () => {
navigate(paths.settings.systemPromptVariables());
};
return (
<div>
<h1 className="text-theme-home-text uppercase text-sm font-semibold mb-4">
{t("main-page.exploreMore.title")}
</h1>
<div className="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<FeatureCard
title={t("main-page.exploreMore.features.customAgents.title")}
description={t(
"main-page.exploreMore.features.customAgents.description"
)}
primaryAction={t(
"main-page.exploreMore.features.customAgents.primaryAction"
)}
secondaryAction={t(
"main-page.exploreMore.features.customAgents.secondaryAction"
)}
onPrimaryAction={chatWithAgent}
onSecondaryAction={buildAgentFlow}
/>
<FeatureCard
title={t("main-page.exploreMore.features.slashCommands.title")}
description={t(
"main-page.exploreMore.features.slashCommands.description"
)}
primaryAction={t(
"main-page.exploreMore.features.slashCommands.primaryAction"
)}
secondaryAction={t(
"main-page.exploreMore.features.slashCommands.secondaryAction"
)}
onPrimaryAction={setSlashCommand}
onSecondaryAction={exploreSlashCommands}
isNew={false}
/>
<FeatureCard
title={t("main-page.exploreMore.features.systemPrompts.title")}
description={t(
"main-page.exploreMore.features.systemPrompts.description"
)}
primaryAction={t(
"main-page.exploreMore.features.systemPrompts.primaryAction"
)}
secondaryAction={t(
"main-page.exploreMore.features.systemPrompts.secondaryAction"
)}
onPrimaryAction={setSystemPrompt}
onSecondaryAction={managePromptVariables}
/>
</div>
</div>
);
}
function FeatureCard({
title,
description,
primaryAction,
secondaryAction,
onPrimaryAction,
onSecondaryAction,
isNew,
}) {
return (
<div className="border border-theme-home-border rounded-lg py-4 px-5 flex flex-col justify-between gap-y-4">
<div className="flex flex-col gap-y-2">
<h2 className="text-theme-home-text font-semibold flex items-center gap-x-2">
{title}
</h2>
<p className="text-theme-home-text-secondary text-sm">{description}</p>
</div>
<div className="flex flex-col gap-y-[10px]">
<button
onClick={onPrimaryAction}
className="w-full h-[36px] border border-white/20 light:border-theme-home-button-secondary-border light:hover:border-theme-home-button-secondary-border-hover text-white rounded-lg text-theme-home-button-primary-text text-sm font-medium flex items-center justify-center gap-x-2.5 transition-all duration-200 light:hover:bg-transparent hover:bg-theme-home-button-secondary-hover hover:text-theme-home-button-secondary-hover-text"
>
{primaryAction}
</button>
{secondaryAction && (
<div className="relative w-full">
{isNew && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 px-2 font-semibold rounded-md text-[10px] text-theme-checklist-item-text bg-theme-checklist-item-bg light:bg-white/60">
New
</div>
)}
<button
onClick={onSecondaryAction}
className="w-full h-[36px] bg-theme-home-button-secondary rounded-lg text-theme-home-button-secondary-text text-sm font-medium flex items-center justify-center transition-all duration-200 hover:bg-theme-home-button-secondary-hover hover:text-theme-home-button-secondary-hover-text"
>
{secondaryAction}
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,96 +0,0 @@
import { ChatCenteredDots, FileArrowDown, Plus } from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom";
import Workspace from "@/models/workspace";
import paths from "@/utils/paths";
import { useManageWorkspaceModal } from "@/components/Modals/ManageWorkspace";
import ManageWorkspace from "@/components/Modals/ManageWorkspace";
import { useState } from "react";
import { useNewWorkspaceModal } from "@/components/Modals/NewWorkspace";
import NewWorkspaceModal from "@/components/Modals/NewWorkspace";
import showToast from "@/utils/toast";
import { useTranslation } from "react-i18next";
export default function QuickLinks() {
const { t } = useTranslation();
const navigate = useNavigate();
const { showModal } = useManageWorkspaceModal();
const [selectedWorkspace, setSelectedWorkspace] = useState(null);
const {
showing: showingNewWsModal,
showModal: showNewWsModal,
hideModal: hideNewWsModal,
} = useNewWorkspaceModal();
const sendChat = async () => {
const workspaces = await Workspace.all();
if (workspaces.length > 0) {
const firstWorkspace = workspaces[0];
navigate(paths.workspace.chat(firstWorkspace.slug));
} else {
showToast(t("main-page.noWorkspaceError"), "warning", {
clear: true,
});
showNewWsModal();
}
};
const embedDocument = async () => {
const workspaces = await Workspace.all();
if (workspaces.length > 0) {
const firstWorkspace = workspaces[0];
setSelectedWorkspace(firstWorkspace);
showModal();
} else {
showToast(t("main-page.noWorkspaceError"), "warning", {
clear: true,
});
showNewWsModal();
}
};
const createWorkspace = () => {
showNewWsModal();
};
return (
<div>
<h1 className="text-theme-home-text uppercase text-sm font-semibold mb-4">
{t("main-page.quickLinks.title")}
</h1>
<div className="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<button
onClick={sendChat}
className="h-[45px] text-sm font-semibold bg-theme-home-button-secondary rounded-lg text-theme-home-button-secondary-text flex items-center justify-center gap-x-2.5 transition-all duration-200 hover:bg-theme-home-button-secondary-hover hover:text-theme-home-button-secondary-hover-text"
>
<ChatCenteredDots size={16} />
{t("main-page.quickLinks.sendChat")}
</button>
<button
onClick={embedDocument}
className="h-[45px] text-sm font-semibold bg-theme-home-button-secondary rounded-lg text-theme-home-button-secondary-text flex items-center justify-center gap-x-2.5 transition-all duration-200 hover:bg-theme-home-button-secondary-hover hover:text-theme-home-button-secondary-hover-text"
>
<FileArrowDown size={16} />
{t("main-page.quickLinks.embedDocument")}
</button>
<button
onClick={createWorkspace}
className="h-[45px] text-sm font-semibold bg-theme-home-button-secondary rounded-lg text-theme-home-button-secondary-text flex items-center justify-center gap-x-2.5 transition-all duration-200 hover:bg-theme-home-button-secondary-hover hover:text-theme-home-button-secondary-hover-text"
>
<Plus size={16} />
{t("main-page.quickLinks.createWorkspace")}
</button>
</div>
{selectedWorkspace && (
<ManageWorkspace
providedSlug={selectedWorkspace.slug}
hideModal={() => {
setSelectedWorkspace(null);
}}
/>
)}
{showingNewWsModal && <NewWorkspaceModal hideModal={hideNewWsModal} />}
</div>
);
}

View File

@ -1,48 +0,0 @@
import paths from "@/utils/paths";
import { ArrowCircleUpRight } from "@phosphor-icons/react";
import { useTranslation } from "react-i18next";
import { KEYBOARD_SHORTCUTS_HELP_EVENT } from "@/utils/keyboardShortcuts";
export default function Resources() {
const { t } = useTranslation();
const showKeyboardShortcuts = () => {
window.dispatchEvent(
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { detail: { show: true } })
);
};
return (
<div>
<h1 className="text-theme-home-text uppercase text-sm font-semibold mb-4">
{t("main-page.resources.title")}
</h1>
<div className="flex gap-x-6">
<a
target="_blank"
rel="noopener noreferrer "
href={paths.docs()}
className="text-theme-home-text text-sm flex items-center gap-x-2 hover:opacity-70"
>
{t("main-page.resources.links.docs")}
<ArrowCircleUpRight weight="fill" size={16} />
</a>
<a
href={paths.github()}
target="_blank"
rel="noopener noreferrer"
className="text-theme-home-text text-sm flex items-center gap-x-2 hover:opacity-70"
>
{t("main-page.resources.links.star")}
<ArrowCircleUpRight weight="fill" size={16} />
</a>
<button
onClick={showKeyboardShortcuts}
className="text-theme-home-text text-sm flex items-center gap-x-2 hover:opacity-70"
>
{t("main-page.resources.keyboardShortcuts")}
<ArrowCircleUpRight weight="fill" size={16} />
</button>
</div>
</div>
);
}

View File

@ -1,209 +0,0 @@
import { useEffect, useState } from "react";
import { safeJsonParse } from "@/utils/request";
import { Link } from "react-router-dom";
import PlaceholderOne from "@/media/announcements/placeholder-1.png";
import PlaceholderTwo from "@/media/announcements/placeholder-2.png";
import PlaceholderThree from "@/media/announcements/placeholder-3.png";
import { useTranslation } from "react-i18next";
/**
* @typedef {Object} NewsItem
* @property {string} title
* @property {string|null} thumbnail_url
* @property {string} short_description
* @property {string|null} goto
* @property {string|null} source
* @property {string|null} date
*/
const NEWS_CACHE_CONFIG = {
articles: "https://cdn.anythingllm.com/support/announcements/list.txt",
announcementsDir: "https://cdn.anythingllm.com/support/announcements",
cacheKey: "anythingllm_announcements",
ttl: 7 * 24 * 60 * 60 * 1000, // 1 week
};
const PLACEHOLDERS = [PlaceholderOne, PlaceholderTwo, PlaceholderThree];
function randomPlaceholder() {
return PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)];
}
export default function Updates() {
const { t } = useTranslation();
const { isLoading, news } = useNewsItems();
if (isLoading || !news?.length) return null;
return (
<div>
<h1 className="text-theme-home-text uppercase text-sm font-semibold mb-4">
{t("main-page.announcements.title")}
</h1>
<div className="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{news.map((item, index) => (
<AnnouncementCard
key={index}
thumbnail_url={item.thumbnail_url}
title={item.title}
subtitle={item.short_description}
source={item.source}
date={item.date}
goto={item.goto}
/>
))}
</div>
</div>
);
}
function isExternal(goto) {
if (!goto) return false;
const url = new URL(goto);
return url.hostname !== window.location.hostname;
}
function AnnouncementCard({
thumbnail_url = null,
title = "",
subtitle = "",
author = "AnythingLLM",
date = null,
goto = "#",
}) {
const placeHolderImage = randomPlaceholder();
const isExternalLink = isExternal(goto);
return (
<Link
to={goto}
target={isExternalLink ? "_blank" : "_self"}
rel="noopener noreferrer"
className="block"
>
<div className="bg-theme-home-update-card-bg rounded-xl p-4 flex gap-x-4 hover:bg-theme-home-update-card-hover transition-colors">
<img
src={thumbnail_url ?? placeHolderImage}
alt={title}
loading="lazy"
onError={(e) => (e.target.src = placeHolderImage)}
className="w-[80px] h-[80px] rounded-lg flex-shrink-0 object-cover"
/>
<div className="flex flex-col gap-y-1">
<h3 className="text-theme-home-text font-medium text-sm">{title}</h3>
<p className="text-theme-home-text-secondary text-xs line-clamp-2">
{subtitle}
</p>
<div className="flex items-center gap-x-4 text-xs text-theme-home-text-secondary">
<span className="text-theme-home-update-source">{author}</span>
<span>{date ?? "Recently"}</span>
</div>
</div>
</div>
</Link>
);
}
/**
* Get cached news from localStorage if it exists and is valid by ttl timestamp
* @returns {null|NewsItem[]} - Array of news items
*/
function getCachedNews() {
try {
const cachedNews = localStorage.getItem(NEWS_CACHE_CONFIG.cacheKey);
if (!cachedNews) return null;
/** @type {{news: NewsItem[]|null, timestamp: number|null}|null} */
const parsedNews = safeJsonParse(cachedNews, null);
if (!parsedNews || !parsedNews?.news?.length || !parsedNews.timestamp)
return null;
const now = new Date();
const cacheExpiration = new Date(
parsedNews.timestamp + NEWS_CACHE_CONFIG.ttl
);
if (now < cacheExpiration) return parsedNews.news;
return null;
} catch (error) {
console.error("Error fetching cached news:", error);
return null;
}
}
/**
* Fetch news from remote source and cache it in localStorage
* @returns {Promise<NewsItem[]|null>} - Array of news items
*/
async function fetchRemoteNews() {
try {
const latestArticleDateRef = await fetch(NEWS_CACHE_CONFIG.articles)
.then((res) => {
if (!res.ok)
throw new Error(
`${res.status} - Failed to fetch remote news from ${NEWS_CACHE_CONFIG.articles}`
);
return res.text();
})
.then((text) => text?.split("\n")?.shift()?.trim())
.catch((err) => {
console.error(err.message);
return null;
});
if (!latestArticleDateRef) return null;
const dataURL = `${NEWS_CACHE_CONFIG.announcementsDir}/${latestArticleDateRef}${latestArticleDateRef.endsWith(".json") ? "" : ".json"}`;
/** @type {NewsItem[]|null} */
const announcementData = await fetch(dataURL)
.then((res) => {
if (!res.ok)
throw new Error(
`${res.status} - Failed to fetch remote news from ${dataURL}`
);
return res.json();
})
.catch((err) => {
console.error(err.message);
return [];
});
if (!announcementData?.length) return null;
localStorage.setItem(
NEWS_CACHE_CONFIG.cacheKey,
JSON.stringify({
news: announcementData,
timestamp: Date.now(),
})
);
return announcementData;
} catch (error) {
console.error("Error fetching remote news:", error);
return null;
}
}
/**
* @returns {{news: NewsItem[], isLoading: boolean}}
*/
function useNewsItems() {
const [news, setNews] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchAnnouncements() {
try {
const cachedNews = getCachedNews();
if (cachedNews) return setNews(cachedNews);
const remoteNews = await fetchRemoteNews();
if (remoteNews) return setNews(remoteNews);
} catch (error) {
console.error("Error fetching cached news:", error);
} finally {
setIsLoading(false);
}
}
fetchAnnouncements();
}, []);
return { news, isLoading };
}

View File

@ -1,25 +1,321 @@
import React from "react";
import QuickLinks from "./QuickLinks";
import ExploreFeatures from "./ExploreFeatures";
import Updates from "./Updates";
import Resources from "./Resources";
import Checklist from "./Checklist";
import React, { useState, useEffect, useRef, useContext } from "react";
import { useNavigate } from "react-router-dom";
import { isMobile } from "react-device-detect";
import { SidebarMobileHeader } from "@/components/Sidebar";
import PromptInput, {
PROMPT_INPUT_EVENT,
PROMPT_INPUT_ID,
} from "@/components/WorkspaceChat/ChatContainer/PromptInput";
import DnDFileUploaderWrapper, {
DndUploaderContext,
DnDFileUploaderProvider,
PASTE_ATTACHMENT_EVENT,
} from "@/components/WorkspaceChat/ChatContainer/DnDWrapper";
import { useTranslation } from "react-i18next";
import {
LAST_VISITED_WORKSPACE,
PENDING_HOME_MESSAGE,
} from "@/utils/constants";
import Workspace from "@/models/workspace";
import paths from "@/utils/paths";
import showToast from "@/utils/toast";
import { safeJsonParse } from "@/utils/request";
import QuickActions from "@/components/lib/QuickActions";
import SuggestedMessages from "@/components/lib/SuggestedMessages";
import useUser from "@/hooks/useUser";
async function getTargetWorkspace() {
const lastVisited = safeJsonParse(
localStorage.getItem(LAST_VISITED_WORKSPACE)
);
if (lastVisited?.slug) {
const workspace = await Workspace.bySlug(lastVisited.slug);
if (workspace) return workspace;
}
const workspaces = await Workspace.all();
return workspaces.length > 0 ? workspaces[0] : null;
}
async function createDefaultWorkspace(workspaceName = "My Workspace") {
const { workspace, message: errorMsg } = await Workspace.new({
name: workspaceName,
});
if (!workspace) {
showToast(errorMsg || "Failed to create workspace", "error");
return null;
}
return workspace;
}
export default function Home() {
const { t } = useTranslation();
const { user } = useUser();
const [workspace, setWorkspace] = useState(null);
const [threadSlug, setThreadSlug] = useState(null);
const [workspaceLoading, setWorkspaceLoading] = useState(true);
const [dragging, setDragging] = useState(false);
const pendingFilesRef = useRef([]);
useEffect(() => {
async function init() {
const ws = await getTargetWorkspace();
if (ws) {
const [suggestedMessages, pfpUrl] = await Promise.all([
Workspace.getSuggestedMessages(ws.slug),
Workspace.fetchPfp(ws.slug),
]);
setWorkspace({ ...ws, suggestedMessages, pfpUrl });
}
setWorkspaceLoading(false);
}
init();
}, []);
// When workspace/thread becomes available and we have pending files, trigger upload
useEffect(() => {
if (workspace && threadSlug && pendingFilesRef.current.length > 0) {
const files = pendingFilesRef.current;
pendingFilesRef.current = [];
window.dispatchEvent(
new CustomEvent(PASTE_ATTACHMENT_EVENT, { detail: { files } })
);
}
}, [workspace, threadSlug]);
// Handle paste events when no thread exists yet
useEffect(() => {
if (threadSlug) return;
async function handlePaste(e) {
const files = e.detail?.files;
if (!files?.length) return;
pendingFilesRef.current = files;
let ws = workspace;
if (!ws) {
ws = await createDefaultWorkspace(t("new-workspace.placeholder"));
if (!ws) return;
setWorkspace(ws);
}
const { thread } = await Workspace.threads.new(ws.slug);
if (thread) setThreadSlug(thread.slug);
}
window.addEventListener(PASTE_ATTACHMENT_EVENT, handlePaste);
return () =>
window.removeEventListener(PASTE_ATTACHMENT_EVENT, handlePaste);
}, [workspace, threadSlug]);
async function handleDropWithoutWorkspace(acceptedFiles) {
setDragging(false);
pendingFilesRef.current = acceptedFiles;
const ws = await createDefaultWorkspace(t("new-workspace.placeholder"));
if (!ws) return;
setWorkspace(ws);
const { thread } = await Workspace.threads.new(ws.slug);
if (thread) setThreadSlug(thread.slug);
}
async function handleDropWithWorkspace(acceptedFiles) {
setDragging(false);
pendingFilesRef.current = acceptedFiles;
const { thread } = await Workspace.threads.new(workspace.slug);
if (thread) setThreadSlug(thread.slug);
}
if (workspaceLoading) {
return (
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-hidden"
/>
);
}
if (!workspace && user?.role === "default") {
return <NoWorkspacesAssigned />;
}
if (workspace && threadSlug) {
return (
<DnDFileUploaderProvider workspace={workspace} threadSlug={threadSlug}>
<HomeContent
workspace={workspace}
setWorkspace={setWorkspace}
threadSlug={threadSlug}
setThreadSlug={setThreadSlug}
/>
</DnDFileUploaderProvider>
);
}
return (
<DndUploaderContext.Provider
value={{
files: [],
ready: true,
dragging,
setDragging,
onDrop: workspace
? handleDropWithWorkspace
: handleDropWithoutWorkspace,
parseAttachments: () => [],
}}
>
<HomeContent
workspace={workspace}
setWorkspace={setWorkspace}
threadSlug={null}
setThreadSlug={setThreadSlug}
/>
</DndUploaderContext.Provider>
);
}
function HomeContent({ workspace, setWorkspace, threadSlug, setThreadSlug }) {
const { t } = useTranslation();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { files, parseAttachments } = useContext(DndUploaderContext);
useEffect(() => {
window.dispatchEvent(
new CustomEvent(PROMPT_INPUT_EVENT, {
detail: { messageContent: "", writeMode: "replace" },
})
);
}, []);
async function submitMessage(message, attachments = []) {
if (!message || loading) return;
setLoading(true);
try {
let targetWorkspace = workspace;
let targetThread = threadSlug;
if (!targetWorkspace) {
targetWorkspace = await createDefaultWorkspace(
t("new-workspace.placeholder")
);
if (!targetWorkspace) {
setLoading(false);
return;
}
setWorkspace(targetWorkspace);
}
if (!targetThread) {
const { thread } = await Workspace.threads.new(targetWorkspace.slug);
targetThread = thread?.slug;
if (thread) setThreadSlug(thread.slug);
}
sessionStorage.setItem(
PENDING_HOME_MESSAGE,
JSON.stringify({ message, attachments })
);
if (targetThread) {
navigate(paths.workspace.thread(targetWorkspace.slug, targetThread));
} else {
navigate(paths.workspace.chat(targetWorkspace.slug));
}
} catch (error) {
console.error("Error submitting message:", error);
showToast("Failed to send message", "error");
setLoading(false);
}
}
async function handleSubmit(e) {
e.preventDefault();
const currentMessage =
document.getElementById(PROMPT_INPUT_ID)?.value?.trim() || "";
await submitMessage(currentMessage, parseAttachments());
}
function sendCommand({
text = "",
autoSubmit = false,
writeMode = "replace",
}) {
if (autoSubmit) {
submitMessage(text.trim());
return;
}
window.dispatchEvent(
new CustomEvent(PROMPT_INPUT_EVENT, {
detail: { messageContent: text, writeMode },
})
);
}
async function handleEditWorkspace() {
let targetWorkspace = workspace;
if (!targetWorkspace) {
targetWorkspace = await createDefaultWorkspace(
t("new-workspace.placeholder")
);
if (!targetWorkspace) return;
setWorkspace(targetWorkspace);
}
navigate(paths.workspace.settings.generalAppearance(targetWorkspace.slug));
}
return (
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-container w-full h-full"
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-hidden"
>
<div className="w-full h-full flex flex-col items-center overflow-y-auto no-scroll">
<div className="w-full max-w-[1200px] flex flex-col gap-y-[24px] p-4 pt-16 md:p-12 md:pt-11">
<Checklist />
<QuickLinks />
<ExploreFeatures />
<Updates />
<Resources />
{isMobile && <SidebarMobileHeader />}
<DnDFileUploaderWrapper>
<div className="flex flex-col h-full w-full items-center justify-center">
<div className="flex flex-col items-center w-full max-w-[750px]">
<h1 className="text-white text-xl md:text-2xl mb-11 text-center">
{t("main-page.greeting")}
</h1>
<PromptInput
submit={handleSubmit}
isStreaming={loading}
sendCommand={sendCommand}
attachments={files}
centered={true}
workspaceSlug={workspace?.slug}
threadSlug={threadSlug}
/>
<QuickActions
hasAvailableWorkspace={!!workspace}
onCreateAgent={() => navigate(paths.settings.agentSkills())}
onEditWorkspace={handleEditWorkspace}
onUploadDocument={() =>
document.getElementById("dnd-chat-file-uploader")?.click()
}
/>
</div>
<SuggestedMessages
suggestedMessages={workspace?.suggestedMessages}
sendCommand={sendCommand}
/>
</div>
</DnDFileUploaderWrapper>
</div>
);
}
function NoWorkspacesAssigned() {
const { t } = useTranslation();
return (
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-hidden"
>
<div className="flex flex-col h-full w-full items-center justify-center">
<p className="text-white/60 text-sm text-center whitespace-pre-line">
{t("home.notAssigned")}
</p>
</div>
</div>
);

View File

@ -2,10 +2,8 @@ import React from "react";
import PasswordModal, { usePasswordModal } from "@/components/Modals/Password";
import { FullScreenLoader } from "@/components/Preloader";
import Home from "./Home";
import DefaultChatContainer from "@/components/DefaultChat";
import { isMobile } from "react-device-detect";
import Sidebar, { SidebarMobileHeader } from "@/components/Sidebar";
import { userFromStorage } from "@/utils/request";
export default function Main() {
const { loading, requiresAuth, mode } = usePasswordModal();
@ -14,11 +12,10 @@ export default function Main() {
if (requiresAuth !== false)
return <>{requiresAuth !== null && <PasswordModal mode={mode} />}</>;
const user = userFromStorage();
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex">
{!isMobile ? <Sidebar /> : <SidebarMobileHeader />}
{!!user && user?.role !== "admin" ? <DefaultChatContainer /> : <Home />}
<Home />
</div>
);
}

View File

@ -24,8 +24,7 @@ export default function SuggestedChatMessages({ slug }) {
const handleSaveSuggestedMessages = async () => {
const validMessages = suggestedMessages.filter(
(msg) =>
msg?.heading?.trim()?.length > 0 || msg?.message?.trim()?.length > 0
(msg) => msg?.message?.trim()?.length > 0
);
const { success, error } = await Workspace.setSuggestedMessages(
slug,
@ -35,6 +34,8 @@ export default function SuggestedChatMessages({ slug }) {
showToast(`Failed to update welcome messages: ${error}`, "error");
return;
}
setSuggestedMessages(validMessages);
setEditingIndex(-1);
showToast("Successfully updated welcome messages.", "success");
setHasChanges(false);
};
@ -46,8 +47,8 @@ export default function SuggestedChatMessages({ slug }) {
return;
}
const defaultMessage = {
heading: t("general.message.heading"),
message: t("general.message.body"),
heading: "",
message: `${t("general.message.heading")} ${t("general.message.body")}`,
};
setNewMessage(defaultMessage);
setSuggestedMessages([...suggestedMessages, { ...defaultMessage }]);
@ -64,7 +65,21 @@ export default function SuggestedChatMessages({ slug }) {
const startEditing = (e, index) => {
e.preventDefault();
setEditingIndex(index);
setNewMessage({ ...suggestedMessages[index] });
const suggestion = suggestedMessages[index];
// Legacy messages may have a separate heading field. Merge it into the message
// on edit so the user can manage everything in a single input going forward.
if (suggestion.heading) {
const merged = {
heading: "",
message: `${suggestion.heading} ${suggestion.message}`,
};
setNewMessage(merged);
setSuggestedMessages(
suggestedMessages.map((msg, i) => (i === index ? merged : msg))
);
} else {
setNewMessage({ ...suggestion });
}
};
const handleRemoveMessage = (index) => {
@ -134,26 +149,16 @@ export default function SuggestedChatMessages({ slug }) {
editingIndex === index ? "border-sky-400" : ""
}`}
>
<p className="font-semibold">{suggestion.heading}</p>
<p>{suggestion.message}</p>
<p className="line-clamp-2 text-theme-text-primary">
{suggestion?.heading ? `${suggestion.heading} ` : ""}
{suggestion?.message ?? ""}
</p>
</button>
</div>
))}
</div>
{editingIndex >= 0 && (
<div className="flex flex-col gap-y-4 mr-2 mt-8">
<div className="w-1/2">
<label className="text-white text-sm font-semibold block mb-2">
Heading
</label>
<input
placeholder="Message heading"
className="border-none bg-theme-settings-input-bg text-white placeholder:text-white/20 text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block p-2.5 w-full"
value={newMessage.heading}
name="heading"
onChange={onEditChange}
/>
</div>
<div className="w-1/2">
<label className="text-white text-sm font-semibold block mb-2">
Message

View File

@ -9,6 +9,7 @@ export const SEEN_DOC_PIN_ALERT = "anythingllm_pinned_document_alert";
export const SEEN_WATCH_ALERT = "anythingllm_watched_document_alert";
export const LAST_VISITED_WORKSPACE = "anythingllm_last_visited_workspace";
export const USER_PROMPT_INPUT_MAP = "anythingllm_user_prompt_input_map";
export const PENDING_HOME_MESSAGE = "anythingllm_pending_home_message";
export const APPEARANCE_SETTINGS = "anythingllm_appearance_settings";