mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-27 02:22:41 +00:00
Compare commits
6 Commits
jamison/re
...
jamison/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2425bd4d8d | ||
|
|
333b2b19cb | ||
|
|
44895b3bd6 | ||
|
|
78c2ecf99f | ||
|
|
e3e0e04edc | ||
|
|
a19fe03bd8 |
@@ -4,6 +4,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ee.onyx.db.persona import update_persona_access
|
||||
from ee.onyx.db.user_group import add_users_to_user_group
|
||||
from ee.onyx.db.user_group import delete_user_group as db_delete_user_group
|
||||
from ee.onyx.db.user_group import fetch_user_group
|
||||
@@ -17,6 +18,7 @@ from ee.onyx.db.user_group import update_user_group
|
||||
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
|
||||
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
|
||||
from ee.onyx.server.user_group.models import SetCuratorRequest
|
||||
from ee.onyx.server.user_group.models import UpdateGroupAgentsRequest
|
||||
from ee.onyx.server.user_group.models import UserGroup
|
||||
from ee.onyx.server.user_group.models import UserGroupCreate
|
||||
from ee.onyx.server.user_group.models import UserGroupRename
|
||||
@@ -29,6 +31,7 @@ from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserRole
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -191,3 +194,38 @@ def delete_user_group(
|
||||
user_group = fetch_user_group(db_session, user_group_id)
|
||||
if user_group:
|
||||
db_delete_user_group(db_session, user_group)
|
||||
|
||||
|
||||
@router.patch("/admin/user-group/{user_group_id}/agents")
|
||||
def update_group_agents(
|
||||
user_group_id: int,
|
||||
request: UpdateGroupAgentsRequest,
|
||||
user: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
for agent_id in request.added_agent_ids:
|
||||
persona = get_persona_by_id(
|
||||
persona_id=agent_id, user=user, db_session=db_session
|
||||
)
|
||||
current_group_ids = [g.id for g in persona.groups]
|
||||
if user_group_id not in current_group_ids:
|
||||
update_persona_access(
|
||||
persona_id=agent_id,
|
||||
creator_user_id=user.id,
|
||||
db_session=db_session,
|
||||
group_ids=current_group_ids + [user_group_id],
|
||||
)
|
||||
|
||||
for agent_id in request.removed_agent_ids:
|
||||
persona = get_persona_by_id(
|
||||
persona_id=agent_id, user=user, db_session=db_session
|
||||
)
|
||||
current_group_ids = [g.id for g in persona.groups]
|
||||
update_persona_access(
|
||||
persona_id=agent_id,
|
||||
creator_user_id=user.id,
|
||||
db_session=db_session,
|
||||
group_ids=[gid for gid in current_group_ids if gid != user_group_id],
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
@@ -112,3 +112,8 @@ class UserGroupRename(BaseModel):
|
||||
class SetCuratorRequest(BaseModel):
|
||||
user_id: UUID
|
||||
is_curator: bool
|
||||
|
||||
|
||||
class UpdateGroupAgentsRequest(BaseModel):
|
||||
added_agent_ids: list[int]
|
||||
removed_agent_ids: list[int]
|
||||
|
||||
@@ -103,7 +103,7 @@ opensearch:
|
||||
- name: OPENSEARCH_INITIAL_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: onyx-opensearch # Must match auth.opensearch.secretName.
|
||||
name: onyx-opensearch # Must match auth.opensearch.secretName or auth.opensearch.existingSecret if defined.
|
||||
key: opensearch_admin_password # Must match auth.opensearch.secretKeys value.
|
||||
|
||||
resources:
|
||||
|
||||
@@ -121,7 +121,9 @@ export { default as SvgNetworkGraph } from "@opal/icons/network-graph";
|
||||
export { default as SvgNotificationBubble } from "@opal/icons/notification-bubble";
|
||||
export { default as SvgOllama } from "@opal/icons/ollama";
|
||||
export { default as SvgOnyxLogo } from "@opal/icons/onyx-logo";
|
||||
export { default as SvgOnyxLogoTyped } from "@opal/icons/onyx-logo-typed";
|
||||
export { default as SvgOnyxOctagon } from "@opal/icons/onyx-octagon";
|
||||
export { default as SvgOnyxTyped } from "@opal/icons/onyx-typed";
|
||||
export { default as SvgOpenai } from "@opal/icons/openai";
|
||||
export { default as SvgOpenrouter } from "@opal/icons/openrouter";
|
||||
export { default as SvgOrganization } from "@opal/icons/organization";
|
||||
|
||||
27
web/lib/opal/src/icons/onyx-logo-typed.tsx
Normal file
27
web/lib/opal/src/icons/onyx-logo-typed.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import SvgOnyxLogo from "@opal/icons/onyx-logo";
|
||||
import SvgOnyxTyped from "@opal/icons/onyx-typed";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
interface OnyxLogoTypedProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// # NOTE(@raunakab):
|
||||
// This ratio is not some random, magical number; it is available on Figma.
|
||||
const HEIGHT_TO_GAP_RATIO = 5 / 16;
|
||||
|
||||
const SvgOnyxLogoTyped = ({ size: height, className }: OnyxLogoTypedProps) => {
|
||||
const gap = height != null ? height * HEIGHT_TO_GAP_RATIO : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`flex flex-row items-center`, className)}
|
||||
style={{ gap }}
|
||||
>
|
||||
<SvgOnyxLogo size={height} />
|
||||
<SvgOnyxTyped size={height} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SvgOnyxLogoTyped;
|
||||
@@ -1,19 +1,27 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgOnyxLogo = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 56 56"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M28 0 10.869 7.77 28 15.539l17.131-7.77L28 0Zm0 40.461-17.131 7.77L28 56l17.131-7.77L28 40.461Zm20.231-29.592L56 28.001l-7.769 17.131L40.462 28l7.769-17.131ZM15.538 28 7.77 10.869 0 28l7.769 17.131L15.538 28Z"
|
||||
fill="currentColor"
|
||||
d="M10.4014 13.25L18.875 32L10.3852 50.75L2 32L10.4014 13.25Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M53.5264 13.25L62 32L53.5102 50.75L45.125 32L53.5264 13.25Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M32 45.125L50.75 53.5625L32 62L13.25 53.5625L32 45.125Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M32 2L50.75 10.4375L32 18.875L13.25 10.4375L32 2Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
28
web/lib/opal/src/icons/onyx-typed.tsx
Normal file
28
web/lib/opal/src/icons/onyx-typed.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgOnyxTyped = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
viewBox="0 0 152 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M19.1795 51.2136C15.6695 51.2136 12.4353 50.3862 9.47691 48.7315C6.56865 47.0768 4.2621 44.8454 2.55726 42.0374C0.85242 39.1793 0 36.0955 0 32.7861C0 30.279 0.451281 27.9223 1.35384 25.716C2.30655 23.4596 3.76068 21.3285 5.71623 19.3228L11.8085 13.08C12.4604 12.6789 13.4131 12.3529 14.6666 12.1022C15.9202 11.8014 17.2991 11.6509 18.8034 11.6509C22.3134 11.6509 25.5225 12.4783 28.4307 14.133C31.3891 15.7877 33.7208 18.0441 35.4256 20.9023C37.1304 23.7103 37.9829 26.794 37.9829 30.1536C37.9829 32.6106 37.5065 34.9673 36.5538 37.2237C35.6512 39.4802 34.147 41.6864 32.041 43.8426L26.3248 49.7845C25.3219 50.2358 24.2188 50.5868 23.0154 50.8375C21.8621 51.0882 20.5835 51.2136 19.1795 51.2136ZM20.1572 43.8426C21.8621 43.8426 23.4917 43.4164 25.0461 42.5639C26.6005 41.6614 27.8541 40.3577 28.8068 38.6528C29.8097 36.948 30.3111 34.9172 30.3111 32.5605C30.3111 30.0032 29.6843 27.6966 28.4307 25.6408C27.2273 23.5849 25.6478 21.9803 23.6923 20.8271C21.7869 19.6236 19.8313 19.0219 17.8256 19.0219C16.0706 19.0219 14.4159 19.4732 12.8615 20.3758C11.3573 21.2282 10.1288 22.5068 9.17606 24.2117C8.22335 25.9166 7.747 27.9473 7.747 30.304C7.747 32.8613 8.34871 35.1679 9.55212 37.2237C10.7555 39.2796 12.31 40.9092 14.2154 42.1127C16.1709 43.2659 18.1515 43.8426 20.1572 43.8426Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M42.6413 50.4614V12.4031H50.6891V17.7433L55.5028 12.7039C56.0544 12.4532 56.8065 12.2276 57.7592 12.027C58.7621 11.7763 59.8903 11.6509 61.1438 11.6509C64.0521 11.6509 66.5843 12.3028 68.7404 13.6065C70.9467 14.8601 72.6264 16.6401 73.7797 18.9467C74.9831 21.2533 75.5848 23.961 75.5848 27.0698V50.4614H67.6122V29.1006C67.6122 26.9946 67.2612 25.1895 66.5592 23.6852C65.9074 22.1308 64.9547 20.9775 63.7011 20.2253C62.4977 19.4231 61.0686 19.0219 59.4139 19.0219C56.7564 19.0219 54.6253 19.9245 53.0208 21.7296C51.4663 23.4846 50.6891 25.9416 50.6891 29.1006V50.4614H42.6413Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M82.3035 64V56.0273H89.9753C91.2288 56.0273 92.2066 55.7264 92.9086 55.1247C93.6607 54.523 94.2625 53.5452 94.7137 52.1913L108.027 12.4031H116.751L103.664 49.4084C103.062 51.1634 102.461 52.5173 101.859 53.47C101.307 54.4227 100.53 55.4506 99.5274 56.5538L92.4573 64H82.3035ZM90.7274 46.6255L76.9633 12.4031H85.989L99.4522 46.6255H90.7274Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M115.657 50.4614L129.045 31.2066L116.033 12.4031H125.435L134.085 24.8134L142.358 12.4031H151.308L138.372 31.0562L151.684 50.4614H142.358L133.332 37.3742L124.683 50.4614H115.657Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgOnyxTyped;
|
||||
@@ -395,7 +395,7 @@ function SeatsCard({
|
||||
<InputLayouts.Vertical title="Seats">
|
||||
<InputNumber
|
||||
value={newSeatCount}
|
||||
onChange={setNewSeatCount}
|
||||
onChange={(v) => setNewSeatCount(v ?? 1)}
|
||||
min={1}
|
||||
defaultValue={totalSeats}
|
||||
showReset
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function CheckoutView({ onAdjustPlan }: CheckoutViewProps) {
|
||||
>
|
||||
<InputNumber
|
||||
value={seats}
|
||||
onChange={setSeats}
|
||||
onChange={(v) => setSeats(v ?? minRequiredSeats)}
|
||||
min={minRequiredSeats}
|
||||
defaultValue={minRequiredSeats}
|
||||
showReset
|
||||
|
||||
@@ -260,7 +260,7 @@ export default function VoiceProviderSetupModal({
|
||||
<SvgArrowExchange className="size-3 text-text-04" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
|
||||
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
|
||||
<SvgOnyxLogo size={24} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ export const WebProviderSetupModal = memo(
|
||||
<SvgArrowExchange className="size-3 text-text-04" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
|
||||
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
|
||||
<SvgOnyxLogo size={24} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1372,7 +1372,7 @@ export default function Page() {
|
||||
} logo`,
|
||||
fallback:
|
||||
selectedContentProviderType === "onyx_web_crawler" ? (
|
||||
<SvgOnyxLogo size={24} className="text-text-05" />
|
||||
<SvgOnyxLogo size={24} />
|
||||
) : undefined,
|
||||
size: 24,
|
||||
containerSize: 28,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getPastedFilesIfNoText } from "@/lib/clipboard";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
import {
|
||||
@@ -230,21 +231,11 @@ const InputBar = memo(
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (items) {
|
||||
const pastedFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item && item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
// Context handles session binding internally
|
||||
uploadFiles(pastedFiles);
|
||||
}
|
||||
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
// Context handles session binding internally
|
||||
uploadFiles(pastedFiles);
|
||||
}
|
||||
},
|
||||
[uploadFiles]
|
||||
|
||||
@@ -413,7 +413,7 @@ const MemoizedBuildSidebarInner = memo(
|
||||
return (
|
||||
<SidebarWrapper folded={folded} onFoldClick={onFoldClick}>
|
||||
<SidebarBody
|
||||
actionButtons={
|
||||
pinnedContent={
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{newBuildButton}
|
||||
{buildConfigurePanel}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
/* Base layers */
|
||||
--z-base: 0;
|
||||
--z-content: 1;
|
||||
--z-settings-header: 8;
|
||||
/* Settings header must sit above sticky table headers (--z-sticky: 10) so
|
||||
the page header scrolls over pinned columns without being obscured. */
|
||||
--z-settings-header: 11;
|
||||
--z-app-layout: 9;
|
||||
--z-sticky: 10;
|
||||
|
||||
|
||||
89
web/src/lib/clipboard.test.ts
Normal file
89
web/src/lib/clipboard.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getPastedFilesIfNoText } from "./clipboard";
|
||||
|
||||
type MockClipboardData = Parameters<typeof getPastedFilesIfNoText>[0];
|
||||
|
||||
function makeClipboardData({
|
||||
textPlain = "",
|
||||
text = "",
|
||||
files = [],
|
||||
}: {
|
||||
textPlain?: string;
|
||||
text?: string;
|
||||
files?: File[];
|
||||
}): MockClipboardData {
|
||||
return {
|
||||
items: files.map((file) => ({
|
||||
kind: "file",
|
||||
getAsFile: () => file,
|
||||
})),
|
||||
getData: (format: string) => {
|
||||
if (format === "text/plain") {
|
||||
return textPlain;
|
||||
}
|
||||
|
||||
if (format === "text") {
|
||||
return text;
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getPastedFilesIfNoText", () => {
|
||||
it("prefers plain text over pasted files when both are present", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(
|
||||
makeClipboardData({
|
||||
textPlain: "Welcome to PowerPoint for Mac",
|
||||
files: [imageFile],
|
||||
})
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to text data when text/plain is empty", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(
|
||||
makeClipboardData({
|
||||
text: "Welcome to PowerPoint for Mac",
|
||||
files: [imageFile],
|
||||
})
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still returns files for image-only pastes", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(makeClipboardData({ files: [imageFile] }))
|
||||
).toEqual([imageFile]);
|
||||
});
|
||||
|
||||
it("ignores whitespace-only text and keeps file pastes working", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(
|
||||
makeClipboardData({
|
||||
textPlain: " ",
|
||||
text: "\n",
|
||||
files: [imageFile],
|
||||
})
|
||||
)
|
||||
).toEqual([imageFile]);
|
||||
});
|
||||
});
|
||||
52
web/src/lib/clipboard.ts
Normal file
52
web/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type ClipboardFileItem = {
|
||||
kind: string;
|
||||
getAsFile: () => File | null;
|
||||
};
|
||||
|
||||
type ClipboardDataLike = {
|
||||
items?: ArrayLike<ClipboardFileItem> | null;
|
||||
getData: (format: string) => string;
|
||||
};
|
||||
|
||||
function getClipboardText(
|
||||
clipboardData: ClipboardDataLike,
|
||||
format: "text/plain" | "text"
|
||||
): string {
|
||||
try {
|
||||
return clipboardData.getData(format);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function getPastedFilesIfNoText(
|
||||
clipboardData?: ClipboardDataLike | null
|
||||
): File[] {
|
||||
if (!clipboardData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const plainText = getClipboardText(clipboardData, "text/plain").trim();
|
||||
const fallbackText = getClipboardText(clipboardData, "text").trim();
|
||||
|
||||
// Apps like PowerPoint on macOS can place both rendered image data and the
|
||||
// original text on the clipboard. Prefer letting the textarea consume text.
|
||||
if (plainText || fallbackText || !clipboardData.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pastedFiles: File[] = [];
|
||||
for (let i = 0; i < clipboardData.items.length; i++) {
|
||||
const item = clipboardData.items[i];
|
||||
if (item?.kind !== "file") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return pastedFiles;
|
||||
}
|
||||
@@ -127,8 +127,7 @@ export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
|
||||
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
|
||||
export const DEFAULT_AVATAR_SIZE_PX = 18;
|
||||
export const HORIZON_DISTANCE_PX = 800;
|
||||
export const LOGO_FOLDED_SIZE_PX = 24;
|
||||
export const LOGO_UNFOLDED_SIZE_PX = 88;
|
||||
export const DEFAULT_LOGO_SIZE_PX = 24;
|
||||
|
||||
export const DEFAULT_CONTEXT_TOKENS = 120_000;
|
||||
export const MAX_CHUNKS_FED_TO_CHAT = 25;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { OnyxIcon, OnyxLogoTypeIcon } from "@/components/icons/icons";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import {
|
||||
LOGO_FOLDED_SIZE_PX,
|
||||
LOGO_UNFOLDED_SIZE_PX,
|
||||
DEFAULT_LOGO_SIZE_PX,
|
||||
NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED,
|
||||
} from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Truncated from "@/refresh-components/texts/Truncated";
|
||||
import { useMemo } from "react";
|
||||
import { SvgOnyxLogo, SvgOnyxLogoTyped } from "@opal/icons";
|
||||
|
||||
export interface LogoProps {
|
||||
folded?: boolean;
|
||||
@@ -19,8 +18,7 @@ export interface LogoProps {
|
||||
}
|
||||
|
||||
export default function Logo({ folded, size, className }: LogoProps) {
|
||||
const foldedSize = size ?? LOGO_FOLDED_SIZE_PX;
|
||||
const unfoldedSize = size ?? LOGO_UNFOLDED_SIZE_PX;
|
||||
const resolvedSize = size ?? DEFAULT_LOGO_SIZE_PX;
|
||||
const settings = useSettingsContext();
|
||||
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
|
||||
const applicationName = settings.enterpriseSettings?.application_name;
|
||||
@@ -42,7 +40,7 @@ export default function Logo({ folded, size, className }: LogoProps) {
|
||||
"aspect-square rounded-full overflow-hidden relative flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
style={{ height: foldedSize, width: foldedSize }}
|
||||
style={{ height: resolvedSize }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -52,7 +50,10 @@ export default function Logo({ folded, size, className }: LogoProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
|
||||
<SvgOnyxLogo
|
||||
size={resolvedSize}
|
||||
className={cn("flex-shrink-0", className)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderNameAndPoweredBy = (opts: {
|
||||
@@ -98,8 +99,11 @@ export default function Logo({ folded, size, className }: LogoProps) {
|
||||
return applicationName ? (
|
||||
renderNameAndPoweredBy({ includeLogo: true, includeName: true })
|
||||
) : folded ? (
|
||||
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
|
||||
<SvgOnyxLogo
|
||||
size={resolvedSize}
|
||||
className={cn("flex-shrink-0", className)}
|
||||
/>
|
||||
) : (
|
||||
<OnyxLogoTypeIcon size={unfoldedSize} className={className} />
|
||||
<SvgOnyxLogoTyped size={resolvedSize} className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ function PopoverContent({
|
||||
collisionPadding={8}
|
||||
className={cn(
|
||||
"bg-background-neutral-00 p-1 z-popover rounded-12 border 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",
|
||||
"flex flex-col",
|
||||
"max-h-[var(--radix-popover-content-available-height)]",
|
||||
"overflow-hidden",
|
||||
widthClasses[width]
|
||||
@@ -227,7 +226,7 @@ export function PopoverMenu({
|
||||
});
|
||||
|
||||
return (
|
||||
<Section alignItems="stretch" height="auto" className="flex-1 min-h-0">
|
||||
<Section alignItems="stretch">
|
||||
<ShadowDiv
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
className="flex flex-col gap-1 max-h-[20rem] w-full"
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function ShadowDiv({
|
||||
}, [containerRef, checkScroll]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex flex-col">
|
||||
<div className="relative min-h-0">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("overflow-y-auto", className)}
|
||||
|
||||
@@ -44,8 +44,8 @@ import { SvgChevronUp, SvgChevronDown, SvgRevert } from "@opal/icons";
|
||||
* ```
|
||||
*/
|
||||
export interface InputNumberProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
@@ -54,6 +54,7 @@ export interface InputNumberProps {
|
||||
variant?: Variants;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function InputNumber({
|
||||
@@ -67,31 +68,36 @@ export default function InputNumber({
|
||||
variant = "primary",
|
||||
disabled = false,
|
||||
className,
|
||||
placeholder,
|
||||
}: InputNumberProps) {
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [inputValue, setInputValue] = React.useState(String(value));
|
||||
const [inputValue, setInputValue] = React.useState(
|
||||
value === null ? "" : String(value)
|
||||
);
|
||||
const isDisabled = disabled || variant === "disabled";
|
||||
|
||||
// Sync input value when external value changes (e.g., from stepper buttons or reset)
|
||||
React.useEffect(() => {
|
||||
setInputValue(String(value));
|
||||
setInputValue(value === null ? "" : String(value));
|
||||
}, [value]);
|
||||
|
||||
const canIncrement = max === undefined || value < max;
|
||||
const canDecrement = min === undefined || value > min;
|
||||
const effectiveValue = value ?? 0;
|
||||
const canIncrement = max === undefined || effectiveValue < max;
|
||||
const canDecrement =
|
||||
value !== null && (min === undefined || effectiveValue > min);
|
||||
const canReset =
|
||||
showReset && defaultValue !== undefined && value !== defaultValue;
|
||||
|
||||
const handleIncrement = () => {
|
||||
if (canIncrement) {
|
||||
const newValue = value + step;
|
||||
const newValue = effectiveValue + step;
|
||||
onChange(max !== undefined ? Math.min(newValue, max) : newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (canDecrement) {
|
||||
const newValue = value - step;
|
||||
const newValue = effectiveValue - step;
|
||||
onChange(min !== undefined ? Math.max(newValue, min) : newValue);
|
||||
}
|
||||
};
|
||||
@@ -103,14 +109,11 @@ export default function InputNumber({
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// On blur, if empty, set fallback value; otherwise sync display with actual value
|
||||
// On blur, if empty, keep as null so placeholder shows
|
||||
if (inputValue.trim() === "") {
|
||||
let fallback = min ?? 0;
|
||||
if (max !== undefined) fallback = Math.min(fallback, max);
|
||||
setInputValue(String(fallback));
|
||||
onChange(fallback);
|
||||
onChange(null);
|
||||
} else {
|
||||
setInputValue(String(value));
|
||||
setInputValue(value === null ? "" : String(value));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,6 +155,7 @@ export default function InputNumber({
|
||||
pattern="[0-9]*"
|
||||
disabled={isDisabled}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
|
||||
@@ -114,6 +114,10 @@ function MCPServerCard({
|
||||
const allToolIds = tools.map((t) => t.id);
|
||||
const serverEnabled =
|
||||
tools.length > 0 && tools.some((t) => isToolEnabled(t.id));
|
||||
const needsAuth = !server.is_authenticated;
|
||||
const authTooltip = needsAuth
|
||||
? "Authenticate this MCP server before enabling its tools."
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
@@ -122,10 +126,13 @@ function MCPServerCard({
|
||||
description={server.description}
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
|
||||
/>
|
||||
<SimpleTooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
}
|
||||
>
|
||||
{tools.length > 0 && (
|
||||
@@ -158,12 +165,15 @@ function MCPServerCard({
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
/>
|
||||
<SimpleTooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -19,6 +19,9 @@ import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
|
||||
import { createGroup } from "./svc";
|
||||
import { apiKeyToMemberRow, memberTableColumns, PAGE_SIZE } from "./shared";
|
||||
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
|
||||
import TokenLimitSection from "./TokenLimitSection";
|
||||
import type { TokenLimit } from "./TokenLimitSection";
|
||||
|
||||
function CreateGroupPage() {
|
||||
const router = useRouter();
|
||||
@@ -26,6 +29,12 @@ function CreateGroupPage() {
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedCcPairIds, setSelectedCcPairIds] = useState<number[]>([]);
|
||||
const [selectedDocSetIds, setSelectedDocSetIds] = useState<number[]>([]);
|
||||
const [selectedAgentIds, setSelectedAgentIds] = useState<number[]>([]);
|
||||
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
|
||||
{ tokenBudget: null, periodHours: null },
|
||||
]);
|
||||
|
||||
const { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
|
||||
|
||||
@@ -53,7 +62,7 @@ function CreateGroupPage() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createGroup(trimmed, selectedUserIds);
|
||||
await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
|
||||
toast.success(`Group "${trimmed}" created`);
|
||||
router.push("/admin/groups");
|
||||
} catch (e) {
|
||||
@@ -81,7 +90,7 @@ function CreateGroupPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Root width="sm">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgUsers}
|
||||
title="Create Group"
|
||||
@@ -150,6 +159,19 @@ function CreateGroupPage() {
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<SharedGroupResources
|
||||
selectedCcPairIds={selectedCcPairIds}
|
||||
onCcPairIdsChange={setSelectedCcPairIds}
|
||||
selectedDocSetIds={selectedDocSetIds}
|
||||
onDocSetIdsChange={setSelectedDocSetIds}
|
||||
selectedAgentIds={selectedAgentIds}
|
||||
onAgentIdsChange={setSelectedAgentIds}
|
||||
/>
|
||||
|
||||
<TokenLimitSection
|
||||
limits={tokenLimits}
|
||||
onLimitsChange={setTokenLimits}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { SvgX } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { Content } from "@opal/layouts";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
|
||||
interface ResourceContentProps {
|
||||
/** SVG icon for connectors/doc sets. */
|
||||
icon?: IconFunctionComponent;
|
||||
/** Custom ReactNode icon (e.g. AgentAvatar). Takes priority over `icon`. */
|
||||
leftContent?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Inline info rendered after description (e.g. source icon stack). */
|
||||
infoContent?: ReactNode;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ResourceContent({
|
||||
icon,
|
||||
leftContent,
|
||||
title,
|
||||
description,
|
||||
infoContent,
|
||||
onRemove,
|
||||
}: ResourceContentProps) {
|
||||
return (
|
||||
<div className="flex flex-1 gap-0.5 items-start p-1.5 rounded-08 bg-background-tint-01 min-w-[240px] max-w-[302px]">
|
||||
<div className="flex flex-1 gap-1 p-0.5 items-start min-w-0">
|
||||
{leftContent ? (
|
||||
<>
|
||||
{leftContent}
|
||||
<Content
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
{infoContent}
|
||||
</div>
|
||||
<IconButton small icon={SvgX} onClick={onRemove} className="shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourceContent;
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ResourcePopoverProps } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
|
||||
|
||||
function ResourcePopover({
|
||||
placeholder,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
sections,
|
||||
}: ResourcePopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const totalItems = sections.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<InputTypeIn
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
width="trigger"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||
{totalItems === 0 ? (
|
||||
<div className="px-3 py-3">
|
||||
<Content
|
||||
icon={SvgEmpty}
|
||||
title="No results found"
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
sections.map(
|
||||
(section, idx) =>
|
||||
section.items.length > 0 && (
|
||||
<div key={section.label ?? `section-${idx}`}>
|
||||
{section.label && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
gap={0.25}
|
||||
padding={0}
|
||||
height="auto"
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
className="px-2 pt-2 pb-1"
|
||||
>
|
||||
<Text secondaryBody text03 className="shrink-0">
|
||||
{section.label}
|
||||
</Text>
|
||||
<Separator noPadding className="flex-1" />
|
||||
</Section>
|
||||
)}
|
||||
<Section
|
||||
gap={0.25}
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={cn(
|
||||
"rounded-08 cursor-pointer",
|
||||
item.disabled
|
||||
? "bg-background-tint-02"
|
||||
: "hover:bg-background-tint-02 transition-colors"
|
||||
)}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.render(!!item.disabled)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourcePopover;
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { SvgEmpty, SvgFiles, SvgXOctagon } from "@opal/icons";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
|
||||
import { useConnectorStatus } from "@/lib/hooks";
|
||||
import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
import type { ValidSources } from "@/lib/types";
|
||||
import ResourceContent from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/ResourceContent";
|
||||
import ResourcePopover from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/ResourcePopover";
|
||||
import type { PopoverSection } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
|
||||
|
||||
interface SharedGroupResourcesProps {
|
||||
selectedCcPairIds: number[];
|
||||
onCcPairIdsChange: (ids: number[]) => void;
|
||||
selectedDocSetIds: number[];
|
||||
onDocSetIdsChange: (ids: number[]) => void;
|
||||
selectedAgentIds: number[];
|
||||
onAgentIdsChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SharedBadge() {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
Shared
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceIconStack({ sources }: { sources: { source: ValidSources }[] }) {
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
const unique = Array.from(
|
||||
new Map(sources.map((s) => [s.source, s])).values()
|
||||
).slice(0, 3);
|
||||
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
height="auto"
|
||||
gap={0}
|
||||
className="shrink-0 px-0.5"
|
||||
>
|
||||
{unique.map((s, i) => {
|
||||
const Icon = getSourceMetadata(s.source).icon;
|
||||
return (
|
||||
<div
|
||||
key={s.source}
|
||||
className="flex items-center justify-center size-4 rounded-04 bg-background-tint-00 border border-border-01 overflow-hidden"
|
||||
style={{ zIndex: unique.length - i, marginLeft: i > 0 ? -6 : 0 }}
|
||||
>
|
||||
<Icon />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SharedGroupResources({
|
||||
selectedCcPairIds,
|
||||
onCcPairIdsChange,
|
||||
selectedDocSetIds,
|
||||
onDocSetIdsChange,
|
||||
selectedAgentIds,
|
||||
onAgentIdsChange,
|
||||
}: SharedGroupResourcesProps) {
|
||||
const [connectorSearch, setConnectorSearch] = useState("");
|
||||
const [agentSearch, setAgentSearch] = useState("");
|
||||
|
||||
const { data: connectors = [] } = useConnectorStatus();
|
||||
const { documentSets } = useDocumentSets();
|
||||
const { agents } = useAgents();
|
||||
|
||||
// --- Derived data ---
|
||||
|
||||
const selectedCcPairSet = useMemo(
|
||||
() => new Set(selectedCcPairIds),
|
||||
[selectedCcPairIds]
|
||||
);
|
||||
const selectedDocSetSet = useMemo(
|
||||
() => new Set(selectedDocSetIds),
|
||||
[selectedDocSetIds]
|
||||
);
|
||||
const selectedAgentSet = useMemo(
|
||||
() => new Set(selectedAgentIds),
|
||||
[selectedAgentIds]
|
||||
);
|
||||
|
||||
const selectedPairs = useMemo(
|
||||
() => connectors.filter((p) => selectedCcPairSet.has(p.cc_pair_id)),
|
||||
[connectors, selectedCcPairSet]
|
||||
);
|
||||
const selectedDocSets = useMemo(
|
||||
() => documentSets.filter((ds) => selectedDocSetSet.has(ds.id)),
|
||||
[documentSets, selectedDocSetSet]
|
||||
);
|
||||
const selectedAgentObjects = useMemo(
|
||||
() => agents.filter((a) => selectedAgentSet.has(a.id)),
|
||||
[agents, selectedAgentSet]
|
||||
);
|
||||
|
||||
// --- Popover sections ---
|
||||
|
||||
const connectorDocSetSections: PopoverSection[] = useMemo(() => {
|
||||
const q = connectorSearch.toLowerCase();
|
||||
|
||||
const connectorItems = connectors
|
||||
.filter((p) => !q || (p.name ?? "").toLowerCase().includes(q))
|
||||
.map((p) => {
|
||||
const isSelected = selectedCcPairSet.has(p.cc_pair_id);
|
||||
return {
|
||||
key: `c-${p.cc_pair_id}`,
|
||||
disabled: isSelected,
|
||||
onSelect: () =>
|
||||
isSelected
|
||||
? onCcPairIdsChange(
|
||||
selectedCcPairIds.filter((id) => id !== p.cc_pair_id)
|
||||
)
|
||||
: onCcPairIdsChange([...selectedCcPairIds, p.cc_pair_id]),
|
||||
render: (dimmed: boolean) => (
|
||||
<LineItem
|
||||
interactive={!dimmed}
|
||||
muted={dimmed}
|
||||
icon={getSourceMetadata(p.connector.source).icon}
|
||||
rightChildren={
|
||||
p.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
|
||||
}
|
||||
>
|
||||
{p.name ?? `Connector #${p.cc_pair_id}`}
|
||||
</LineItem>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const docSetItems = documentSets
|
||||
.filter((ds) => !q || ds.name.toLowerCase().includes(q))
|
||||
.map((ds) => {
|
||||
const isSelected = selectedDocSetSet.has(ds.id);
|
||||
return {
|
||||
key: `d-${ds.id}`,
|
||||
disabled: isSelected,
|
||||
onSelect: () =>
|
||||
isSelected
|
||||
? onDocSetIdsChange(
|
||||
selectedDocSetIds.filter((id) => id !== ds.id)
|
||||
)
|
||||
: onDocSetIdsChange([...selectedDocSetIds, ds.id]),
|
||||
render: (dimmed: boolean) => (
|
||||
<LineItem
|
||||
interactive={!dimmed}
|
||||
muted={dimmed}
|
||||
icon={SvgFiles}
|
||||
rightChildren={
|
||||
ds.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
|
||||
}
|
||||
>
|
||||
{ds.name}
|
||||
</LineItem>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...(connectorItems.length > 0
|
||||
? [{ label: "Connectors", items: connectorItems }]
|
||||
: []),
|
||||
...(docSetItems.length > 0
|
||||
? [{ label: "Document Sets", items: docSetItems }]
|
||||
: []),
|
||||
];
|
||||
}, [
|
||||
connectors,
|
||||
documentSets,
|
||||
connectorSearch,
|
||||
selectedCcPairSet,
|
||||
selectedDocSetSet,
|
||||
selectedCcPairIds,
|
||||
selectedDocSetIds,
|
||||
onCcPairIdsChange,
|
||||
onDocSetIdsChange,
|
||||
]);
|
||||
|
||||
const agentSections: PopoverSection[] = useMemo(() => {
|
||||
const q = agentSearch.toLowerCase();
|
||||
|
||||
const items = agents
|
||||
.filter((a) => !q || a.name.toLowerCase().includes(q))
|
||||
.map((a) => {
|
||||
const isSelected = selectedAgentSet.has(a.id);
|
||||
return {
|
||||
key: `a-${a.id}`,
|
||||
disabled: isSelected,
|
||||
onSelect: () =>
|
||||
isSelected
|
||||
? onAgentIdsChange(selectedAgentIds.filter((id) => id !== a.id))
|
||||
: onAgentIdsChange([...selectedAgentIds, a.id]),
|
||||
render: (dimmed: boolean) => (
|
||||
<LineItem
|
||||
interactive={!dimmed}
|
||||
muted={dimmed}
|
||||
icon={(_props) => <AgentAvatar agent={a} size={16} />}
|
||||
description="agent"
|
||||
rightChildren={
|
||||
!a.is_public || dimmed ? <SharedBadge /> : undefined
|
||||
}
|
||||
>
|
||||
{a.name}
|
||||
</LineItem>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return items.length > 0 ? [{ items }] : [];
|
||||
}, [
|
||||
agents,
|
||||
agentSearch,
|
||||
selectedAgentSet,
|
||||
selectedAgentIds,
|
||||
onAgentIdsChange,
|
||||
]);
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function removeConnector(id: number) {
|
||||
onCcPairIdsChange(selectedCcPairIds.filter((cid) => cid !== id));
|
||||
}
|
||||
|
||||
function removeDocSet(id: number) {
|
||||
onDocSetIdsChange(selectedDocSetIds.filter((did) => did !== id));
|
||||
}
|
||||
|
||||
function removeAgent(id: number) {
|
||||
onAgentIdsChange(selectedAgentIds.filter((aid) => aid !== id));
|
||||
}
|
||||
|
||||
const hasSelectedResources =
|
||||
selectedPairs.length > 0 || selectedDocSets.length > 0;
|
||||
|
||||
return (
|
||||
<SimpleCollapsible>
|
||||
<SimpleCollapsible.Header
|
||||
title="Shared with This Group"
|
||||
description="Share connectors, document sets, agents with members of this group."
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<Card>
|
||||
<Section
|
||||
gap={1}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
width="full"
|
||||
>
|
||||
{/* Connectors & Document Sets */}
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Section
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Text mainUiAction text04>
|
||||
Connectors & Document Sets
|
||||
</Text>
|
||||
<ResourcePopover
|
||||
placeholder="Add connectors, document sets"
|
||||
searchValue={connectorSearch}
|
||||
onSearchChange={setConnectorSearch}
|
||||
sections={connectorDocSetSections}
|
||||
/>
|
||||
</Section>
|
||||
{hasSelectedResources ? (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
wrap
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="start"
|
||||
justifyContent="start"
|
||||
>
|
||||
{selectedPairs.map((pair) => (
|
||||
<ResourceContent
|
||||
key={`c-${pair.cc_pair_id}`}
|
||||
icon={getSourceMetadata(pair.connector.source).icon}
|
||||
title={pair.name ?? `Connector #${pair.cc_pair_id}`}
|
||||
description="Connector"
|
||||
onRemove={() => removeConnector(pair.cc_pair_id)}
|
||||
/>
|
||||
))}
|
||||
{selectedDocSets.map((ds) => (
|
||||
<ResourceContent
|
||||
key={`d-${ds.id}`}
|
||||
icon={SvgFiles}
|
||||
title={ds.name}
|
||||
description={`Document Set - ${ds.cc_pair_summaries.length} Sources`}
|
||||
infoContent={
|
||||
<SourceIconStack sources={ds.cc_pair_summaries} />
|
||||
}
|
||||
onRemove={() => removeDocSet(ds.id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Content
|
||||
icon={SvgEmpty}
|
||||
title="No connectors or document sets added"
|
||||
description="Add connectors or document set to share with this group."
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Separator noPadding />
|
||||
|
||||
{/* Agents */}
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Section
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Text mainUiAction text04>
|
||||
Agents
|
||||
</Text>
|
||||
<ResourcePopover
|
||||
placeholder="Add agents"
|
||||
searchValue={agentSearch}
|
||||
onSearchChange={setAgentSearch}
|
||||
sections={agentSections}
|
||||
/>
|
||||
</Section>
|
||||
{selectedAgentObjects.length > 0 ? (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
wrap
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="start"
|
||||
justifyContent="start"
|
||||
>
|
||||
{selectedAgentObjects.map((agent) => (
|
||||
<ResourceContent
|
||||
key={agent.id}
|
||||
leftContent={
|
||||
<div className="flex items-center justify-center shrink-0 size-5 p-0.5 rounded-04">
|
||||
<AgentAvatar agent={agent} size={16} />
|
||||
</div>
|
||||
}
|
||||
title={agent.name}
|
||||
description="agent"
|
||||
onRemove={() => removeAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Content
|
||||
icon={SvgXOctagon}
|
||||
title="No agents added"
|
||||
description="Add agents to share with this group."
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
</Card>
|
||||
</SimpleCollapsible.Content>
|
||||
</SimpleCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default SharedGroupResources;
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface PopoverItem {
|
||||
key: string;
|
||||
render: (disabled: boolean) => React.ReactNode;
|
||||
onSelect: () => void;
|
||||
/** When true, the item is already selected — shown dimmed with bg-tint-02. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PopoverSection {
|
||||
label?: string;
|
||||
items: PopoverItem[];
|
||||
}
|
||||
|
||||
export interface ResourcePopoverProps {
|
||||
placeholder: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
sections: PopoverSection[];
|
||||
}
|
||||
148
web/src/refresh-pages/admin/GroupsPage/TokenLimitSection.tsx
Normal file
148
web/src/refresh-pages/admin/GroupsPage/TokenLimitSection.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { SvgPlusCircle, SvgMinusCircle } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import InputNumber from "@/refresh-components/inputs/InputNumber";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TokenLimit {
|
||||
tokenBudget: number | null;
|
||||
periodHours: number | null;
|
||||
}
|
||||
|
||||
interface TokenLimitSectionProps {
|
||||
limits: TokenLimit[];
|
||||
onLimitsChange: (limits: TokenLimit[]) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TokenLimitSection({ limits, onLimitsChange }: TokenLimitSectionProps) {
|
||||
const nextKeyRef = useRef(limits.length);
|
||||
const keysRef = useRef<number[]>(limits.map((_, i) => i));
|
||||
|
||||
// Sync keys if the parent provides a different number of limits externally
|
||||
// (e.g. loaded from server after initial mount).
|
||||
if (keysRef.current.length < limits.length) {
|
||||
while (keysRef.current.length < limits.length) {
|
||||
keysRef.current.push(nextKeyRef.current++);
|
||||
}
|
||||
} else if (keysRef.current.length > limits.length) {
|
||||
keysRef.current = keysRef.current.slice(0, limits.length);
|
||||
}
|
||||
|
||||
function addLimit() {
|
||||
const emptyIndex = limits.findIndex(
|
||||
(l) => l.tokenBudget === null && l.periodHours === null
|
||||
);
|
||||
if (emptyIndex !== -1) return;
|
||||
const key = nextKeyRef.current++;
|
||||
keysRef.current = [...keysRef.current, key];
|
||||
onLimitsChange([...limits, { tokenBudget: null, periodHours: null }]);
|
||||
}
|
||||
|
||||
function removeLimit(index: number) {
|
||||
keysRef.current = keysRef.current.filter((_, i) => i !== index);
|
||||
onLimitsChange(limits.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function updateLimit(
|
||||
index: number,
|
||||
field: keyof TokenLimit,
|
||||
value: number | null
|
||||
) {
|
||||
onLimitsChange(
|
||||
limits.map((l, i) => (i === index ? { ...l, [field]: value } : l))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleCollapsible>
|
||||
<SimpleCollapsible.Header
|
||||
title="Token Rate Limit"
|
||||
description="Limit number of tokens this group can use within a given time period."
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<Card>
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
width="full"
|
||||
>
|
||||
{/* Column headers */}
|
||||
<div className="flex flex-wrap items-center gap-1 pr-[40px]">
|
||||
<div className="flex-1 flex items-center min-w-[160px]">
|
||||
<Text mainUiAction text04>
|
||||
Token Limit
|
||||
</Text>
|
||||
<Text mainUiMuted text03 className="ml-0.5">
|
||||
(thousand tokens)
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center min-w-[160px]">
|
||||
<Text mainUiAction text04>
|
||||
Time Window
|
||||
</Text>
|
||||
<Text mainUiMuted text03 className="ml-0.5">
|
||||
(hours)
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limit rows */}
|
||||
{limits.map((limit, i) => (
|
||||
<div key={keysRef.current[i]} className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<InputNumber
|
||||
value={limit.tokenBudget}
|
||||
onChange={(v) => updateLimit(i, "tokenBudget", v)}
|
||||
min={0}
|
||||
placeholder="Token limit in thousands"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<InputNumber
|
||||
value={limit.periodHours}
|
||||
onChange={(v) => updateLimit(i, "periodHours", v)}
|
||||
min={1}
|
||||
placeholder="24"
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
small
|
||||
icon={SvgMinusCircle}
|
||||
onClick={() => removeLimit(i)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
prominence="secondary"
|
||||
size="md"
|
||||
onClick={addLimit}
|
||||
>
|
||||
Add Limit
|
||||
</Button>
|
||||
</Section>
|
||||
</Card>
|
||||
</SimpleCollapsible.Content>
|
||||
</SimpleCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenLimitSection;
|
||||
@@ -27,7 +27,7 @@ function GroupsPage() {
|
||||
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Root width="sm">
|
||||
{/* This is the sticky header for the groups page. It is used to display
|
||||
* the groups page title and search input when scrolling down.
|
||||
*/}
|
||||
|
||||
@@ -16,14 +16,18 @@ async function renameGroup(groupId: number, newName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function createGroup(name: string, userIds: string[]): Promise<void> {
|
||||
async function createGroup(
|
||||
name: string,
|
||||
userIds: string[],
|
||||
ccPairIds: number[] = []
|
||||
): Promise<number> {
|
||||
const res = await fetch(USER_GROUP_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
user_ids: userIds,
|
||||
cc_pair_ids: [],
|
||||
cc_pair_ids: ccPairIds,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -32,6 +36,234 @@ async function createGroup(name: string, userIds: string[]): Promise<void> {
|
||||
detail?.detail ?? `Failed to create group: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
const group = await res.json();
|
||||
return group.id;
|
||||
}
|
||||
|
||||
export { USER_GROUP_URL, renameGroup, createGroup };
|
||||
async function deleteGroup(groupId: number): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to delete group: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent (persona) sharing — managed from the persona side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function updateAgentGroupSharing(
|
||||
groupId: number,
|
||||
initialAgentIds: number[],
|
||||
currentAgentIds: number[]
|
||||
): Promise<void> {
|
||||
const initialSet = new Set(initialAgentIds);
|
||||
const currentSet = new Set(currentAgentIds);
|
||||
|
||||
const added_agent_ids = currentAgentIds.filter((id) => !initialSet.has(id));
|
||||
const removed_agent_ids = initialAgentIds.filter((id) => !currentSet.has(id));
|
||||
|
||||
if (added_agent_ids.length === 0 && removed_agent_ids.length === 0) return;
|
||||
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}/agents`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ added_agent_ids, removed_agent_ids }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to update agent sharing: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document set sharing — managed from the document set side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DocumentSetSummary {
|
||||
id: number;
|
||||
description: string;
|
||||
cc_pair_summaries: { id: number }[];
|
||||
federated_connector_summaries: { id: number }[];
|
||||
is_public: boolean;
|
||||
users: string[];
|
||||
groups: number[];
|
||||
}
|
||||
|
||||
async function updateDocSetGroupSharing(
|
||||
groupId: number,
|
||||
initialDocSetIds: number[],
|
||||
currentDocSetIds: number[]
|
||||
): Promise<void> {
|
||||
const initialSet = new Set(initialDocSetIds);
|
||||
const currentSet = new Set(currentDocSetIds);
|
||||
|
||||
const added = currentDocSetIds.filter((id) => !initialSet.has(id));
|
||||
const removed = initialDocSetIds.filter((id) => !currentSet.has(id));
|
||||
|
||||
if (added.length === 0 && removed.length === 0) return;
|
||||
|
||||
// Fetch all document sets to get their current state
|
||||
const allRes = await fetch("/api/manage/document-set");
|
||||
if (!allRes.ok) {
|
||||
throw new Error("Failed to fetch document sets");
|
||||
}
|
||||
const allDocSets: DocumentSetSummary[] = await allRes.json();
|
||||
const docSetMap = new Map(allDocSets.map((ds) => [ds.id, ds]));
|
||||
|
||||
for (const dsId of added) {
|
||||
const ds = docSetMap.get(dsId);
|
||||
if (!ds) {
|
||||
throw new Error(`Document set ${dsId} not found`);
|
||||
}
|
||||
const updatedGroups = ds.groups.includes(groupId)
|
||||
? ds.groups
|
||||
: [...ds.groups, groupId];
|
||||
const res = await fetch("/api/manage/admin/document-set", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: ds.id,
|
||||
description: ds.description,
|
||||
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
|
||||
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
|
||||
federated_connector_id: fc.id,
|
||||
})),
|
||||
is_public: ds.is_public,
|
||||
users: ds.users,
|
||||
groups: updatedGroups,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to add group to document set ${dsId}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dsId of removed) {
|
||||
const ds = docSetMap.get(dsId);
|
||||
if (!ds) {
|
||||
throw new Error(`Document set ${dsId} not found`);
|
||||
}
|
||||
const updatedGroups = ds.groups.filter((id) => id !== groupId);
|
||||
const res = await fetch("/api/manage/admin/document-set", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: ds.id,
|
||||
description: ds.description,
|
||||
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
|
||||
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
|
||||
federated_connector_id: fc.id,
|
||||
})),
|
||||
is_public: ds.is_public,
|
||||
users: ds.users,
|
||||
groups: updatedGroups,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to remove group from document set ${dsId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token rate limits — create / update / delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenLimitPayload {
|
||||
tokenBudget: number | null;
|
||||
periodHours: number | null;
|
||||
}
|
||||
|
||||
interface ExistingTokenLimit {
|
||||
token_id: number;
|
||||
enabled: boolean;
|
||||
token_budget: number;
|
||||
period_hours: number;
|
||||
}
|
||||
|
||||
async function saveTokenLimits(
|
||||
groupId: number,
|
||||
limits: TokenLimitPayload[],
|
||||
existing: ExistingTokenLimit[]
|
||||
): Promise<void> {
|
||||
// Filter to only valid (non-null) limits
|
||||
const validLimits = limits.filter(
|
||||
(l): l is { tokenBudget: number; periodHours: number } =>
|
||||
l.tokenBudget != null && l.periodHours != null
|
||||
);
|
||||
|
||||
// Update existing limits (match by index position)
|
||||
const toUpdate = Math.min(validLimits.length, existing.length);
|
||||
for (let i = 0; i < toUpdate; i++) {
|
||||
const limit = validLimits[i]!;
|
||||
const existingLimit = existing[i]!;
|
||||
const updateRes = await fetch(
|
||||
`/api/admin/token-rate-limits/rate-limit/${existingLimit.token_id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled: existingLimit.enabled,
|
||||
token_budget: limit.tokenBudget,
|
||||
period_hours: limit.periodHours,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!updateRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to update token rate limit ${existingLimit.token_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new limits beyond existing count
|
||||
for (let i = toUpdate; i < validLimits.length; i++) {
|
||||
const limit = validLimits[i]!;
|
||||
const createRes = await fetch(
|
||||
`/api/admin/token-rate-limits/user-group/${groupId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
token_budget: limit.tokenBudget,
|
||||
period_hours: limit.periodHours,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!createRes.ok) {
|
||||
throw new Error("Failed to create token rate limit");
|
||||
}
|
||||
}
|
||||
|
||||
// Delete excess existing limits
|
||||
for (let i = toUpdate; i < existing.length; i++) {
|
||||
const existingLimit = existing[i]!;
|
||||
const deleteRes = await fetch(
|
||||
`/api/admin/token-rate-limits/rate-limit/${existingLimit.token_id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!deleteRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to delete token rate limit ${existingLimit.token_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
USER_GROUP_URL,
|
||||
renameGroup,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { getPastedFilesIfNoText } from "@/lib/clipboard";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
@@ -300,20 +301,10 @@ const AppInputBar = React.memo(
|
||||
}, [showFiles, currentMessageFiles]);
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (items) {
|
||||
const pastedFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item && item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileUpload(pastedFiles);
|
||||
}
|
||||
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileUpload(pastedFiles);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
<SidebarWrapper>
|
||||
<SidebarBody
|
||||
scrollKey="admin-sidebar"
|
||||
actionButtons={
|
||||
pinnedContent={
|
||||
<div className="flex flex-col w-full">
|
||||
<SidebarTab
|
||||
icon={({ className }) => <SvgX className={className} size={16} />}
|
||||
|
||||
@@ -678,21 +678,18 @@ const MemoizedAppSidebarInner = memo(
|
||||
<SidebarBody
|
||||
scrollKey="app-sidebar"
|
||||
footer={settingsButton}
|
||||
actionButtons={
|
||||
pinnedContent={
|
||||
<div className="flex flex-col">
|
||||
{newSessionButton}
|
||||
{searchChatsButton}
|
||||
{isOnyxCraftEnabled && buildButton}
|
||||
{folded && moreAgentsButton}
|
||||
{folded && newProjectButton}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* When folded, show icons immediately without waiting for data */}
|
||||
{folded ? (
|
||||
<>
|
||||
{moreAgentsButton}
|
||||
{newProjectButton}
|
||||
</>
|
||||
) : isLoadingDynamicContent ? null : (
|
||||
{/* When folded, all nav buttons are in pinnedContent — nothing here */}
|
||||
{folded ? null : isLoadingDynamicContent ? null : (
|
||||
<>
|
||||
{/* Agents */}
|
||||
<DndContext
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import OverflowDiv from "@/refresh-components/OverflowDiv";
|
||||
|
||||
export interface SidebarBodyProps {
|
||||
actionButtons?: React.ReactNode;
|
||||
pinnedContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/**
|
||||
@@ -15,21 +15,14 @@ export interface SidebarBodyProps {
|
||||
}
|
||||
|
||||
export default function SidebarBody({
|
||||
actionButtons,
|
||||
pinnedContent,
|
||||
children,
|
||||
footer,
|
||||
scrollKey,
|
||||
}: SidebarBodyProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 h-full gap-3">
|
||||
<div className="flex flex-col gap-1.5 px-2">
|
||||
{actionButtons &&
|
||||
(Array.isArray(actionButtons)
|
||||
? actionButtons.map((button, index) => (
|
||||
<div key={index}>{button}</div>
|
||||
))
|
||||
: actionButtons)}
|
||||
</div>
|
||||
{pinnedContent && <div className="px-2">{pinnedContent}</div>}
|
||||
<OverflowDiv className="gap-3 px-2" scrollKey={scrollKey}>
|
||||
{children}
|
||||
</OverflowDiv>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@opal/components";
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
@@ -12,53 +12,48 @@ interface LogoSectionProps {
|
||||
|
||||
function LogoSection({ folded, onFoldClick }: LogoSectionProps) {
|
||||
const settings = useSettingsContext();
|
||||
const applicationName = settings.enterpriseSettings?.application_name;
|
||||
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
|
||||
|
||||
const logo = useCallback(
|
||||
(className?: string) => <Logo folded={folded} className={className} />,
|
||||
const logo = useMemo(
|
||||
() => (
|
||||
<div className="px-1">
|
||||
<Logo folded={folded} size={28} />
|
||||
</div>
|
||||
),
|
||||
[folded]
|
||||
);
|
||||
const closeButton = useCallback(
|
||||
(shouldFold: boolean) => (
|
||||
<Button
|
||||
icon={SvgSidebar}
|
||||
prominence="tertiary"
|
||||
tooltip="Close Sidebar"
|
||||
onClick={onFoldClick}
|
||||
/>
|
||||
const closeButton = useMemo(
|
||||
() => (
|
||||
<div className="px-1">
|
||||
<Button
|
||||
icon={SvgSidebar}
|
||||
prominence="tertiary"
|
||||
tooltip="Close Sidebar"
|
||||
size="md"
|
||||
onClick={onFoldClick}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[onFoldClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
/* px-2.5 => 2 for the standard sidebar padding + 0.5 for internal padding specific to this component. */
|
||||
"flex px-2.5 py-2",
|
||||
folded ? "justify-center" : "justify-between",
|
||||
applicationName
|
||||
? "h-[3.75rem] min-h-[3.75rem]"
|
||||
: "h-[3.25rem] min-h-[3.25rem]"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-start pt-3 px-2">
|
||||
{folded === undefined ? (
|
||||
<div className="p-1">{logo()}</div>
|
||||
logo
|
||||
) : folded && logoDisplayStyle !== "name_only" ? (
|
||||
<>
|
||||
<div className="group-hover/SidebarWrapper:hidden pt-1.5">
|
||||
{logo()}
|
||||
</div>
|
||||
<div className="w-full justify-center hidden group-hover/SidebarWrapper:flex">
|
||||
{closeButton(false)}
|
||||
<div className="group-hover/SidebarWrapper:hidden">{logo}</div>
|
||||
<div className="hidden group-hover/SidebarWrapper:flex">
|
||||
{closeButton}
|
||||
</div>
|
||||
</>
|
||||
) : folded ? (
|
||||
<div className="flex w-full justify-center">{closeButton(false)}</div>
|
||||
closeButton
|
||||
) : (
|
||||
<>
|
||||
<div className="p-1"> {logo()}</div>
|
||||
{closeButton(true)}
|
||||
{logo}
|
||||
{closeButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user