mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-08 16:32:43 +00:00
Compare commits
27 Commits
cli/v0.2.1
...
full_chat_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1599ebe4 | ||
|
|
1ee63261e5 | ||
|
|
1241d5cf3e | ||
|
|
1d97377774 | ||
|
|
1a38eb6d63 | ||
|
|
77eb1c435b | ||
|
|
8bf35962d9 | ||
|
|
91501f454a | ||
|
|
3c8682a365 | ||
|
|
6f27937dbe | ||
|
|
9b242170e2 | ||
|
|
083c718681 | ||
|
|
ce11a0eac9 | ||
|
|
7a6f495baa | ||
|
|
c63d34bf85 | ||
|
|
43e0bab934 | ||
|
|
6f5895ca1b | ||
|
|
40d971d2b0 | ||
|
|
f2965f92ca | ||
|
|
1b4fd41c95 | ||
|
|
1129d1cd75 | ||
|
|
5e1a5c11f9 | ||
|
|
22d9f79c22 | ||
|
|
cd1183a991 | ||
|
|
d8c4d8887f | ||
|
|
85d515f0f4 | ||
|
|
0b88052f25 |
@@ -23,7 +23,6 @@ def load_no_auth_user_preferences(store: KeyValueStore) -> UserPreferences:
|
||||
preferences_data = cast(
|
||||
Mapping[str, Any], store.load(KV_NO_AUTH_USER_PREFERENCES_KEY)
|
||||
)
|
||||
print("preferences_data", preferences_data)
|
||||
return UserPreferences(**preferences_data)
|
||||
except KvKeyNotFoundError:
|
||||
return UserPreferences(
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from fastapi import UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_admin_user
|
||||
@@ -236,8 +237,14 @@ def create_label(
|
||||
_: User | None = Depends(current_user),
|
||||
) -> PersonaLabelResponse:
|
||||
"""Create a new assistant label"""
|
||||
label_model = create_assistant_label(name=label.name, db_session=db)
|
||||
return PersonaLabelResponse.from_model(label_model)
|
||||
try:
|
||||
label_model = create_assistant_label(name=label.name, db_session=db)
|
||||
return PersonaLabelResponse.from_model(label_model)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Label with name '{label.name}' already exists. Please choose a different name.",
|
||||
)
|
||||
|
||||
|
||||
@admin_router.patch("/label/{label_id}")
|
||||
|
||||
@@ -714,7 +714,6 @@ def update_user_pinned_assistants(
|
||||
store = get_kv_store()
|
||||
no_auth_user = fetch_no_auth_user(store)
|
||||
no_auth_user.preferences.pinned_assistants = ordered_assistant_ids
|
||||
print("ordered_assistant_ids", ordered_assistant_ids)
|
||||
set_no_auth_user_preferences(store, no_auth_user.preferences)
|
||||
return
|
||||
else:
|
||||
|
||||
@@ -22,6 +22,7 @@ const cspHeader = `
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
publicRuntimeConfig: {
|
||||
version,
|
||||
|
||||
133
web/package-lock.json
generated
133
web/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -3843,6 +3844,138 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz",
|
||||
"integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.0",
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
|
||||
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
|
||||
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import React from "react";
|
||||
import { Option } from "@/components/Dropdown";
|
||||
import { generateRandomIconShape } from "@/lib/assistantIconUtils";
|
||||
import { CCPairBasicInfo, DocumentSet, User, UserGroup } from "@/lib/types";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IsPublicGroupSelector } from "@/components/IsPublicGroupSelector";
|
||||
import { ArrayHelpers, FieldArray, Form, Formik, FormikProps } from "formik";
|
||||
|
||||
import {
|
||||
BooleanFormField,
|
||||
Label,
|
||||
SelectorFormField,
|
||||
TextFormField,
|
||||
} from "@/components/admin/connectors/Field";
|
||||
|
||||
@@ -38,23 +35,12 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FiInfo, FiRefreshCcw, FiUsers } from "react-icons/fi";
|
||||
import { FiInfo } from "react-icons/fi";
|
||||
import * as Yup from "yup";
|
||||
import CollapsibleSection from "./CollapsibleSection";
|
||||
import { SuccessfulPersonaUpdateRedirectType } from "./enums";
|
||||
import {
|
||||
Persona,
|
||||
PersonaLabel,
|
||||
StarterMessage,
|
||||
StarterMessageBase,
|
||||
} from "./interfaces";
|
||||
import {
|
||||
createPersonaLabel,
|
||||
createPersona,
|
||||
deletePersonaLabel,
|
||||
updatePersonaLabel,
|
||||
updatePersona,
|
||||
} from "./lib";
|
||||
import { Persona, PersonaLabel, StarterMessage } from "./interfaces";
|
||||
import { createPersona, updatePersona } from "./lib";
|
||||
import {
|
||||
CameraIcon,
|
||||
GroupsIconSkeleton,
|
||||
@@ -67,34 +53,26 @@ import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { debounce } from "lodash";
|
||||
import { FullLLMProvider } from "../configuration/llm/interfaces";
|
||||
import StarterMessagesList from "./StarterMessageList";
|
||||
import { LabelCard } from "./LabelCard";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { generateIdenticon } from "@/components/assistants/AssistantIcon";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
|
||||
import { AssistantVisibilityPopover } from "@/app/assistants/mine/AssistantVisibilityPopover";
|
||||
import { MinimalUserSnapshot } from "@/lib/types";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
import { useUsers } from "@/lib/hooks";
|
||||
import { AllUsersResponse } from "@/lib/types";
|
||||
// import { Badge } from "@/components/ui/Badge";
|
||||
// import {
|
||||
// addUsersToAssistantSharedList,
|
||||
// shareAssistantWithGroups,
|
||||
// } from "@/lib/assistants/shareAssistant";
|
||||
import {
|
||||
SearchMultiSelectDropdown,
|
||||
Option as DropdownOption,
|
||||
} from "@/components/Dropdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SourceChip } from "@/app/chat/input/ChatInputBar";
|
||||
import { GroupIcon, TagIcon, UserIcon } from "lucide-react";
|
||||
import { TagIcon, UserIcon, XIcon } from "lucide-react";
|
||||
import { LLMSelector } from "@/components/llm/LLMSelector";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
|
||||
import { DeletePersonaButton } from "./[id]/DeletePersonaButton";
|
||||
import Title from "@/components/ui/title";
|
||||
|
||||
function findSearchTool(tools: ToolSnapshot[]) {
|
||||
return tools.find((tool) => tool.in_code_tool_id === "SearchTool");
|
||||
@@ -146,8 +124,8 @@ export function AssistantEditor({
|
||||
const router = useRouter();
|
||||
|
||||
const { popup, setPopup } = usePopup();
|
||||
const { data, refreshLabels } = useLabels();
|
||||
const labels = data || [];
|
||||
const { labels, refreshLabels, createLabel, updateLabel, deleteLabel } =
|
||||
useLabels();
|
||||
|
||||
const colorOptions = [
|
||||
"#FF6FBF",
|
||||
@@ -162,8 +140,6 @@ export function AssistantEditor({
|
||||
const [showSearchTool, setShowSearchTool] = useState(false);
|
||||
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [hasEditedStarterMessage, setHasEditedStarterMessage] = useState(false);
|
||||
const [showPersonaLabel, setShowPersonaLabel] = useState(!admin);
|
||||
|
||||
// state to persist across formik reformatting
|
||||
const [defautIconColor, _setDeafultIconColor] = useState(
|
||||
@@ -349,6 +325,10 @@ export function AssistantEditor({
|
||||
}));
|
||||
};
|
||||
|
||||
if (!labels) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<style>
|
||||
@@ -370,7 +350,7 @@ export function AssistantEditor({
|
||||
entityName={labelToDelete.name}
|
||||
onClose={() => setLabelToDelete(null)}
|
||||
onSubmit={async () => {
|
||||
const response = await deletePersonaLabel(labelToDelete.id);
|
||||
const response = await deleteLabel(labelToDelete.id);
|
||||
if (response?.ok) {
|
||||
setPopup({
|
||||
message: `Label deleted successfully`,
|
||||
@@ -602,7 +582,7 @@ export function AssistantEditor({
|
||||
return (
|
||||
<Form className="w-full text-text-950 assistant-editor">
|
||||
{/* Refresh starter messages when name or description changes */}
|
||||
<p className="text-base font-normal !text-2xl">
|
||||
<p className="text-base font-normal text-2xl">
|
||||
{existingPersona ? (
|
||||
<>
|
||||
Edit assistant <b>{existingPersona.name}</b>
|
||||
@@ -771,97 +751,6 @@ export function AssistantEditor({
|
||||
className="[&_input]:placeholder:text-text-muted/50"
|
||||
/>
|
||||
|
||||
<div className=" w-full max-w-4xl">
|
||||
<Separator />
|
||||
<div className="flex gap-x-2 items-center mt-4 ">
|
||||
<div className="block font-medium text-sm">Labels</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-subtle"
|
||||
style={{ color: "rgb(113, 114, 121)" }}
|
||||
>
|
||||
Select labels to categorize this assistant
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<SearchMultiSelectDropdown
|
||||
onCreateLabel={async (name: string) => {
|
||||
await createPersonaLabel(name);
|
||||
const currentLabels = await refreshLabels();
|
||||
|
||||
setTimeout(() => {
|
||||
const newLabelId = currentLabels.find(
|
||||
(l: { name: string }) => l.name === name
|
||||
)?.id;
|
||||
const updatedLabelIds = [
|
||||
...values.label_ids,
|
||||
newLabelId as number,
|
||||
];
|
||||
setFieldValue("label_ids", updatedLabelIds);
|
||||
}, 300);
|
||||
}}
|
||||
options={Array.from(
|
||||
new Set(labels.map((label) => label.name))
|
||||
).map((name) => ({
|
||||
name,
|
||||
value: name,
|
||||
}))}
|
||||
onSelect={(selected) => {
|
||||
const newLabelIds = [
|
||||
...values.label_ids,
|
||||
labels.find((l) => l.name === selected.value)
|
||||
?.id as number,
|
||||
];
|
||||
setFieldValue("label_ids", newLabelIds);
|
||||
}}
|
||||
itemComponent={({ option }) => (
|
||||
<div
|
||||
className="flex items-center px-4 py-2.5 text-sm hover:bg-hover cursor-pointer"
|
||||
onClick={() => {
|
||||
const label = labels.find(
|
||||
(l) => l.name === option.value
|
||||
);
|
||||
if (label) {
|
||||
const isSelected = values.label_ids.includes(
|
||||
label.id
|
||||
);
|
||||
const newLabelIds = isSelected
|
||||
? values.label_ids.filter(
|
||||
(id: number) => id !== label.id
|
||||
)
|
||||
: [...values.label_ids, label.id];
|
||||
setFieldValue("label_ids", newLabelIds);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-medium leading-none">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{values.label_ids.map((labelId: number) => {
|
||||
const label = labels.find((l) => l.id === labelId);
|
||||
return label ? (
|
||||
<SourceChip
|
||||
key={label.id}
|
||||
onRemove={() => {
|
||||
setFieldValue(
|
||||
"label_ids",
|
||||
values.label_ids.filter(
|
||||
(id: number) => id !== label.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
title={label.name}
|
||||
icon={<TagIcon size={12} />}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<TextFormField
|
||||
@@ -900,6 +789,11 @@ export function AssistantEditor({
|
||||
}`}
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
values.enabled_tools_map[
|
||||
searchTool.id
|
||||
]
|
||||
}
|
||||
size="sm"
|
||||
onCheckedChange={(checked) => {
|
||||
setShowSearchTool(checked);
|
||||
@@ -916,8 +810,7 @@ export function AssistantEditor({
|
||||
<TooltipContent side="top" align="center">
|
||||
<p className="bg-background-900 max-w-[200px] text-sm rounded-lg p-1.5 text-white">
|
||||
To use the Knowledge Action, you need to
|
||||
have at least one Connector-Credential
|
||||
pair configured.
|
||||
have at least one Connector configured.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
@@ -1107,6 +1000,7 @@ export function AssistantEditor({
|
||||
<React.Fragment key={tool.id}>
|
||||
<div className="flex items-center content-start mb-2">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
id={`enabled_tools_map.${tool.id}`}
|
||||
checked={values.enabled_tools_map[tool.id]}
|
||||
onCheckedChange={() => {
|
||||
@@ -1141,7 +1035,6 @@ export function AssistantEditor({
|
||||
)
|
||||
: null
|
||||
}
|
||||
userDefault={user?.preferences?.default_model || null}
|
||||
requiresImageGeneration={
|
||||
imageGenerationTool
|
||||
? values.enabled_tools_map[imageGenerationTool.id]
|
||||
@@ -1163,106 +1056,6 @@ export function AssistantEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{admin && labels && labels.length > 0 && (
|
||||
<div className=" max-w-4xl">
|
||||
<Separator />
|
||||
<div className="flex gap-x-2 items-center ">
|
||||
<div className="block font-medium text-sm">
|
||||
Manage Labels
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<FiInfo size={12} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
Manage existing labels or create new ones to group
|
||||
similar assistants
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<SubLabel>Edit or delete existing labels</SubLabel>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{labels.map((label: PersonaLabel) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="grid grid-cols-[1fr,2fr,auto] gap-4 items-end"
|
||||
>
|
||||
<TextFormField
|
||||
fontSize="sm"
|
||||
name={`editLabelName_${label.id}`}
|
||||
label="Label Name"
|
||||
value={
|
||||
values.editLabelId === label.id
|
||||
? values.editLabelName
|
||||
: label.name
|
||||
}
|
||||
onChange={(e) => {
|
||||
setFieldValue("editLabelId", label.id);
|
||||
setFieldValue("editLabelName", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{values.editLabelId === label.id ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const updatedName =
|
||||
values.editLabelName || label.name;
|
||||
const response = await updatePersonaLabel(
|
||||
label.id,
|
||||
updatedName
|
||||
);
|
||||
if (response?.ok) {
|
||||
setPopup({
|
||||
message: `Label "${updatedName}" updated successfully`,
|
||||
type: "success",
|
||||
});
|
||||
await refreshLabels();
|
||||
setFieldValue("editLabelId", null);
|
||||
setFieldValue("editLabelName", "");
|
||||
setFieldValue("editLabelDescription", "");
|
||||
} else {
|
||||
setPopup({
|
||||
message: `Failed to update label - ${await response.text()}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFieldValue("editLabelId", null);
|
||||
setFieldValue("editLabelName", "");
|
||||
setFieldValue("editLabelDescription", "");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
setLabelToDelete(label);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<AdvancedOptionsToggle
|
||||
showAdvancedOptions={showAdvancedOptions}
|
||||
@@ -1424,19 +1217,124 @@ export function AssistantEditor({
|
||||
autoStarterMessageEnabled={
|
||||
autoStarterMessageEnabled
|
||||
}
|
||||
errors={errors}
|
||||
isRefreshing={isRefreshing}
|
||||
values={values.starter_messages}
|
||||
arrayHelpers={arrayHelpers}
|
||||
touchStarterMessages={() => {
|
||||
setHasEditedStarterMessage(true);
|
||||
}}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" w-full max-w-4xl">
|
||||
<Separator />
|
||||
<div className="flex gap-x-2 items-center mt-4 ">
|
||||
<div className="block font-medium text-sm">Labels</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-subtle"
|
||||
style={{ color: "rgb(113, 114, 121)" }}
|
||||
>
|
||||
Select labels to categorize this assistant
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<SearchMultiSelectDropdown
|
||||
onCreate={async (name: string) => {
|
||||
await createLabel(name);
|
||||
const currentLabels = await refreshLabels();
|
||||
|
||||
setTimeout(() => {
|
||||
const newLabelId = currentLabels.find(
|
||||
(l: { name: string }) => l.name === name
|
||||
)?.id;
|
||||
const updatedLabelIds = [
|
||||
...values.label_ids,
|
||||
newLabelId as number,
|
||||
];
|
||||
setFieldValue("label_ids", updatedLabelIds);
|
||||
}, 300);
|
||||
}}
|
||||
options={Array.from(
|
||||
new Set(labels.map((label) => label.name))
|
||||
).map((name) => ({
|
||||
name,
|
||||
value: name,
|
||||
}))}
|
||||
onSelect={(selected) => {
|
||||
const newLabelIds = [
|
||||
...values.label_ids,
|
||||
labels.find((l) => l.name === selected.value)
|
||||
?.id as number,
|
||||
];
|
||||
setFieldValue("label_ids", newLabelIds);
|
||||
}}
|
||||
itemComponent={({ option }) => (
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm hover:bg-hover cursor-pointer border-b border-border last:border-b-0">
|
||||
<div
|
||||
className="flex-grow"
|
||||
onClick={() => {
|
||||
const label = labels.find(
|
||||
(l) => l.name === option.value
|
||||
);
|
||||
if (label) {
|
||||
const isSelected = values.label_ids.includes(
|
||||
label.id
|
||||
);
|
||||
const newLabelIds = isSelected
|
||||
? values.label_ids.filter(
|
||||
(id: number) => id !== label.id
|
||||
)
|
||||
: [...values.label_ids, label.id];
|
||||
setFieldValue("label_ids", newLabelIds);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="font-normal leading-none">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
{admin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const label = labels.find(
|
||||
(l) => l.name === option.value
|
||||
);
|
||||
if (label) {
|
||||
deleteLabel(label.id);
|
||||
}
|
||||
}}
|
||||
className="ml-2 p-1 hover:bg-background-hover rounded"
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{values.label_ids.map((labelId: number) => {
|
||||
const label = labels.find((l) => l.id === labelId);
|
||||
return label ? (
|
||||
<SourceChip
|
||||
key={label.id}
|
||||
onRemove={() => {
|
||||
setFieldValue(
|
||||
"label_ids",
|
||||
values.label_ids.filter(
|
||||
(id: number) => id !== label.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
title={label.name}
|
||||
icon={<TagIcon size={12} />}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
@@ -1462,7 +1360,6 @@ export function AssistantEditor({
|
||||
small
|
||||
subtext="Documents prior to this date will be ignored."
|
||||
label="[Optional] Knowledge Cutoff Date"
|
||||
value={values.search_start_date}
|
||||
name="search_start_date"
|
||||
/>
|
||||
|
||||
@@ -1501,6 +1398,14 @@ export function AssistantEditor({
|
||||
explanationLink="https://docs.onyx.app/guides/assistants"
|
||||
className="[&_textarea]:placeholder:text-text-muted/50"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
{existingPersona && (
|
||||
<DeletePersonaButton
|
||||
personaId={existingPersona!.id}
|
||||
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
182
web/src/app/admin/assistants/LabelManagement.tsx
Normal file
182
web/src/app/admin/assistants/LabelManagement.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SubLabel, TextFormField } from "@/components/admin/connectors/Field";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useLabels } from "@/lib/hooks";
|
||||
import { PersonaLabel } from "./interfaces";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import Title from "@/components/ui/title";
|
||||
|
||||
interface FormValues {
|
||||
newLabelName: string;
|
||||
editLabelId: number | null;
|
||||
editLabelName: string;
|
||||
}
|
||||
|
||||
export default function LabelManagement() {
|
||||
const { labels, createLabel, updateLabel, deleteLabel } = useLabels();
|
||||
const { setPopup, popup } = usePopup();
|
||||
|
||||
if (!labels) return null;
|
||||
|
||||
const handleSubmit = async (
|
||||
values: FormValues,
|
||||
{ setSubmitting, resetForm }: FormikHelpers<FormValues>
|
||||
) => {
|
||||
if (values.newLabelName.trim()) {
|
||||
const response = await createLabel(values.newLabelName.trim());
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: `Label "${values.newLabelName}" created successfully`,
|
||||
type: "success",
|
||||
});
|
||||
resetForm();
|
||||
} else {
|
||||
const errorMsg = (await response.json()).detail;
|
||||
setPopup({
|
||||
message: `Failed to create label - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{popup}
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex gap-x-2 items-center">
|
||||
<Title size="lg">Manage Labels</Title>
|
||||
</div>
|
||||
|
||||
<Formik<FormValues>
|
||||
initialValues={{
|
||||
newLabelName: "",
|
||||
editLabelId: null,
|
||||
editLabelName: "",
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ values, setFieldValue, isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="flex flex-col gap-4 mt-4 mb-6">
|
||||
<div className="flex flex-col">
|
||||
<Title className="text-lg">Create New Label</Title>
|
||||
<SubLabel>
|
||||
Labels are used to categorize personas. You can create a new
|
||||
label by entering a name below.
|
||||
</SubLabel>
|
||||
</div>
|
||||
<div className="max-w-3xl w-full justify-start flex gap-4 items-end">
|
||||
<TextFormField
|
||||
width="max-w-xs"
|
||||
fontSize="sm"
|
||||
name="newLabelName"
|
||||
label="Label Name"
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 w-full gap-4">
|
||||
<div className="flex flex-col">
|
||||
<Title className="text-lg">Edit Labels</Title>
|
||||
<SubLabel>
|
||||
You can edit the name of a label by clicking on the label
|
||||
name and entering a new name.
|
||||
</SubLabel>
|
||||
</div>
|
||||
|
||||
{labels.map((label: PersonaLabel) => (
|
||||
<div key={label.id} className="flex w-full gap-4 items-end">
|
||||
<TextFormField
|
||||
fontSize="sm"
|
||||
width="w-full max-w-xs"
|
||||
name={`editLabelName_${label.id}`}
|
||||
label="Label Name"
|
||||
value={
|
||||
values.editLabelId === label.id
|
||||
? values.editLabelName
|
||||
: label.name
|
||||
}
|
||||
onChange={(e) => {
|
||||
setFieldValue("editLabelId", label.id);
|
||||
setFieldValue("editLabelName", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{values.editLabelId === label.id ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const updatedName =
|
||||
values.editLabelName || label.name;
|
||||
const response = await updateLabel(
|
||||
label.id,
|
||||
updatedName
|
||||
);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: `Label "${updatedName}" updated successfully`,
|
||||
type: "success",
|
||||
});
|
||||
setFieldValue("editLabelId", null);
|
||||
setFieldValue("editLabelName", "");
|
||||
} else {
|
||||
setPopup({
|
||||
message: `Failed to update label - ${await response.text()}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFieldValue("editLabelId", null);
|
||||
setFieldValue("editLabelName", "");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const response = await deleteLabel(label.id);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: `Label "${label.name}" deleted successfully`,
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
setPopup({
|
||||
message: `Failed to delete label - ${await response.text()}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,12 +102,6 @@ export function PersonasTable() {
|
||||
<div>
|
||||
{popup}
|
||||
|
||||
<Text className="my-2">
|
||||
Assistants will be displayed as options on the Chat / Search interfaces
|
||||
in the order they are displayed below. Assistants marked as hidden will
|
||||
not be displayed. Editable assistants are shown at the top.
|
||||
</Text>
|
||||
|
||||
<DraggableTable
|
||||
headers={["Name", "Description", "Type", "Is Visible", "Delete"]}
|
||||
isAdmin={isAdmin}
|
||||
|
||||
@@ -18,25 +18,20 @@ export default function StarterMessagesList({
|
||||
values,
|
||||
arrayHelpers,
|
||||
isRefreshing,
|
||||
touchStarterMessages,
|
||||
debouncedRefreshPrompts,
|
||||
autoStarterMessageEnabled,
|
||||
errors,
|
||||
setFieldValue,
|
||||
}: {
|
||||
values: StarterMessage[];
|
||||
arrayHelpers: ArrayHelpers;
|
||||
isRefreshing: boolean;
|
||||
touchStarterMessages: () => void;
|
||||
debouncedRefreshPrompts: () => void;
|
||||
autoStarterMessageEnabled: boolean;
|
||||
errors: any;
|
||||
setFieldValue: any;
|
||||
}) {
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
|
||||
const handleInputChange = (index: number, value: string) => {
|
||||
touchStarterMessages();
|
||||
setFieldValue(`starter_messages.${index}.message`, value);
|
||||
|
||||
if (value && index === values.length - 1 && values.length < 4) {
|
||||
|
||||
@@ -29,12 +29,6 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
|
||||
defaultPublic={true}
|
||||
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
|
||||
/>
|
||||
<Title>Delete Assistant</Title>
|
||||
|
||||
<DeletePersonaButton
|
||||
personaId={values.existingPersona!.id}
|
||||
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
|
||||
/>
|
||||
</CardSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { AssistantEditor } from "../AssistantEditor";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { RobotIcon } from "@/components/icons/icons";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
|
||||
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
|
||||
|
||||
export default async function Page() {
|
||||
const [values, error] = await fetchAssistantEditorInfoSS();
|
||||
|
||||
let body;
|
||||
if (!values) {
|
||||
body = (
|
||||
return (
|
||||
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<CardSection className="!border-none !bg-transparent !ring-none">
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AssistantEditor
|
||||
{...values}
|
||||
admin
|
||||
defaultPublic={true}
|
||||
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
|
||||
/>
|
||||
</CardSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="w-full">{body}</div>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client";
|
||||
import { PersonasTable } from "./PersonaTable";
|
||||
import { FiPlusSquare } from "react-icons/fi";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +7,8 @@ import Title from "@/components/ui/title";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AssistantsIcon } from "@/components/icons/icons";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import LabelManagement from "./LabelManagement";
|
||||
import { SubLabel } from "@/components/admin/connectors/Field";
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
@@ -43,6 +46,12 @@ export default async function Page() {
|
||||
<Separator />
|
||||
|
||||
<Title>Existing Assistants</Title>
|
||||
<SubLabel>
|
||||
Assistants will be displayed as options on the Chat / Search
|
||||
interfaces in the order they are displayed below. Assistants marked as
|
||||
hidden will not be displayed. Editable assistants are shown at the
|
||||
top.
|
||||
</SubLabel>
|
||||
<PersonasTable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,13 +11,13 @@ import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { getErrorMsg } from "@/lib/fetchUtils";
|
||||
import { ScoreSection } from "../ScoreEditor";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { HorizontalFilters } from "@/components/search/filtering/Filters";
|
||||
import { useFilters } from "@/lib/hooks";
|
||||
import { buildFilters } from "@/lib/search/utils";
|
||||
import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge";
|
||||
import { DocumentSet } from "@/lib/types";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { Connector } from "@/lib/connectors/connectors";
|
||||
import { HorizontalFilters } from "@/app/chat/shared_chat_search/Filters";
|
||||
|
||||
const DocumentDisplay = ({
|
||||
document,
|
||||
@@ -200,6 +200,9 @@ export function Explorer({
|
||||
availableDocumentSets={documentSets}
|
||||
existingSources={connectors.map((connector) => connector.source)}
|
||||
availableTags={[]}
|
||||
toggleFilters={() => {}}
|
||||
filtersUntoggled={false}
|
||||
tagsOnLeft={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import InvitedUserTable from "@/components/admin/users/InvitedUserTable";
|
||||
import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
|
||||
import { SearchBar } from "@/components/search/SearchBar";
|
||||
|
||||
import { FiPlusSquare } from "react-icons/fi";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
@@ -18,6 +18,7 @@ import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import BulkAdd from "@/components/admin/users/BulkAdd";
|
||||
import Text from "@/components/ui/text";
|
||||
import { InvitedUserSnapshot } from "@/lib/types";
|
||||
import { SearchBar } from "@/components/search/SearchBar";
|
||||
|
||||
const UsersTables = ({
|
||||
q,
|
||||
|
||||
@@ -117,7 +117,6 @@ export default function SidebarWrapper<T extends object>({
|
||||
{" "}
|
||||
<HistorySidebar
|
||||
setShowAssistantsModal={setShowAssistantsModal}
|
||||
assistants={assistants}
|
||||
page={"chat"}
|
||||
explicitlyUntoggle={explicitlyUntoggle}
|
||||
ref={sidebarElementRef}
|
||||
@@ -126,7 +125,6 @@ export default function SidebarWrapper<T extends object>({
|
||||
existingChats={chatSessions}
|
||||
currentChatSession={null}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useContext, useState, useRef, useLayoutEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FiMoreHorizontal,
|
||||
FiShare2,
|
||||
FiEye,
|
||||
FiEyeOff,
|
||||
FiTrash,
|
||||
FiEdit,
|
||||
FiHash,
|
||||
FiBarChart,
|
||||
FiLock,
|
||||
FiUnlock,
|
||||
FiSearch,
|
||||
} from "react-icons/fi";
|
||||
import { FaHashtag } from "react-icons/fa";
|
||||
import {
|
||||
@@ -26,33 +21,38 @@ import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { checkUserOwnsAssistant } from "@/lib/assistants/utils";
|
||||
import { toggleAssistantPinnedStatus } from "@/lib/assistants/pinnedAssistants";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PinnedIcon } from "@/components/icons/icons";
|
||||
import {
|
||||
deletePersona,
|
||||
togglePersonaPublicStatus,
|
||||
} from "@/app/admin/assistants/lib";
|
||||
import { HammerIcon } from "lucide-react";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { truncateString } from "@/lib/utils";
|
||||
|
||||
export const AssistantBadge = ({
|
||||
text,
|
||||
className,
|
||||
maxLength,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
maxLength?: number;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`h-4 px-1.5 py-1 text-[10px] bg-[#e6e3dd]/50 rounded-lg justify-center items-center gap-1 inline-flex ${className}`}
|
||||
className={`h-4 px-1.5 py-1 text-[10px] flex-none bg-[#e6e3dd]/50 rounded-lg justify-center items-center gap-1 inline-flex ${className}`}
|
||||
>
|
||||
<div className="text-[#4a4a4a] font-normal leading-[8px]">{text}</div>
|
||||
<div className="text-[#4a4a4a] font-normal leading-[8px]">
|
||||
{maxLength ? truncateString(text, maxLength) : text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -62,7 +62,7 @@ const AssistantCard: React.FC<{
|
||||
pinned: boolean;
|
||||
closeModal: () => void;
|
||||
}> = ({ persona, pinned, closeModal }) => {
|
||||
const { user, refreshUser } = useUser();
|
||||
const { user, toggleAssistantPinnedStatus } = useUser();
|
||||
const router = useRouter();
|
||||
const { refreshAssistants } = useAssistants();
|
||||
|
||||
@@ -72,7 +72,8 @@ const AssistantCard: React.FC<{
|
||||
undefined
|
||||
);
|
||||
|
||||
const handleShare = () => setActivePopover("visibility");
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const handleDelete = () => setActivePopover("delete");
|
||||
const handleEdit = () => {
|
||||
router.push(`/assistants/edit/${persona.id}`);
|
||||
@@ -81,6 +82,24 @@ const AssistantCard: React.FC<{
|
||||
|
||||
const closePopover = () => setActivePopover(undefined);
|
||||
|
||||
const nameRef = useRef<HTMLHeadingElement>(null);
|
||||
const hiddenNameRef = useRef<HTMLSpanElement>(null);
|
||||
const [isNameTruncated, setIsNameTruncated] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const checkTruncation = () => {
|
||||
if (nameRef.current && hiddenNameRef.current) {
|
||||
const visibleWidth = nameRef.current.offsetWidth;
|
||||
const fullTextWidth = hiddenNameRef.current.offsetWidth;
|
||||
setIsNameTruncated(fullTextWidth > visibleWidth);
|
||||
}
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
window.addEventListener("resize", checkTruncation);
|
||||
return () => window.removeEventListener("resize", checkTruncation);
|
||||
}, [persona.name]);
|
||||
|
||||
return (
|
||||
<div className="w-full p-2 overflow-visible pb-4 pt-3 bg-[#fefcf9] rounded shadow-[0px_0px_4px_0px_rgba(0,0,0,0.25)] flex flex-col">
|
||||
<div className="w-full flex">
|
||||
@@ -90,24 +109,47 @@ const AssistantCard: React.FC<{
|
||||
<div className="flex-1 mt-1 flex flex-col">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="flex items-end gap-x-2 leading-none">
|
||||
<h3 className="text-black leading-none font-semibold text-base lg-normal">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h3
|
||||
ref={nameRef}
|
||||
className={` text-black line-clamp-1 break-all text-ellipsis leading-none font-semibold text-base lg-normal w-full overflow-hidden`}
|
||||
>
|
||||
{persona.name}
|
||||
</h3>
|
||||
</TooltipTrigger>
|
||||
{isNameTruncated && (
|
||||
<TooltipContent>{persona.name}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span
|
||||
ref={hiddenNameRef}
|
||||
className="absolute left-0 top-0 invisible whitespace-nowrap"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{persona.name}
|
||||
</h3>
|
||||
</span>
|
||||
{persona.labels && persona.labels.length > 0 && (
|
||||
<>
|
||||
{persona.labels.slice(0, 3).map((label, index) => (
|
||||
<AssistantBadge key={index} text={label.name} />
|
||||
))}
|
||||
{persona.labels.length > 3 && (
|
||||
{persona.labels.slice(0, 2).map((label, index) => (
|
||||
<AssistantBadge
|
||||
text={`+${persona.labels.length - 3} more`}
|
||||
key={index}
|
||||
text={label.name}
|
||||
maxLength={10}
|
||||
/>
|
||||
))}
|
||||
{persona.labels.length > 2 && (
|
||||
<AssistantBadge
|
||||
text={`+${persona.labels.length - 2} more`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isOwnedByUser && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex ml-2 items-center gap-x-2">
|
||||
<Popover
|
||||
open={activePopover !== undefined}
|
||||
onOpenChange={(open) =>
|
||||
@@ -141,41 +183,29 @@ const AssistantCard: React.FC<{
|
||||
<FiEdit size={12} className="inline mr-2" />
|
||||
Edit
|
||||
</button>
|
||||
{/*
|
||||
<button
|
||||
onClick={isOwnedByUser ? handleShare : undefined}
|
||||
className={`w-full text-left flex items-center px-2 py-1 rounded ${
|
||||
isOwnedByUser
|
||||
? "hover:bg-neutral-100"
|
||||
: "opacity-50 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!isOwnedByUser}
|
||||
>
|
||||
<FiShare2 size={12} className="inline mr-2" />
|
||||
Share
|
||||
</button> */}
|
||||
|
||||
<button
|
||||
onClick={
|
||||
isOwnedByUser
|
||||
? () => {
|
||||
router.push(
|
||||
`/assistants/stats/${persona.id}`
|
||||
);
|
||||
closePopover();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`w-full text-left items-center px-2 py-1 rounded ${
|
||||
isOwnedByUser
|
||||
? "hover:bg-neutral-100"
|
||||
: "opacity-50 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!isOwnedByUser}
|
||||
>
|
||||
<FiBarChart size={12} className="inline mr-2" />
|
||||
Stats
|
||||
</button>
|
||||
{isPaidEnterpriseFeaturesEnabled && (
|
||||
<button
|
||||
onClick={
|
||||
isOwnedByUser
|
||||
? () => {
|
||||
router.push(
|
||||
`/assistants/stats/${persona.id}`
|
||||
);
|
||||
closePopover();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`w-full text-left items-center px-2 py-1 rounded ${
|
||||
isOwnedByUser
|
||||
? "hover:bg-neutral-100"
|
||||
: "opacity-50 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!isOwnedByUser}
|
||||
>
|
||||
<FiBarChart size={12} className="inline mr-2" />
|
||||
Stats
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={isOwnedByUser ? handleDelete : undefined}
|
||||
className={`w-full text-left items-center px-2 py-1 rounded ${
|
||||
@@ -221,33 +251,33 @@ const AssistantCard: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-black font-[350] mt-0 text-sm mb-1 line-clamp-2 h-[2.7em]">
|
||||
<p className="text-black font-[350] mt-0 text-sm line-clamp-2 h-[2.7em]">
|
||||
{persona.description || "\u00A0"}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col ">
|
||||
{/* <div className="mb-1 mt-1">
|
||||
<div className="flex items-center">
|
||||
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className="my-1">
|
||||
<span className="flex items-center text-black text-xs opacity-50">
|
||||
{(persona.owner?.email || persona.builtin_persona) && "By "}
|
||||
{persona.owner?.email || (persona.builtin_persona && "Onyx")}
|
||||
{(persona.owner?.email || persona.builtin_persona) && (
|
||||
<span className="mx-2">•</span>
|
||||
)}
|
||||
{persona.tools.length > 0 ? (
|
||||
<div className="my-1.5">
|
||||
<p className="flex items-center text-black text-xs opacity-50">
|
||||
{persona.owner?.email || persona.builtin_persona ? (
|
||||
<>
|
||||
{persona.tools.length}
|
||||
{" Action"}
|
||||
{persona.tools.length !== 1 ? "s" : ""}
|
||||
<span className="truncate">
|
||||
By {persona.owner?.email || "Onyx"}
|
||||
</span>
|
||||
|
||||
<span className="mx-2">•</span>
|
||||
</>
|
||||
) : (
|
||||
"No Actions"
|
||||
)}
|
||||
) : null}
|
||||
<span className="flex-none truncate">
|
||||
{persona.tools.length > 0 ? (
|
||||
<>
|
||||
{persona.tools.length}
|
||||
{" Action"}
|
||||
{persona.tools.length !== 1 ? "s" : ""}
|
||||
</>
|
||||
) : (
|
||||
"No Actions"
|
||||
)}
|
||||
</span>
|
||||
<span className="mx-2">•</span>
|
||||
{persona.is_public ? (
|
||||
<>
|
||||
@@ -260,17 +290,7 @@ const AssistantCard: React.FC<{
|
||||
Private
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex flex-wrap">
|
||||
{persona.document_sets.slice(0, 5).map((set, index) => (
|
||||
<AssistantBadge
|
||||
className="!text-base"
|
||||
key={index}
|
||||
text={set.name}
|
||||
/>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -284,7 +304,7 @@ const AssistantCard: React.FC<{
|
||||
}}
|
||||
className="hover:bg-neutral-100 hover:text-text px-2 py-1 gap-x-1 rounded border border-black flex items-center"
|
||||
>
|
||||
<FaHashtag size={12} className="flex-none" />
|
||||
<PencilIcon size={12} className="flex-none" />
|
||||
<span className="text-xs">Start Chat</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -296,20 +316,25 @@ const AssistantCard: React.FC<{
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<div
|
||||
onClick={async () => {
|
||||
await toggleAssistantPinnedStatus(
|
||||
user?.preferences.pinned_assistants || [],
|
||||
persona.id,
|
||||
!pinned
|
||||
);
|
||||
await refreshUser();
|
||||
}}
|
||||
className="hover:bg-neutral-100 px-2 py-1 gap-x-1 rounded border border-black flex items-center w-[65px]"
|
||||
className="hover:bg-neutral-100 px-2 group cursor-pointer py-1 gap-x-1 relative rounded border border-black flex items-center w-[65px]"
|
||||
>
|
||||
<PinnedIcon size={12} />
|
||||
<p className="text-xs">{pinned ? "Unpin" : "Pin"}</p>
|
||||
</button>
|
||||
{!pinned ? (
|
||||
<p className="absolute w-full left-0 group-hover:text-black w-full text-center transform text-xs">
|
||||
Pin
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs group-hover:text-black">Unpin</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{pinned ? "Remove from" : "Add to"} your pinned list
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Modal } from "@/components/Modal";
|
||||
import AssistantCard from "./AssistantCard";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLabels } from "@/lib/hooks";
|
||||
import { FilterIcon } from "lucide-react";
|
||||
import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership";
|
||||
|
||||
export const AssistantBadgeSelector = ({
|
||||
text,
|
||||
@@ -27,7 +25,7 @@ export const AssistantBadgeSelector = ({
|
||||
selected
|
||||
? "bg-neutral-900 text-white"
|
||||
: "bg-transparent text-neutral-900"
|
||||
} h-5 px-1 py-0.5 rounded-lg cursor-pointer text-[12px] font-normal leading-[10px] border border-black justify-center items-center gap-1 inline-flex`}
|
||||
} w-12 h-5 text-center px-1 py-0.5 rounded-lg cursor-pointer text-[12px] font-normal leading-[10px] border border-black justify-center items-center gap-1 inline-flex`}
|
||||
onClick={toggleFilter}
|
||||
>
|
||||
{text}
|
||||
@@ -39,6 +37,7 @@ export enum AssistantFilter {
|
||||
Pinned = "Pinned",
|
||||
Public = "Public",
|
||||
Private = "Private",
|
||||
Mine = "Mine",
|
||||
}
|
||||
|
||||
const useAssistantFilter = () => {
|
||||
@@ -48,6 +47,7 @@ const useAssistantFilter = () => {
|
||||
[AssistantFilter.Pinned]: false,
|
||||
[AssistantFilter.Public]: false,
|
||||
[AssistantFilter.Private]: false,
|
||||
[AssistantFilter.Mine]: false,
|
||||
});
|
||||
|
||||
const toggleAssistantFilter = (filter: AssistantFilter) => {
|
||||
@@ -67,7 +67,7 @@ export default function AssistantModal({
|
||||
}) {
|
||||
const [showAllFeaturedAssistants, setShowAllFeaturedAssistants] =
|
||||
useState(false);
|
||||
const { assistants, visibleAssistants, pinnedAssistants } = useAssistants();
|
||||
const { assistants, visibleAssistants } = useAssistants();
|
||||
const { assistantFilters, toggleAssistantFilter, setAssistantFilters } =
|
||||
useAssistantFilter();
|
||||
const router = useRouter();
|
||||
@@ -89,16 +89,21 @@ export default function AssistantModal({
|
||||
!assistantFilters[AssistantFilter.Private] || !assistant.is_public;
|
||||
const pinnedFilter =
|
||||
!assistantFilters[AssistantFilter.Pinned] ||
|
||||
pinnedAssistants.map((a: Persona) => a.id).includes(assistant.id);
|
||||
(user?.preferences?.pinned_assistants?.includes(assistant.id) ?? false);
|
||||
|
||||
const mineFilter =
|
||||
!assistantFilters[AssistantFilter.Mine] ||
|
||||
assistants.map((a: Persona) => checkUserOwnsAssistant(user, a));
|
||||
|
||||
return (
|
||||
(nameMatches || labelMatches) &&
|
||||
publicFilter &&
|
||||
privateFilter &&
|
||||
pinnedFilter
|
||||
pinnedFilter &&
|
||||
mineFilter
|
||||
);
|
||||
});
|
||||
}, [assistants, searchQuery, assistantFilters, pinnedAssistants]);
|
||||
}, [assistants, searchQuery, assistantFilters]);
|
||||
|
||||
const featuredAssistants = [
|
||||
...memoizedCurrentlyVisibleAssistants.filter(
|
||||
@@ -122,10 +127,10 @@ export default function AssistantModal({
|
||||
heightOverride={`${height}px`}
|
||||
onOutsideClick={hideModal}
|
||||
removeBottomPadding
|
||||
className={`max-w-4xl ${height} w-[95%] overflow-hidden`}
|
||||
className={`max-w-4xl max-h-[90vh] ${height} w-[95%] overflow-hidden`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col sticky top-0 z-10">
|
||||
<div className="flex bg-background flex-col sticky top-0 z-10">
|
||||
<div className="flex px-2 justify-between items-center gap-x-2 mb-0">
|
||||
<div className="h-12 w-full rounded-lg flex-col justify-center items-start gap-2.5 inline-flex">
|
||||
<div className="h-12 rounded-md w-full shadow-[0px_0px_2px_0px_rgba(0,0,0,0.25)] border border-[#dcdad4] flex items-center px-3">
|
||||
@@ -164,16 +169,18 @@ export default function AssistantModal({
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 flex py-2 items-center gap-x-2 mb-2 flex-wrap">
|
||||
<div className="px-2 flex py-4 items-center gap-x-2 flex-wrap">
|
||||
<FilterIcon size={16} />
|
||||
<AssistantBadgeSelector
|
||||
text="Pinned"
|
||||
selected={assistantFilters[AssistantFilter.Pinned]}
|
||||
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Pinned)}
|
||||
/>
|
||||
|
||||
<AssistantBadgeSelector
|
||||
text="Public"
|
||||
selected={assistantFilters[AssistantFilter.Public]}
|
||||
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Public)}
|
||||
text="Mine"
|
||||
selected={assistantFilters[AssistantFilter.Mine]}
|
||||
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Mine)}
|
||||
/>
|
||||
<AssistantBadgeSelector
|
||||
text="Private"
|
||||
@@ -182,6 +189,11 @@ export default function AssistantModal({
|
||||
toggleAssistantFilter(AssistantFilter.Private)
|
||||
}
|
||||
/>
|
||||
<AssistantBadgeSelector
|
||||
text="Public"
|
||||
selected={assistantFilters[AssistantFilter.Public]}
|
||||
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Public)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full border-t border-neutral-200" />
|
||||
</div>
|
||||
@@ -196,7 +208,11 @@ export default function AssistantModal({
|
||||
featuredAssistants.map((assistant, index) => (
|
||||
<div key={index}>
|
||||
<AssistantCard
|
||||
pinned={pinnedAssistants.includes(assistant)}
|
||||
pinned={
|
||||
user?.preferences?.pinned_assistants?.includes(
|
||||
assistant.id
|
||||
) ?? false
|
||||
}
|
||||
persona={assistant}
|
||||
closeModal={hideModal}
|
||||
/>
|
||||
@@ -221,7 +237,11 @@ export default function AssistantModal({
|
||||
.map((assistant, index) => (
|
||||
<div key={index}>
|
||||
<AssistantCard
|
||||
pinned={pinnedAssistants.includes(assistant)}
|
||||
pinned={
|
||||
user?.preferences?.pinned_assistants?.includes(
|
||||
assistant.id
|
||||
) ?? false
|
||||
}
|
||||
persona={assistant}
|
||||
closeModal={hideModal}
|
||||
/>
|
||||
|
||||
@@ -60,7 +60,6 @@ export function ChatBanner() {
|
||||
`}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
aria-expanded={isExpanded}
|
||||
role="region"
|
||||
>
|
||||
<div className="text-emphasis text-sm w-full">
|
||||
{/* Padding for consistent spacing */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,7 @@ import {
|
||||
LlmOverride,
|
||||
useLlmOverride,
|
||||
} from "@/lib/hooks";
|
||||
import {
|
||||
DefaultDropdownElement,
|
||||
StringOrNumberOption,
|
||||
} from "@/components/Dropdown";
|
||||
import { StringOrNumberOption } from "@/components/Dropdown";
|
||||
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { destructureValue, getFinalLLM, structureValue } from "@/lib/llm/utils";
|
||||
@@ -15,7 +12,7 @@ import { useState } from "react";
|
||||
import { Hoverable } from "@/components/Hoverable";
|
||||
import { Popover } from "@/components/popover/Popover";
|
||||
import { IconType } from "react-icons";
|
||||
import { FiRefreshCw } from "react-icons/fi";
|
||||
import { FiRefreshCw, FiCheck } from "react-icons/fi";
|
||||
|
||||
export function RegenerateDropdown({
|
||||
options,
|
||||
@@ -43,45 +40,33 @@ export function RegenerateDropdown({
|
||||
};
|
||||
|
||||
const Dropdown = (
|
||||
<div
|
||||
className={`
|
||||
border
|
||||
border
|
||||
rounded-lg
|
||||
flex
|
||||
flex-col
|
||||
mx-2
|
||||
bg-background
|
||||
${maxHeight || "max-h-72"}
|
||||
overflow-y-auto
|
||||
overscroll-contain relative`}
|
||||
>
|
||||
<p
|
||||
className="
|
||||
sticky
|
||||
top-0
|
||||
flex
|
||||
bg-background
|
||||
font-medium
|
||||
px-2
|
||||
text-sm
|
||||
py-1.5
|
||||
"
|
||||
>
|
||||
Regenerate with
|
||||
</p>
|
||||
{options.map((option, ind) => {
|
||||
const isSelected = option.value === selected;
|
||||
return (
|
||||
<DefaultDropdownElement
|
||||
key={option.value}
|
||||
name={getDisplayNameForModel(option.name)}
|
||||
description={option.description}
|
||||
onSelect={() => onSelect(option.value)}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="overflow-y-auto py-2 min-w-fit bg-white dark:bg-gray-800 rounded-md shadow-lg">
|
||||
<div className="mb-1 flex items-center justify-between px-4 pt-2">
|
||||
<span className="text-sm text-text-500 dark:text-text-400">
|
||||
Regenerate with
|
||||
</span>
|
||||
</div>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
className={`flex items-center m-1.5 p-1.5 text-sm cursor-pointer focus-visible:outline-0 group relative hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md my-0 px-3 mx-2 gap-2.5 py-3 !pr-3 ${
|
||||
option.value === selected ? "bg-gray-100 dark:bg-gray-700" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(option.value)}
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div>{getDisplayNameForModel(option.name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{option.value === selected && (
|
||||
<FiCheck className="text-blue-500 dark:text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -79,13 +79,9 @@ export function ChatDocumentDisplay({
|
||||
document.updated_at || Object.keys(document.metadata).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`desktop:max-w-[400px] opacity-100 ${
|
||||
modal ? "w-[90vw]" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<div className="desktop:max-w-[400px] opacity-100 w-full">
|
||||
<div
|
||||
className={`flex relative flex-col px-3 py-2.5 gap-0.5 rounded-xl mx-2 my-1 ${
|
||||
className={`flex relative flex-col px-3 py-2.5 gap-0.5 rounded-xl my-1 ${
|
||||
isSelected ? "bg-[#ebe7de]" : "bg- hover:bg-[#ebe7de]/80"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ interface DocumentResultsProps {
|
||||
isSharedChat?: boolean;
|
||||
modal: boolean;
|
||||
setPresentingDocument: Dispatch<SetStateAction<OnyxDocument | null>>;
|
||||
removeHeader?: boolean;
|
||||
}
|
||||
|
||||
export const DocumentResults = forwardRef<HTMLDivElement, DocumentResultsProps>(
|
||||
@@ -43,10 +44,10 @@ export const DocumentResults = forwardRef<HTMLDivElement, DocumentResultsProps>(
|
||||
isSharedChat,
|
||||
isOpen,
|
||||
setPresentingDocument,
|
||||
removeHeader,
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const { popup, setPopup } = usePopup();
|
||||
const [delayedSelectedDocumentCount, setDelayedSelectedDocumentCount] =
|
||||
useState(0);
|
||||
|
||||
@@ -98,21 +99,29 @@ export const DocumentResults = forwardRef<HTMLDivElement, DocumentResultsProps>(
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{popup}
|
||||
<div className="p-4 flex items-center justify-between gap-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{/* <SourcesIcon size={32} /> */}
|
||||
<h2 className="text-xl font-bold text-text-900">Sources</h2>
|
||||
</div>
|
||||
<button className="my-auto" onClick={closeSidebar}>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-b border-divider-history-sidebar-bar mx-3" />
|
||||
<div className="overflow-y-auto h-fit mb-8 pb-8 -mx-1 sm:mx-0 flex-grow gap-y-0 default-scrollbar dark-scrollbar flex flex-col">
|
||||
{!removeHeader && (
|
||||
<>
|
||||
<div className="p-4 flex items-center justify-between gap-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h2 className="text-xl font-bold text-text-900">
|
||||
Sources
|
||||
</h2>
|
||||
</div>
|
||||
<button className="my-auto" onClick={closeSidebar}>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-b border-divider-history-sidebar-bar mx-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto h-fit mb-8 pb-8 sm:mx-0 flex-grow gap-y-0 default-scrollbar dark-scrollbar flex flex-col">
|
||||
{dedupedDocuments.length > 0 ? (
|
||||
dedupedDocuments.map((document, ind) => (
|
||||
<div key={document.document_id} className="w-full">
|
||||
<div
|
||||
key={document.document_id}
|
||||
className={`desktop:px-2 w-full`}
|
||||
>
|
||||
<ChatDocumentDisplay
|
||||
setPresentingDocument={setPresentingDocument}
|
||||
closeSidebar={closeSidebar}
|
||||
|
||||
@@ -34,6 +34,7 @@ interface FolderDropdownProps {
|
||||
onDelete?: (folderId: number) => void;
|
||||
onDrop?: (folderId: number, chatSessionId: string) => void;
|
||||
children?: ReactNode;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const FolderDropdown = forwardRef<HTMLDivElement, FolderDropdownProps>(
|
||||
@@ -46,6 +47,7 @@ export const FolderDropdown = forwardRef<HTMLDivElement, FolderDropdownProps>(
|
||||
onEdit,
|
||||
onDrop,
|
||||
children,
|
||||
index,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -155,117 +157,123 @@ export const FolderDropdown = forwardRef<HTMLDivElement, FolderDropdownProps>(
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="overflow-visible w-full"
|
||||
className="overflow-visible mt-2 w-full"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex overflow-visible items-center w-full text-[#6c6c6c] rounded-md p-1 relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className="sticky top-0 bg-background-sidebar z-10"
|
||||
style={{ zIndex: 1000 - index }}
|
||||
>
|
||||
<button
|
||||
className="flex overflow-hidden items-center flex-grow"
|
||||
onClick={() => !isEditing && setIsOpen(!isOpen)}
|
||||
{...(isEditing ? {} : listeners)}
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex overflow-visible items-center w-full text-text-darker rounded-md p-1 relative bg-background-sidebar sticky top-0"
|
||||
style={{ zIndex: 10 - index }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<Caret size={16} className="mr-1" />
|
||||
) : (
|
||||
<Caret size={16} className="-rotate-90 mr-1" />
|
||||
)}
|
||||
{isEditing ? (
|
||||
<div ref={editingRef} className="flex-grow z-[9999] relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
className="text-sm font-medium bg-transparent outline-none w-full pb-1 border-b border-[#6c6c6c] transition-colors duration-200"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEdit();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{folder.folder_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{isHovered && !isEditing && folder.folder_id && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="ml-auto px-1"
|
||||
className="flex overflow-hidden items-center flex-grow"
|
||||
onClick={() => !isEditing && setIsOpen(!isOpen)}
|
||||
{...(isEditing ? {} : listeners)}
|
||||
>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
{(isHovered || isDeletePopoverOpen) &&
|
||||
!isEditing &&
|
||||
folder.folder_id && (
|
||||
<Popover
|
||||
open={isDeletePopoverOpen}
|
||||
onOpenChange={setIsDeletePopoverOpen}
|
||||
content={
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick();
|
||||
{isOpen ? (
|
||||
<Caret size={16} className="mr-1" />
|
||||
) : (
|
||||
<Caret size={16} className="-rotate-90 mr-1" />
|
||||
)}
|
||||
{isEditing ? (
|
||||
<div ref={editingRef} className="flex-grow z-[9999] relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
className="text-sm font-medium bg-transparent outline-none w-full pb-1 border-b border-[#6c6c6c] transition-colors duration-200"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEdit();
|
||||
}
|
||||
}}
|
||||
className="px-1"
|
||||
>
|
||||
<FiTrash2 size={14} />
|
||||
</button>
|
||||
}
|
||||
popover={
|
||||
<div className="p-3 w-64 border border-border rounded-lg bg-background z-50">
|
||||
<p className="text-sm mb-3">
|
||||
Are you sure you want to delete this folder?
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-200 rounded"
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-red-500 text-white rounded"
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
requiresContentPadding
|
||||
sideOffset={6}
|
||||
/>
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-[500]">
|
||||
{folder.folder_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{isHovered && !isEditing && folder.folder_id && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="ml-auto px-1"
|
||||
>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div className="flex -my-1 z-[9999]">
|
||||
<button onClick={handleEdit} className="p-1">
|
||||
<FiCheck size={14} />
|
||||
</button>
|
||||
<button onClick={() => setIsEditing(false)} className="p-1">
|
||||
<FiX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{(isHovered || isDeletePopoverOpen) &&
|
||||
!isEditing &&
|
||||
folder.folder_id && (
|
||||
<Popover
|
||||
open={isDeletePopoverOpen}
|
||||
onOpenChange={setIsDeletePopoverOpen}
|
||||
content={
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick();
|
||||
}}
|
||||
className="px-1"
|
||||
>
|
||||
<FiTrash2 size={14} />
|
||||
</button>
|
||||
}
|
||||
popover={
|
||||
<div className="p-3 w-64 border border-border rounded-lg bg-background z-50">
|
||||
<p className="text-sm mb-3">
|
||||
Are you sure you want to delete this folder?
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-200 rounded"
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-red-500 text-white rounded"
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
requiresContentPadding
|
||||
sideOffset={6}
|
||||
/>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div className="flex -my-1 z-[9999]">
|
||||
<button onClick={handleEdit} className="p-1">
|
||||
<FiCheck size={14} />
|
||||
</button>
|
||||
<button onClick={() => setIsEditing(false)} className="p-1">
|
||||
<FiX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="overflow-visible mr-3 ml-1 mt-1">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="overflow-visible mr-3 ml-1 mt-1">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
284
web/src/app/chat/hooks/scroll.ts
Normal file
284
web/src/app/chat/hooks/scroll.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect, useRef, RefObject } from "react";
|
||||
import { Message } from "../interfaces";
|
||||
|
||||
/**
|
||||
* A basic interface for hooking into ChatPage's DOM.
|
||||
*/
|
||||
export interface UseChatScrollingRefs {
|
||||
/** The main scrollable container for messages. */
|
||||
scrollableDivRef: RefObject<HTMLDivElement>;
|
||||
/** Reference to the very end of the chat. We scroll this into view. */
|
||||
endDivRef: RefObject<HTMLDivElement>;
|
||||
/** The input bar container ref, so we can measure heights to adjust padding. */
|
||||
inputRef: RefObject<HTMLDivElement>;
|
||||
/** A div at the bottom that we can dynamically resize to offset the text area height. */
|
||||
endPaddingRef: RefObject<HTMLDivElement>;
|
||||
/** Optional last-message ref if you want it. */
|
||||
lastMessageRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export interface VisibleRange {
|
||||
start: number;
|
||||
end: number;
|
||||
mostVisibleMessageId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameters you might need for your scrolling logic.
|
||||
*/
|
||||
export interface UseChatScrollingParams {
|
||||
messageHistoryLength: number; // how many total messages
|
||||
autoScrollEnabled: boolean; // whether user has 'auto-scroll' turned on
|
||||
buffer?: number; // how far from the bottom triggers the floating button
|
||||
onAboveHorizonChange?: (above: boolean) => void; // callback if we want to track "above horizon"
|
||||
messageHistory: Message[];
|
||||
updateVisibleRangeBasedOnScroll: (mostVisibleIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return values from the hook:
|
||||
*/
|
||||
export interface UseChatScrollingReturn extends UseChatScrollingRefs {
|
||||
/**
|
||||
* Whether the user is far enough “above” the bottom that we’d want to show a
|
||||
* “Scroll to bottom” button.
|
||||
*/
|
||||
aboveHorizon: boolean;
|
||||
/**
|
||||
* Call this to smooth-scroll to the bottom of the chat. You can pass `fast=true`
|
||||
* if you want to jump instantly (no smooth animation).
|
||||
*/
|
||||
scrollToBottom: (fast?: boolean) => void;
|
||||
/**
|
||||
* A function you can call after the input bar height changes — it handles
|
||||
* adjusting the bottom padding or auto-scrolling if needed.
|
||||
*/
|
||||
handleInputResize: () => void;
|
||||
clientHandleScroll: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the scroll management into a single hook.
|
||||
*/
|
||||
export function useChatScrolling({
|
||||
messageHistoryLength,
|
||||
autoScrollEnabled,
|
||||
messageHistory,
|
||||
updateVisibleRangeBasedOnScroll,
|
||||
buffer = 500,
|
||||
onAboveHorizonChange,
|
||||
}: UseChatScrollingParams): UseChatScrollingReturn {
|
||||
// Refs to important elements.
|
||||
const scrollableDivRef = useRef<HTMLDivElement>(null);
|
||||
const endDivRef = useRef<HTMLDivElement>(null);
|
||||
const endPaddingRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const lastMessageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Tracks how far from the bottom we are. If "aboveHorizon" is true, we might show a "scroll to bottom" button.
|
||||
const [aboveHorizon, setAboveHorizon] = useState(false);
|
||||
|
||||
// Keep track if we already performed that initial auto-scroll (so we don’t keep forcing it).
|
||||
const [hasPerformedInitialScroll, setHasPerformedInitialScroll] =
|
||||
useState(false);
|
||||
|
||||
// We also keep a small ref to help avoid double-jumping if the user is actively scrolling.
|
||||
const waitForScrollRef = useRef(false);
|
||||
|
||||
// For measuring how the input bar's height changes over time.
|
||||
const previousHeightRef = useRef<number>(0);
|
||||
|
||||
function clientHandleScroll() {
|
||||
if (!scrollableDivRef.current) return;
|
||||
const scroller = scrollableDivRef.current;
|
||||
const viewportHeight = scroller.clientHeight;
|
||||
let mostVisibleIndex = -1;
|
||||
|
||||
// E.g., loop over your messages to see which one is in view
|
||||
for (let i = 0; i < messageHistoryLength; i++) {
|
||||
const msg = messageHistory[i];
|
||||
const el = document.getElementById(`message-${msg.messageId}`);
|
||||
if (!el) continue;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
// etc. track “largest portion in view” or “lowest fully visible message”
|
||||
if (rect.bottom <= viewportHeight && rect.bottom > 0) {
|
||||
mostVisibleIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Then call:
|
||||
if (mostVisibleIndex >= 0) {
|
||||
updateVisibleRangeBasedOnScroll(mostVisibleIndex);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Scroll event handler: updates `aboveHorizon` based on how far we are from the bottom.
|
||||
*/
|
||||
const handleScroll = () => {
|
||||
if (!scrollableDivRef.current || !endDivRef.current) return;
|
||||
const bounding = endDivRef.current.getBoundingClientRect();
|
||||
const containerBounds = scrollableDivRef.current.getBoundingClientRect();
|
||||
|
||||
// If the bottom is well below the container bottom, we’re definitely “above”.
|
||||
const distance = bounding.top - containerBounds.top;
|
||||
const isAbove = distance > buffer;
|
||||
setAboveHorizon(isAbove);
|
||||
|
||||
// If a parent component wants to know about it:
|
||||
if (onAboveHorizonChange) {
|
||||
onAboveHorizonChange(isAbove);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Smoothly (or instantly, if `fast=true`) scroll the user to the bottom of the chat.
|
||||
*/
|
||||
const scrollToBottom = (fast?: boolean) => {
|
||||
waitForScrollRef.current = true;
|
||||
setTimeout(() => {
|
||||
if (!endDivRef.current || !scrollableDivRef.current) return;
|
||||
|
||||
endDivRef.current.scrollIntoView({
|
||||
behavior: fast ? "auto" : "smooth",
|
||||
});
|
||||
|
||||
setHasPerformedInitialScroll(true);
|
||||
|
||||
// Release the "lock" after a short delay so the user can manually scroll again.
|
||||
setTimeout(() => {
|
||||
waitForScrollRef.current = false;
|
||||
}, 1500);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Called whenever the input bar’s height changes.
|
||||
* We recalc padding at the bottom so messages never hide behind the bar.
|
||||
*/
|
||||
const handleInputResize = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!inputRef.current || !endPaddingRef.current) return;
|
||||
|
||||
const newHeight = inputRef.current.getBoundingClientRect().height;
|
||||
const oldHeight = previousHeightRef.current;
|
||||
const heightDiff = newHeight - oldHeight;
|
||||
|
||||
// Update bottom padding (somewhat optional).
|
||||
endPaddingRef.current.style.height = Math.max(newHeight - 50, 0) + "px";
|
||||
previousHeightRef.current = newHeight;
|
||||
|
||||
// If auto-scroll is enabled and we have a net increase in height, adjust the scroll so the user sees everything.
|
||||
if (autoScrollEnabled && heightDiff > 0 && !waitForScrollRef.current) {
|
||||
scrollableDivRef.current?.scrollBy({
|
||||
left: 0,
|
||||
top: heightDiff,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a scroll listener on mount, remove on unmount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const div = scrollableDivRef.current;
|
||||
if (!div) return;
|
||||
div.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
div.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Any time our message list changes (especially if we have new messages),
|
||||
* we can optionally auto-scroll if the user is near the bottom.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// If we haven't done the initial scroll on page load or new session, do it once:
|
||||
if (
|
||||
!hasPerformedInitialScroll &&
|
||||
autoScrollEnabled &&
|
||||
messageHistoryLength > 0
|
||||
) {
|
||||
scrollToBottom(true);
|
||||
} else {
|
||||
// If the user is already near the bottom, keep them pinned at the bottom.
|
||||
// But if they've scrolled far away, don't forcibly yank them down.
|
||||
// If you want that behavior, you can uncomment:
|
||||
//
|
||||
// if (!aboveHorizon && autoScrollEnabled) {
|
||||
// scrollToBottom(true);
|
||||
// }
|
||||
}
|
||||
}, [messageHistoryLength]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
scrollableDivRef,
|
||||
endDivRef,
|
||||
endPaddingRef,
|
||||
inputRef,
|
||||
lastMessageRef,
|
||||
aboveHorizon,
|
||||
scrollToBottom,
|
||||
handleInputResize,
|
||||
clientHandleScroll,
|
||||
};
|
||||
}
|
||||
interface UseVirtualMessagesParams {
|
||||
messageHistory: any[]; // or your real type
|
||||
bufferCount: number;
|
||||
}
|
||||
|
||||
export function useVirtualMessages({
|
||||
messageHistory,
|
||||
bufferCount,
|
||||
}: UseVirtualMessagesParams) {
|
||||
const [visibleRange, setVisibleRange] = useState<VisibleRange>({
|
||||
start: 0,
|
||||
end: Math.min(bufferCount, messageHistory.length),
|
||||
mostVisibleMessageId: null,
|
||||
});
|
||||
|
||||
const scrollInitialized = useRef(false);
|
||||
|
||||
// Called to “expand” the visible slice if user scrolls near the top/bottom, etc.
|
||||
const updateVisibleRangeBasedOnScroll = (mostVisibleIndex: number) => {
|
||||
if (!scrollInitialized.current) return;
|
||||
|
||||
// Basic example: shift the window so that the mostVisibleIndex is near the middle
|
||||
const start = Math.max(0, mostVisibleIndex - bufferCount);
|
||||
const end = Math.min(
|
||||
messageHistory.length,
|
||||
mostVisibleIndex + bufferCount + 1
|
||||
);
|
||||
|
||||
setVisibleRange({
|
||||
start,
|
||||
end,
|
||||
mostVisibleMessageId: messageHistory[mostVisibleIndex]?.messageId ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize once
|
||||
useEffect(() => {
|
||||
if (!scrollInitialized.current && messageHistory.length > 0) {
|
||||
const newEnd = Math.min(messageHistory.length, bufferCount);
|
||||
setVisibleRange({
|
||||
start: 0,
|
||||
end: newEnd,
|
||||
mostVisibleMessageId: messageHistory[newEnd - 1]?.messageId ?? null,
|
||||
});
|
||||
scrollInitialized.current = true;
|
||||
}
|
||||
}, [bufferCount, messageHistory]);
|
||||
|
||||
// Or re-init if message list changes significantly
|
||||
|
||||
return {
|
||||
visibleRange,
|
||||
updateVisibleRangeBasedOnScroll,
|
||||
setVisibleRange, // if you want direct control
|
||||
};
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export default function InputPrompts() {
|
||||
<Title>Prompt Shortcuts</Title>
|
||||
<Text>
|
||||
Manage and customize prompt shortcuts for your assistants. Use your
|
||||
prompt shortcuts by starting a new message “/” in chat
|
||||
prompt shortcuts by starting a new message “/” in chat.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,6 +328,7 @@ export function ChatInputBar({
|
||||
<div className="flex justify-center mx-auto">
|
||||
<div
|
||||
className="
|
||||
max-w-full
|
||||
w-[800px]
|
||||
relative
|
||||
desktop:px-4
|
||||
@@ -505,7 +506,10 @@ export function ChatInputBar({
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder={`Message ${selectedAssistant.name} assistant...`}
|
||||
placeholder={`Message ${truncateString(
|
||||
selectedAssistant.name,
|
||||
70
|
||||
)} assistant...`}
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -649,7 +653,7 @@ export function ChatInputBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-1 mr-12 px-4 pb-2">
|
||||
<div className="flex items-center space-x-1 mr-12 overflow-hidden px-4 pb-2">
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ChatInputOptionProps {
|
||||
tooltipContent?: React.ReactNode;
|
||||
flexPriority?: "shrink" | "stiff" | "second";
|
||||
toggle?: boolean;
|
||||
minimize?: boolean;
|
||||
}
|
||||
|
||||
export const ChatInputOption: React.FC<ChatInputOptionProps> = ({
|
||||
@@ -26,28 +27,10 @@ export const ChatInputOption: React.FC<ChatInputOptionProps> = ({
|
||||
tooltipContent,
|
||||
toggle,
|
||||
onClick,
|
||||
minimize,
|
||||
}) => {
|
||||
const [isDropupVisible, setDropupVisible] = useState(false);
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
|
||||
const componentRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
componentRef.current &&
|
||||
!componentRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsTooltipVisible(false);
|
||||
setDropupVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -86,7 +69,7 @@ export const ChatInputOption: React.FC<ChatInputOptionProps> = ({
|
||||
size={size}
|
||||
className="h-4 w-4 my-auto text-[#4a4a4a] group-hover:text-text flex-none"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<div className={`flex items-center ${minimize && "mobile:hidden"}`}>
|
||||
{name && (
|
||||
<span className="text-sm text-[#4a4a4a] group-hover:text-text break-all line-clamp-1">
|
||||
{name}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -32,6 +32,7 @@ export default function LLMPopover({
|
||||
requiresImageGeneration,
|
||||
currentAssistant,
|
||||
}: LLMPopoverProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { llmOverride, updateLLMOverride, globalDefault } = llmOverrideManager;
|
||||
const currentLlm = llmOverride.modelName || globalDefault.modelName;
|
||||
|
||||
@@ -81,10 +82,11 @@ export default function LLMPopover({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
<ChatInputOption
|
||||
minimize
|
||||
toggle
|
||||
flexPriority="stiff"
|
||||
name={getDisplayNameForModel(
|
||||
@@ -119,7 +121,10 @@ export default function LLMPopover({
|
||||
? "bg-gray-100 text-text"
|
||||
: "text-text-darker"
|
||||
}`}
|
||||
onClick={() => updateLLMOverride(destructureValue(value))}
|
||||
onClick={() => {
|
||||
updateLLMOverride(destructureValue(value));
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{icon({ size: 16, className: "flex-none my-auto " })}
|
||||
<span className="line-clamp-1 ">
|
||||
@@ -132,14 +137,7 @@ export default function LLMPopover({
|
||||
(assistant)
|
||||
</span>
|
||||
);
|
||||
} else if (globalDefault.modelName === name) {
|
||||
return (
|
||||
<span className="flex-none ml-auto text-xs">
|
||||
(user default)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ export function SimplifiedChatInputBar({
|
||||
rounded-lg
|
||||
relative
|
||||
text-text-chatbar
|
||||
bg-background-chatbar
|
||||
bg-white
|
||||
[&:has(textarea:focus)]::ring-1
|
||||
[&:has(textarea:focus)]::ring-black
|
||||
"
|
||||
@@ -146,7 +146,7 @@ export function SimplifiedChatInputBar({
|
||||
resize-none
|
||||
rounded-lg
|
||||
border-0
|
||||
bg-background-chatbar
|
||||
bg-white
|
||||
placeholder:text-text-chatbar-subtle
|
||||
${
|
||||
textAreaRef.current &&
|
||||
|
||||
@@ -363,8 +363,8 @@ export function groupSessionsByDateRange(chatSessions: ChatSession[]) {
|
||||
const groups: Record<string, ChatSession[]> = {
|
||||
Today: [],
|
||||
"Previous 7 Days": [],
|
||||
"Previous 30 Days": [],
|
||||
"Over 30 days ago": [],
|
||||
"Previous 30 days": [],
|
||||
"Over 30 days": [],
|
||||
};
|
||||
|
||||
chatSessions.forEach((chatSession) => {
|
||||
@@ -378,9 +378,9 @@ export function groupSessionsByDateRange(chatSessions: ChatSession[]) {
|
||||
} else if (diffDays <= 7) {
|
||||
groups["Previous 7 Days"].push(chatSession);
|
||||
} else if (diffDays <= 30) {
|
||||
groups["Previous 30 Days"].push(chatSession);
|
||||
groups["Previous 30 days"].push(chatSession);
|
||||
} else {
|
||||
groups["Over 30 days ago"].push(chatSession);
|
||||
groups["Over 30 days"].push(chatSession);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -424,9 +424,10 @@ export function processRawChatHistory(
|
||||
message: messageInfo.message,
|
||||
type: messageInfo.message_type as "user" | "assistant",
|
||||
files: messageInfo.files,
|
||||
alternateAssistantID: messageInfo.alternate_assistant_id
|
||||
? Number(messageInfo.alternate_assistant_id)
|
||||
: null,
|
||||
alternateAssistantID:
|
||||
messageInfo.alternate_assistant_id !== null
|
||||
? Number(messageInfo.alternate_assistant_id)
|
||||
: null,
|
||||
// only include these fields if this is an assistant message so that
|
||||
// this is identical to what is computed at streaming time
|
||||
...(messageInfo.message_type === "assistant"
|
||||
|
||||
@@ -164,7 +164,6 @@ function FileDisplay({
|
||||
export const AIMessage = ({
|
||||
regenerate,
|
||||
overriddenModel,
|
||||
selectedMessageForDocDisplay,
|
||||
continueGenerating,
|
||||
shared,
|
||||
isActive,
|
||||
@@ -172,7 +171,6 @@ export const AIMessage = ({
|
||||
alternativeAssistant,
|
||||
docs,
|
||||
messageId,
|
||||
documentSelectionToggled,
|
||||
content,
|
||||
files,
|
||||
selectedDocuments,
|
||||
@@ -182,7 +180,6 @@ export const AIMessage = ({
|
||||
isComplete,
|
||||
hasDocs,
|
||||
handleFeedback,
|
||||
handleShowRetrieved,
|
||||
handleSearchQueryEdit,
|
||||
handleForceSearch,
|
||||
retrievalDisabled,
|
||||
@@ -194,7 +191,6 @@ export const AIMessage = ({
|
||||
toggledDocumentSidebar,
|
||||
}: {
|
||||
index?: number;
|
||||
selectedMessageForDocDisplay?: number | null;
|
||||
shared?: boolean;
|
||||
isActive?: boolean;
|
||||
continueGenerating?: () => void;
|
||||
@@ -207,7 +203,6 @@ export const AIMessage = ({
|
||||
currentPersona: Persona;
|
||||
messageId: number | null;
|
||||
content: string | JSX.Element;
|
||||
documentSelectionToggled?: boolean;
|
||||
files?: FileDescriptor[];
|
||||
query?: string;
|
||||
citedDocuments?: [string, OnyxDocument][] | null;
|
||||
@@ -216,7 +211,6 @@ export const AIMessage = ({
|
||||
toggledDocumentSidebar?: boolean;
|
||||
hasDocs?: boolean;
|
||||
handleFeedback?: (feedbackType: FeedbackType) => void;
|
||||
handleShowRetrieved?: (messageNumber: number | null) => void;
|
||||
handleSearchQueryEdit?: (query: string) => void;
|
||||
handleForceSearch?: () => void;
|
||||
retrievalDisabled?: boolean;
|
||||
@@ -530,7 +524,7 @@ export const AIMessage = ({
|
||||
className={`
|
||||
flex md:flex-row gap-x-0.5 mt-1
|
||||
transition-transform duration-300 ease-in-out
|
||||
transform opacity-100 translate-y-0"
|
||||
transform opacity-100 "
|
||||
`}
|
||||
>
|
||||
<TooltipGroup>
|
||||
@@ -611,10 +605,6 @@ export const AIMessage = ({
|
||||
settings?.isMobile) &&
|
||||
"!opacity-100"
|
||||
}
|
||||
translate-y-2 ${
|
||||
(isHovering || settings?.isMobile) && "!translate-y-0"
|
||||
}
|
||||
transition-transform duration-300 ease-in-out
|
||||
flex md:flex-row gap-x-0.5 bg-background-125/40 -mx-1.5 p-1.5 rounded-lg
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -183,7 +183,6 @@ export function UserSettingsModal({
|
||||
checked={user?.preferences?.shortcut_enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
updateUserShortcuts(checked);
|
||||
refreshUser();
|
||||
}}
|
||||
/>
|
||||
<Label className="text-sm">Enable Prompt Shortcuts</Label>
|
||||
@@ -205,6 +204,7 @@ export function UserSettingsModal({
|
||||
Scroll to see all options
|
||||
</div>
|
||||
<LLMSelector
|
||||
userSettings
|
||||
llmProviders={llmProviders}
|
||||
currentLlm={
|
||||
defaultModelDestructured
|
||||
@@ -215,7 +215,6 @@ export function UserSettingsModal({
|
||||
)
|
||||
: null
|
||||
}
|
||||
userDefault={null}
|
||||
requiresImageGeneration={false}
|
||||
onSelect={(selected) => {
|
||||
if (selected === null) {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { LlmOverrideManager } from "@/lib/hooks";
|
||||
import React, { forwardRef, useCallback, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import Text from "@/components/ui/text";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { destructureValue } from "@/lib/llm/utils";
|
||||
import { updateModelOverrideForChatSession } from "../../lib";
|
||||
import { GearIcon } from "@/components/icons/icons";
|
||||
import { LlmList } from "@/components/llm/LLMList";
|
||||
import { checkPersonaRequiresImageGeneration } from "@/app/admin/assistants/lib";
|
||||
|
||||
interface LlmTabProps {
|
||||
llmOverrideManager: LlmOverrideManager;
|
||||
currentLlm: string;
|
||||
openModelSettings: () => void;
|
||||
chatSessionId?: string;
|
||||
close: () => void;
|
||||
currentAssistant: Persona;
|
||||
}
|
||||
|
||||
export const LlmTab = forwardRef<HTMLDivElement, LlmTabProps>(
|
||||
(
|
||||
{
|
||||
llmOverrideManager,
|
||||
chatSessionId,
|
||||
currentLlm,
|
||||
close,
|
||||
openModelSettings,
|
||||
currentAssistant,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const requiresImageGeneration =
|
||||
checkPersonaRequiresImageGeneration(currentAssistant);
|
||||
|
||||
const { llmProviders } = useChatContext();
|
||||
const { updateLLMOverride, temperature, updateTemperature } =
|
||||
llmOverrideManager;
|
||||
const [isTemperatureExpanded, setIsTemperatureExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex w-full justify-between content-center mb-2 gap-x-2">
|
||||
<label className="block text-sm font-medium">Choose Model</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
close();
|
||||
openModelSettings();
|
||||
}}
|
||||
>
|
||||
<GearIcon />
|
||||
</button>
|
||||
</div>
|
||||
<LlmList
|
||||
requiresImageGeneration={requiresImageGeneration}
|
||||
llmProviders={llmProviders}
|
||||
currentLlm={currentLlm}
|
||||
onSelect={(value: string | null) => {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
updateLLMOverride(destructureValue(value));
|
||||
if (chatSessionId) {
|
||||
updateModelOverrideForChatSession(chatSessionId, value as string);
|
||||
}
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
className="flex items-center text-sm font-medium transition-colors duration-200"
|
||||
onClick={() => setIsTemperatureExpanded(!isTemperatureExpanded)}
|
||||
>
|
||||
<span className="mr-2 text-xs text-primary">
|
||||
{isTemperatureExpanded ? "▼" : "►"}
|
||||
</span>
|
||||
<span>Temperature</span>
|
||||
</button>
|
||||
|
||||
{isTemperatureExpanded && (
|
||||
<>
|
||||
<Text className="mt-2 mb-8">
|
||||
Adjust the temperature of the LLM. Higher temperatures will make
|
||||
the LLM generate more creative and diverse responses, while
|
||||
lower temperature will make the LLM generate more conservative
|
||||
and focused responses.
|
||||
</Text>
|
||||
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="range"
|
||||
onChange={(e) =>
|
||||
updateTemperature(parseFloat(e.target.value))
|
||||
}
|
||||
className="w-full p-2 border border-border rounded-md"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.01"
|
||||
value={temperature || 0}
|
||||
/>
|
||||
<div
|
||||
className="absolute text-sm"
|
||||
style={{
|
||||
left: `${(temperature || 0) * 50}%`,
|
||||
transform: `translateX(-${Math.min(
|
||||
Math.max((temperature || 0) * 50, 10),
|
||||
90
|
||||
)}%)`,
|
||||
top: "-1.5rem",
|
||||
}}
|
||||
>
|
||||
{temperature}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
LlmTab.displayName = "LlmTab";
|
||||
827
web/src/app/chat/sendMessage.ts
Normal file
827
web/src/app/chat/sendMessage.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ReadonlyURLSearchParams,
|
||||
redirect,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from "next/navigation";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Prism from "prismjs";
|
||||
import Cookies from "js-cookie";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { FiArrowDown } from "react-icons/fi";
|
||||
|
||||
export interface RegenerationRequest {
|
||||
messageId: number;
|
||||
parentMessage: Message;
|
||||
forceSearch?: boolean;
|
||||
}
|
||||
import {
|
||||
BackendMessage,
|
||||
ChatFileType,
|
||||
ChatSession,
|
||||
ChatSessionSharedStatus,
|
||||
FileChatDisplay,
|
||||
FileDescriptor,
|
||||
Message,
|
||||
MessageResponseIDInfo,
|
||||
RetrievalType,
|
||||
StreamingError,
|
||||
ToolCallMetadata,
|
||||
} from "./interfaces";
|
||||
// ^ import your actual definitions as needed
|
||||
import {
|
||||
LlmOverrideManager,
|
||||
FilterManager,
|
||||
LlmOverride,
|
||||
useFilters,
|
||||
useLlmOverride,
|
||||
} from "@/lib/hooks";
|
||||
import {
|
||||
buildChatUrl,
|
||||
buildLatestMessageChain,
|
||||
createChatSession,
|
||||
deleteAllChatSessions,
|
||||
getCitedDocumentsFromMessage,
|
||||
getHumanAndAIMessageFromMessageNumber,
|
||||
getLastSuccessfulMessageId,
|
||||
handleChatFeedback,
|
||||
nameChatSession,
|
||||
PacketType,
|
||||
personaIncludesRetrieval,
|
||||
processRawChatHistory,
|
||||
removeMessage,
|
||||
sendMessage,
|
||||
setMessageAsLatest,
|
||||
updateParentChildren,
|
||||
uploadFilesForChat,
|
||||
useScrollonStream,
|
||||
} from "./lib";
|
||||
|
||||
import { ChatState, FeedbackType, RegenerationState } from "./types";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { useDocumentSelection } from "./useDocumentSelection";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
|
||||
import { ChatInputBar } from "./input/ChatInputBar";
|
||||
import { AIMessage, HumanMessage } from "./message/Messages";
|
||||
import { ChatIntro } from "./ChatIntro";
|
||||
import { StarterMessages } from "../../components/assistants/StarterMessage";
|
||||
import { DocumentResults } from "./documentSidebar/DocumentResults";
|
||||
import { FeedbackModal } from "./modal/FeedbackModal";
|
||||
import { ShareChatSessionModal } from "./modal/ShareChatSessionModal";
|
||||
import { HealthCheckBanner } from "@/components/health/healthcheck";
|
||||
import { HistorySidebar } from "./sessionSidebar/HistorySidebar";
|
||||
|
||||
import { ApiKeyModal } from "@/components/llm/ApiKeyModal";
|
||||
import { ChatPopup } from "./ChatPopup";
|
||||
import FunctionalHeader from "@/components/chat_search/Header";
|
||||
import TextView from "@/components/chat_search/TextView";
|
||||
import { MinimalMarkdown } from "@/components/chat_search/MinimalMarkdown";
|
||||
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
||||
import { DeleteEntityModal } from "../../components/modals/DeleteEntityModal";
|
||||
import { OnyxInitializingLoader } from "@/components/OnyxInitializingLoader";
|
||||
import { NoAssistantModal } from "@/components/modals/NoAssistantModal";
|
||||
import { UserSettingsModal } from "./modal/UserSettingsModal";
|
||||
import AssistantModal from "../assistants/mine/AssistantModal";
|
||||
|
||||
import {
|
||||
checkLLMSupportsImageInput,
|
||||
destructureValue,
|
||||
getFinalLLM,
|
||||
getLLMProviderOverrideForPersona,
|
||||
} from "@/lib/llm/utils";
|
||||
|
||||
import {
|
||||
CHROME_MESSAGE,
|
||||
SUBMIT_MESSAGE_TYPES,
|
||||
} from "@/lib/extension/constants";
|
||||
import { useSendMessageToParent } from "@/lib/extension/utils";
|
||||
import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams";
|
||||
import { StreamStopInfo, StreamStopReason } from "@/lib/search/interfaces";
|
||||
|
||||
import { useSidebarVisibility } from "@/components/chat_search/hooks";
|
||||
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
|
||||
import FixedLogo from "./shared_chat_search/FixedLogo";
|
||||
import BlurBackground from "./shared_chat_search/BlurBackground";
|
||||
import { useChatSession } from "@/hooks/chat/useChatSession";
|
||||
import { Persona } from "../admin/assistants/interfaces";
|
||||
import {
|
||||
DocumentInfoPacket,
|
||||
AnswerPiecePacket,
|
||||
OnyxDocument,
|
||||
} from "@/lib/search/interfaces";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { buildFilters } from "@/lib/search/utils";
|
||||
import { SEARCH_TOOL_NAME } from "./tools/constants";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
import { useChatScrolling, useVirtualMessages } from "./hooks/scroll";
|
||||
import { LLMProviderDescriptor } from "../admin/configuration/llm/interfaces";
|
||||
|
||||
const TEMP_USER_MESSAGE_ID = -1;
|
||||
const TEMP_ASSISTANT_MESSAGE_ID = -2;
|
||||
const SYSTEM_MESSAGE_ID = -3;
|
||||
const BUFFER_COUNT = 20; // or whichever value suits your pagination
|
||||
|
||||
async function updateCurrentMessageFIFO(
|
||||
stack: CurrentMessageFIFO,
|
||||
params: any
|
||||
) {
|
||||
try {
|
||||
for await (const packet of sendMessage(params)) {
|
||||
if (params.signal?.aborted) {
|
||||
throw new Error("AbortError");
|
||||
}
|
||||
stack.push(packet);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError") {
|
||||
console.debug("Stream aborted");
|
||||
} else {
|
||||
stack.error = error.message;
|
||||
}
|
||||
} else {
|
||||
stack.error = String(error);
|
||||
}
|
||||
} finally {
|
||||
stack.isComplete = true;
|
||||
}
|
||||
}
|
||||
class CurrentMessageFIFO {
|
||||
private stack: PacketType[] = [];
|
||||
isComplete: boolean = false;
|
||||
error: string | null = null;
|
||||
|
||||
push(packetBunch: PacketType) {
|
||||
this.stack.push(packetBunch);
|
||||
}
|
||||
|
||||
nextPacket(): PacketType | undefined {
|
||||
return this.stack.shift();
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.stack.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const useSendMessage = ({
|
||||
abortControllers,
|
||||
llmProviders,
|
||||
setSubmittedMessage,
|
||||
setPopup,
|
||||
setAlternativeGeneratingAssistant,
|
||||
scrollToBottom,
|
||||
chatSessionIdRef,
|
||||
searchParams,
|
||||
messageHistory,
|
||||
currentMessageMap,
|
||||
currentSessionId,
|
||||
currentChatState,
|
||||
updateCanContinue,
|
||||
liveAssistant,
|
||||
handleNewSessionId,
|
||||
setAbortControllers,
|
||||
updateRegenerationState,
|
||||
resetRegenerationState,
|
||||
updateChatState,
|
||||
message,
|
||||
currentMessageFiles,
|
||||
selectedDocuments,
|
||||
resetInputBar,
|
||||
alternativeAssistant,
|
||||
filterManager,
|
||||
setChatState,
|
||||
setLoadingError,
|
||||
llmOverrideManager,
|
||||
setSelectedMessageForDocDisplay,
|
||||
upsertToCompleteMessageMap,
|
||||
setCurrentMessageFiles,
|
||||
}: {
|
||||
abortControllers: Map<string | null, AbortController>;
|
||||
llmProviders: LLMProviderDescriptor[];
|
||||
currentSessionId: () => string;
|
||||
setSubmittedMessage: (message: string) => void;
|
||||
setPopup: (popup: PopupSpec) => void;
|
||||
setAlternativeGeneratingAssistant: (assistant: Persona | null) => void;
|
||||
scrollToBottom: () => void;
|
||||
chatSessionIdRef: React.RefObject<string | null>;
|
||||
searchParams: ReadonlyURLSearchParams;
|
||||
messageHistory: Message[];
|
||||
currentMessageMap: () => Map<number, Message>;
|
||||
currentChatState: () => string;
|
||||
updateCanContinue: (canContinue: boolean) => void;
|
||||
liveAssistant: Persona;
|
||||
handleNewSessionId: (sessionId: string) => void;
|
||||
setAbortControllers: (
|
||||
value: SetStateAction<Map<string | null, AbortController>>
|
||||
) => void;
|
||||
updateRegenerationState: (state: RegenerationState | null) => void;
|
||||
resetRegenerationState: () => void;
|
||||
updateChatState: (state: ChatState) => void;
|
||||
message: string;
|
||||
currentMessageFiles: FileDescriptor[];
|
||||
selectedDocuments: OnyxDocument[];
|
||||
resetInputBar: () => void;
|
||||
alternativeAssistant: Persona | null;
|
||||
filterManager: FilterManager;
|
||||
setChatState: Dispatch<SetStateAction<Map<string | null, ChatState>>>;
|
||||
setLoadingError: Dispatch<SetStateAction<string | null>>;
|
||||
llmOverrideManager: LlmOverrideManager;
|
||||
setSelectedMessageForDocDisplay: (messageId: number) => void;
|
||||
upsertToCompleteMessageMap: (params: {
|
||||
messages: Message[];
|
||||
replacementsMap?: Map<number, number>;
|
||||
completeMessageMapOverride?: Map<number, Message>;
|
||||
chatSessionId?: string;
|
||||
}) => { messageMap: Map<number, Message> };
|
||||
setCurrentMessageFiles: Dispatch<SetStateAction<FileDescriptor[]>>;
|
||||
}) => {
|
||||
const { refreshChatSessions } = useChatContext();
|
||||
const router = useRouter();
|
||||
|
||||
function createRegenerator(rr: RegenerationRequest) {
|
||||
return async function (modelOverride: LlmOverride) {
|
||||
return onSubmit({
|
||||
messageIdToResend: rr.parentMessage.messageId,
|
||||
regenerationRequest: rr,
|
||||
forceSearch: rr.forceSearch,
|
||||
modelOverRide: modelOverride,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const onSubmit = async ({
|
||||
messageIdToResend,
|
||||
messageOverride,
|
||||
queryOverride,
|
||||
forceSearch,
|
||||
isSeededChat,
|
||||
alternativeAssistantOverride = null,
|
||||
modelOverRide,
|
||||
regenerationRequest,
|
||||
overrideFileDescriptors,
|
||||
}: {
|
||||
messageIdToResend?: number;
|
||||
messageOverride?: string;
|
||||
queryOverride?: string;
|
||||
forceSearch?: boolean;
|
||||
isSeededChat?: boolean;
|
||||
alternativeAssistantOverride?: Persona | null;
|
||||
modelOverRide?: LlmOverride;
|
||||
regenerationRequest?: RegenerationRequest | null;
|
||||
overrideFileDescriptors?: FileDescriptor[];
|
||||
} = {}) => {
|
||||
let frozenSessionId = currentSessionId();
|
||||
updateCanContinue(false);
|
||||
|
||||
if (currentChatState() != "input") {
|
||||
if (currentChatState() == "uploading") {
|
||||
setPopup({
|
||||
message: "Please wait for the content to upload",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
setPopup({
|
||||
message: "Please wait for the response to complete",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setAlternativeGeneratingAssistant(alternativeAssistantOverride);
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
let currChatSessionId: string;
|
||||
const isNewSession = chatSessionIdRef.current === null;
|
||||
|
||||
const searchParamBasedChatSessionName =
|
||||
searchParams.get(SEARCH_PARAM_NAMES.TITLE) || null;
|
||||
|
||||
if (isNewSession) {
|
||||
currChatSessionId = await createChatSession(
|
||||
liveAssistant?.id || 0,
|
||||
searchParamBasedChatSessionName
|
||||
);
|
||||
handleNewSessionId(currChatSessionId);
|
||||
} else {
|
||||
currChatSessionId = chatSessionIdRef.current as string;
|
||||
}
|
||||
frozenSessionId = currChatSessionId;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
setAbortControllers((prev) =>
|
||||
new Map(prev).set(currChatSessionId, controller)
|
||||
);
|
||||
|
||||
const messageToResend = messageHistory.find(
|
||||
(message) => message.messageId === messageIdToResend
|
||||
);
|
||||
|
||||
updateRegenerationState(
|
||||
regenerationRequest
|
||||
? { regenerating: true, finalMessageIndex: messageIdToResend || 0 }
|
||||
: null
|
||||
);
|
||||
const messageMap = currentMessageMap();
|
||||
const messageToResendParent =
|
||||
messageToResend?.parentMessageId !== null &&
|
||||
messageToResend?.parentMessageId !== undefined
|
||||
? messageMap.get(messageToResend.parentMessageId)
|
||||
: null;
|
||||
const messageToResendIndex = messageToResend
|
||||
? messageHistory.indexOf(messageToResend)
|
||||
: null;
|
||||
|
||||
if (!messageToResend && messageIdToResend !== undefined) {
|
||||
setPopup({
|
||||
message:
|
||||
"Failed to re-send message - please refresh the page and try again.",
|
||||
type: "error",
|
||||
});
|
||||
resetRegenerationState();
|
||||
updateChatState("input");
|
||||
return;
|
||||
}
|
||||
let currMessage = messageToResend ? messageToResend.message : message;
|
||||
if (messageOverride) {
|
||||
currMessage = messageOverride;
|
||||
}
|
||||
|
||||
setSubmittedMessage(currMessage);
|
||||
|
||||
updateChatState("loading");
|
||||
|
||||
const currMessageHistory =
|
||||
messageToResendIndex !== null
|
||||
? messageHistory.slice(0, messageToResendIndex)
|
||||
: messageHistory;
|
||||
|
||||
let parentMessage =
|
||||
messageToResendParent ||
|
||||
(currMessageHistory.length > 0
|
||||
? currMessageHistory[currMessageHistory.length - 1]
|
||||
: null) ||
|
||||
(messageMap.size === 1 ? Array.from(messageMap.values())[0] : null);
|
||||
|
||||
const currentAssistantId = alternativeAssistantOverride
|
||||
? alternativeAssistantOverride.id
|
||||
: alternativeAssistant
|
||||
? alternativeAssistant.id
|
||||
: liveAssistant.id;
|
||||
|
||||
resetInputBar();
|
||||
let messageUpdates: Message[] | null = null;
|
||||
|
||||
let answer = "";
|
||||
|
||||
const stopReason: StreamStopReason | null = null;
|
||||
let query: string | null = null;
|
||||
let retrievalType: RetrievalType =
|
||||
selectedDocuments.length > 0
|
||||
? RetrievalType.SelectedDocs
|
||||
: RetrievalType.None;
|
||||
let documents: OnyxDocument[] = selectedDocuments;
|
||||
let aiMessageImages: FileDescriptor[] | null = null;
|
||||
let error: string | null = null;
|
||||
let stackTrace: string | null = null;
|
||||
|
||||
let finalMessage: BackendMessage | null = null;
|
||||
let toolCall: ToolCallMetadata | null = null;
|
||||
|
||||
let initialFetchDetails: null | {
|
||||
user_message_id: number;
|
||||
assistant_message_id: number;
|
||||
frozenMessageMap: Map<number, Message>;
|
||||
} = null;
|
||||
try {
|
||||
const mapKeys = Array.from(currentMessageMap().keys());
|
||||
const systemMessage = Math.min(...mapKeys);
|
||||
|
||||
const lastSuccessfulMessageId =
|
||||
getLastSuccessfulMessageId(currMessageHistory) || systemMessage;
|
||||
|
||||
const stack = new CurrentMessageFIFO();
|
||||
updateCurrentMessageFIFO(stack, {
|
||||
signal: controller.signal,
|
||||
message: currMessage,
|
||||
alternateAssistantId: currentAssistantId,
|
||||
fileDescriptors: overrideFileDescriptors || currentMessageFiles,
|
||||
parentMessageId:
|
||||
regenerationRequest?.parentMessage.messageId ||
|
||||
lastSuccessfulMessageId,
|
||||
chatSessionId: currChatSessionId,
|
||||
promptId: liveAssistant?.prompts[0]?.id || 0,
|
||||
filters: buildFilters(
|
||||
filterManager.selectedSources,
|
||||
filterManager.selectedDocumentSets,
|
||||
filterManager.timeRange,
|
||||
filterManager.selectedTags
|
||||
),
|
||||
selectedDocumentIds: selectedDocuments
|
||||
.filter(
|
||||
(document) =>
|
||||
document.db_doc_id !== undefined && document.db_doc_id !== null
|
||||
)
|
||||
.map((document) => document.db_doc_id as number),
|
||||
queryOverride,
|
||||
forceSearch,
|
||||
regenerate: regenerationRequest !== undefined,
|
||||
modelProvider:
|
||||
modelOverRide?.name ||
|
||||
llmOverrideManager.llmOverride.name ||
|
||||
llmOverrideManager.globalDefault.name ||
|
||||
undefined,
|
||||
modelVersion:
|
||||
modelOverRide?.modelName ||
|
||||
llmOverrideManager.llmOverride.modelName ||
|
||||
searchParams.get(SEARCH_PARAM_NAMES.MODEL_VERSION) ||
|
||||
llmOverrideManager.globalDefault.modelName ||
|
||||
undefined,
|
||||
temperature: llmOverrideManager.temperature || undefined,
|
||||
systemPromptOverride:
|
||||
searchParams.get(SEARCH_PARAM_NAMES.SYSTEM_PROMPT) || undefined,
|
||||
useExistingUserMessage: isSeededChat,
|
||||
});
|
||||
|
||||
const delay = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
await delay(50);
|
||||
while (!stack.isComplete || !stack.isEmpty()) {
|
||||
if (stack.isEmpty()) {
|
||||
await delay(0.5);
|
||||
}
|
||||
|
||||
if (!stack.isEmpty() && !controller.signal.aborted) {
|
||||
const packet = stack.nextPacket();
|
||||
if (!packet) {
|
||||
continue;
|
||||
}
|
||||
if (!initialFetchDetails) {
|
||||
if (!Object.hasOwn(packet, "user_message_id")) {
|
||||
console.error(
|
||||
"First packet should contain message response info "
|
||||
);
|
||||
if (Object.hasOwn(packet, "error")) {
|
||||
const error = (packet as StreamingError).error;
|
||||
setLoadingError(error);
|
||||
updateChatState("input");
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const messageResponseIDInfo = packet as MessageResponseIDInfo;
|
||||
|
||||
const user_message_id = messageResponseIDInfo.user_message_id!;
|
||||
const assistant_message_id =
|
||||
messageResponseIDInfo.reserved_assistant_message_id;
|
||||
|
||||
// we will use tempMessages until the regenerated message is complete
|
||||
messageUpdates = [
|
||||
{
|
||||
messageId: regenerationRequest
|
||||
? regenerationRequest?.parentMessage?.messageId!
|
||||
: user_message_id,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: currentMessageFiles,
|
||||
toolCall: null,
|
||||
parentMessageId: parentMessage?.messageId || SYSTEM_MESSAGE_ID,
|
||||
},
|
||||
];
|
||||
|
||||
if (parentMessage && !regenerationRequest) {
|
||||
messageUpdates.push({
|
||||
...parentMessage,
|
||||
childrenMessageIds: (
|
||||
parentMessage.childrenMessageIds || []
|
||||
).concat([user_message_id]),
|
||||
latestChildMessageId: user_message_id,
|
||||
});
|
||||
}
|
||||
|
||||
const { messageMap: currentFrozenMessageMap } =
|
||||
upsertToCompleteMessageMap({
|
||||
messages: messageUpdates,
|
||||
chatSessionId: currChatSessionId,
|
||||
});
|
||||
|
||||
const frozenMessageMap = currentFrozenMessageMap;
|
||||
initialFetchDetails = {
|
||||
frozenMessageMap,
|
||||
assistant_message_id,
|
||||
user_message_id,
|
||||
};
|
||||
|
||||
resetRegenerationState();
|
||||
} else {
|
||||
const { user_message_id, frozenMessageMap } = initialFetchDetails;
|
||||
|
||||
setChatState((prevState) => {
|
||||
if (prevState.get(chatSessionIdRef.current!) === "loading") {
|
||||
return new Map(prevState).set(
|
||||
chatSessionIdRef.current!,
|
||||
"streaming"
|
||||
);
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
|
||||
if (Object.hasOwn(packet, "answer_piece")) {
|
||||
answer += (packet as AnswerPiecePacket).answer_piece;
|
||||
} else if (Object.hasOwn(packet, "top_documents")) {
|
||||
documents = (packet as DocumentInfoPacket).top_documents;
|
||||
retrievalType = RetrievalType.Search;
|
||||
if (documents && documents.length > 0) {
|
||||
// point to the latest message (we don't know the messageId yet, which is why
|
||||
// we have to use -1)
|
||||
setSelectedMessageForDocDisplay(user_message_id);
|
||||
}
|
||||
} else if (Object.hasOwn(packet, "tool_name")) {
|
||||
// Will only ever be one tool call per message
|
||||
toolCall = {
|
||||
tool_name: (packet as ToolCallMetadata).tool_name,
|
||||
tool_args: (packet as ToolCallMetadata).tool_args,
|
||||
tool_result: (packet as ToolCallMetadata).tool_result,
|
||||
};
|
||||
|
||||
if (!toolCall.tool_result || toolCall.tool_result == undefined) {
|
||||
updateChatState("toolBuilding");
|
||||
} else {
|
||||
updateChatState("streaming");
|
||||
}
|
||||
|
||||
// This will be consolidated in upcoming tool calls udpate,
|
||||
// but for now, we need to set query as early as possible
|
||||
if (toolCall.tool_name == SEARCH_TOOL_NAME) {
|
||||
query = toolCall.tool_args["query"];
|
||||
}
|
||||
} else if (Object.hasOwn(packet, "file_ids")) {
|
||||
aiMessageImages = (packet as FileChatDisplay).file_ids.map(
|
||||
(fileId) => {
|
||||
return {
|
||||
id: fileId,
|
||||
type: ChatFileType.IMAGE,
|
||||
};
|
||||
}
|
||||
);
|
||||
} else if (Object.hasOwn(packet, "error")) {
|
||||
error = (packet as StreamingError).error;
|
||||
stackTrace = (packet as StreamingError).stack_trace;
|
||||
} else if (Object.hasOwn(packet, "message_id")) {
|
||||
finalMessage = packet as BackendMessage;
|
||||
} else if (Object.hasOwn(packet, "stop_reason")) {
|
||||
const stop_reason = (packet as StreamStopInfo).stop_reason;
|
||||
if (stop_reason === StreamStopReason.CONTEXT_LENGTH) {
|
||||
updateCanContinue(true);
|
||||
}
|
||||
}
|
||||
|
||||
// on initial message send, we insert a dummy system message
|
||||
// set this as the parent here if no parent is set
|
||||
parentMessage =
|
||||
parentMessage || frozenMessageMap?.get(SYSTEM_MESSAGE_ID)!;
|
||||
|
||||
const updateFn = (messages: Message[]) => {
|
||||
const replacementsMap = regenerationRequest
|
||||
? new Map([
|
||||
[
|
||||
regenerationRequest?.parentMessage?.messageId,
|
||||
regenerationRequest?.parentMessage?.messageId,
|
||||
],
|
||||
[
|
||||
regenerationRequest?.messageId,
|
||||
initialFetchDetails?.assistant_message_id,
|
||||
],
|
||||
] as [number, number][])
|
||||
: null;
|
||||
|
||||
return upsertToCompleteMessageMap({
|
||||
messages: messages,
|
||||
replacementsMap: replacementsMap || undefined,
|
||||
completeMessageMapOverride: frozenMessageMap,
|
||||
chatSessionId: frozenSessionId!,
|
||||
});
|
||||
};
|
||||
|
||||
updateFn([
|
||||
{
|
||||
messageId: regenerationRequest
|
||||
? regenerationRequest?.parentMessage?.messageId!
|
||||
: initialFetchDetails.user_message_id!,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: currentMessageFiles,
|
||||
toolCall: null,
|
||||
parentMessageId: error ? null : lastSuccessfulMessageId,
|
||||
childrenMessageIds: [
|
||||
...(regenerationRequest?.parentMessage?.childrenMessageIds ||
|
||||
[]),
|
||||
initialFetchDetails.assistant_message_id!,
|
||||
],
|
||||
latestChildMessageId: initialFetchDetails.assistant_message_id,
|
||||
},
|
||||
{
|
||||
messageId: initialFetchDetails.assistant_message_id!,
|
||||
message: error || answer,
|
||||
type: error ? "error" : "assistant",
|
||||
retrievalType,
|
||||
query: finalMessage?.rephrased_query || query,
|
||||
documents: documents,
|
||||
citations: finalMessage?.citations || {},
|
||||
files: finalMessage?.files || aiMessageImages || [],
|
||||
toolCall: finalMessage?.tool_call || toolCall,
|
||||
parentMessageId: regenerationRequest
|
||||
? regenerationRequest?.parentMessage?.messageId!
|
||||
: initialFetchDetails.user_message_id,
|
||||
alternateAssistantID: alternativeAssistant?.id,
|
||||
stackTrace: stackTrace,
|
||||
overridden_model: finalMessage?.overridden_model,
|
||||
stopReason: stopReason,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const errorMsg = e.message;
|
||||
upsertToCompleteMessageMap({
|
||||
messages: [
|
||||
{
|
||||
messageId:
|
||||
initialFetchDetails?.user_message_id || TEMP_USER_MESSAGE_ID,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: currentMessageFiles,
|
||||
toolCall: null,
|
||||
parentMessageId: parentMessage?.messageId || SYSTEM_MESSAGE_ID,
|
||||
},
|
||||
{
|
||||
messageId:
|
||||
initialFetchDetails?.assistant_message_id ||
|
||||
TEMP_ASSISTANT_MESSAGE_ID,
|
||||
message: errorMsg,
|
||||
type: "error",
|
||||
files: aiMessageImages || [],
|
||||
toolCall: null,
|
||||
parentMessageId:
|
||||
initialFetchDetails?.user_message_id || TEMP_USER_MESSAGE_ID,
|
||||
},
|
||||
],
|
||||
completeMessageMapOverride: currentMessageMap(),
|
||||
});
|
||||
}
|
||||
resetRegenerationState();
|
||||
|
||||
updateChatState("input");
|
||||
if (isNewSession) {
|
||||
if (finalMessage) {
|
||||
setSelectedMessageForDocDisplay(finalMessage.message_id);
|
||||
}
|
||||
|
||||
if (!searchParamBasedChatSessionName) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await nameChatSession(currChatSessionId);
|
||||
refreshChatSessions();
|
||||
}
|
||||
|
||||
// NOTE: don't switch pages if the user has navigated away from the chat
|
||||
if (
|
||||
currChatSessionId === chatSessionIdRef.current ||
|
||||
chatSessionIdRef.current === null
|
||||
) {
|
||||
const newUrl = buildChatUrl(searchParams, currChatSessionId, null);
|
||||
// newUrl is like /chat?chatId=10
|
||||
// current page is like /chat
|
||||
router.push(newUrl, { scroll: false });
|
||||
}
|
||||
}
|
||||
if (
|
||||
finalMessage?.context_docs &&
|
||||
finalMessage.context_docs.top_documents.length > 0 &&
|
||||
retrievalType === RetrievalType.Search
|
||||
) {
|
||||
setSelectedMessageForDocDisplay(finalMessage.message_id);
|
||||
}
|
||||
setAlternativeGeneratingAssistant(null);
|
||||
setSubmittedMessage("");
|
||||
};
|
||||
async function handleImageUpload(acceptedFiles: File[]) {
|
||||
if (!liveAssistant) return;
|
||||
const [, llmModel] = getFinalLLM(
|
||||
llmProviders,
|
||||
liveAssistant,
|
||||
llmOverrideManager.llmOverride
|
||||
);
|
||||
const supportsImages = checkLLMSupportsImageInput(llmModel);
|
||||
|
||||
const images = acceptedFiles.filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length > 0 && !supportsImages) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message:
|
||||
"This Assistant does not support image input. Please use a Vision-capable model.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert placeholders:
|
||||
const placeholders = acceptedFiles.map((file) => ({
|
||||
id: uuidv4(),
|
||||
type: file.type.startsWith("image/")
|
||||
? ChatFileType.IMAGE
|
||||
: ChatFileType.DOCUMENT,
|
||||
isUploading: true,
|
||||
}));
|
||||
const totalSize = acceptedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||
if (totalSize > 50 * 1024) {
|
||||
setCurrentMessageFiles((prev) => [...prev, ...placeholders]);
|
||||
}
|
||||
|
||||
updateChatState("uploading");
|
||||
const [files, error] = await uploadFilesForChat(acceptedFiles);
|
||||
if (error) {
|
||||
setCurrentMessageFiles((prev) =>
|
||||
prev.filter((fd) => !placeholders.some((ph) => ph.id === fd.id))
|
||||
);
|
||||
setPopup({ type: "error", message: error });
|
||||
} else {
|
||||
// remove placeholders, add real files:
|
||||
setCurrentMessageFiles((prev) => {
|
||||
const withoutPlaceholders = prev.filter(
|
||||
(fd) => !placeholders.some((ph) => ph.id === fd.id)
|
||||
);
|
||||
return [...withoutPlaceholders, ...files];
|
||||
});
|
||||
}
|
||||
updateChatState("input");
|
||||
}
|
||||
|
||||
function continueGenerating() {
|
||||
onSubmit({
|
||||
messageOverride:
|
||||
"Continue Generating (pick up exactly where you left off)",
|
||||
});
|
||||
}
|
||||
|
||||
// The actual "stop" button:
|
||||
function stopGenerating() {
|
||||
const currSession = currentSessionId();
|
||||
const controller = abortControllers.get(currSession);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
setAbortControllers((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(currSession);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
// fix up message if tool call was partial:
|
||||
const lastMsg = messageHistory[messageHistory.length - 1];
|
||||
if (
|
||||
lastMsg &&
|
||||
lastMsg.type === "assistant" &&
|
||||
lastMsg.toolCall &&
|
||||
lastMsg.toolCall.tool_result === undefined
|
||||
) {
|
||||
const cloned = currentMessageMap();
|
||||
cloned.set(lastMsg.messageId, { ...lastMsg, toolCall: null });
|
||||
upsertToCompleteMessageMap({
|
||||
messages: [{ ...lastMsg, toolCall: null }],
|
||||
chatSessionId: currSession,
|
||||
completeMessageMapOverride: cloned,
|
||||
});
|
||||
}
|
||||
updateChatState("input");
|
||||
}
|
||||
|
||||
return {
|
||||
onSubmit,
|
||||
createRegenerator,
|
||||
handleImageUpload,
|
||||
continueGenerating,
|
||||
stopGenerating,
|
||||
};
|
||||
};
|
||||
@@ -26,9 +26,7 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { DragHandle } from "@/components/table/DragHandle";
|
||||
import { WarningCircle } from "@phosphor-icons/react";
|
||||
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
|
||||
import SlideOverModal from "@/components/ui/SlideOverModal";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ChatSessionDisplay({
|
||||
chatSession,
|
||||
@@ -44,8 +42,6 @@ export function ChatSessionDisplay({
|
||||
chatSession: ChatSession;
|
||||
isSelected: boolean;
|
||||
search?: boolean;
|
||||
// needed when the parent is trying to apply some background effect
|
||||
// if not set, the gradient will still be applied and cause weirdness
|
||||
skipGradient?: boolean;
|
||||
closeSidebar?: () => void;
|
||||
showShareModal?: (chatSession: ChatSession) => void;
|
||||
@@ -55,11 +51,7 @@ export function ChatSessionDisplay({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||
const [isMoreOptionsDropdownOpen, setIsMoreOptionsDropdownOpen] =
|
||||
useState(false);
|
||||
const [isShareModalVisible, setIsShareModalVisible] = useState(false);
|
||||
const [chatName, setChatName] = useState(chatSession.name);
|
||||
const settings = useContext(SettingsContext);
|
||||
@@ -69,8 +61,9 @@ export function ChatSessionDisplay({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const renamingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { refreshChatSessions, reorderFolders, refreshFolders } =
|
||||
useChatContext();
|
||||
const { refreshChatSessions, refreshFolders } = useChatContext();
|
||||
|
||||
const isMobile = settings?.isMobile;
|
||||
const handlePopoverOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setPopoverOpen(open);
|
||||
@@ -105,7 +98,7 @@ export function ChatSessionDisplay({
|
||||
setIsDeleteModalOpen(false);
|
||||
setPopoverOpen(false);
|
||||
},
|
||||
[chatSession, showDeleteModal]
|
||||
[chatSession, showDeleteModal, refreshChatSessions, refreshFolders]
|
||||
);
|
||||
|
||||
const onRename = useCallback(
|
||||
@@ -151,6 +144,34 @@ export function ChatSessionDisplay({
|
||||
settings?.settings
|
||||
);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent<HTMLAnchorElement>) => {
|
||||
event.dataTransfer.setData(CHAT_SESSION_ID_KEY, chatSession.id.toString());
|
||||
event.dataTransfer.setData(
|
||||
FOLDER_ID_KEY,
|
||||
chatSession.folder_id?.toString() || ""
|
||||
);
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
// Prevent default touch behavior
|
||||
event.preventDefault();
|
||||
|
||||
// Create a custom event to mimic drag start
|
||||
const customEvent = new Event("dragstart", { bubbles: true });
|
||||
(customEvent as any).dataTransfer = new DataTransfer();
|
||||
(customEvent as any).dataTransfer.setData(
|
||||
CHAT_SESSION_ID_KEY,
|
||||
chatSession.id.toString()
|
||||
);
|
||||
(customEvent as any).dataTransfer.setData(
|
||||
FOLDER_ID_KEY,
|
||||
chatSession.folder_id?.toString() || ""
|
||||
);
|
||||
|
||||
// Dispatch the custom event
|
||||
event.currentTarget.dispatchEvent(customEvent);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isShareModalVisible && (
|
||||
@@ -167,8 +188,6 @@ export function ChatSessionDisplay({
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsMoreOptionsDropdownOpen(false);
|
||||
setIsHovering(false);
|
||||
setIsHovered(false);
|
||||
}}
|
||||
className="flex group items-center w-full relative"
|
||||
@@ -184,25 +203,19 @@ export function ChatSessionDisplay({
|
||||
: `/chat?chatId=${chatSession.id}`
|
||||
}
|
||||
scroll={false}
|
||||
draggable="true"
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(
|
||||
CHAT_SESSION_ID_KEY,
|
||||
chatSession.id.toString()
|
||||
);
|
||||
event.dataTransfer.setData(
|
||||
FOLDER_ID_KEY,
|
||||
chatSession.folder_id?.toString() || ""
|
||||
);
|
||||
}}
|
||||
draggable={!isMobile}
|
||||
onDragStart={!isMobile ? handleDragStart : undefined}
|
||||
>
|
||||
<DragHandle
|
||||
size={16}
|
||||
className={`w-3 ml-[4px] mr-[2px] invisible flex-none ${
|
||||
foldersExisting ? "group-hover:visible" : "invisible"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`${
|
||||
isMobile ? "visible" : "invisible group-hover:visible"
|
||||
} flex-none`}
|
||||
onTouchStart={isMobile ? handleTouchStart : undefined}
|
||||
>
|
||||
<DragHandle size={16} className="w-3 ml-[4px] mr-[2px]" />
|
||||
</div>
|
||||
<BasicSelectable
|
||||
padding="extra"
|
||||
isHovered={isHovered}
|
||||
isDragging={isDragging}
|
||||
fullWidth
|
||||
@@ -254,7 +267,7 @@ export function ChatSessionDisplay({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="break-all overflow-hidden whitespace-nowrap w-full mr-3 relative">
|
||||
<p className="break-all font-normal overflow-hidden whitespace-nowrap w-full mr-3 relative">
|
||||
{chatName || `Unnamed Chat`}
|
||||
<span
|
||||
className={`absolute right-0 top-0 h-full w-8 bg-gradient-to-r from-transparent
|
||||
@@ -295,9 +308,7 @@ export function ChatSessionDisplay({
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsMoreOptionsDropdownOpen(
|
||||
!isMoreOptionsDropdownOpen
|
||||
);
|
||||
setPopoverOpen(!popoverOpen);
|
||||
}}
|
||||
className="-my-1"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { FiEdit, FiFolderPlus, FiMoreHorizontal, FiPlus } from "react-icons/fi";
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
@@ -16,14 +15,7 @@ import { Folder } from "../folders/interfaces";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
|
||||
import {
|
||||
AssistantsIconSkeleton,
|
||||
DocumentIcon2,
|
||||
NewChatIcon,
|
||||
OnyxIcon,
|
||||
PinnedIcon,
|
||||
PlusIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { DocumentIcon2, NewChatIcon } from "@/components/icons/icons";
|
||||
import { PagesTab } from "./PagesTab";
|
||||
import { pageType } from "./types";
|
||||
import LogoWithText from "@/components/header/LogoWithText";
|
||||
@@ -32,7 +24,7 @@ import { DragEndEvent } from "@dnd-kit/core";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
|
||||
import { buildChatUrl } from "../lib";
|
||||
import { toggleAssistantPinnedStatus } from "@/lib/assistants/pinnedAssistants";
|
||||
import { reorderPinnedAssistants } from "@/lib/assistants/updateAssistantPreferences";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { DragHandle } from "@/components/table/DragHandle";
|
||||
import {
|
||||
@@ -51,7 +43,6 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { reorderPinnedAssistants } from "@/lib/assistants/pinnedAssistants";
|
||||
import { CircleX } from "lucide-react";
|
||||
|
||||
interface HistorySidebarProps {
|
||||
@@ -59,18 +50,14 @@ interface HistorySidebarProps {
|
||||
existingChats?: ChatSession[];
|
||||
currentChatSession?: ChatSession | null | undefined;
|
||||
folders?: Folder[];
|
||||
openedFolders?: { [key: number]: boolean };
|
||||
toggleSidebar?: () => void;
|
||||
toggled?: boolean;
|
||||
removeToggle?: () => void;
|
||||
reset?: () => void;
|
||||
showShareModal?: (chatSession: ChatSession) => void;
|
||||
showDeleteModal?: (chatSession: ChatSession) => void;
|
||||
stopGenerating?: () => void;
|
||||
explicitlyUntoggle: () => void;
|
||||
showDeleteAllModal?: () => void;
|
||||
backgroundToggled?: boolean;
|
||||
assistants: Persona[];
|
||||
currentAssistantId?: number | null;
|
||||
setShowAssistantsModal: (show: boolean) => void;
|
||||
}
|
||||
@@ -129,7 +116,9 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
|
||||
} relative flex items-center gap-x-2 py-1 px-2 rounded-md`}
|
||||
>
|
||||
<AssistantIcon assistant={assistant} size={16} className="flex-none" />
|
||||
<p className="text-base text-black">{assistant.name}</p>
|
||||
<p className="text-base text-left w-fit line-clamp-1 text-ellipsis text-black">
|
||||
{assistant.name}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -153,32 +142,23 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
|
||||
page,
|
||||
existingChats,
|
||||
currentChatSession,
|
||||
assistants,
|
||||
folders,
|
||||
openedFolders,
|
||||
explicitlyUntoggle,
|
||||
toggleSidebar,
|
||||
removeToggle,
|
||||
stopGenerating = () => null,
|
||||
showShareModal,
|
||||
showDeleteModal,
|
||||
showDeleteAllModal,
|
||||
backgroundToggled,
|
||||
currentAssistantId,
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { refreshUser, user } = useUser();
|
||||
const { user, toggleAssistantPinnedStatus } = useUser();
|
||||
const { refreshAssistants, pinnedAssistants, setPinnedAssistants } =
|
||||
useAssistants();
|
||||
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
// For determining intial focus state
|
||||
const [newFolderId, setNewFolderId] = useState<number | null>(null);
|
||||
|
||||
const currentChatId = currentChatSession?.id;
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -235,7 +215,6 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
@@ -316,7 +295,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="h-full relative overflow-y-auto">
|
||||
<div className="flex px-4 font-normal text-sm gap-x-2 leading-normal text-[#6c6c6c]/80 items-center font-normal leading-normal">
|
||||
Assistants
|
||||
</div>
|
||||
@@ -349,7 +328,6 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
|
||||
assistant.id,
|
||||
false
|
||||
);
|
||||
await refreshUser();
|
||||
await refreshAssistants();
|
||||
}}
|
||||
/>
|
||||
@@ -365,19 +343,17 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
|
||||
Explore Assistants
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PagesTab
|
||||
setNewFolderId={setNewFolderId}
|
||||
newFolderId={newFolderId}
|
||||
showDeleteModal={showDeleteModal}
|
||||
showShareModal={showShareModal}
|
||||
closeSidebar={removeToggle}
|
||||
existingChats={existingChats}
|
||||
currentChatId={currentChatId}
|
||||
folders={folders}
|
||||
showDeleteAllModal={showDeleteAllModal}
|
||||
/>
|
||||
<PagesTab
|
||||
showDeleteModal={showDeleteModal}
|
||||
showShareModal={showShareModal}
|
||||
closeSidebar={removeToggle}
|
||||
existingChats={existingChats}
|
||||
currentChatId={currentChatId}
|
||||
folders={folders}
|
||||
showDeleteAllModal={showDeleteAllModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
import { Folder } from "../folders/interfaces";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { pageType } from "./types";
|
||||
import { FiPlus, FiTrash2, FiEdit, FiCheck, FiX } from "react-icons/fi";
|
||||
import { FiPlus, FiTrash2, FiCheck, FiX } from "react-icons/fi";
|
||||
import { NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED } from "@/lib/constants";
|
||||
import { FolderDropdown } from "../folders/FolderDropdown";
|
||||
import { ChatSessionDisplay } from "./ChatSessionDisplay";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useCallback, useRef, useContext } from "react";
|
||||
import { Caret } from "@/components/icons/icons";
|
||||
import { CaretCircleDown } from "@phosphor-icons/react";
|
||||
import { groupSessionsByDateRange } from "../lib";
|
||||
import React from "react";
|
||||
import {
|
||||
@@ -36,8 +34,8 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { DragHandle } from "@/components/table/DragHandle";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
|
||||
interface SortableFolderProps {
|
||||
folder: Folder;
|
||||
@@ -49,53 +47,27 @@ interface SortableFolderProps {
|
||||
onEdit: (folderId: number, newName: string) => void;
|
||||
onDelete: (folderId: number) => void;
|
||||
onDrop: (folderId: number, chatSessionId: string) => void;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const SortableFolder: React.FC<SortableFolderProps> = (props) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: props.folder.folder_id?.toString() ?? "",
|
||||
data: {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
const settings = useContext(SettingsContext);
|
||||
const mobile = settings?.isMobile;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({
|
||||
id: props.folder.folder_id?.toString() ?? "",
|
||||
data: {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
disabled: mobile,
|
||||
});
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const isInside =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
if (isInside) {
|
||||
setIsHovering(true);
|
||||
} else {
|
||||
setIsHovering(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -103,7 +75,12 @@ const SortableFolder: React.FC<SortableFolderProps> = (props) => {
|
||||
className="pr-3 ml-4 overflow-visible flex items-start"
|
||||
style={style}
|
||||
>
|
||||
<FolderDropdown ref={ref} {...props} {...attributes} {...listeners} />
|
||||
<FolderDropdown
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...(mobile ? {} : attributes)}
|
||||
{...(mobile ? {} : listeners)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -113,21 +90,17 @@ export function PagesTab({
|
||||
currentChatId,
|
||||
folders,
|
||||
closeSidebar,
|
||||
newFolderId,
|
||||
showShareModal,
|
||||
showDeleteModal,
|
||||
showDeleteAllModal,
|
||||
setNewFolderId,
|
||||
}: {
|
||||
existingChats?: ChatSession[];
|
||||
currentChatId?: string;
|
||||
folders?: Folder[];
|
||||
closeSidebar?: () => void;
|
||||
newFolderId: number | null;
|
||||
showShareModal?: (chatSession: ChatSession) => void;
|
||||
showDeleteModal?: (chatSession: ChatSession) => void;
|
||||
showDeleteAllModal?: () => void;
|
||||
setNewFolderId: (folderId: number) => void;
|
||||
}) {
|
||||
const { setPopup, popup } = usePopup();
|
||||
const router = useRouter();
|
||||
@@ -196,9 +169,8 @@ export function PagesTab({
|
||||
const newFolderName = newFolderInputRef.current?.value;
|
||||
if (newFolderName) {
|
||||
try {
|
||||
const folderId = await createFolder(newFolderName);
|
||||
await createFolder(newFolderName);
|
||||
await refreshFolders();
|
||||
setNewFolderId(folderId);
|
||||
router.refresh();
|
||||
setPopup({
|
||||
message: "Folder created successfully",
|
||||
@@ -216,7 +188,7 @@ export function PagesTab({
|
||||
}
|
||||
setIsCreatingFolder(false);
|
||||
},
|
||||
[router, setNewFolderId, setPopup, refreshFolders]
|
||||
[router, setPopup, refreshFolders]
|
||||
);
|
||||
|
||||
const existingChatsNotinFolders = existingChats?.filter(
|
||||
@@ -265,8 +237,11 @@ export function PagesTab({
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
className="-ml-4 bg-transparent -mr-2"
|
||||
className="-ml-4 bg-transparent -mr-2"
|
||||
draggable
|
||||
style={{
|
||||
touchAction: "none",
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
setIsDraggingSessionId(chat.id);
|
||||
e.dataTransfer.setData("text/plain", chat.id);
|
||||
@@ -332,9 +307,9 @@ export function PagesTab({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2 overflow-y-auto flex-grow">
|
||||
<div className="flex flex-col gap-y-2 flex-grow">
|
||||
{popup}
|
||||
<div className="px-4 mt-2 group mr-2">
|
||||
<div className="px-4 mt-2 group mr-2 bg-background-sidebar z-20">
|
||||
<div className="flex justify-between text-sm gap-x-2 text-[#6c6c6c]/80 items-center font-normal leading-normal">
|
||||
<p>Chats</p>
|
||||
<button
|
||||
@@ -392,31 +367,35 @@ export function PagesTab({
|
||||
items={folders.map((f) => f.folder_id?.toString() ?? "")}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{folders
|
||||
.sort(
|
||||
(a, b) => (a.display_priority ?? 0) - (b.display_priority ?? 0)
|
||||
)
|
||||
.map((folder) => (
|
||||
<SortableFolder
|
||||
key={folder.folder_id}
|
||||
folder={folder}
|
||||
currentChatId={currentChatId}
|
||||
showShareModal={showShareModal}
|
||||
showDeleteModal={showDeleteModal}
|
||||
closeSidebar={closeSidebar}
|
||||
onEdit={handleEditFolder}
|
||||
onDelete={handleDeleteFolder}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{folder.chat_sessions &&
|
||||
folder.chat_sessions.map((chat) =>
|
||||
renderChatSession(
|
||||
chat,
|
||||
folders != undefined && folders.length > 0
|
||||
)
|
||||
)}
|
||||
</SortableFolder>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
{folders
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.display_priority ?? 0) - (b.display_priority ?? 0)
|
||||
)
|
||||
.map((folder, index) => (
|
||||
<SortableFolder
|
||||
key={folder.folder_id}
|
||||
folder={folder}
|
||||
currentChatId={currentChatId}
|
||||
showShareModal={showShareModal}
|
||||
showDeleteModal={showDeleteModal}
|
||||
closeSidebar={closeSidebar}
|
||||
onEdit={handleEditFolder}
|
||||
onDelete={handleDeleteFolder}
|
||||
onDrop={handleDrop}
|
||||
index={index}
|
||||
>
|
||||
{folder.chat_sessions &&
|
||||
folder.chat_sessions.map((chat) =>
|
||||
renderChatSession(
|
||||
chat,
|
||||
folders != undefined && folders.length > 0
|
||||
)
|
||||
)}
|
||||
</SortableFolder>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
@@ -430,7 +409,7 @@ export function PagesTab({
|
||||
<>
|
||||
{Object.entries(groupedChatSesssions)
|
||||
.filter(([groupName, chats]) => chats.length > 0)
|
||||
.map(([groupName, chats]) => (
|
||||
.map(([groupName, chats], index) => (
|
||||
<FolderDropdown
|
||||
key={groupName}
|
||||
folder={{
|
||||
@@ -443,6 +422,7 @@ export function PagesTab({
|
||||
closeSidebar={closeSidebar}
|
||||
onEdit={handleEditFolder}
|
||||
onDrop={handleDrop}
|
||||
index={folders ? folders.length + index : index}
|
||||
>
|
||||
{chats.map((chat) =>
|
||||
renderChatSession(
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
export default function BlurBackground({ visible }: { visible: boolean }) {
|
||||
export default function BlurBackground({
|
||||
visible,
|
||||
onClick,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`desktop:hidden w-full h-full fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-30 transition-opacity duration-300 ease-in-out ${visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
onClick={onClick}
|
||||
className={`desktop:hidden w-full h-full fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-30 transition-opacity duration-300 ease-in-out ${
|
||||
visible
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,6 @@ export function SourceSelector({
|
||||
<SectionTitle>Tags</SectionTitle>
|
||||
</div>
|
||||
<TagFilter
|
||||
showTagsOnLeft={true}
|
||||
tags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
@@ -337,11 +336,12 @@ export function HorizontalFilters({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-x-3">
|
||||
<div className="w-64">
|
||||
<div className="w-52">
|
||||
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
<FilterDropdown
|
||||
width="w-52"
|
||||
options={availableSources.map((source) => {
|
||||
return {
|
||||
key: source.displayName,
|
||||
@@ -366,30 +366,32 @@ export function HorizontalFilters({
|
||||
}
|
||||
defaultDisplay="All Sources"
|
||||
/>
|
||||
|
||||
<FilterDropdown
|
||||
options={availableDocumentSets.map((documentSet) => {
|
||||
return {
|
||||
key: documentSet.name,
|
||||
display: (
|
||||
<>
|
||||
<div className="my-auto">
|
||||
<FiBookmark />
|
||||
</div>
|
||||
<span className="ml-2 text-sm">{documentSet.name}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
})}
|
||||
selected={selectedDocumentSets}
|
||||
handleSelect={(option) => handleDocumentSetSelect(option.key)}
|
||||
icon={
|
||||
<div className="my-auto mr-2 w-[16px] h-[16px]">
|
||||
<FiBook size={16} />
|
||||
</div>
|
||||
}
|
||||
defaultDisplay="All Document Sets"
|
||||
/>
|
||||
{availableDocumentSets.length > 0 && (
|
||||
<FilterDropdown
|
||||
width="w-52"
|
||||
options={availableDocumentSets.map((documentSet) => {
|
||||
return {
|
||||
key: documentSet.name,
|
||||
display: (
|
||||
<>
|
||||
<div className="my-auto">
|
||||
<FiBookmark />
|
||||
</div>
|
||||
<span className="ml-2 text-sm">{documentSet.name}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
})}
|
||||
selected={selectedDocumentSets}
|
||||
handleSelect={(option) => handleDocumentSetSelect(option.key)}
|
||||
icon={
|
||||
<div className="my-auto mr-2 w-[16px] h-[16px]">
|
||||
<FiBook size={16} />
|
||||
</div>
|
||||
}
|
||||
defaultDisplay="All Document Sets"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex pb-4 mt-2 h-12">
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
|
||||
import { SourceMetadata } from "@/lib/search/interfaces";
|
||||
import { InfoIcon, defaultTailwindCSS } from "@/components/icons/icons";
|
||||
import { HoverPopup } from "@/components/HoverPopup";
|
||||
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { TagFilter } from "@/components/search/filtering/TagFilter";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { listSourceMetadata } from "@/lib/sources";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { getDateRangeString } from "@/lib/dateUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ToolTipDetails } from "@/components/admin/connectors/Field";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
|
||||
const SectionTitle = ({
|
||||
children,
|
||||
modal,
|
||||
}: {
|
||||
children: string;
|
||||
modal?: boolean;
|
||||
}) => (
|
||||
<div className={`mt-4 pb-2 ${modal ? "w-[80vw]" : "w-full"}`}>
|
||||
<p className="text-sm font-semibold">{children}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface SourceSelectorProps {
|
||||
timeRange: DateRangePickerValue | null;
|
||||
setTimeRange: React.Dispatch<
|
||||
React.SetStateAction<DateRangePickerValue | null>
|
||||
>;
|
||||
showDocSidebar?: boolean;
|
||||
selectedSources: SourceMetadata[];
|
||||
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
|
||||
selectedDocumentSets: string[];
|
||||
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedTags: Tag[];
|
||||
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
|
||||
availableDocumentSets: DocumentSet[];
|
||||
existingSources: ValidSources[];
|
||||
availableTags: Tag[];
|
||||
filtersUntoggled: boolean;
|
||||
modal?: boolean;
|
||||
tagsOnLeft: boolean;
|
||||
}
|
||||
|
||||
export function SourceSelector({
|
||||
timeRange,
|
||||
filtersUntoggled,
|
||||
setTimeRange,
|
||||
selectedSources,
|
||||
setSelectedSources,
|
||||
selectedDocumentSets,
|
||||
setSelectedDocumentSets,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
availableDocumentSets,
|
||||
existingSources,
|
||||
modal,
|
||||
availableTags,
|
||||
}: SourceSelectorProps) {
|
||||
const handleSelect = (source: SourceMetadata) => {
|
||||
setSelectedSources((prev: SourceMetadata[]) => {
|
||||
if (
|
||||
prev.map((source) => source.internalName).includes(source.internalName)
|
||||
) {
|
||||
return prev.filter((s) => s.internalName !== source.internalName);
|
||||
} else {
|
||||
return [...prev, source];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentSetSelect = (documentSetName: string) => {
|
||||
setSelectedDocumentSets((prev: string[]) => {
|
||||
if (prev.includes(documentSetName)) {
|
||||
return prev.filter((s) => s !== documentSetName);
|
||||
} else {
|
||||
return [...prev, documentSetName];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let allSourcesSelected = selectedSources.length == existingSources.length;
|
||||
|
||||
const toggleAllSources = () => {
|
||||
if (allSourcesSelected) {
|
||||
setSelectedSources([]);
|
||||
} else {
|
||||
const allSources = listSourceMetadata().filter((source) =>
|
||||
existingSources.includes(source.internalName)
|
||||
);
|
||||
setSelectedSources(allSources);
|
||||
}
|
||||
};
|
||||
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const calendar = document.querySelector(".rdp");
|
||||
if (calendar && !calendar.contains(event.target as Node)) {
|
||||
setIsCalendarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!filtersUntoggled && (
|
||||
<CardContent className=" space-y-2">
|
||||
<div>
|
||||
<div className="flex py-2 mt-2 justify-start gap-x-2 items-center">
|
||||
<p className="text-sm font-semibold">Time Range</p>
|
||||
{timeRange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setTimeRange(null);
|
||||
}}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`w-full justify-start text-left font-normal`}
|
||||
>
|
||||
<span>
|
||||
{getDateRangeString(timeRange?.from!, timeRange?.to!) ||
|
||||
"Select a time range"}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-[10000] w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={
|
||||
timeRange
|
||||
? {
|
||||
from: new Date(timeRange.from),
|
||||
to: new Date(timeRange.to),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSelect={(daterange) => {
|
||||
const today = new Date();
|
||||
const initialDate = daterange?.from
|
||||
? new Date(
|
||||
Math.min(daterange.from.getTime(), today.getTime())
|
||||
)
|
||||
: today;
|
||||
const endDate = daterange?.to
|
||||
? new Date(
|
||||
Math.min(daterange.to.getTime(), today.getTime())
|
||||
)
|
||||
: today;
|
||||
setTimeRange({
|
||||
from: initialDate,
|
||||
to: endDate,
|
||||
selectValue: timeRange?.selectValue || "",
|
||||
});
|
||||
}}
|
||||
className="rounded-md"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{availableTags.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle modal={modal}>Tags</SectionTitle>
|
||||
<TagFilter
|
||||
modal={modal}
|
||||
showTagsOnLeft={true}
|
||||
tags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingSources.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle modal={modal}>Sources</SectionTitle>
|
||||
|
||||
<div className="space-y-0">
|
||||
{existingSources.length > 1 && (
|
||||
<div className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2">
|
||||
<Checkbox
|
||||
id="select-all-sources"
|
||||
checked={allSourcesSelected}
|
||||
onCheckedChange={toggleAllSources}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="select-all-sources"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{listSourceMetadata()
|
||||
.filter((source) =>
|
||||
existingSources.includes(source.internalName)
|
||||
)
|
||||
.map((source) => (
|
||||
<div
|
||||
key={source.internalName}
|
||||
className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2"
|
||||
onClick={() => handleSelect(source)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSources
|
||||
.map((s) => s.internalName)
|
||||
.includes(source.internalName)}
|
||||
/>
|
||||
<SourceIcon
|
||||
sourceType={source.internalName}
|
||||
iconSize={16}
|
||||
/>
|
||||
<span className="text-sm">{source.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableDocumentSets.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle modal={modal}>Knowledge Sets</SectionTitle>
|
||||
<div className="space-y-2">
|
||||
{availableDocumentSets.map((documentSet) => (
|
||||
<div
|
||||
key={documentSet.name}
|
||||
className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2"
|
||||
onClick={() => handleDocumentSetSelect(documentSet.name)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedDocumentSets.includes(documentSet.name)}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon
|
||||
className={`${defaultTailwindCSS} h-4 w-4`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm w-64">
|
||||
<div className="font-medium">Description</div>
|
||||
<div className="mt-1">
|
||||
{documentSet.description}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span className="text-sm">{documentSet.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
"use client";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { getDatesList } from "@/app/ee/admin/performance/lib";
|
||||
import Text from "@/components/ui/text";
|
||||
import Title from "@/components/ui/title";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { AreaChartDisplay } from "@/components/ui/areaChart";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
@@ -12,7 +8,8 @@ import {
|
||||
} from "@/app/ee/admin/performance/DateRangeSelector";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AreaChartDisplay } from "@/components/ui/areaChart";
|
||||
|
||||
type AssistantDailyUsageEntry = {
|
||||
date: string;
|
||||
@@ -120,7 +117,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
|
||||
);
|
||||
} else if (error) {
|
||||
content = (
|
||||
<div className="h-80 text-red-600 text-bold flex flex-col">
|
||||
<div className="h-80 text-red-600 font-bold flex flex-col">
|
||||
<p className="m-auto">{error}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -139,52 +136,60 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
|
||||
data={chartData}
|
||||
categories={["Messages", "Unique Users"]}
|
||||
index="Day"
|
||||
colors={["indigo", "fuchsia"]}
|
||||
colors={["#4A4A4A", "#A0A0A0"]}
|
||||
yAxisWidth={60}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Title>Assistant Analytics</Title>
|
||||
<Text>
|
||||
Messages and unique users per day for the assistant{" "}
|
||||
<b>{assistant?.name}</b>
|
||||
</Text>
|
||||
<DateRangeSelector value={dateRange} onValueChange={setDateRange} />
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<p className="text-base font-normal text-2xl">Assistant Analytics</p>
|
||||
<DateRangeSelector value={dateRange} onValueChange={setDateRange} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{assistant && (
|
||||
<AssistantIcon
|
||||
disableToolip
|
||||
size="large"
|
||||
assistant={assistant}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-normal">{assistant?.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{assistant?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Total Messages
|
||||
</p>
|
||||
<p className="text-2xl font-normal">{totalMessages}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Total Unique Users
|
||||
</p>
|
||||
<p className="text-2xl font-normal">{totalUniqueUsers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{assistant && (
|
||||
<div className="bg-gray-100 p-4 w-full max-w-64 rounded-lg shadow-sm">
|
||||
<div className="flex items-center mb-2">
|
||||
<AssistantIcon
|
||||
disableToolip
|
||||
size="medium"
|
||||
assistant={assistant}
|
||||
/>
|
||||
<Title className="text-lg ml-3">{assistant?.name}</Title>
|
||||
</div>
|
||||
<Text className="text-gray-600 text-sm">
|
||||
{assistant?.description}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<Text className="font-semibold">Total Messages</Text>
|
||||
<Text>{totalMessages}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-semibold">Total Unique Users</Text>
|
||||
<Text>{totalUniqueUsers}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</>
|
||||
{content}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
|
||||
import { fetchChatData } from "@/lib/chat/fetchChatData";
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
|
||||
import { cookies } from "next/headers";
|
||||
import { ChatProvider } from "@/components/context/ChatContext";
|
||||
import WrappedAssistantsStats from "./WrappedAssistantsStats";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { AssistantStats } from "./AssistantStats";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ import PostHogPageView from "./PostHogPageView";
|
||||
import Script from "next/script";
|
||||
import { LogoType } from "@/components/logo/Logo";
|
||||
import { Hanken_Grotesk } from "next/font/google";
|
||||
import { fetchChatData } from "@/lib/chat/fetchChatData";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
import { ChevronDownIcon, PlusIcon } from "./icons/icons";
|
||||
import { FiCheck, FiChevronDown } from "react-icons/fi";
|
||||
import { Popover } from "./popover/Popover";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useDropdownPosition } from "@/lib/dropdown";
|
||||
|
||||
export interface Option<T> {
|
||||
name: string;
|
||||
@@ -52,17 +50,18 @@ export function SearchMultiSelectDropdown({
|
||||
options,
|
||||
onSelect,
|
||||
itemComponent,
|
||||
onCreateLabel,
|
||||
onCreate,
|
||||
onDelete,
|
||||
}: {
|
||||
options: StringOrNumberOption[];
|
||||
onSelect: (selected: StringOrNumberOption) => void;
|
||||
itemComponent?: FC<{ option: StringOrNumberOption }>;
|
||||
onCreateLabel?: (name: string) => void;
|
||||
onCreate?: (name: string) => void;
|
||||
onDelete?: (name: string) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSelect = (option: StringOrNumberOption) => {
|
||||
onSelect(option);
|
||||
@@ -78,9 +77,7 @@ export function SearchMultiSelectDropdown({
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
dropdownMenuRef.current &&
|
||||
!dropdownMenuRef.current.contains(event.target as Node)
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
@@ -92,8 +89,6 @@ export function SearchMultiSelectDropdown({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useDropdownPosition({ isOpen, dropdownRef, dropdownMenuRef });
|
||||
|
||||
return (
|
||||
<div className="relative text-left w-full" ref={dropdownRef}>
|
||||
<div>
|
||||
@@ -110,24 +105,11 @@ export function SearchMultiSelectDropdown({
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
className={`inline-flex
|
||||
justify-between
|
||||
w-full
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
bg-background
|
||||
border
|
||||
border-border
|
||||
rounded-md
|
||||
shadow-sm
|
||||
`}
|
||||
className="inline-flex justify-between w-full px-4 py-2 text-sm bg-background border border-border rounded-md shadow-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute top-0 right-0
|
||||
text-sm
|
||||
h-full px-2 border-l border-border`}
|
||||
className="absolute top-0 right-0 text-sm h-full px-2 border-l border-border"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
@@ -136,78 +118,65 @@ export function SearchMultiSelectDropdown({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-md shadow-lg bg-background border border-border max-h-60 overflow-y-auto">
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
className={`origin-bottom-right
|
||||
rounded-md
|
||||
shadow-lg
|
||||
bg-background
|
||||
border
|
||||
border-border
|
||||
max-h-80
|
||||
overflow-y-auto
|
||||
overscroll-contain`}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
<div
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{filteredOptions.map((option, index) =>
|
||||
itemComponent ? (
|
||||
<div
|
||||
key={option.name}
|
||||
{filteredOptions.map((option, index) =>
|
||||
itemComponent ? (
|
||||
<div
|
||||
key={option.name}
|
||||
onClick={() => {
|
||||
handleSelect(option);
|
||||
}}
|
||||
>
|
||||
{itemComponent({ option })}
|
||||
</div>
|
||||
) : (
|
||||
<StandardDropdownOption
|
||||
key={index}
|
||||
option={option}
|
||||
index={index}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{onCreate &&
|
||||
searchTerm.trim() !== "" &&
|
||||
!filteredOptions.some(
|
||||
(option) =>
|
||||
option.name.toLowerCase() === searchTerm.toLowerCase()
|
||||
) && (
|
||||
<>
|
||||
<div className="border-t border-border"></div>
|
||||
<button
|
||||
className="w-full text-left flex items-center px-4 py-2 text-sm hover:bg-hover"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
handleSelect(option);
|
||||
onCreate(searchTerm);
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
>
|
||||
{itemComponent({ option })}
|
||||
</div>
|
||||
) : (
|
||||
<StandardDropdownOption
|
||||
key={index}
|
||||
option={option}
|
||||
index={index}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
)
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Create label "{searchTerm}"
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onCreateLabel &&
|
||||
searchTerm.trim() !== "" &&
|
||||
!filteredOptions.some(
|
||||
(option) =>
|
||||
option.name.toLowerCase() === searchTerm.toLowerCase()
|
||||
) && (
|
||||
<>
|
||||
<div className="border-t border-border"></div>
|
||||
<button
|
||||
className="w-full text-left flex items-center px-4 py-2 text-sm hover:bg-hover"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onCreateLabel(searchTerm);
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Create label "{searchTerm}"
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredOptions.length === 0 &&
|
||||
(!onCreateLabel || searchTerm.trim() === "") && (
|
||||
<div className="px-4 py-2.5 text-sm text-text-muted">
|
||||
No matches found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{filteredOptions.length === 0 &&
|
||||
(!onCreate || searchTerm.trim() === "") && (
|
||||
<div className="px-4 py-2.5 text-sm text-text-muted">
|
||||
No matches found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,11 +82,12 @@ export function Modal({
|
||||
duration-300
|
||||
ease-in-out
|
||||
relative
|
||||
overflow-visible
|
||||
${width ?? "w-11/12 max-w-4xl"}
|
||||
${noPadding ? "" : removeBottomPadding ? "pt-10 px-10" : "p-10"}
|
||||
|
||||
${className || ""}
|
||||
flex
|
||||
flex-col
|
||||
${heightOverride ? `h-${heightOverride}` : "max-h-[90vh]"}
|
||||
`}
|
||||
>
|
||||
{onOutsideClick && !hideCloseButton && (
|
||||
@@ -100,10 +101,10 @@ export function Modal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full overflow-y-auto overflow-x-visible p-1 flex flex-col h-full justify-stretch">
|
||||
<div className="flex-shrink-0">
|
||||
{title && (
|
||||
<>
|
||||
<div className="flex mb-4">
|
||||
<div className="flex">
|
||||
<h2
|
||||
className={`my-auto flex content-start gap-x-4 font-bold ${
|
||||
titleSize || "text-2xl"
|
||||
@@ -113,18 +114,12 @@ export function Modal({
|
||||
{icon && icon({ size: 30 })}
|
||||
</h2>
|
||||
</div>
|
||||
{!hideDividerForTitle && <Separator />}
|
||||
{!hideDividerForTitle && <Separator className="mb-0" />}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{ height: heightOverride }}
|
||||
className={cn(
|
||||
noScroll ? "overflow-auto" : "overflow-x-visible",
|
||||
!heightOverride && (height || "max-h-[60vh]")
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto overflow-x-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { SourceIcon } from "./SourceIcon";
|
||||
import { useState } from "react";
|
||||
import { OnyxIcon } from "./icons/icons";
|
||||
|
||||
export function WebResultIcon({ url }: { url: string }) {
|
||||
const [error, setError] = useState(false);
|
||||
const hostname = new URL(url).hostname;
|
||||
return (
|
||||
<>
|
||||
{!error ? (
|
||||
{hostname == "docs.onyx.app" ? (
|
||||
<OnyxIcon size={18} />
|
||||
) : !error ? (
|
||||
<img
|
||||
className="my-0 w-5 h-5 rounded-full py-0"
|
||||
src={`https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${hostname}&size=128`}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
SlackIconSkeleton,
|
||||
DocumentSetIconSkeleton,
|
||||
AssistantsIconSkeleton,
|
||||
ClosedBookIcon,
|
||||
SearchIcon,
|
||||
DocumentIcon2,
|
||||
} from "@/components/icons/icons";
|
||||
@@ -31,7 +30,6 @@ import { usePathname } from "next/navigation";
|
||||
import { SettingsContext } from "../settings/SettingsProvider";
|
||||
import { useContext, useState } from "react";
|
||||
import { MdOutlineCreditCard } from "react-icons/md";
|
||||
import { set } from "lodash";
|
||||
import { UserSettingsModal } from "@/app/chat/modal/UserSettingsModal";
|
||||
import { usePopup } from "./connectors/Popup";
|
||||
import { useChatContext } from "../context/ChatContext";
|
||||
@@ -64,9 +62,9 @@ export function ClientLayout({
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// const { user} = useUser
|
||||
return (
|
||||
<div className="h-screen overflow-y-hidden">
|
||||
{popup}
|
||||
<div className="flex h-full">
|
||||
{userSettingsOpen && (
|
||||
<UserSettingsModal
|
||||
@@ -423,15 +421,16 @@ export function ClientLayout({
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="pb-8 relative h-full overflow-y-auto w-full">
|
||||
<div className="pb-8 relative h-full overflow-y-hidden w-full">
|
||||
<div className="fixed left-0 gap-x-4 px-4 top-4 h-8 px-0 mb-auto w-full items-start flex justify-end">
|
||||
<UserDropdown toggleUserSettings={toggleUserSettings} />
|
||||
</div>
|
||||
<div className="pt-20 flex overflow-y-auto overflow-x-hidden h-full px-4 md:px-12">
|
||||
<div className="pt-20 flex w-full overflow-y-auto overflow-x-hidden h-full px-4 md:px-12">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Is there a clean way to add this to some piece of text where we need to enbale for copy-paste in a react app?
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function TextFormField({
|
||||
vertical,
|
||||
className,
|
||||
}: {
|
||||
value?: string;
|
||||
value?: string; // Escape hatch for setting the value of the field - conflicts with Formik
|
||||
name: string;
|
||||
removeLabel?: boolean;
|
||||
label: string;
|
||||
@@ -185,7 +185,7 @@ export function TextFormField({
|
||||
heightString = "h-28";
|
||||
}
|
||||
|
||||
const [field, , helpers] = useField(name);
|
||||
const [, , helpers] = useField(name);
|
||||
const { setValue } = helpers;
|
||||
|
||||
const handleChange = (
|
||||
|
||||
@@ -1,35 +1,83 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle, XCircle } from "lucide-react";
|
||||
const popupVariants = cva(
|
||||
"fixed bottom-4 left-4 p-4 rounded-lg shadow-xl text-white z-[10000] flex items-center space-x-3 transition-all duration-300 ease-in-out",
|
||||
{
|
||||
variants: {
|
||||
type: {
|
||||
success: "bg-green-500",
|
||||
error: "bg-red-500",
|
||||
info: "bg-blue-500",
|
||||
warning: "bg-yellow-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "info",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface PopupSpec {
|
||||
export interface PopupSpec extends VariantProps<typeof popupVariants> {
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}
|
||||
|
||||
export const Popup: React.FC<PopupSpec> = ({ message, type }) => (
|
||||
<div
|
||||
className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-[10000] ${
|
||||
type === "success" ? "bg-green-500" : "bg-error"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
<div className={cn(popupVariants({ type }))}>
|
||||
{type === "success" ? (
|
||||
<CheckCircle className="w-6 h-6 animate-pulse" />
|
||||
) : type === "error" ? (
|
||||
<XCircle className="w-6 h-6 animate-pulse" />
|
||||
) : type === "info" ? (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="font-medium">{message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const usePopup = () => {
|
||||
const [popup, setPopup] = useState<PopupSpec | null>(null);
|
||||
// using NodeJS.Timeout because setTimeout in NodeJS returns a different type than in browsers
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const setPopupWithExpiration = (popupSpec: PopupSpec | null) => {
|
||||
// Clear any previous timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
setPopup(popupSpec);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setPopup(null);
|
||||
}, 4000);
|
||||
|
||||
if (popupSpec) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setPopup(null);
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function StarterMessages({
|
||||
<div
|
||||
key={-4}
|
||||
className={`
|
||||
short:hidden
|
||||
mx-auto
|
||||
w-full
|
||||
${
|
||||
@@ -55,7 +56,7 @@ export function StarterMessages({
|
||||
disabled:cursor-not-allowed
|
||||
line-clamp-3
|
||||
`}
|
||||
style={{ height: "5.2rem" }}
|
||||
style={{ height: "5.4rem" }}
|
||||
>
|
||||
{starterMessage.name}
|
||||
</button>
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { FiChevronDown } from "react-icons/fi";
|
||||
import { destructureValue, getFinalLLM } from "@/lib/llm/utils";
|
||||
import { updateModelOverrideForChatSession } from "@/app/chat/lib";
|
||||
import { debounce } from "lodash";
|
||||
import { LlmList } from "@/components/llm/LLMList";
|
||||
import { checkPersonaRequiresImageGeneration } from "@/app/admin/assistants/lib";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { DraggableAssistantCard } from "@/components/assistants/AssistantCards";
|
||||
import { updateUserAssistantList } from "@/lib/assistants/updateAssistantPreferences";
|
||||
|
||||
import Text from "@/components/ui/text";
|
||||
import { getDisplayNameForModel, LlmOverrideManager } from "@/lib/hooks";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { AssistantIcon } from "../assistants/AssistantIcon";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "../ui/drawer";
|
||||
import { truncateString } from "@/lib/utils";
|
||||
import { SettingsContext } from "../settings/SettingsProvider";
|
||||
|
||||
const AssistantSelector = ({
|
||||
liveAssistant,
|
||||
onAssistantChange,
|
||||
chatSessionId,
|
||||
llmOverrideManager,
|
||||
isMobile,
|
||||
}: {
|
||||
liveAssistant: Persona;
|
||||
onAssistantChange: (assistant: Persona) => void;
|
||||
chatSessionId?: string;
|
||||
llmOverrideManager: LlmOverrideManager;
|
||||
isMobile: boolean;
|
||||
}) => {
|
||||
const { finalAssistants } = useAssistants();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { llmProviders } = useChatContext();
|
||||
const { user } = useUser();
|
||||
|
||||
const [assistants, setAssistants] = useState<Persona[]>(finalAssistants);
|
||||
const [isTemperatureExpanded, setIsTemperatureExpanded] = useState(false);
|
||||
|
||||
// Initialize selectedTab from localStorage
|
||||
const [selectedTab, setSelectedTab] = useState<number | undefined>();
|
||||
useEffect(() => {
|
||||
const storedTab = localStorage.getItem("assistantSelectorSelectedTab");
|
||||
const tab = storedTab !== null ? Number(storedTab) : 0;
|
||||
setSelectedTab(tab);
|
||||
}, [localStorage]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = assistants.findIndex(
|
||||
(item) => item.id.toString() === active.id
|
||||
);
|
||||
const newIndex = assistants.findIndex(
|
||||
(item) => item.id.toString() === over.id
|
||||
);
|
||||
const updatedAssistants = arrayMove(assistants, oldIndex, newIndex);
|
||||
setAssistants(updatedAssistants);
|
||||
await updateUserAssistantList(updatedAssistants.map((a) => a.id));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle tab change and update localStorage
|
||||
const handleTabChange = (index: number) => {
|
||||
setSelectedTab(index);
|
||||
localStorage.setItem("assistantSelectorSelectedTab", index.toString());
|
||||
};
|
||||
|
||||
const settings = useContext(SettingsContext);
|
||||
|
||||
// Get the user's default model
|
||||
const userDefaultModel = user?.preferences.default_model;
|
||||
|
||||
const [_, currentLlm] = getFinalLLM(
|
||||
llmProviders,
|
||||
liveAssistant,
|
||||
llmOverrideManager.llmOverride ?? null
|
||||
);
|
||||
|
||||
const requiresImageGeneration =
|
||||
checkPersonaRequiresImageGeneration(liveAssistant);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Tab.Group selectedIndex={selectedTab} onChange={handleTabChange}>
|
||||
<Tab.List className="flex p-1 space-x-1 bg-gray-100 rounded-t-md">
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full py-2.5 text-sm leading-5 font-medium rounded-md
|
||||
${
|
||||
selected
|
||||
? "bg-white text-gray-700 shadow"
|
||||
: "text-gray-500 hover:bg-white/[0.12] hover:text-gray-700"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Assistant
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full py-2.5 text-sm leading-5 font-medium rounded-md
|
||||
${
|
||||
selected
|
||||
? "bg-white text-gray-700 shadow"
|
||||
: "text-gray-500 hover:bg-white/[0.12] hover:text-gray-700"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Model
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="p-3">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-center text-lg font-semibold text-gray-800">
|
||||
Choose an Assistant
|
||||
</h3>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
>
|
||||
<SortableContext
|
||||
items={assistants.map((a) => a.id.toString())}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{assistants.map((assistant) => (
|
||||
<DraggableAssistantCard
|
||||
key={assistant.id.toString()}
|
||||
assistant={assistant}
|
||||
isSelected={liveAssistant.id === assistant.id}
|
||||
onSelect={(assistant) => {
|
||||
onAssistantChange(assistant);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
llmName={
|
||||
assistant.llm_model_version_override ??
|
||||
userDefaultModel ??
|
||||
currentLlm
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="p-3">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-center text-lg font-semibold text-gray-800 ">
|
||||
Choose a Model
|
||||
</h3>
|
||||
</div>
|
||||
<LlmList
|
||||
currentAssistant={liveAssistant}
|
||||
requiresImageGeneration={requiresImageGeneration}
|
||||
llmProviders={llmProviders}
|
||||
currentLlm={currentLlm}
|
||||
userDefault={userDefaultModel}
|
||||
onSelect={(value: string | null) => {
|
||||
if (value == null) return;
|
||||
const { modelName, name, provider } = destructureValue(value);
|
||||
llmOverrideManager.updateLLMOverride({
|
||||
name,
|
||||
provider,
|
||||
modelName,
|
||||
});
|
||||
if (chatSessionId) {
|
||||
updateModelOverrideForChatSession(chatSessionId, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
className="flex items-center text-sm font-medium transition-colors duration-200"
|
||||
onClick={() => setIsTemperatureExpanded(!isTemperatureExpanded)}
|
||||
>
|
||||
<span className="mr-2 text-xs text-primary">
|
||||
{isTemperatureExpanded ? "▼" : "►"}
|
||||
</span>
|
||||
<span>Temperature</span>
|
||||
</button>
|
||||
|
||||
{isTemperatureExpanded && (
|
||||
<>
|
||||
<Text className="mt-2 mb-8">
|
||||
Adjust the temperature of the LLM. Higher temperatures will
|
||||
make the LLM generate more creative and diverse responses,
|
||||
while lower temperature will make the LLM generate more
|
||||
conservative and focused responses.
|
||||
</Text>
|
||||
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="range"
|
||||
onChange={(e) =>
|
||||
llmOverrideManager.updateTemperature(
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full p-2 border border-border rounded-md"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.01"
|
||||
value={llmOverrideManager.temperature?.toString() || "0"}
|
||||
/>
|
||||
<div
|
||||
className="absolute text-sm"
|
||||
style={{
|
||||
left: `${(llmOverrideManager.temperature || 0) * 50}%`,
|
||||
transform: `translateX(-${Math.min(
|
||||
Math.max(
|
||||
(llmOverrideManager.temperature || 0) * 50,
|
||||
10
|
||||
),
|
||||
90
|
||||
)}%)`,
|
||||
top: "-1.5rem",
|
||||
}}
|
||||
>
|
||||
{llmOverrideManager.temperature}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto relative" ref={dropdownRef}>
|
||||
<div
|
||||
className={`h-12 items-end flex justify-center
|
||||
${
|
||||
settings?.enterpriseSettings?.custom_header_content &&
|
||||
(settings?.enterpriseSettings?.two_lines_for_chat_header
|
||||
? "mt-16 "
|
||||
: "mt-10")
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
// Get selectedTab from localStorage when opening
|
||||
const storedTab = localStorage.getItem(
|
||||
"assistantSelectorSelectedTab"
|
||||
);
|
||||
setSelectedTab(storedTab !== null ? Number(storedTab) : 0);
|
||||
}}
|
||||
className="flex items-center gap-x-2 justify-between px-6 py-3 text-sm font-medium text-white bg-black rounded-full shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
<div className="h-4 flex gap-x-2 items-center">
|
||||
<AssistantIcon assistant={liveAssistant} size="xs" />
|
||||
<span className="font-bold">{liveAssistant.name}</span>
|
||||
</div>
|
||||
<div className="h-4 flex items-center">
|
||||
<span className="mr-2 text-xs">
|
||||
{truncateString(getDisplayNameForModel(currentLlm), 30)}
|
||||
</span>
|
||||
<FiChevronDown
|
||||
className={`w-3 h-3 text-white transition-transform duration-300 transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="invisible w-0">
|
||||
<AssistantIcon assistant={liveAssistant} size="xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Assistant Selector</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
isOpen && (
|
||||
<div className="absolute z-10 w-96 mt-2 origin-top-center left-1/2 transform -translate-x-1/2 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantSelector;
|
||||
@@ -18,13 +18,13 @@ export default function SourceCard({
|
||||
onClick={() => openDocument(doc, setPresentingDocument)}
|
||||
className="cursor-pointer text-left overflow-hidden flex flex-col gap-0.5 rounded-lg px-3 py-2 hover:bg-background-dark/80 bg-background-dark/60 w-[200px]"
|
||||
>
|
||||
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center gap-2 text-sm">
|
||||
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center gap-2 text-sm">
|
||||
{doc.is_internet || doc.source_type === "web" ? (
|
||||
<WebResultIcon url={doc.link} />
|
||||
) : (
|
||||
<SourceIcon sourceType={doc.source_type} iconSize={18} />
|
||||
)}
|
||||
<p>{truncateString(doc.semantic_identifier || doc.document_id, 20)}</p>
|
||||
<p>{truncateString(doc.semantic_identifier || doc.document_id, 16)}</p>
|
||||
</div>
|
||||
<div className="line-clamp-2 text-sm font-semibold"></div>
|
||||
<div className="line-clamp-2 text-sm font-normal leading-snug text-text-700">
|
||||
|
||||
@@ -23,8 +23,6 @@ interface AssistantsContextProps {
|
||||
hiddenAssistants: Persona[];
|
||||
finalAssistants: Persona[];
|
||||
ownedButHiddenAssistants: Persona[];
|
||||
pinnedAssistants: Persona[];
|
||||
setPinnedAssistants: Dispatch<SetStateAction<Persona[]>>;
|
||||
refreshAssistants: () => Promise<void>;
|
||||
isImageGenerationAvailable: boolean;
|
||||
recentAssistants: Persona[];
|
||||
@@ -32,6 +30,8 @@ interface AssistantsContextProps {
|
||||
// Admin only
|
||||
editablePersonas: Persona[];
|
||||
allAssistants: Persona[];
|
||||
pinnedAssistants: Persona[];
|
||||
setPinnedAssistants: Dispatch<SetStateAction<Persona[]>>;
|
||||
}
|
||||
|
||||
const AssistantsContext = createContext<AssistantsContextProps | undefined>(
|
||||
@@ -52,11 +52,23 @@ export const AssistantsProvider: React.FC<{
|
||||
const [assistants, setAssistants] = useState<Persona[]>(
|
||||
initialAssistants || []
|
||||
);
|
||||
const [pinnedAssistants, setPinnedAssistants] = useState<Persona[]>([]);
|
||||
const { user, isAdmin, isCurator } = useUser();
|
||||
const [editablePersonas, setEditablePersonas] = useState<Persona[]>([]);
|
||||
const [allAssistants, setAllAssistants] = useState<Persona[]>([]);
|
||||
|
||||
const [pinnedAssistants, setPinnedAssistants] = useState<Persona[]>(
|
||||
assistants.filter((assistant) =>
|
||||
user?.preferences?.pinned_assistants?.includes(assistant.id)
|
||||
)
|
||||
);
|
||||
useEffect(() => {
|
||||
setPinnedAssistants(
|
||||
assistants.filter((assistant) =>
|
||||
user?.preferences?.pinned_assistants?.includes(assistant.id)
|
||||
)
|
||||
);
|
||||
}, [user?.preferences?.pinned_assistants, assistants]);
|
||||
|
||||
const [recentAssistants, setRecentAssistants] = useState<Persona[]>(
|
||||
user?.preferences.recent_assistants
|
||||
?.filter((assistantId) =>
|
||||
@@ -183,25 +195,6 @@ export const AssistantsProvider: React.FC<{
|
||||
user,
|
||||
assistants
|
||||
);
|
||||
const pinnedAssistants = user?.preferences.pinned_assistants
|
||||
? visibleAssistants
|
||||
.filter((assistant) =>
|
||||
user.preferences.pinned_assistants.includes(assistant.id)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const indexA = user.preferences.pinned_assistants.indexOf(a.id);
|
||||
const indexB = user.preferences.pinned_assistants.indexOf(b.id);
|
||||
return indexA - indexB;
|
||||
})
|
||||
: visibleAssistants.filter(
|
||||
(assistant) =>
|
||||
assistant.builtin_persona || assistant.is_default_persona
|
||||
);
|
||||
|
||||
setPinnedAssistants(pinnedAssistants);
|
||||
// Fallback to first 3 assistants if pinnedAssistants is empty
|
||||
const finalPinnedAssistants =
|
||||
pinnedAssistants.length > 0 ? pinnedAssistants : assistants.slice(0, 3);
|
||||
|
||||
const finalAssistants = user
|
||||
? orderAssistantsForUser(visibleAssistants, user)
|
||||
@@ -216,7 +209,6 @@ export const AssistantsProvider: React.FC<{
|
||||
visibleAssistants,
|
||||
hiddenAssistants,
|
||||
finalAssistants,
|
||||
pinnedAssistants,
|
||||
ownedButHiddenAssistants,
|
||||
};
|
||||
}, [user, assistants]);
|
||||
@@ -228,8 +220,6 @@ export const AssistantsProvider: React.FC<{
|
||||
visibleAssistants,
|
||||
hiddenAssistants,
|
||||
finalAssistants,
|
||||
pinnedAssistants,
|
||||
setPinnedAssistants,
|
||||
ownedButHiddenAssistants,
|
||||
refreshAssistants,
|
||||
editablePersonas,
|
||||
@@ -237,6 +227,8 @@ export const AssistantsProvider: React.FC<{
|
||||
isImageGenerationAvailable,
|
||||
recentAssistants,
|
||||
refreshRecentAssistants,
|
||||
setPinnedAssistants,
|
||||
pinnedAssistants,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -43,9 +43,9 @@ export default function LogoWithText({
|
||||
} z-[100] ml-2 mt-1 h-8 mb-auto shrink-0 flex gap-x-0 items-center text-xl`}
|
||||
>
|
||||
{toggleSidebar && page == "chat" ? (
|
||||
<button
|
||||
<div
|
||||
onClick={() => toggleSidebar()}
|
||||
className="flex gap-x-2 items-center ml-0 desktop:hidden "
|
||||
className="flex gap-x-2 items-center ml-0 cursor-pointer desktop:hidden "
|
||||
>
|
||||
{!toggled ? (
|
||||
<Logo className="desktop:hidden -my-2" height={24} width={24} />
|
||||
@@ -63,7 +63,7 @@ export default function LogoWithText({
|
||||
toggled && "mobile:hidden"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mr-1 invisible mb-auto h-6 w-6">
|
||||
<Logo height={24} width={24} />
|
||||
|
||||
@@ -249,111 +249,56 @@ export const ColorSlackIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
className={`w-[${size}px] h-[${size}px] ` + className}
|
||||
>
|
||||
<Image src={slackIcon} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={slackIcon} />;
|
||||
};
|
||||
|
||||
export const ColorDiscordIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
className={`w-[${size}px] h-[${size}px] ` + className}
|
||||
>
|
||||
<Image src={discordIcon} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={discordIcon} />;
|
||||
};
|
||||
|
||||
export const LiteLLMIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
|
||||
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
|
||||
>
|
||||
<Image src={litellmIcon} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={litellmIcon} />;
|
||||
};
|
||||
|
||||
export const OpenSourceIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
|
||||
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
|
||||
>
|
||||
<Image src={openSourceIcon} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={openSourceIcon} />;
|
||||
};
|
||||
|
||||
export const MixedBreadIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
|
||||
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
|
||||
>
|
||||
<Image src={mixedBreadSVG} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={mixedBreadSVG} />;
|
||||
};
|
||||
|
||||
export const NomicIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
|
||||
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
|
||||
>
|
||||
<Image src={nomicSVG} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={nomicSVG} />;
|
||||
};
|
||||
|
||||
export const MicrosoftIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
|
||||
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
|
||||
>
|
||||
<Image src={microsoftIcon} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={microsoftIcon} />;
|
||||
};
|
||||
|
||||
export const AnthropicIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
|
||||
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
|
||||
>
|
||||
<Image src={anthropicSVG} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={anthropicSVG} />;
|
||||
};
|
||||
|
||||
export const LeftToLineIcon = ({
|
||||
@@ -1835,7 +1780,6 @@ export const RobotIcon = ({
|
||||
return <FaRobot size={size} className={className} />;
|
||||
};
|
||||
|
||||
slackIcon;
|
||||
export const SlackIconSkeleton = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
@@ -2798,28 +2742,14 @@ export const EgnyteIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
className={`w-[${size}px] h-[${size}px] ` + className}
|
||||
>
|
||||
<Image src={egnyteIcon} alt="Egnyte" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={egnyteIcon} />;
|
||||
};
|
||||
|
||||
export const AirtableIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
className={`w-[${size}px] h-[${size}px] ` + className}
|
||||
>
|
||||
<Image src={airtableIcon} alt="Airtable" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
return <LogoIcon size={size} className={className} src={airtableIcon} />;
|
||||
};
|
||||
|
||||
export const PinnedIcon = ({
|
||||
@@ -3065,7 +2995,7 @@ export const GeneralAssistantIcon = ({
|
||||
height="22.7"
|
||||
rx="11.35"
|
||||
stroke="black"
|
||||
stroke-width="1.3"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M8.06264 10.3125C8.06253 9.66355 8.22283 9.02463 8.52926 8.45258C8.83569 7.88054 9.27876 7.3931 9.81906 7.03363C10.3594 6.67415 10.9801 6.4538 11.6261 6.39216C12.2722 6.33052 12.9234 6.42951 13.5219 6.68032C14.1204 6.93113 14.6477 7.32598 15.0568 7.82976C15.4659 8.33353 15.7441 8.93061 15.8667 9.56787C15.9893 10.2051 15.9525 10.8628 15.7596 11.4824C15.5667 12.102 15.2236 12.6644 14.7609 13.1194C14.5438 13.3331 14.3525 13.611 14.2603 13.9474L13.8721 15.375H10.1281L9.73889 13.9474C9.64847 13.6321 9.47612 13.3464 9.23939 13.1194C8.86681 12.753 8.57088 12.3161 8.36885 11.8342C8.16682 11.3523 8.06272 10.835 8.06264 10.3125ZM10.4364 16.5H13.5639L13.3715 17.211C13.3389 17.3301 13.2681 17.4351 13.1699 17.5099C13.0717 17.5847 12.9516 17.6252 12.8281 17.625H11.1721C11.0487 17.6252 10.9286 17.5847 10.8304 17.5099C10.7322 17.4351 10.6614 17.3301 10.6288 17.211L10.4364 16.5ZM12.0001 5.25C10.9954 5.25017 10.0134 5.5493 9.17925 6.10932C8.34506 6.66934 7.69637 7.46491 7.31577 8.39477C6.93516 9.32463 6.83985 10.3467 7.04197 11.3309C7.24409 12.3151 7.7345 13.2169 8.45076 13.9215C8.54562 14.0093 8.61549 14.1207 8.65326 14.2444L9.54426 17.5069C9.64173 17.8639 9.85387 18.179 10.148 18.4037C10.4422 18.6283 10.802 18.75 11.1721 18.75H12.8281C13.1983 18.75 13.5581 18.6283 13.8523 18.4037C14.1464 18.179 14.3585 17.8639 14.456 17.5069L15.3459 14.2444C15.384 14.1206 15.4542 14.0092 15.5495 13.9215C16.2658 13.2169 16.7562 12.3151 16.9583 11.3309C17.1604 10.3467 17.0651 9.32463 16.6845 8.39477C16.3039 7.46491 15.6552 6.66934 14.821 6.10932C13.9868 5.5493 13.0049 5.25017 12.0001 5.25Z"
|
||||
@@ -3094,7 +3024,7 @@ export const SearchAssistantIcon = ({
|
||||
height="22.7"
|
||||
rx="11.35"
|
||||
stroke="black"
|
||||
stroke-width="1.3"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M17.0667 18L12.8667 13.8C12.5333 14.0667 12.15 14.2778 11.7167 14.4333C11.2833 14.5889 10.8222 14.6667 10.3333 14.6667C9.12222 14.6667 8.09733 14.2471 7.25867 13.408C6.42 12.5689 6.00044 11.544 6 10.3333C5.99956 9.12267 6.41911 8.09778 7.25867 7.25867C8.09822 6.41956 9.12311 6 10.3333 6C11.5436 6 12.5687 6.41956 13.4087 7.25867C14.2487 8.09778 14.668 9.12267 14.6667 10.3333C14.6667 10.8222 14.5889 11.2833 14.4333 11.7167C14.2778 12.15 14.0667 12.5333 13.8 12.8667L18 17.0667L17.0667 18ZM10.3333 13.3333C11.1667 13.3333 11.8751 13.0418 12.4587 12.4587C13.0422 11.8756 13.3338 11.1671 13.3333 10.3333C13.3329 9.49956 13.0413 8.79133 12.4587 8.20867C11.876 7.626 11.1676 7.33422 10.3333 7.33333C9.49911 7.33244 8.79089 7.62422 8.20867 8.20867C7.62644 8.79311 7.33467 9.50133 7.33333 10.3333C7.332 11.1653 7.62378 11.8738 8.20867 12.4587C8.79356 13.0436 9.50178 13.3351 10.3333 13.3333Z"
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import React from "react";
|
||||
import { getDisplayNameForModel } from "@/lib/hooks";
|
||||
import {
|
||||
checkLLMSupportsImageInput,
|
||||
destructureValue,
|
||||
structureValue,
|
||||
} from "@/lib/llm/utils";
|
||||
import {
|
||||
getProviderIcon,
|
||||
LLMProviderDescriptor,
|
||||
} from "@/app/admin/configuration/llm/interfaces";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
|
||||
interface LlmListProps {
|
||||
llmProviders: LLMProviderDescriptor[];
|
||||
currentLlm: string;
|
||||
onSelect: (value: string | null) => void;
|
||||
userDefault?: string | null;
|
||||
scrollable?: boolean;
|
||||
hideProviderIcon?: boolean;
|
||||
requiresImageGeneration?: boolean;
|
||||
currentAssistant?: Persona;
|
||||
}
|
||||
|
||||
export const LlmList: React.FC<LlmListProps> = ({
|
||||
currentAssistant,
|
||||
llmProviders,
|
||||
currentLlm,
|
||||
onSelect,
|
||||
userDefault,
|
||||
scrollable,
|
||||
requiresImageGeneration,
|
||||
}) => {
|
||||
const llmOptionsByProvider: {
|
||||
[provider: string]: {
|
||||
name: string;
|
||||
value: string;
|
||||
icon: React.FC<{ size?: number; className?: string }>;
|
||||
}[];
|
||||
} = {};
|
||||
const uniqueModelNames = new Set<string>();
|
||||
|
||||
llmProviders.forEach((llmProvider) => {
|
||||
if (!llmOptionsByProvider[llmProvider.provider]) {
|
||||
llmOptionsByProvider[llmProvider.provider] = [];
|
||||
}
|
||||
|
||||
(llmProvider.display_model_names || llmProvider.model_names).forEach(
|
||||
(modelName) => {
|
||||
if (!uniqueModelNames.has(modelName)) {
|
||||
uniqueModelNames.add(modelName);
|
||||
llmOptionsByProvider[llmProvider.provider].push({
|
||||
name: modelName,
|
||||
value: structureValue(
|
||||
llmProvider.name,
|
||||
llmProvider.provider,
|
||||
modelName
|
||||
),
|
||||
icon: getProviderIcon(llmProvider.provider, modelName),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const llmOptions = Object.entries(llmOptionsByProvider).flatMap(
|
||||
([provider, options]) => [...options]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
scrollable
|
||||
? "max-h-[200px] default-scrollbar overflow-x-hidden"
|
||||
: "max-h-[300px]"
|
||||
} bg-background-175 flex flex-col gap-y-2 mt-1 overflow-y-scroll`}
|
||||
>
|
||||
{llmOptions.map(({ name, icon, value }, index) => {
|
||||
if (!requiresImageGeneration || checkLLMSupportsImageInput(name)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
className={`w-full items-center flex gap-x-2 text-sm text-left rounded`}
|
||||
onClick={() => onSelect(value)}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className={`overflow-hidden rounded-full ${
|
||||
currentLlm == name ? "bg-accent border-none " : ""
|
||||
}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{currentLlm != name && (
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
{icon({ size: 16 })}
|
||||
<p className="text-sm">{getDisplayNameForModel(name)}</p>
|
||||
{(() => {
|
||||
if (
|
||||
currentAssistant?.llm_model_version_override === name &&
|
||||
userDefault &&
|
||||
name === destructureValue(userDefault).modelName
|
||||
) {
|
||||
return " (assistant + user default)";
|
||||
} else if (
|
||||
currentAssistant?.llm_model_version_override === name
|
||||
) {
|
||||
return " (assistant)";
|
||||
} else if (
|
||||
userDefault &&
|
||||
name === destructureValue(userDefault).modelName
|
||||
) {
|
||||
return " (user default)";
|
||||
}
|
||||
return "";
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -18,28 +18,48 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface LLMSelectorProps {
|
||||
userSettings?: boolean;
|
||||
llmProviders: LLMProviderDescriptor[];
|
||||
currentLlm: string | null;
|
||||
onSelect: (value: string | null) => void;
|
||||
userDefault?: string | null;
|
||||
requiresImageGeneration?: boolean;
|
||||
}
|
||||
|
||||
export const LLMSelector: React.FC<LLMSelectorProps> = ({
|
||||
userSettings,
|
||||
llmProviders,
|
||||
currentLlm,
|
||||
onSelect,
|
||||
userDefault,
|
||||
requiresImageGeneration,
|
||||
}) => {
|
||||
const llmOptions = llmProviders.flatMap((provider) =>
|
||||
(provider.display_model_names || provider.model_names).map((modelName) => ({
|
||||
name: getDisplayNameForModel(modelName),
|
||||
value: structureValue(provider.name, provider.provider, modelName),
|
||||
icon: getProviderIcon(provider.provider, modelName),
|
||||
}))
|
||||
const seenModelNames = new Set();
|
||||
|
||||
const llmOptions = llmProviders.flatMap((provider) => {
|
||||
return (provider.display_model_names || provider.model_names)
|
||||
.filter((modelName) => {
|
||||
const displayName = getDisplayNameForModel(modelName);
|
||||
if (seenModelNames.has(displayName)) {
|
||||
return false;
|
||||
}
|
||||
seenModelNames.add(displayName);
|
||||
return true;
|
||||
})
|
||||
.map((modelName) => ({
|
||||
name: getDisplayNameForModel(modelName),
|
||||
value: structureValue(provider.name, provider.provider, modelName),
|
||||
icon: getProviderIcon(provider.provider, modelName),
|
||||
}));
|
||||
});
|
||||
|
||||
const defaultProvider = llmProviders.find(
|
||||
(llmProvider) => llmProvider.is_default_provider
|
||||
);
|
||||
|
||||
const defaultModelName = defaultProvider?.default_model_name;
|
||||
const defaultModelDisplayName = defaultModelName
|
||||
? getDisplayNameForModel(defaultModelName)
|
||||
: null;
|
||||
|
||||
const destructuredCurrentValue = currentLlm
|
||||
? destructureValue(currentLlm)
|
||||
: null;
|
||||
@@ -55,12 +75,19 @@ export const LLMSelector: React.FC<LLMSelectorProps> = ({
|
||||
<SelectValue>
|
||||
{currentLlmName
|
||||
? getDisplayNameForModel(currentLlmName)
|
||||
: "User Default"}
|
||||
: userSettings
|
||||
? "System Default"
|
||||
: "User Default"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem hideCheck value="default">
|
||||
User Default
|
||||
<SelectItem className="flex" hideCheck value="default">
|
||||
<span>{userSettings ? "System Default" : "User Default"}</span>
|
||||
{userSettings && (
|
||||
<span className=" my-auto font-normal ml-1">
|
||||
({defaultModelDisplayName})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
{llmOptions.map((option) => {
|
||||
if (
|
||||
@@ -69,16 +96,9 @@ export const LLMSelector: React.FC<LLMSelectorProps> = ({
|
||||
) {
|
||||
return (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="mt-2 flex items-center">
|
||||
<div className="my-1 flex items-center">
|
||||
{option.icon && option.icon({ size: 16 })}
|
||||
<span className="ml-2">{option.name}</span>
|
||||
{userDefault &&
|
||||
option.value ===
|
||||
structureValue(userDefault, "", userDefault) && (
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
(user default)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import React from "react";
|
||||
import {
|
||||
OnyxDocument,
|
||||
DocumentRelevance,
|
||||
LoadedOnyxDocument,
|
||||
SearchOnyxDocument,
|
||||
} from "@/lib/search/interfaces";
|
||||
import { DocumentFeedbackBlock } from "./DocumentFeedbackBlock";
|
||||
@@ -12,7 +11,7 @@ import { PopupSpec } from "../admin/connectors/Popup";
|
||||
import { DocumentUpdatedAtBadge } from "./DocumentUpdatedAtBadge";
|
||||
import { SourceIcon } from "../SourceIcon";
|
||||
import { MetadataBadge } from "../MetadataBadge";
|
||||
import { BookIcon, GlobeIcon, LightBulbIcon, SearchIcon } from "../icons/icons";
|
||||
import { BookIcon, LightBulbIcon } from "../icons/icons";
|
||||
|
||||
import { FaStar } from "react-icons/fa";
|
||||
import { FiTag } from "react-icons/fi";
|
||||
@@ -20,8 +19,6 @@ import { SettingsContext } from "../settings/SettingsProvider";
|
||||
import { CustomTooltip, TooltipGroup } from "../tooltip/CustomTooltip";
|
||||
import { WarningCircle } from "@phosphor-icons/react";
|
||||
import TextView from "../chat_search/TextView";
|
||||
import { SearchResultIcon } from "../SearchResultIcon";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { openDocument } from "@/lib/search/utils";
|
||||
|
||||
export const buildDocumentSummaryDisplay = (
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Lightbulb } from "@phosphor-icons/react/dist/ssr";
|
||||
import { PopupSpec } from "../admin/connectors/Popup";
|
||||
import {
|
||||
BookmarkIcon,
|
||||
ChevronsDownIcon,
|
||||
ChevronsUpIcon,
|
||||
LightBulbIcon,
|
||||
LightSettingsIcon,
|
||||
} from "../icons/icons";
|
||||
import { ChevronsDownIcon, ChevronsUpIcon } from "../icons/icons";
|
||||
import { CustomTooltip } from "../tooltip/CustomTooltip";
|
||||
|
||||
type DocumentFeedbackType = "endorse" | "reject" | "hide" | "unhide";
|
||||
|
||||
@@ -28,8 +28,8 @@ import { SendIcon } from "../icons/icons";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CustomTooltip } from "../tooltip/CustomTooltip";
|
||||
import KeyboardSymbol from "@/lib/browserUtilities";
|
||||
import { HorizontalSourceSelector } from "./filtering/Filters";
|
||||
import { CCPairBasicInfo, DocumentSet, Tag } from "@/lib/types";
|
||||
import { HorizontalSourceSelector } from "./filtering/HorizontalSourceSelector";
|
||||
|
||||
export const AnimatedToggle = ({
|
||||
isOn,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import {
|
||||
FlowType,
|
||||
SearchDefaultOverrides,
|
||||
SearchRequestOverrides,
|
||||
SearchResponse,
|
||||
SearchType,
|
||||
} from "@/lib/search/interfaces";
|
||||
import { BrainIcon } from "../icons/icons";
|
||||
|
||||
const CLICKABLE_CLASS_NAME = "text-link cursor-pointer";
|
||||
const NUM_DOCUMENTS_FED_TO_GPT = 5;
|
||||
|
||||
interface Props {
|
||||
isFetching: boolean;
|
||||
searchResponse: SearchResponse | null;
|
||||
selectedSearchType: SearchType;
|
||||
setSelectedSearchType: (searchType: SearchType) => void;
|
||||
defaultOverrides: SearchDefaultOverrides;
|
||||
restartSearch: (overrides?: SearchRequestOverrides) => void;
|
||||
forceQADisplay: () => void;
|
||||
setOffset: (offset: number) => void;
|
||||
}
|
||||
|
||||
const getAssistantMessage = ({
|
||||
isFetching,
|
||||
searchResponse,
|
||||
selectedSearchType,
|
||||
setSelectedSearchType,
|
||||
defaultOverrides,
|
||||
restartSearch,
|
||||
forceQADisplay,
|
||||
setOffset,
|
||||
}: Props): string | JSX.Element | null => {
|
||||
if (!searchResponse || !searchResponse.suggestedFlowType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
searchResponse.suggestedFlowType === FlowType.SEARCH &&
|
||||
!defaultOverrides.forceDisplayQA &&
|
||||
searchResponse.answer
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
This doesn't seem like a question for a Generative AI. Do you still
|
||||
want to have{" "}
|
||||
<span className={CLICKABLE_CLASS_NAME} onClick={forceQADisplay}>
|
||||
GPT give a response?
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(searchResponse.suggestedFlowType === FlowType.QUESTION_ANSWER ||
|
||||
defaultOverrides.forceDisplayQA) &&
|
||||
!isFetching &&
|
||||
searchResponse.answer === ""
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
GPT was unable to find an answer in the most relevant{" "}
|
||||
<b>{` ${(defaultOverrides.offset + 1) * NUM_DOCUMENTS_FED_TO_GPT} `}</b>{" "}
|
||||
documents. Do you want to{" "}
|
||||
<span
|
||||
className={CLICKABLE_CLASS_NAME}
|
||||
onClick={() => {
|
||||
const newOffset = defaultOverrides.offset + 1;
|
||||
setOffset(newOffset);
|
||||
restartSearch({
|
||||
offset: newOffset,
|
||||
});
|
||||
}}
|
||||
>
|
||||
keep searching?
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SearchHelper: React.FC<Props> = (props) => {
|
||||
const message = getAssistantMessage(props);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded p-3 text-sm">
|
||||
<div className="flex">
|
||||
<BrainIcon size={20} />
|
||||
<b className="ml-2 text-strong">AI Assistant</b>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">{message}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export function FilterDropdown({
|
||||
select-none
|
||||
cursor-pointer
|
||||
flex-none
|
||||
w-fit
|
||||
w-full
|
||||
text-emphasis
|
||||
items-center
|
||||
gap-x-1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
FiCalendar,
|
||||
FiFilter,
|
||||
FiFolder,
|
||||
FiTag,
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
@@ -21,6 +19,8 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { SelectableDropdown, TagFilter } from "./TagFilter";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface FilterPopupProps {
|
||||
filterManager: FilterManager;
|
||||
@@ -48,6 +48,18 @@ export function FilterPopup({
|
||||
FilterCategories.date
|
||||
);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [documentSetSearch, setDocumentSetSearch] = useState("");
|
||||
const [filteredDocumentSets, setFilteredDocumentSets] = useState<
|
||||
DocumentSet[]
|
||||
>(availableDocumentSets);
|
||||
|
||||
useEffect(() => {
|
||||
const lowercasedFilter = documentSetSearch.toLowerCase();
|
||||
const filtered = availableDocumentSets.filter((docSet) =>
|
||||
docSet.name.toLowerCase().includes(lowercasedFilter)
|
||||
);
|
||||
setFilteredDocumentSets(filtered);
|
||||
}, [documentSetSearch, availableDocumentSets]);
|
||||
|
||||
const FilterOption = ({
|
||||
category,
|
||||
@@ -198,6 +210,45 @@ export function FilterPopup({
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllSources = () => {
|
||||
if (filterManager.selectedSources.length === availableSources.length) {
|
||||
filterManager.setSelectedSources([]);
|
||||
} else {
|
||||
filterManager.setSelectedSources([...availableSources]);
|
||||
}
|
||||
};
|
||||
|
||||
const isSourceSelected = (source: SourceMetadata) =>
|
||||
filterManager.selectedSources.some(
|
||||
(s) => s.internalName === source.internalName
|
||||
);
|
||||
|
||||
const toggleSource = (source: SourceMetadata) => {
|
||||
if (isSourceSelected(source)) {
|
||||
filterManager.setSelectedSources(
|
||||
filterManager.selectedSources.filter(
|
||||
(s) => s.internalName !== source.internalName
|
||||
)
|
||||
);
|
||||
} else {
|
||||
filterManager.setSelectedSources([
|
||||
...filterManager.selectedSources,
|
||||
source,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const isDocumentSetSelected = (docSet: DocumentSet) =>
|
||||
filterManager.selectedDocumentSets.includes(docSet.id.toString());
|
||||
|
||||
const toggleDocumentSet = (docSet: DocumentSet) => {
|
||||
filterManager.setSelectedDocumentSets((prev) =>
|
||||
prev.includes(docSet.id.toString())
|
||||
? prev.filter((id) => id !== docSet.id.toString())
|
||||
: [...prev, docSet.id.toString()]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -238,9 +289,9 @@ export function FilterPopup({
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="w-2/3 p-4 overflow-y-auto">
|
||||
<div className="w-2/3 overflow-y-auto">
|
||||
{selectedFilter === FilterCategories.date && (
|
||||
<div>
|
||||
<div className="p-4">
|
||||
{renderCalendar()}
|
||||
{filterManager.timeRange ? (
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
@@ -267,112 +318,68 @@ export function FilterPopup({
|
||||
</div>
|
||||
)}
|
||||
{selectedFilter === FilterCategories.sources && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Sources</h3>
|
||||
<ul className="space-y-2">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold">Sources</h3>
|
||||
<div className="flex gap-x-2 items-center ">
|
||||
<p className="text-xs text-text-dark">Select all</p>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
id="select-all-sources"
|
||||
checked={
|
||||
filterManager.selectedSources.length ===
|
||||
availableSources.length
|
||||
}
|
||||
onCheckedChange={toggleAllSources}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{availableSources.map((source) => (
|
||||
<li
|
||||
key={source.internalName}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={source.internalName}
|
||||
checked={filterManager.selectedSources.some(
|
||||
(s) => s.internalName === source.internalName
|
||||
)}
|
||||
onCheckedChange={() => {
|
||||
filterManager.setSelectedSources((prev) =>
|
||||
prev.some(
|
||||
(s) => s.internalName === source.internalName
|
||||
)
|
||||
? prev.filter(
|
||||
(s) => s.internalName !== source.internalName
|
||||
)
|
||||
: [...prev, source]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center space-x-1">
|
||||
<SelectableDropdown
|
||||
icon={
|
||||
<SourceIcon
|
||||
sourceType={source.internalName}
|
||||
iconSize={14}
|
||||
/>
|
||||
<label
|
||||
htmlFor={source.internalName}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{source.displayName}
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
key={source.internalName}
|
||||
value={source.displayName}
|
||||
selected={isSourceSelected(source)}
|
||||
toggle={() => toggleSource(source)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{selectedFilter === FilterCategories.documentSets && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Document Sets</h3>
|
||||
<ul className="space-y-2">
|
||||
{availableDocumentSets.map((docSet) => (
|
||||
<li key={docSet.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={docSet.id.toString()}
|
||||
checked={filterManager.selectedDocumentSets.includes(
|
||||
docSet.id.toString()
|
||||
)}
|
||||
onCheckedChange={() => {
|
||||
filterManager.setSelectedDocumentSets((prev) =>
|
||||
prev.includes(docSet.id.toString())
|
||||
? prev.filter((id) => id !== docSet.id.toString())
|
||||
: [...prev, docSet.id.toString()]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={docSet.id.toString()}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{docSet.name}
|
||||
</label>
|
||||
</li>
|
||||
<div className="pt-4 h-full flex flex-col w-full">
|
||||
<div className="flex pb-2 px-4">
|
||||
<Input
|
||||
placeholder="Search document sets..."
|
||||
value={documentSetSearch}
|
||||
onChange={(e) => setDocumentSetSearch(e.target.value)}
|
||||
className="border border-text-subtle w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 border-t pt-2 border-t-text-subtle px-4 default-scrollbar w-full max-h-64 overflow-y-auto">
|
||||
{filteredDocumentSets.map((docSet) => (
|
||||
<SelectableDropdown
|
||||
key={docSet.id}
|
||||
value={docSet.name}
|
||||
selected={isDocumentSetSelected(docSet)}
|
||||
toggle={() => toggleDocumentSet(docSet)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedFilter === FilterCategories.tags && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Tags</h3>
|
||||
<ul className="space-y-2">
|
||||
{availableTags.map((tag) => (
|
||||
<li
|
||||
key={tag.tag_value}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={tag.tag_value}
|
||||
checked={filterManager.selectedTags.some(
|
||||
(t) => t.tag_value === tag.tag_value
|
||||
)}
|
||||
onCheckedChange={() => {
|
||||
filterManager.setSelectedTags((prev) =>
|
||||
prev.some((t) => t.tag_value === tag.tag_value)
|
||||
? prev.filter(
|
||||
(t) => t.tag_value !== tag.tag_value
|
||||
)
|
||||
: [...prev, tag]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={tag.tag_value}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{tag.tag_value}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<TagFilter
|
||||
tags={availableTags}
|
||||
selectedTags={filterManager.selectedTags}
|
||||
setSelectedTags={filterManager.setSelectedTags}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
|
||||
import { SourceMetadata } from "@/lib/search/interfaces";
|
||||
import {
|
||||
GearIcon,
|
||||
InfoIcon,
|
||||
MinusIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
defaultTailwindCSS,
|
||||
} from "../../icons/icons";
|
||||
import { HoverPopup } from "../../HoverPopup";
|
||||
import {
|
||||
FiBook,
|
||||
FiBookmark,
|
||||
FiFilter,
|
||||
FiMap,
|
||||
FiTag,
|
||||
FiX,
|
||||
} from "react-icons/fi";
|
||||
import { DateRangeSelector } from "../DateRangeSelector";
|
||||
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
|
||||
import { FilterDropdown } from "./FilterDropdown";
|
||||
import { listSourceMetadata } from "@/lib/sources";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { TagFilter } from "./TagFilter";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { PopoverContent } from "@radix-ui/react-popover";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import {
|
||||
buildDateString,
|
||||
getDateRangeString,
|
||||
getTimeAgoString,
|
||||
} from "@/lib/dateUtils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const SectionTitle = ({ children }: { children: string }) => (
|
||||
<div className="font-bold text-xs mt-2 flex">{children}</div>
|
||||
);
|
||||
|
||||
export interface SourceSelectorProps {
|
||||
timeRange: DateRangePickerValue | null;
|
||||
setTimeRange: React.Dispatch<
|
||||
React.SetStateAction<DateRangePickerValue | null>
|
||||
>;
|
||||
showDocSidebar?: boolean;
|
||||
selectedSources: SourceMetadata[];
|
||||
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
|
||||
selectedDocumentSets: string[];
|
||||
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedTags: Tag[];
|
||||
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
|
||||
availableDocumentSets: DocumentSet[];
|
||||
existingSources: ValidSources[];
|
||||
availableTags: Tag[];
|
||||
toggleFilters?: () => void;
|
||||
filtersUntoggled?: boolean;
|
||||
tagsOnLeft?: boolean;
|
||||
}
|
||||
|
||||
export function SourceSelector({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
selectedSources,
|
||||
setSelectedSources,
|
||||
selectedDocumentSets,
|
||||
setSelectedDocumentSets,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
availableDocumentSets,
|
||||
existingSources,
|
||||
availableTags,
|
||||
showDocSidebar,
|
||||
toggleFilters,
|
||||
filtersUntoggled,
|
||||
tagsOnLeft,
|
||||
}: SourceSelectorProps) {
|
||||
const handleSelect = (source: SourceMetadata) => {
|
||||
setSelectedSources((prev: SourceMetadata[]) => {
|
||||
if (
|
||||
prev.map((source) => source.internalName).includes(source.internalName)
|
||||
) {
|
||||
return prev.filter((s) => s.internalName !== source.internalName);
|
||||
} else {
|
||||
return [...prev, source];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentSetSelect = (documentSetName: string) => {
|
||||
setSelectedDocumentSets((prev: string[]) => {
|
||||
if (prev.includes(documentSetName)) {
|
||||
return prev.filter((s) => s !== documentSetName);
|
||||
} else {
|
||||
return [...prev, documentSetName];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let allSourcesSelected = selectedSources.length > 0;
|
||||
|
||||
const toggleAllSources = () => {
|
||||
if (allSourcesSelected) {
|
||||
setSelectedSources([]);
|
||||
} else {
|
||||
const allSources = listSourceMetadata().filter((source) =>
|
||||
existingSources.includes(source.internalName)
|
||||
);
|
||||
setSelectedSources(allSources);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`hidden ${
|
||||
showDocSidebar ? "4xl:block" : "!block"
|
||||
} duration-1000 flex ease-out transition-all transform origin-top-right`}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleFilters && toggleFilters()}
|
||||
className="flex text-emphasis"
|
||||
>
|
||||
<h2 className="font-bold my-auto">Filters</h2>
|
||||
<FiFilter className="my-auto ml-2" size="16" />
|
||||
</button>
|
||||
{!filtersUntoggled && (
|
||||
<>
|
||||
<Separator />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer">
|
||||
<SectionTitle>Time Range</SectionTitle>
|
||||
<p className="text-sm text-default mt-2">
|
||||
{getDateRangeString(timeRange?.from!, timeRange?.to!) ||
|
||||
"Select a time range"}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={
|
||||
timeRange
|
||||
? {
|
||||
from: new Date(timeRange.from),
|
||||
to: new Date(timeRange.to),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSelect={(daterange) => {
|
||||
const initialDate = daterange?.from || new Date();
|
||||
const endDate = daterange?.to || new Date();
|
||||
setTimeRange({
|
||||
from: initialDate,
|
||||
to: endDate,
|
||||
selectValue: timeRange?.selectValue || "",
|
||||
});
|
||||
}}
|
||||
className="rounded-md "
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{availableTags.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 mb-2">
|
||||
<SectionTitle>Tags</SectionTitle>
|
||||
</div>
|
||||
<TagFilter
|
||||
showTagsOnLeft={true}
|
||||
tags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{existingSources.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex w-full gap-x-2 items-center">
|
||||
<div className="font-bold text-xs mt-2 flex items-center gap-x-2">
|
||||
<p>Sources</p>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSourcesSelected}
|
||||
onChange={toggleAllSources}
|
||||
className="my-auto form-checkbox h-3 w-3 text-primary border-background-900 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
{listSourceMetadata()
|
||||
.filter((source) =>
|
||||
existingSources.includes(source.internalName)
|
||||
)
|
||||
.map((source) => (
|
||||
<div
|
||||
key={source.internalName}
|
||||
className={
|
||||
"flex cursor-pointer w-full items-center " +
|
||||
"py-1.5 my-1.5 rounded-lg px-2 select-none " +
|
||||
(selectedSources
|
||||
.map((source) => source.internalName)
|
||||
.includes(source.internalName)
|
||||
? "bg-hover"
|
||||
: "hover:bg-hover-light")
|
||||
}
|
||||
onClick={() => handleSelect(source)}
|
||||
>
|
||||
<SourceIcon
|
||||
sourceType={source.internalName}
|
||||
iconSize={16}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-default">
|
||||
{source.displayName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableDocumentSets.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4">
|
||||
<SectionTitle>Knowledge Sets</SectionTitle>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
{availableDocumentSets.map((documentSet) => (
|
||||
<div key={documentSet.name} className="my-1.5 flex">
|
||||
<div
|
||||
key={documentSet.name}
|
||||
className={
|
||||
"flex cursor-pointer w-full items-center " +
|
||||
"py-1.5 rounded-lg px-2 " +
|
||||
(selectedDocumentSets.includes(documentSet.name)
|
||||
? "bg-hover"
|
||||
: "hover:bg-hover-light")
|
||||
}
|
||||
onClick={() => handleDocumentSetSelect(documentSet.name)}
|
||||
>
|
||||
<HoverPopup
|
||||
mainContent={
|
||||
<div className="flex my-auto mr-2">
|
||||
<InfoIcon className={defaultTailwindCSS} />
|
||||
</div>
|
||||
}
|
||||
popupContent={
|
||||
<div className="text-sm w-64">
|
||||
<div className="flex font-medium">Description</div>
|
||||
<div className="mt-1">
|
||||
{documentSet.description}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
classNameModifications="-ml-2"
|
||||
/>
|
||||
<span className="text-sm">{documentSet.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectedBubble({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: string | JSX.Element;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex cursor-pointer items-center border border-border " +
|
||||
"py-1 my-1.5 rounded-lg px-2 w-fit hover:bg-hover"
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
<FiX className="ml-2" size={14} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HorizontalFilters({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
selectedSources,
|
||||
setSelectedSources,
|
||||
selectedDocumentSets,
|
||||
setSelectedDocumentSets,
|
||||
availableDocumentSets,
|
||||
existingSources,
|
||||
}: SourceSelectorProps) {
|
||||
const handleSourceSelect = (source: SourceMetadata) => {
|
||||
setSelectedSources((prev: SourceMetadata[]) => {
|
||||
const prevSourceNames = prev.map((source) => source.internalName);
|
||||
if (prevSourceNames.includes(source.internalName)) {
|
||||
return prev.filter((s) => s.internalName !== source.internalName);
|
||||
} else {
|
||||
return [...prev, source];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentSetSelect = (documentSetName: string) => {
|
||||
setSelectedDocumentSets((prev: string[]) => {
|
||||
if (prev.includes(documentSetName)) {
|
||||
return prev.filter((s) => s !== documentSetName);
|
||||
} else {
|
||||
return [...prev, documentSetName];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const allSources = listSourceMetadata();
|
||||
const availableSources = allSources.filter((source) =>
|
||||
existingSources.includes(source.internalName)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-x-3">
|
||||
<div className="w-64">
|
||||
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
<FilterDropdown
|
||||
options={availableSources.map((source) => {
|
||||
return {
|
||||
key: source.displayName,
|
||||
display: (
|
||||
<>
|
||||
<SourceIcon sourceType={source.internalName} iconSize={16} />
|
||||
<span className="ml-2 text-sm">{source.displayName}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
})}
|
||||
selected={selectedSources.map((source) => source.displayName)}
|
||||
handleSelect={(option) =>
|
||||
handleSourceSelect(
|
||||
allSources.find((source) => source.displayName === option.key)!
|
||||
)
|
||||
}
|
||||
icon={
|
||||
<div className="my-auto mr-2 w-[16px] h-[16px]">
|
||||
<FiMap size={16} />
|
||||
</div>
|
||||
}
|
||||
defaultDisplay="All Sources"
|
||||
/>
|
||||
|
||||
<FilterDropdown
|
||||
options={availableDocumentSets.map((documentSet) => {
|
||||
return {
|
||||
key: documentSet.name,
|
||||
display: (
|
||||
<>
|
||||
<div className="my-auto">
|
||||
<FiBookmark />
|
||||
</div>
|
||||
<span className="ml-2 text-sm">{documentSet.name}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
})}
|
||||
selected={selectedDocumentSets}
|
||||
handleSelect={(option) => handleDocumentSetSelect(option.key)}
|
||||
icon={
|
||||
<div className="my-auto mr-2 w-[16px] h-[16px]">
|
||||
<FiBook size={16} />
|
||||
</div>
|
||||
}
|
||||
defaultDisplay="All Document Sets"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex pb-4 mt-2 h-12">
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
{timeRange && timeRange.selectValue && (
|
||||
<SelectedBubble onClick={() => setTimeRange(null)}>
|
||||
<div className="text-sm flex">{timeRange.selectValue}</div>
|
||||
</SelectedBubble>
|
||||
)}
|
||||
{existingSources.length > 0 &&
|
||||
selectedSources.map((source) => (
|
||||
<SelectedBubble
|
||||
key={source.internalName}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<>
|
||||
<SourceIcon sourceType={source.internalName} iconSize={16} />
|
||||
<span className="ml-2 text-sm">{source.displayName}</span>
|
||||
</>
|
||||
</SelectedBubble>
|
||||
))}
|
||||
{selectedDocumentSets.length > 0 &&
|
||||
selectedDocumentSets.map((documentSetName) => (
|
||||
<SelectedBubble
|
||||
key={documentSetName}
|
||||
onClick={() => handleDocumentSetSelect(documentSetName)}
|
||||
>
|
||||
<>
|
||||
<div>
|
||||
<FiBookmark />
|
||||
</div>
|
||||
<span className="ml-2 text-sm">{documentSetName}</span>
|
||||
</>
|
||||
</SelectedBubble>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HorizontalSourceSelector({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
selectedSources,
|
||||
setSelectedSources,
|
||||
selectedDocumentSets,
|
||||
setSelectedDocumentSets,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
availableDocumentSets,
|
||||
existingSources,
|
||||
availableTags,
|
||||
}: SourceSelectorProps) {
|
||||
const handleSourceSelect = (source: SourceMetadata) => {
|
||||
setSelectedSources((prev: SourceMetadata[]) => {
|
||||
if (prev.map((s) => s.internalName).includes(source.internalName)) {
|
||||
return prev.filter((s) => s.internalName !== source.internalName);
|
||||
} else {
|
||||
return [...prev, source];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentSetSelect = (documentSetName: string) => {
|
||||
setSelectedDocumentSets((prev: string[]) => {
|
||||
if (prev.includes(documentSetName)) {
|
||||
return prev.filter((s) => s !== documentSetName);
|
||||
} else {
|
||||
return [...prev, documentSetName];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
setSelectedTags((prev: Tag[]) => {
|
||||
if (
|
||||
prev.some(
|
||||
(t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value
|
||||
)
|
||||
) {
|
||||
return prev.filter(
|
||||
(t) => !(t.tag_key === tag.tag_key && t.tag_value === tag.tag_value)
|
||||
);
|
||||
} else {
|
||||
return [...prev, tag];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetSources = () => {
|
||||
setSelectedSources([]);
|
||||
};
|
||||
const resetDocuments = () => {
|
||||
setSelectedDocumentSets([]);
|
||||
};
|
||||
|
||||
const resetTags = () => {
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-nowrap space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={`
|
||||
border
|
||||
max-w-64
|
||||
border-border
|
||||
rounded-lg
|
||||
max-h-96
|
||||
overflow-y-scroll
|
||||
overscroll-contain
|
||||
px-3
|
||||
text-sm
|
||||
py-1.5
|
||||
select-none
|
||||
cursor-pointer
|
||||
w-fit
|
||||
gap-x-1
|
||||
hover:bg-hover
|
||||
flex
|
||||
items-center
|
||||
bg-background-search-filter
|
||||
`}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
|
||||
{timeRange?.from
|
||||
? getDateRangeString(timeRange.from, timeRange.to)
|
||||
: "Since"}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={
|
||||
timeRange
|
||||
? { from: new Date(timeRange.from), to: new Date(timeRange.to) }
|
||||
: undefined
|
||||
}
|
||||
onSelect={(daterange) => {
|
||||
const initialDate = daterange?.from || new Date();
|
||||
const endDate = daterange?.to || new Date();
|
||||
setTimeRange({
|
||||
from: initialDate,
|
||||
to: endDate,
|
||||
selectValue: timeRange?.selectValue || "",
|
||||
});
|
||||
}}
|
||||
className="rounded-md"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{existingSources.length > 0 && (
|
||||
<FilterDropdown
|
||||
backgroundColor="bg-background-search-filter"
|
||||
options={listSourceMetadata()
|
||||
.filter((source) => existingSources.includes(source.internalName))
|
||||
.map((source) => ({
|
||||
key: source.internalName,
|
||||
display: (
|
||||
<>
|
||||
<SourceIcon sourceType={source.internalName} iconSize={16} />
|
||||
<span className="ml-2 text-sm">{source.displayName}</span>
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
selected={selectedSources.map((source) => source.internalName)}
|
||||
handleSelect={(option) =>
|
||||
handleSourceSelect(
|
||||
listSourceMetadata().find((s) => s.internalName === option.key)!
|
||||
)
|
||||
}
|
||||
icon={<FiMap size={16} />}
|
||||
defaultDisplay="Sources"
|
||||
dropdownColor="bg-background-search-filter-dropdown"
|
||||
width="w-fit ellipsis truncate"
|
||||
resetValues={resetSources}
|
||||
dropdownWidth="w-40"
|
||||
optionClassName="truncate w-full break-all ellipsis"
|
||||
/>
|
||||
)}
|
||||
|
||||
{availableDocumentSets.length > 0 && (
|
||||
<FilterDropdown
|
||||
backgroundColor="bg-background-search-filter"
|
||||
options={availableDocumentSets.map((documentSet) => ({
|
||||
key: documentSet.name,
|
||||
display: <>{documentSet.name}</>,
|
||||
}))}
|
||||
selected={selectedDocumentSets}
|
||||
handleSelect={(option) => handleDocumentSetSelect(option.key)}
|
||||
icon={<FiBook size={16} />}
|
||||
defaultDisplay="Sets"
|
||||
resetValues={resetDocuments}
|
||||
width="w-fit max-w-24 text-ellipsis truncate"
|
||||
dropdownColor="bg-background-search-filter-dropdown"
|
||||
dropdownWidth="max-w-36 w-fit"
|
||||
optionClassName="truncate w-full break-all"
|
||||
/>
|
||||
)}
|
||||
|
||||
{availableTags.length > 0 && (
|
||||
<FilterDropdown
|
||||
backgroundColor="bg-background-search-filter"
|
||||
options={availableTags.map((tag) => ({
|
||||
key: `${tag.tag_key}=${tag.tag_value}`,
|
||||
display: (
|
||||
<span className="text-sm">
|
||||
{tag.tag_key}
|
||||
<b>=</b>
|
||||
{tag.tag_value}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
selected={selectedTags.map(
|
||||
(tag) => `${tag.tag_key}=${tag.tag_value}`
|
||||
)}
|
||||
handleSelect={(option) => {
|
||||
const [tag_key, tag_value] = option.key.split("=");
|
||||
const selectedTag = availableTags.find(
|
||||
(tag) => tag.tag_key === tag_key && tag.tag_value === tag_value
|
||||
);
|
||||
if (selectedTag) {
|
||||
handleTagSelect(selectedTag);
|
||||
}
|
||||
}}
|
||||
icon={<FiTag size={16} />}
|
||||
defaultDisplay="Tags"
|
||||
resetValues={resetTags}
|
||||
dropdownColor="bg-background-search-filter-dropdown"
|
||||
width="w-fit max-w-24 ellipsis truncate"
|
||||
dropdownWidth="max-w-80 w-fit"
|
||||
optionClassName="truncate w-full break-all ellipsis"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +1,95 @@
|
||||
import { containsObject, objectsAreEquivalent } from "@/lib/contains";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Tag } from "@/lib/types";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FiTag, FiX } from "react-icons/fi";
|
||||
import debounce from "lodash/debounce";
|
||||
import { getValidTags } from "@/lib/tags/tagUtils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export const SelectableDropdown = ({
|
||||
value,
|
||||
selected,
|
||||
icon,
|
||||
toggle,
|
||||
}: {
|
||||
value: string;
|
||||
selected: boolean;
|
||||
icon?: React.ReactNode;
|
||||
toggle: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className={`p-2 flex gap-x-2 items-center rounded cursor-pointer transition-colors duration-200 ${
|
||||
selected
|
||||
? "bg-gray-200 dark:bg-gray-700"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{icon && <div className="flex-none">{icon}</div>}
|
||||
<span className="text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function TagFilter({
|
||||
modal,
|
||||
tags,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
showTagsOnLeft = false,
|
||||
}: {
|
||||
modal?: boolean;
|
||||
tags: Tag[];
|
||||
selectedTags: Tag[];
|
||||
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
|
||||
showTagsOnLeft?: boolean;
|
||||
}) {
|
||||
const [filterValue, setFilterValue] = useState("");
|
||||
const [tagOptionsAreVisible, setTagOptionsAreVisible] = useState(false);
|
||||
const [filteredTags, setFilteredTags] = useState<Tag[]>(tags);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onSelectTag = (tag: Tag) => {
|
||||
setSelectedTags((prev) => {
|
||||
if (containsObject(prev, tag)) {
|
||||
return prev.filter((t) => !objectsAreEquivalent(t, tag));
|
||||
} else {
|
||||
return [...prev, tag];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setTagOptionsAreVisible(false);
|
||||
}
|
||||
};
|
||||
const lowercasedFilter = filterValue.toLowerCase();
|
||||
const filtered = tags.filter(
|
||||
(tag) =>
|
||||
tag.tag_key.toLowerCase().includes(lowercasedFilter) ||
|
||||
tag.tag_value.toLowerCase().includes(lowercasedFilter)
|
||||
);
|
||||
setFilteredTags(filtered);
|
||||
}, [filterValue, tags]);
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const debouncedFetchTags = useRef(
|
||||
debounce(async (value: string) => {
|
||||
if (value) {
|
||||
const fetchedTags = await getValidTags(value);
|
||||
setFilteredTags(fetchedTags);
|
||||
} else {
|
||||
setFilteredTags(tags);
|
||||
}
|
||||
}, 50)
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
debouncedFetchTags(filterValue);
|
||||
|
||||
return () => {
|
||||
debouncedFetchTags.cancel();
|
||||
};
|
||||
}, [filterValue, tags, debouncedFetchTags]);
|
||||
|
||||
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilterValue(event.target.value);
|
||||
const toggleTag = (tag: Tag) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.some(
|
||||
(t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value
|
||||
)
|
||||
? prev.filter(
|
||||
(t) => t.tag_key !== tag.tag_key || t.tag_value !== tag.tag_value
|
||||
)
|
||||
: [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const isTagSelected = (tag: Tag) =>
|
||||
selectedTags.some(
|
||||
(t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full ">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={` border border-border py-0.5 px-2 rounded text-sm h-8 ${
|
||||
modal ? "w-[80vw]" : "w-full"
|
||||
}`}
|
||||
placeholder="Find a tag"
|
||||
value={filterValue}
|
||||
onChange={handleFilterChange}
|
||||
onFocus={() => setTagOptionsAreVisible(true)}
|
||||
/>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="mt-1 flex flex-wrap gap-x-1 gap-y-1">
|
||||
{selectedTags.map((tag) => (
|
||||
<div
|
||||
key={tag.tag_key + tag.tag_value}
|
||||
onClick={() => onSelectTag(tag)}
|
||||
className={`
|
||||
max-w-full
|
||||
break-all
|
||||
line-clamp-1
|
||||
text-ellipsis
|
||||
flex
|
||||
text-sm
|
||||
border
|
||||
border-border
|
||||
py-0.5
|
||||
px-2
|
||||
rounded
|
||||
cursor-pointer
|
||||
bg-background-search-filter
|
||||
hover:bg-background-search-filter-dropdown
|
||||
`}
|
||||
>
|
||||
{tag.tag_key}
|
||||
<b>=</b>
|
||||
{tag.tag_value}
|
||||
<FiX className="my-auto ml-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setSelectedTags([])}
|
||||
className="pl-0.5 text-xs text-accent cursor-pointer mt-2 w-fit"
|
||||
>
|
||||
Clear all
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tagOptionsAreVisible && (
|
||||
<div
|
||||
className={` absolute z-[100] ${
|
||||
showTagsOnLeft
|
||||
? "left-0 top-0 translate-y-[2rem]"
|
||||
: "right-0 translate-x-[105%] top-0"
|
||||
} z-40`}
|
||||
>
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="p-2 border border-border rounded shadow-lg w-72 bg-background-search-filter"
|
||||
>
|
||||
<div className="flex border-b border-border font-medium pb-1 text-xs mb-2">
|
||||
<FiTag className="mr-1 my-auto" />
|
||||
Tags
|
||||
</div>
|
||||
<div className="flex overflow-y-scroll overflow-x-hidden input-scrollbar max-h-96 flex-wrap gap-x-1 gap-y-1">
|
||||
{filteredTags.length > 0 ? (
|
||||
filteredTags.map((tag) => (
|
||||
<div
|
||||
key={tag.tag_key + tag.tag_value}
|
||||
onClick={() => onSelectTag(tag)}
|
||||
className={`
|
||||
text-sm
|
||||
max-w-full
|
||||
border
|
||||
border-border
|
||||
py-0.5
|
||||
px-2
|
||||
rounded
|
||||
cursor-pointer
|
||||
bg-background
|
||||
hover:bg-hover
|
||||
${
|
||||
selectedTags.includes(tag)
|
||||
? "bg-background-search-filter-dropdown"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tag.tag_key}
|
||||
<b>=</b>
|
||||
{tag.tag_value}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm px-2 py-2">No matching tags found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-4 h-full flex flex-col w-full">
|
||||
<div className="flex pb-2 px-4">
|
||||
<Input
|
||||
placeholder="Search tags..."
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
className="border border-text-subtle w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 border-t pt-2 border-t-text-subtle px-4 default-scrollbar w-full max-h-64 overflow-y-auto">
|
||||
{filteredTags
|
||||
.sort((a, b) => a.tag_key.localeCompare(b.tag_key))
|
||||
.map((tag, index) => (
|
||||
<SelectableDropdown
|
||||
key={index}
|
||||
value={`${tag.tag_key}=${tag.tag_value}`}
|
||||
selected={isTagSelected(tag)}
|
||||
toggle={() => toggleTag(tag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export const CustomTooltip = ({
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const { groupHovered, setGroupHovered, hoverCountRef } =
|
||||
useContext(TooltipGroupContext);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Checkbox = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
>(({ className, size = "md", ...props }, ref) => {
|
||||
>(({ className, size = "md", type = "button", ...props }, ref) => {
|
||||
const sizeClasses = {
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
@@ -21,6 +21,7 @@ const Checkbox = React.forwardRef<
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"peer shrink-0 rounded-sm border border-neutral-200 border-neutral-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-neutral-800 dark:border-neutral-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900",
|
||||
sizeClasses[size],
|
||||
|
||||
48
web/src/components/ui/scroll-area.tsx
Normal file
48
web/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -39,7 +39,8 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
"absolute top-0 left-0 right-0 z-10 flex cursor-default items-center justify-center h-8",
|
||||
"bg-gradient-to-b from-white to-transparent dark:from-neutral-950 dark:to-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -56,7 +57,8 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
"absolute bottom-0 left-0 right-0 z-10 flex cursor-default items-center justify-center h-8",
|
||||
"bg-gradient-to-t from-white to-transparent dark:from-neutral-950 dark:to-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -66,7 +68,6 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
@@ -77,7 +78,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white text-neutral-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white text-neutral-950 shadow-md dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
@@ -88,7 +89,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
"relative p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,22 @@ import { cn } from "@/lib/utils";
|
||||
export default function Title({
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
size?: "lg" | "md" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<h1 className={cn("text-lg text-text-800 font-medium", className)}>
|
||||
<h1
|
||||
className={cn(
|
||||
"text-lg text-text-800 font-medium",
|
||||
size === "lg" && "text-2xl",
|
||||
size === "md" && "text-xl",
|
||||
size === "sm" && "text-lg",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,14 @@ const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
const TooltipTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
|
||||
>(({ type = "button", ...props }, ref) => (
|
||||
<TooltipPrimitive.Trigger ref={ref} type={type} {...props} />
|
||||
));
|
||||
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
|
||||
@@ -13,6 +13,11 @@ interface UserContextType {
|
||||
isCloudSuperuser: boolean;
|
||||
updateUserAutoScroll: (autoScroll: boolean | null) => Promise<void>;
|
||||
updateUserShortcuts: (enabled: boolean) => Promise<void>;
|
||||
toggleAssistantPinnedStatus: (
|
||||
currentPinnedAssistantIDs: number[],
|
||||
assistantId: number,
|
||||
isPinned: boolean
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
@@ -54,15 +59,6 @@ export function UserProvider({
|
||||
};
|
||||
const updateUserShortcuts = async (enabled: boolean) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/shortcut-enabled?shortcut_enabled=${enabled}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
setUpToDateUser((prevUser) => {
|
||||
if (prevUser) {
|
||||
return {
|
||||
@@ -76,7 +72,18 @@ export function UserProvider({
|
||||
return prevUser;
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`/api/shortcut-enabled?shortcut_enabled=${enabled}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await refreshUser();
|
||||
throw new Error("Failed to update user shortcut setting");
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -116,6 +123,56 @@ export function UserProvider({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAssistantPinnedStatus = async (
|
||||
currentPinnedAssistantIDs: number[],
|
||||
assistantId: number,
|
||||
isPinned: boolean
|
||||
) => {
|
||||
setUpToDateUser((prevUser) => {
|
||||
if (!prevUser) return prevUser;
|
||||
return {
|
||||
...prevUser,
|
||||
preferences: {
|
||||
...prevUser.preferences,
|
||||
pinned_assistants: isPinned
|
||||
? [...currentPinnedAssistantIDs, assistantId]
|
||||
: currentPinnedAssistantIDs.filter((id) => id !== assistantId),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let updatedPinnedAssistantsIds = currentPinnedAssistantIDs;
|
||||
|
||||
if (isPinned) {
|
||||
updatedPinnedAssistantsIds.push(assistantId);
|
||||
} else {
|
||||
updatedPinnedAssistantsIds = updatedPinnedAssistantsIds.filter(
|
||||
(id) => id !== assistantId
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/user/pinned-assistants`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ordered_assistant_ids: updatedPinnedAssistantsIds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update pinned assistants");
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error updating pinned assistants:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
await fetchUser();
|
||||
};
|
||||
@@ -127,6 +184,7 @@ export function UserProvider({
|
||||
refreshUser,
|
||||
updateUserAutoScroll,
|
||||
updateUserShortcuts,
|
||||
toggleAssistantPinnedStatus,
|
||||
isAdmin: upToDateUser?.role === UserRole.ADMIN,
|
||||
// Curator status applies for either global or basic curator
|
||||
isCurator:
|
||||
|
||||
397
web/src/hooks/chat/useChatSession.ts
Normal file
397
web/src/hooks/chat/useChatSession.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
buildLatestMessageChain,
|
||||
processRawChatHistory,
|
||||
removeMessage,
|
||||
updateParentChildren,
|
||||
nameChatSession,
|
||||
} from "@/app/chat/lib";
|
||||
import {
|
||||
ChatSession,
|
||||
Message,
|
||||
BackendChatSession,
|
||||
ChatSessionSharedStatus,
|
||||
} from "@/app/chat/interfaces";
|
||||
import { ChatState, RegenerationState } from "@/app/chat/types";
|
||||
|
||||
/**
|
||||
* Hook: useChatSession
|
||||
*
|
||||
* Manages:
|
||||
* - Which chat session is currently loaded (and its ID).
|
||||
* - Fetching messages for that session from the backend, storing them in a Map.
|
||||
* - Producing the "messageHistory" array from the stored message-map.
|
||||
* - Tracking whether the session is currently being fetched.
|
||||
* - Tracking the "shared status" of the session (private/public).
|
||||
*
|
||||
* Returns everything the consuming UI needs to:
|
||||
* - Know which session is selected.
|
||||
* - Access/modify the stored messages.
|
||||
* - Check if fetching is in progress.
|
||||
* - Possibly rename the session if needed, etc.
|
||||
*/
|
||||
export function useChatSession(params: {
|
||||
chatSessions: ChatSession[]; // from context or props
|
||||
existingChatSessionId: string | null; // e.g. from searchParams.get("chatId")
|
||||
defaultAssistantId?: number; // if you need a default assistant ID
|
||||
refreshChatSessions: () => void; // callback to refresh session list
|
||||
}) {
|
||||
const {
|
||||
chatSessions,
|
||||
existingChatSessionId,
|
||||
defaultAssistantId,
|
||||
refreshChatSessions,
|
||||
} = params;
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Tracks which session we're "actively" viewing.
|
||||
const chatSessionIdRef = useRef<string | null>(existingChatSessionId);
|
||||
|
||||
// Tracks which session has been "fully loaded" (used to differentiate brand-new sessions from existing ones).
|
||||
const loadedIdSessionRef = useRef<string | null>(existingChatSessionId);
|
||||
|
||||
// The chat session object from the global list (if found).
|
||||
const selectedChatSession = chatSessions.find(
|
||||
(session) => session.id === existingChatSessionId
|
||||
);
|
||||
|
||||
// Whether we are actively fetching the messages for the current session.
|
||||
const [isFetchingChatMessages, setIsFetchingChatMessages] = useState(
|
||||
existingChatSessionId !== null
|
||||
);
|
||||
|
||||
// For storing "private/public" status, etc.
|
||||
const [chatSessionSharedStatus, setChatSessionSharedStatus] =
|
||||
useState<ChatSessionSharedStatus>(
|
||||
selectedChatSession?.shared_status ?? ChatSessionSharedStatus.Private
|
||||
);
|
||||
|
||||
/**
|
||||
* A map of all messages, by session:
|
||||
* Map< sessionId, Map<messageId, Message> >
|
||||
*/
|
||||
const [completeMessageDetail, setCompleteMessageDetail] = useState<
|
||||
Map<string | null, Map<number, Message>>
|
||||
>(new Map());
|
||||
|
||||
// New state variables
|
||||
const [chatState, setChatState] = useState<Map<string | null, ChatState>>(
|
||||
new Map([[existingChatSessionId, "input"]])
|
||||
);
|
||||
const [abortControllers, setAbortControllers] = useState<
|
||||
Map<string | null, AbortController>
|
||||
>(new Map());
|
||||
|
||||
// New state variable for canContinue
|
||||
const [canContinue, setCanContinue] = useState<Map<string | null, boolean>>(
|
||||
new Map([[existingChatSessionId, false]])
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the "completeMessageDetail" for a specific session ID with a fresh Map of messages.
|
||||
*/
|
||||
function updateCompleteMessageDetail(
|
||||
sessionId: string | null,
|
||||
messageMap: Map<number, Message>
|
||||
) {
|
||||
setCompleteMessageDetail((prevState) => {
|
||||
const newState = new Map(prevState);
|
||||
newState.set(sessionId, messageMap);
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the message map for the "current" session (pointed to by `chatSessionIdRef`).
|
||||
*/
|
||||
function currentMessageMap(): Map<number, Message> {
|
||||
return completeMessageDetail.get(chatSessionIdRef.current) || new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update messages inside `completeMessageDetail` for a given session,
|
||||
* optionally applying a "replacementsMap" (for regenerated messages).
|
||||
*/
|
||||
function upsertToCompleteMessageMap(params: {
|
||||
messages: Message[];
|
||||
chatSessionId?: string;
|
||||
completeMessageMapOverride?: Map<number, Message> | null;
|
||||
replacementsMap?: Map<number, number> | null;
|
||||
makeLatestChildMessage?: boolean;
|
||||
}) {
|
||||
const {
|
||||
messages,
|
||||
chatSessionId,
|
||||
completeMessageMapOverride,
|
||||
replacementsMap = null,
|
||||
makeLatestChildMessage = false,
|
||||
} = params;
|
||||
|
||||
// If none is given, we work with the "current" session's map:
|
||||
const activeSessionId = chatSessionId || chatSessionIdRef.current;
|
||||
const frozenCompleteMessageMap =
|
||||
completeMessageMapOverride || currentMessageMap();
|
||||
|
||||
// Shallow clone:
|
||||
const newCompleteMessageMap = new Map(frozenCompleteMessageMap);
|
||||
|
||||
// Possibly insert a dummy system message if this is brand new with no prior messages:
|
||||
if (newCompleteMessageMap.size === 0 && messages.length > 0) {
|
||||
const systemMessageId = messages[0].parentMessageId ?? -3; // SYSTEM_MESSAGE_ID
|
||||
const firstMessageId = messages[0].messageId;
|
||||
|
||||
const dummySystemMessage: Message = {
|
||||
messageId: systemMessageId,
|
||||
message: "",
|
||||
type: "system",
|
||||
files: [],
|
||||
toolCall: null,
|
||||
parentMessageId: null,
|
||||
childrenMessageIds: [firstMessageId],
|
||||
latestChildMessageId: firstMessageId,
|
||||
};
|
||||
newCompleteMessageMap.set(
|
||||
dummySystemMessage.messageId,
|
||||
dummySystemMessage
|
||||
);
|
||||
messages[0].parentMessageId = systemMessageId;
|
||||
}
|
||||
|
||||
// Insert messages:
|
||||
messages.forEach((msg) => {
|
||||
const replacementTargetId = replacementsMap?.get(msg.messageId);
|
||||
if (replacementTargetId) {
|
||||
removeMessage(replacementTargetId, newCompleteMessageMap);
|
||||
}
|
||||
// Ensure parent's children list is updated if it's a new message:
|
||||
if (!newCompleteMessageMap.has(msg.messageId) && msg.parentMessageId) {
|
||||
updateParentChildren(msg, newCompleteMessageMap, true);
|
||||
}
|
||||
newCompleteMessageMap.set(msg.messageId, msg);
|
||||
});
|
||||
|
||||
// If asked, make the new message(s) the "latest" child in the chain
|
||||
if (makeLatestChildMessage && messages.length > 0) {
|
||||
const oldChain = buildLatestMessageChain(frozenCompleteMessageMap);
|
||||
const lastMessage = oldChain[oldChain.length - 1];
|
||||
if (lastMessage) {
|
||||
newCompleteMessageMap.get(lastMessage.messageId)!.latestChildMessageId =
|
||||
messages[0].messageId;
|
||||
}
|
||||
}
|
||||
|
||||
// Store back into our main map
|
||||
updateCompleteMessageDetail(activeSessionId, newCompleteMessageMap);
|
||||
|
||||
return { sessionId: activeSessionId, messageMap: newCompleteMessageMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the "latest message chain" (linear history) from the messageMap for the active session.
|
||||
*/
|
||||
const messageHistory = buildLatestMessageChain(currentMessageMap());
|
||||
|
||||
/**
|
||||
* On mount / whenever `existingChatSessionId` changes, fetch that session's messages if needed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const priorSessionId = chatSessionIdRef.current;
|
||||
chatSessionIdRef.current = existingChatSessionId;
|
||||
loadedIdSessionRef.current = existingChatSessionId;
|
||||
|
||||
if (existingChatSessionId === null) {
|
||||
// No session => we can reset or do nothing:
|
||||
setIsFetchingChatMessages(false);
|
||||
updateCompleteMessageDetail(null, new Map());
|
||||
setChatSessionSharedStatus(ChatSessionSharedStatus.Private);
|
||||
return;
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
setIsFetchingChatMessages(true);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/chat/get-chat-session/${existingChatSessionId}`
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.error("Failed to fetch chat session from server.");
|
||||
setIsFetchingChatMessages(false);
|
||||
return;
|
||||
}
|
||||
const chatSession = (await resp.json()) as BackendChatSession;
|
||||
|
||||
// Update shared status
|
||||
setChatSessionSharedStatus(chatSession.shared_status);
|
||||
|
||||
// Convert raw => Map
|
||||
const newMessageMap = processRawChatHistory(chatSession.messages);
|
||||
updateCompleteMessageDetail(existingChatSessionId, newMessageMap);
|
||||
|
||||
// If the session has no description but does have messages, we rename.
|
||||
// (Mimicking original logic that you might have used in ChatPage.)
|
||||
if (!chatSession.description && newMessageMap.size > 0) {
|
||||
console.log("renameCurrentSessionIfEmpty desc");
|
||||
await nameChatSession(existingChatSessionId!);
|
||||
refreshChatSessions();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error while loading chat session:", err);
|
||||
} finally {
|
||||
setIsFetchingChatMessages(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSession();
|
||||
}, [existingChatSessionId]);
|
||||
|
||||
/**
|
||||
* If needed, a little helper to rename the *current* session if it has no description yet.
|
||||
*/
|
||||
async function renameCurrentSessionIfEmpty() {
|
||||
console.log("renameCurrentSessionIfEmpty");
|
||||
if (!chatSessionIdRef.current) return;
|
||||
await nameChatSession(chatSessionIdRef.current);
|
||||
refreshChatSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* When we create a brand-new session, we can call `handleNewSessionId(newId)`
|
||||
* to shift any "null-based" messages to the new ID, etc.
|
||||
*/
|
||||
function handleNewSessionId(newSessionId: string) {
|
||||
const existingMessagesForNull = completeMessageDetail.get(null);
|
||||
if (existingMessagesForNull) {
|
||||
// Move them to the new ID
|
||||
updateCompleteMessageDetail(newSessionId, existingMessagesForNull);
|
||||
setCompleteMessageDetail((prev) => {
|
||||
const clone = new Map(prev);
|
||||
clone.delete(null);
|
||||
return clone;
|
||||
});
|
||||
}
|
||||
// Now track that we're on the new session
|
||||
chatSessionIdRef.current = newSessionId;
|
||||
loadedIdSessionRef.current = newSessionId;
|
||||
}
|
||||
|
||||
// New helper functions
|
||||
function currentChatState(): ChatState {
|
||||
return chatState.get(chatSessionIdRef.current!) || "input";
|
||||
}
|
||||
|
||||
function updateChatState(state: ChatState) {
|
||||
setChatState((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(chatSessionIdRef.current!, state);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
|
||||
// function currentRegenerationState(): RegenerationState | null {
|
||||
// return regenerationState.get(chatSessionIdRef.current!) || null;
|
||||
// }
|
||||
|
||||
// function updateRegenerationState(state: RegenerationState | null) {
|
||||
// setRegenerationState(
|
||||
// (prev: Map<string | null, RegenerationState | null>) => {
|
||||
// const newMap = new Map(prev);
|
||||
// newMap.set(chatSessionIdRef.current!, state);
|
||||
// return newMap;
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
function resetRegenerationState() {
|
||||
updateRegenerationState(null);
|
||||
}
|
||||
|
||||
function getAbortController(
|
||||
sessionId: string | null
|
||||
): AbortController | undefined {
|
||||
return abortControllers.get(sessionId);
|
||||
}
|
||||
|
||||
function setAbortController(
|
||||
sessionId: string | null,
|
||||
controller: AbortController
|
||||
) {
|
||||
setAbortControllers((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(sessionId, controller);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
|
||||
function removeAbortController(sessionId: string | null) {
|
||||
setAbortControllers((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(sessionId);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
|
||||
// New helper functions for canContinue
|
||||
function currentCanContinue(): boolean {
|
||||
return canContinue.get(chatSessionIdRef.current!) || false;
|
||||
}
|
||||
|
||||
function updateCanContinue(value: boolean, sessionId?: string | null) {
|
||||
setCanContinue((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(sessionId || chatSessionIdRef.current!, value);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/** Refs to track current session IDs */
|
||||
chatSessionIdRef,
|
||||
loadedIdSessionRef,
|
||||
|
||||
/** The chat session object from your global array, if present */
|
||||
selectedChatSession,
|
||||
|
||||
/** The entire map of messages, plus the function to update it */
|
||||
completeMessageDetail,
|
||||
updateCompleteMessageDetail,
|
||||
upsertToCompleteMessageMap,
|
||||
|
||||
/** The linear chain of messages to display in the UI */
|
||||
messageHistory,
|
||||
|
||||
/** Shared/public status, plus setter */
|
||||
chatSessionSharedStatus,
|
||||
setChatSessionSharedStatus,
|
||||
|
||||
/** Is the session currently being fetched? */
|
||||
isFetchingChatMessages,
|
||||
setIsFetchingChatMessages,
|
||||
|
||||
/** Some helpers for special flows */
|
||||
renameCurrentSessionIfEmpty,
|
||||
handleNewSessionId,
|
||||
currentMessageMap,
|
||||
|
||||
// New return values
|
||||
chatState: currentChatState(),
|
||||
updateChatState,
|
||||
regenerationState: currentRegenerationState(),
|
||||
updateRegenerationState,
|
||||
resetRegenerationState,
|
||||
getAbortController,
|
||||
setAbortController,
|
||||
removeAbortController,
|
||||
abortControllers,
|
||||
setAbortControllers,
|
||||
|
||||
// New return values for canContinue
|
||||
canContinue: currentCanContinue(),
|
||||
updateCanContinue,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export const toggleAssistantPinnedStatus = async (
|
||||
currentPinnedAssistantIDs: number[],
|
||||
assistantId: number,
|
||||
isPinned: boolean
|
||||
) => {
|
||||
let updatedPinnedAssistantsIds = currentPinnedAssistantIDs;
|
||||
|
||||
if (isPinned) {
|
||||
updatedPinnedAssistantsIds.push(assistantId);
|
||||
} else {
|
||||
updatedPinnedAssistantsIds = updatedPinnedAssistantsIds.filter(
|
||||
(id) => id !== assistantId
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/user/pinned-assistants`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ordered_assistant_ids: updatedPinnedAssistantsIds }),
|
||||
});
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
export const reorderPinnedAssistants = async (
|
||||
assistantIds: number[]
|
||||
): Promise<boolean> => {
|
||||
const response = await fetch(`/api/user/pinned-assistants`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ordered_assistant_ids: assistantIds }),
|
||||
});
|
||||
return response.ok;
|
||||
};
|
||||
@@ -71,3 +71,16 @@ export async function moveAssistantDown(
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const reorderPinnedAssistants = async (
|
||||
assistantIds: number[]
|
||||
): Promise<boolean> => {
|
||||
const response = await fetch(`/api/user/pinned-assistants`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ordered_assistant_ids: assistantIds }),
|
||||
});
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { RefObject, useCallback, useEffect } from "react";
|
||||
|
||||
interface DropdownPositionProps {
|
||||
isOpen: boolean;
|
||||
dropdownRef: RefObject<HTMLElement>;
|
||||
dropdownMenuRef: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
// This hook manages the positioning of a dropdown menu relative to its trigger element.
|
||||
// It ensures the menu is positioned correctly, adjusting for viewport boundaries and scroll position.
|
||||
// Also adds event listeners for window resize and scroll to update the position dynamically.
|
||||
export const useDropdownPosition = ({
|
||||
isOpen,
|
||||
dropdownRef,
|
||||
dropdownMenuRef,
|
||||
}: DropdownPositionProps) => {
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
if (isOpen && dropdownRef.current && dropdownMenuRef.current) {
|
||||
const rect = dropdownRef.current.getBoundingClientRect();
|
||||
const menuRect = dropdownMenuRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
let top = rect.bottom + window.scrollY;
|
||||
let left = rect.left + window.scrollX;
|
||||
|
||||
// Check if there's enough space below
|
||||
if (rect.bottom + menuRect.height <= viewportHeight) {
|
||||
// Position below the trigger
|
||||
top = rect.bottom + window.scrollY;
|
||||
} else if (rect.top - menuRect.height >= 0) {
|
||||
// Position above the trigger if there's not enough space below
|
||||
top = rect.top + window.scrollY - menuRect.height;
|
||||
} else {
|
||||
// If there's not enough space above or below, position at the bottom of the viewport
|
||||
top = viewportHeight + window.scrollY - menuRect.height;
|
||||
}
|
||||
|
||||
// Ensure the dropdown doesn't go off the right edge of the screen
|
||||
if (left + menuRect.width > viewportWidth) {
|
||||
left = viewportWidth - menuRect.width + window.scrollX;
|
||||
}
|
||||
|
||||
dropdownMenuRef.current.style.position = "absolute";
|
||||
dropdownMenuRef.current.style.top = `${top}px`;
|
||||
dropdownMenuRef.current.style.left = `${rect.left + window.scrollX}px`;
|
||||
dropdownMenuRef.current.style.width = `${rect.width}px`;
|
||||
dropdownMenuRef.current.style.zIndex = "10000";
|
||||
}
|
||||
}, [isOpen, dropdownRef, dropdownMenuRef]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMenuPosition();
|
||||
window.addEventListener("resize", updateMenuPosition);
|
||||
window.addEventListener("scroll", updateMenuPosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateMenuPosition);
|
||||
window.removeEventListener("scroll", updateMenuPosition);
|
||||
};
|
||||
}, [isOpen, updateMenuPosition]);
|
||||
|
||||
return updateMenuPosition;
|
||||
};
|
||||
@@ -124,19 +124,72 @@ export const useBasicConnectorStatus = () => {
|
||||
|
||||
export const useLabels = () => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const swrResponse = useSWR<PersonaLabel[]>(
|
||||
const { data: labels, error } = useSWR<PersonaLabel[]>(
|
||||
"/api/persona/labels",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const refreshLabels = async () => {
|
||||
const updatedLabels = await mutate("/api/persona/labels");
|
||||
return updatedLabels;
|
||||
return mutate("/api/persona/labels");
|
||||
};
|
||||
|
||||
const createLabel = async (name: string) => {
|
||||
const response = await fetch("/api/persona/labels", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newLabel = await response.json();
|
||||
mutate("/api/persona/labels", [...(labels || []), newLabel], false);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const updateLabel = async (id: number, name: string) => {
|
||||
const response = await fetch(`/api/admin/persona/label/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ label_name: name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
mutate(
|
||||
"/api/persona/labels",
|
||||
labels?.map((label) => (label.id === id ? { ...label, name } : label)),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const deleteLabel = async (id: number) => {
|
||||
const response = await fetch(`/api/admin/persona/label/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
mutate(
|
||||
"/api/persona/labels",
|
||||
labels?.filter((label) => label.id !== id),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
labels,
|
||||
error,
|
||||
refreshLabels,
|
||||
createLabel,
|
||||
updateLabel,
|
||||
deleteLabel,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ interface UserPreferences {
|
||||
chosen_assistants: number[] | null;
|
||||
visible_assistants: number[];
|
||||
hidden_assistants: number[];
|
||||
pinned_assistants: number[];
|
||||
pinned_assistants?: number[];
|
||||
default_model: string | null;
|
||||
recent_assistants: number[];
|
||||
auto_scroll: boolean | null;
|
||||
|
||||
@@ -47,6 +47,9 @@ module.exports = {
|
||||
"4xl": "2000px",
|
||||
mobile: { max: "767px" },
|
||||
desktop: "768px",
|
||||
tall: { raw: "(min-height: 800px)" },
|
||||
short: { raw: "(max-height: 799px)" },
|
||||
"very-short": { raw: "(max-height: 600px)" },
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Hanken Grotesk", "var(--font-inter)", "sans-serif"],
|
||||
|
||||
Reference in New Issue
Block a user