mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-23 18:55:45 +00:00
Compare commits
6 Commits
csv_render
...
fix-onboar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a7d598b0 | ||
|
|
4ed7b99cfe | ||
|
|
e39b8438f6 | ||
|
|
82d9820e0c | ||
|
|
00a404d3cd | ||
|
|
787cf90d96 |
@@ -0,0 +1,31 @@
|
||||
"""code interpreter server model
|
||||
|
||||
Revision ID: 7cb492013621
|
||||
Revises: 0bb4558f35df
|
||||
Create Date: 2026-02-22 18:54:54.007265
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7cb492013621"
|
||||
down_revision = "0bb4558f35df"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_interpreter_server",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"server_enabled", sa.Boolean, nullable=False, server_default=sa.true()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("code_interpreter_server")
|
||||
@@ -4978,3 +4978,12 @@ class ScimGroupMapping(Base):
|
||||
user_group: Mapped[UserGroup] = relationship(
|
||||
"UserGroup", foreign_keys=[user_group_id]
|
||||
)
|
||||
|
||||
|
||||
class CodeInterpreterServer(Base):
|
||||
"""Details about the code interpreter server"""
|
||||
|
||||
__tablename__ = "code_interpreter_server"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
server_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
@@ -243,12 +243,12 @@ USAGE_LIMIT_CHUNKS_INDEXED_PAID = int(
|
||||
)
|
||||
|
||||
# Per-week API calls using API keys or Personal Access Tokens
|
||||
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "400"))
|
||||
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "0"))
|
||||
USAGE_LIMIT_API_CALLS_PAID = int(os.environ.get("USAGE_LIMIT_API_CALLS_PAID", "40000"))
|
||||
|
||||
# Per-week non-streaming API calls (more expensive, so lower limits)
|
||||
USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL = int(
|
||||
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "80")
|
||||
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "0")
|
||||
)
|
||||
USAGE_LIMIT_NON_STREAMING_CALLS_PAID = int(
|
||||
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_PAID", "160")
|
||||
|
||||
@@ -31,6 +31,7 @@ import Button from "@/refresh-components/buttons/Button";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgEdit, SvgKey, SvgRefreshCw } from "@opal/icons";
|
||||
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
|
||||
|
||||
function Main() {
|
||||
const {
|
||||
@@ -39,6 +40,8 @@ function Main() {
|
||||
error,
|
||||
} = useSWR<APIKey[]>("/api/admin/api-key", errorHandlingFetcher);
|
||||
|
||||
const canCreateKeys = useCloudSubscription();
|
||||
|
||||
const [fullApiKey, setFullApiKey] = useState<string | null>(null);
|
||||
const [keyIsGenerating, setKeyIsGenerating] = useState(false);
|
||||
const [showCreateUpdateForm, setShowCreateUpdateForm] = useState(false);
|
||||
@@ -70,12 +73,23 @@ function Main() {
|
||||
const introSection = (
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Text as="p">
|
||||
API Keys allow you to access Onyx APIs programmatically. Click the
|
||||
button below to generate a new API Key.
|
||||
API Keys allow you to access Onyx APIs programmatically.
|
||||
{canCreateKeys
|
||||
? " Click the button below to generate a new API Key."
|
||||
: ""}
|
||||
</Text>
|
||||
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
|
||||
Create API Key
|
||||
</CreateButton>
|
||||
{canCreateKeys ? (
|
||||
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
|
||||
Create API Key
|
||||
</CreateButton>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-background-tint-02 p-4">
|
||||
<Text as="p" text04>
|
||||
This feature requires an active paid subscription.
|
||||
</Text>
|
||||
<Button href="/admin/billing">Upgrade Plan</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -109,7 +123,7 @@ function Main() {
|
||||
title="New API Key"
|
||||
icon={SvgKey}
|
||||
onClose={() => setFullApiKey(null)}
|
||||
description="Make sure you copy your new API key. You won’t be able to see this key again."
|
||||
description="Make sure you copy your new API key. You won't be able to see this key again."
|
||||
/>
|
||||
<Modal.Body>
|
||||
<Text as="p" className="break-all flex-1">
|
||||
@@ -124,88 +138,94 @@ function Main() {
|
||||
|
||||
{introSection}
|
||||
|
||||
<Separator />
|
||||
{canCreateKeys && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<Title className="mt-6">Existing API Keys</Title>
|
||||
<Table className="overflow-visible">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Regenerate</TableHead>
|
||||
<TableHead>Delete</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredApiKeys.map((apiKey) => (
|
||||
<TableRow key={apiKey.api_key_id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
leftIcon={SvgEdit}
|
||||
>
|
||||
{apiKey.api_key_name || <i>null</i>}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_display}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_role.toUpperCase()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
leftIcon={SvgRefreshCw}
|
||||
onClick={async () => {
|
||||
setKeyIsGenerating(true);
|
||||
const response = await regenerateApiKey(apiKey);
|
||||
setKeyIsGenerating(false);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(`Failed to regenerate API Key: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
const newKey = (await response.json()) as APIKey;
|
||||
setFullApiKey(newKey.api_key);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await deleteApiKey(apiKey.api_key_id);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(`Failed to delete API Key: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Title className="mt-6">Existing API Keys</Title>
|
||||
<Table className="overflow-visible">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Regenerate</TableHead>
|
||||
<TableHead>Delete</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredApiKeys.map((apiKey) => (
|
||||
<TableRow key={apiKey.api_key_id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
leftIcon={SvgEdit}
|
||||
>
|
||||
{apiKey.api_key_name || <i>null</i>}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_display}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_role.toUpperCase()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
leftIcon={SvgRefreshCw}
|
||||
onClick={async () => {
|
||||
setKeyIsGenerating(true);
|
||||
const response = await regenerateApiKey(apiKey);
|
||||
setKeyIsGenerating(false);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(
|
||||
`Failed to regenerate API Key: ${errorMsg}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newKey = (await response.json()) as APIKey;
|
||||
setFullApiKey(newKey.api_key);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await deleteApiKey(apiKey.api_key_id);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(`Failed to delete API Key: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{showCreateUpdateForm && (
|
||||
<OnyxApiKeyForm
|
||||
onCreateApiKey={(apiKey) => {
|
||||
setFullApiKey(apiKey.api_key);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowCreateUpdateForm(false);
|
||||
setSelectedApiKey(undefined);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
apiKey={selectedApiKey}
|
||||
/>
|
||||
{showCreateUpdateForm && (
|
||||
<OnyxApiKeyForm
|
||||
onCreateApiKey={(apiKey) => {
|
||||
setFullApiKey(apiKey.api_key);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowCreateUpdateForm(false);
|
||||
setSelectedApiKey(undefined);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
apiKey={selectedApiKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
25
web/src/hooks/useCloudSubscription.ts
Normal file
25
web/src/hooks/useCloudSubscription.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import { hasPaidSubscription } from "@/lib/billing/interfaces";
|
||||
import { useBillingInformation } from "@/hooks/useBillingInformation";
|
||||
|
||||
/**
|
||||
* Returns whether the current tenant has an active paid subscription on cloud.
|
||||
*
|
||||
* Self-hosted deployments always return true (no billing gate).
|
||||
* Cloud deployments check billing status via the billing API.
|
||||
* Returns true while loading to avoid flashing the upgrade prompt.
|
||||
*/
|
||||
export function useCloudSubscription(): boolean {
|
||||
const { data: billingData, isLoading } = useBillingInformation();
|
||||
|
||||
if (!NEXT_PUBLIC_CLOUD_ENABLED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat loading as subscribed to avoid UI flash
|
||||
if (isLoading || billingData == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasPaidSubscription(billingData);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
import { useOnboardingState } from "@/refresh-components/onboarding/useOnboardingState";
|
||||
|
||||
function getOnboardingCompletedKey(userId: string): string {
|
||||
return `onyx:onboardingCompleted:${userId}`;
|
||||
}
|
||||
|
||||
interface UseShowOnboardingParams {
|
||||
liveAssistant: MinimalPersonaSnapshot | undefined;
|
||||
isLoadingProviders: boolean;
|
||||
@@ -21,6 +26,15 @@ export function useShowOnboarding({
|
||||
userId,
|
||||
}: UseShowOnboardingParams) {
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [onboardingDismissed, setOnboardingDismissed] = useState(false);
|
||||
|
||||
// Read localStorage once userId is available to check if onboarding was dismissed
|
||||
useEffect(() => {
|
||||
if (userId === undefined) return;
|
||||
const dismissed =
|
||||
localStorage.getItem(getOnboardingCompletedKey(userId)) === "true";
|
||||
setOnboardingDismissed(dismissed);
|
||||
}, [userId]);
|
||||
|
||||
// Initialize onboarding state
|
||||
const {
|
||||
@@ -38,13 +52,23 @@ export function useShowOnboarding({
|
||||
// Show onboarding only if no LLM providers are configured.
|
||||
// Skip entirely if user has existing chat sessions.
|
||||
useEffect(() => {
|
||||
// If onboarding was previously dismissed, never show it again
|
||||
if (onboardingDismissed) {
|
||||
setShowOnboarding(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for data to load
|
||||
if (isLoadingProviders || isLoadingChatSessions || userId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check once per user
|
||||
// Only check once per user — but allow self-correction from true→false
|
||||
// when provider data arrives (e.g. after a transient fetch error).
|
||||
if (hasCheckedOnboardingForUserId.current === userId) {
|
||||
if (showOnboarding && hasAnyProvider && onboardingState.stepIndex === 0) {
|
||||
setShowOnboarding(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
hasCheckedOnboardingForUserId.current = userId;
|
||||
@@ -63,18 +87,24 @@ export function useShowOnboarding({
|
||||
hasAnyProvider,
|
||||
chatSessionsCount,
|
||||
userId,
|
||||
showOnboarding,
|
||||
onboardingDismissed,
|
||||
onboardingState.stepIndex,
|
||||
]);
|
||||
|
||||
const hideOnboarding = () => {
|
||||
const dismissOnboarding = useCallback(() => {
|
||||
if (userId === undefined) return;
|
||||
setShowOnboarding(false);
|
||||
};
|
||||
setOnboardingDismissed(true);
|
||||
localStorage.setItem(getOnboardingCompletedKey(userId), "true");
|
||||
}, [userId]);
|
||||
|
||||
const finishOnboarding = () => {
|
||||
setShowOnboarding(false);
|
||||
};
|
||||
const hideOnboarding = dismissOnboarding;
|
||||
const finishOnboarding = dismissOnboarding;
|
||||
|
||||
return {
|
||||
showOnboarding,
|
||||
onboardingDismissed,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
llmDescriptors,
|
||||
|
||||
@@ -133,6 +133,19 @@ export function hasActiveSubscription(
|
||||
return data.status !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the response indicates an active *paid* subscription.
|
||||
* Returns true only for status === "active" (excludes trialing, past_due, etc.).
|
||||
*/
|
||||
export function hasPaidSubscription(
|
||||
data: BillingInformation | SubscriptionStatus
|
||||
): data is BillingInformation {
|
||||
if ("subscribed" in data) {
|
||||
return false;
|
||||
}
|
||||
return data.status === BillingStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a license is valid and active.
|
||||
*/
|
||||
|
||||
@@ -17,11 +17,13 @@ const mockActions = {
|
||||
reset: jest.fn(),
|
||||
};
|
||||
|
||||
let mockStepIndex = 0;
|
||||
|
||||
jest.mock("@/refresh-components/onboarding/useOnboardingState", () => ({
|
||||
useOnboardingState: () => ({
|
||||
state: {
|
||||
currentStep: OnboardingStep.Welcome,
|
||||
stepIndex: 0,
|
||||
stepIndex: mockStepIndex,
|
||||
totalSteps: 3,
|
||||
data: {},
|
||||
isButtonActive: true,
|
||||
@@ -60,6 +62,8 @@ function renderUseShowOnboarding(
|
||||
describe("useShowOnboarding", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockStepIndex = 0;
|
||||
});
|
||||
|
||||
it("returns showOnboarding=false while providers are loading", () => {
|
||||
@@ -107,7 +111,7 @@ describe("useShowOnboarding", () => {
|
||||
expect(result.current.showOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it("only evaluates once per userId", () => {
|
||||
it("self-corrects showOnboarding to false when providers arrive late", () => {
|
||||
const { result, rerender } = renderUseShowOnboarding({
|
||||
hasAnyProvider: false,
|
||||
chatSessionsCount: 0,
|
||||
@@ -115,7 +119,7 @@ describe("useShowOnboarding", () => {
|
||||
});
|
||||
expect(result.current.showOnboarding).toBe(true);
|
||||
|
||||
// Re-render with same userId but different provider state
|
||||
// Re-render with same userId but provider data now available
|
||||
rerender({
|
||||
liveAssistant: undefined,
|
||||
isLoadingProviders: false,
|
||||
@@ -125,7 +129,32 @@ describe("useShowOnboarding", () => {
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
// Should still be true because it was already evaluated for this userId
|
||||
// Should correct to false — providers exist, no need for LLM setup flow
|
||||
expect(result.current.showOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it("does not self-correct when user has advanced past Welcome step", () => {
|
||||
const { result, rerender } = renderUseShowOnboarding({
|
||||
hasAnyProvider: false,
|
||||
chatSessionsCount: 0,
|
||||
userId: "user-1",
|
||||
});
|
||||
expect(result.current.showOnboarding).toBe(true);
|
||||
|
||||
// Simulate user advancing past Welcome (e.g. they configured an LLM provider)
|
||||
mockStepIndex = 1;
|
||||
|
||||
// Re-render with same userId but provider data now available
|
||||
rerender({
|
||||
liveAssistant: undefined,
|
||||
isLoadingProviders: false,
|
||||
hasAnyProvider: true,
|
||||
isLoadingChatSessions: false,
|
||||
chatSessionsCount: 0,
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
// Should stay true — user is actively using onboarding
|
||||
expect(result.current.showOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
@@ -186,4 +215,83 @@ describe("useShowOnboarding", () => {
|
||||
expect(result.current.onboardingActions).toBeDefined();
|
||||
expect(result.current.llmDescriptors).toEqual([]);
|
||||
});
|
||||
|
||||
describe("localStorage persistence", () => {
|
||||
it("finishOnboarding sets localStorage flag and onboardingDismissed", () => {
|
||||
const { result } = renderUseShowOnboarding({
|
||||
hasAnyProvider: false,
|
||||
chatSessionsCount: 0,
|
||||
});
|
||||
expect(result.current.showOnboarding).toBe(true);
|
||||
expect(result.current.onboardingDismissed).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.finishOnboarding();
|
||||
});
|
||||
|
||||
expect(result.current.showOnboarding).toBe(false);
|
||||
expect(result.current.onboardingDismissed).toBe(true);
|
||||
expect(localStorage.getItem("onyx:onboardingCompleted:user-1")).toBe(
|
||||
"true"
|
||||
);
|
||||
});
|
||||
|
||||
it("hideOnboarding sets localStorage flag and onboardingDismissed", () => {
|
||||
const { result } = renderUseShowOnboarding({
|
||||
hasAnyProvider: false,
|
||||
chatSessionsCount: 0,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.hideOnboarding();
|
||||
});
|
||||
|
||||
expect(result.current.onboardingDismissed).toBe(true);
|
||||
expect(localStorage.getItem("onyx:onboardingCompleted:user-1")).toBe(
|
||||
"true"
|
||||
);
|
||||
});
|
||||
|
||||
it("showOnboarding stays false when localStorage flag is set", () => {
|
||||
localStorage.setItem("onyx:onboardingCompleted:user-1", "true");
|
||||
|
||||
const { result } = renderUseShowOnboarding({
|
||||
hasAnyProvider: false,
|
||||
chatSessionsCount: 0,
|
||||
});
|
||||
|
||||
expect(result.current.showOnboarding).toBe(false);
|
||||
expect(result.current.onboardingDismissed).toBe(true);
|
||||
});
|
||||
|
||||
it("onboardingDismissed is false when localStorage flag is not set", () => {
|
||||
const { result } = renderUseShowOnboarding();
|
||||
expect(result.current.onboardingDismissed).toBe(false);
|
||||
});
|
||||
|
||||
it("dismissal for user-1 does not suppress onboarding for user-2", () => {
|
||||
const { result: result1 } = renderUseShowOnboarding({
|
||||
hasAnyProvider: false,
|
||||
chatSessionsCount: 0,
|
||||
userId: "1",
|
||||
});
|
||||
expect(result1.current.showOnboarding).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result1.current.finishOnboarding();
|
||||
});
|
||||
expect(result1.current.onboardingDismissed).toBe(true);
|
||||
expect(localStorage.getItem("onyx:onboardingCompleted:1")).toBe("true");
|
||||
|
||||
// user-2 should still see onboarding
|
||||
const { result: result2 } = renderUseShowOnboarding({
|
||||
hasAnyProvider: false,
|
||||
chatSessionsCount: 0,
|
||||
userId: "2",
|
||||
});
|
||||
expect(result2.current.showOnboarding).toBe(true);
|
||||
expect(result2.current.onboardingDismissed).toBe(false);
|
||||
expect(localStorage.getItem("onyx:onboardingCompleted:2")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function NonAdminStep() {
|
||||
<>
|
||||
{showHeader && (
|
||||
<div
|
||||
className="flex items-center justify-between w-full max-w-[800px] min-h-11 py-1 pl-3 pr-2 bg-background-tint-00 rounded-16 shadow-01 mb-2"
|
||||
className="flex items-center justify-between w-full min-h-11 py-1 pl-3 pr-2 bg-background-tint-00 rounded-16 shadow-01 mb-2"
|
||||
aria-label="non-admin-confirmation"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -25,7 +25,9 @@ import { AppPopup } from "@/app/app/components/AppPopup";
|
||||
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import NoAssistantModal from "@/components/modals/NoAssistantModal";
|
||||
import PreviewModal from "@/sections/modals/PreviewModal";
|
||||
import TextViewModal from "@/sections/modals/TextViewModal";
|
||||
import CodeViewModal from "@/sections/modals/CodeViewModal";
|
||||
import { getCodeLanguage } from "@/lib/languages";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { useSendMessageToParent } from "@/lib/extension/utils";
|
||||
import { SUBMIT_MESSAGE_TYPES } from "@/lib/extension/constants";
|
||||
@@ -226,6 +228,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const {
|
||||
showOnboarding,
|
||||
onboardingDismissed,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
llmDescriptors,
|
||||
@@ -461,7 +464,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
});
|
||||
if (showOnboarding) {
|
||||
if (showOnboarding || !onboardingDismissed) {
|
||||
finishOnboarding();
|
||||
}
|
||||
},
|
||||
@@ -471,6 +474,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
currentMessageFiles,
|
||||
deepResearchEnabled,
|
||||
showOnboarding,
|
||||
onboardingDismissed,
|
||||
finishOnboarding,
|
||||
]
|
||||
);
|
||||
@@ -504,7 +508,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
});
|
||||
if (showOnboarding) {
|
||||
if (showOnboarding || !onboardingDismissed) {
|
||||
finishOnboarding();
|
||||
}
|
||||
return;
|
||||
@@ -525,6 +529,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
currentMessageFiles,
|
||||
deepResearchEnabled,
|
||||
showOnboarding,
|
||||
onboardingDismissed,
|
||||
finishOnboarding,
|
||||
]
|
||||
);
|
||||
@@ -684,12 +689,18 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{presentingDocument && (
|
||||
<PreviewModal
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
)}
|
||||
{presentingDocument &&
|
||||
(getCodeLanguage(presentingDocument.semantic_identifier || "") ? (
|
||||
<CodeViewModal
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
) : (
|
||||
<TextViewModal
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{stackTraceModalContent && (
|
||||
<ExceptionTraceModal
|
||||
@@ -796,7 +807,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
{/* OnboardingUI */}
|
||||
{(appFocus.isNewSession() || appFocus.isAgent()) &&
|
||||
!classification &&
|
||||
(showOnboarding || !user?.personalization?.name) && (
|
||||
(showOnboarding || !user?.personalization?.name) &&
|
||||
!onboardingDismissed && (
|
||||
<OnboardingFlow
|
||||
showOnboarding={showOnboarding}
|
||||
handleHideOnboarding={hideOnboarding}
|
||||
|
||||
@@ -65,6 +65,7 @@ import { Interactive } from "@opal/core";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
|
||||
|
||||
interface PAT {
|
||||
id: number;
|
||||
@@ -937,6 +938,8 @@ function AccountsAccessSettings() {
|
||||
useState<CreatedTokenState | null>(null);
|
||||
const [tokenToDelete, setTokenToDelete] = useState<PAT | null>(null);
|
||||
|
||||
const canCreateTokens = useCloudSubscription();
|
||||
|
||||
const showPasswordSection = Boolean(user?.password_configured);
|
||||
const showTokensSection = authType !== null;
|
||||
|
||||
@@ -1245,93 +1248,104 @@ function AccountsAccessSettings() {
|
||||
{showTokensSection && (
|
||||
<Section gap={0.75}>
|
||||
<InputLayouts.Title title="Access Tokens" />
|
||||
<Card padding={0.25}>
|
||||
<Section gap={0}>
|
||||
{/* Header with search/empty state and create button */}
|
||||
<Section flexDirection="row" padding={0.25} gap={0.5}>
|
||||
{pats.length === 0 ? (
|
||||
<Section padding={0.5} alignItems="start">
|
||||
<Text as="span" text03 secondaryBody>
|
||||
{isLoading
|
||||
? "Loading tokens..."
|
||||
: "No access tokens created."}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
/>
|
||||
)}
|
||||
<CreateButton
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
secondary={false}
|
||||
internal
|
||||
transient={showCreateModal}
|
||||
rightIcon
|
||||
>
|
||||
New Access Token
|
||||
</CreateButton>
|
||||
</Section>
|
||||
{canCreateTokens ? (
|
||||
<Card padding={0.25}>
|
||||
<Section gap={0}>
|
||||
<Section flexDirection="row" padding={0.25} gap={0.5}>
|
||||
{pats.length === 0 ? (
|
||||
<Section padding={0.5} alignItems="start">
|
||||
<Text text03 secondaryBody>
|
||||
{isLoading
|
||||
? "Loading tokens..."
|
||||
: "No access tokens created."}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
/>
|
||||
)}
|
||||
<CreateButton
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
secondary={false}
|
||||
internal
|
||||
transient={showCreateModal}
|
||||
rightIcon
|
||||
>
|
||||
New Access Token
|
||||
</CreateButton>
|
||||
</Section>
|
||||
|
||||
{/* Token List */}
|
||||
<Section gap={0.25}>
|
||||
{filteredPats.map((pat) => {
|
||||
const now = new Date();
|
||||
const createdDate = new Date(pat.created_at);
|
||||
const daysSinceCreation = Math.floor(
|
||||
(now.getTime() - createdDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
let expiryText = "Never expires";
|
||||
if (pat.expires_at) {
|
||||
const expiresDate = new Date(pat.expires_at);
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(expiresDate.getTime() - now.getTime()) /
|
||||
<Section gap={0.25}>
|
||||
{filteredPats.map((pat) => {
|
||||
const now = new Date();
|
||||
const createdDate = new Date(pat.created_at);
|
||||
const daysSinceCreation = Math.floor(
|
||||
(now.getTime() - createdDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
expiryText = `Expires in ${daysUntilExpiry} day${
|
||||
daysUntilExpiry === 1 ? "" : "s"
|
||||
}`;
|
||||
}
|
||||
|
||||
const middleText = `Created ${daysSinceCreation} day${
|
||||
daysSinceCreation === 1 ? "" : "s"
|
||||
} ago - ${expiryText}`;
|
||||
let expiryText = "Never expires";
|
||||
if (pat.expires_at) {
|
||||
const expiresDate = new Date(pat.expires_at);
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(expiresDate.getTime() - now.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
expiryText = `Expires in ${daysUntilExpiry} day${
|
||||
daysUntilExpiry === 1 ? "" : "s"
|
||||
}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Interactive.Container
|
||||
key={pat.id}
|
||||
heightVariant="fit"
|
||||
widthVariant="full"
|
||||
>
|
||||
<div className="w-full bg-background-tint-01">
|
||||
<AttachmentItemLayout
|
||||
icon={SvgKey}
|
||||
title={pat.name}
|
||||
description={pat.token_display}
|
||||
middleText={middleText}
|
||||
rightChildren={
|
||||
<OpalButton
|
||||
icon={SvgTrash}
|
||||
onClick={() => setTokenToDelete(pat)}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
aria-label={`Delete token ${pat.name}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
);
|
||||
})}
|
||||
const middleText = `Created ${daysSinceCreation} day${
|
||||
daysSinceCreation === 1 ? "" : "s"
|
||||
} ago - ${expiryText}`;
|
||||
|
||||
return (
|
||||
<Interactive.Container
|
||||
key={pat.id}
|
||||
heightVariant="fit"
|
||||
widthVariant="full"
|
||||
>
|
||||
<div className="w-full bg-background-tint-01">
|
||||
<AttachmentItemLayout
|
||||
icon={SvgKey}
|
||||
title={pat.name}
|
||||
description={pat.token_display}
|
||||
middleText={middleText}
|
||||
rightChildren={
|
||||
<OpalButton
|
||||
icon={SvgTrash}
|
||||
onClick={() => setTokenToDelete(pat)}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
aria-label={`Delete token ${pat.name}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
</Section>
|
||||
</Section>
|
||||
</Card>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Section flexDirection="row" justifyContent="between">
|
||||
<Text text03 secondaryBody>
|
||||
Access tokens require an active paid subscription.
|
||||
</Text>
|
||||
<Button secondary href="/admin/billing">
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</Section>
|
||||
</Card>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
188
web/src/sections/modals/CodeViewModal.tsx
Normal file
188
web/src/sections/modals/CodeViewModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgDownload } from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { getCodeLanguage } from "@/lib/languages";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { CodeBlock } from "@/app/app/message/CodeBlock";
|
||||
import { extractCodeText } from "@/app/app/message/codeUtils";
|
||||
import { fetchChatFile } from "@/lib/chat/svc";
|
||||
|
||||
export interface CodeViewProps {
|
||||
presentingDocument: MinimalOnyxDocument;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CodeViewModal({
|
||||
presentingDocument,
|
||||
onClose,
|
||||
}: CodeViewProps) {
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [fileUrl, setFileUrl] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const language =
|
||||
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
|
||||
"plaintext";
|
||||
|
||||
const lineCount = useMemo(() => {
|
||||
if (!fileContent) return 0;
|
||||
return fileContent.split("\n").length;
|
||||
}, [fileContent]);
|
||||
|
||||
const fileSize = useMemo(() => {
|
||||
if (!fileContent) return "";
|
||||
const bytes = new TextEncoder().encode(fileContent).length;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(2)} KB`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}, [fileContent]);
|
||||
|
||||
const headerDescription = useMemo(() => {
|
||||
if (!fileContent) return "";
|
||||
return `${language} - ${lineCount} ${
|
||||
lineCount === 1 ? "line" : "lines"
|
||||
} · ${fileSize}`;
|
||||
}, [fileContent, language, lineCount, fileSize]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
setFileContent("");
|
||||
const fileIdLocal =
|
||||
presentingDocument.document_id.split("__")[1] ||
|
||||
presentingDocument.document_id;
|
||||
|
||||
try {
|
||||
const response = await fetchChatFile(fileIdLocal);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
setFileUrl((prev) => {
|
||||
if (prev) {
|
||||
window.URL.revokeObjectURL(prev);
|
||||
}
|
||||
return url;
|
||||
});
|
||||
setFileName(presentingDocument.semantic_identifier || "document");
|
||||
setFileContent(await blob.text());
|
||||
} catch {
|
||||
setLoadError("Failed to load document.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [presentingDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fileUrl) {
|
||||
window.URL.revokeObjectURL(fileUrl);
|
||||
}
|
||||
};
|
||||
}, [fileUrl]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Content
|
||||
width="md"
|
||||
height="lg"
|
||||
preventAccidentalClose={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Modal.Header
|
||||
title={fileName || "Code"}
|
||||
description={headerDescription}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<Modal.Body padding={0} gap={0}>
|
||||
<Section padding={0} gap={0}>
|
||||
{isLoading ? (
|
||||
<Section>
|
||||
<SimpleLoader className="h-8 w-8" />
|
||||
</Section>
|
||||
) : loadError ? (
|
||||
<Section padding={1}>
|
||||
<Text text03 mainUiBody>
|
||||
{loadError}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
<MinimalMarkdown
|
||||
content={`\`\`\`${language}\n${fileContent}\n\`\`\``}
|
||||
className="w-full h-full break-words"
|
||||
components={{
|
||||
code: ({
|
||||
node,
|
||||
className: codeClassName,
|
||||
children,
|
||||
...props
|
||||
}: any) => {
|
||||
const codeText = extractCodeText(
|
||||
node,
|
||||
fileContent,
|
||||
children
|
||||
);
|
||||
return (
|
||||
<CodeBlock className="" codeText={codeText}>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text text03 mainContentMuted>
|
||||
{lineCount} {lineCount === 1 ? "line" : "lines"}
|
||||
</Text>
|
||||
<Section flexDirection="row" gap={0.5} width="fit">
|
||||
<CopyIconButton
|
||||
getCopyText={() => fileContent}
|
||||
tooltip="Copy code"
|
||||
size="sm"
|
||||
/>
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={fileName || presentingDocument.document_id}
|
||||
>
|
||||
<Button
|
||||
icon={SvgDownload}
|
||||
tooltip="Download"
|
||||
size="sm"
|
||||
prominence="tertiary"
|
||||
/>
|
||||
</a>
|
||||
</Section>
|
||||
</Section>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { getCodeLanguage } from "@/lib/languages";
|
||||
import { fetchChatFile } from "@/lib/chat/svc";
|
||||
import {
|
||||
PreviewContext,
|
||||
resolveVariant,
|
||||
} from "@/sections/modals/PreviewModalVariants";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIME resolution helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveMimeType(mimeType: string, fileName: string): string {
|
||||
if (mimeType !== "application/octet-stream") return mimeType;
|
||||
const lower = fileName.toLowerCase();
|
||||
if (lower.endsWith(".md") || lower.endsWith(".markdown"))
|
||||
return "text/markdown";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PreviewModal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PreviewModalProps {
|
||||
presentingDocument: MinimalOnyxDocument;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PreviewModal({
|
||||
presentingDocument,
|
||||
onClose,
|
||||
}: PreviewModalProps) {
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [fileUrl, setFileUrl] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [mimeType, setMimeType] = useState("application/octet-stream");
|
||||
const [zoom, setZoom] = useState(100);
|
||||
|
||||
// Resolve variant ----------------------------------------------------------
|
||||
|
||||
const variant = useMemo(
|
||||
() => resolveVariant(presentingDocument.semantic_identifier, mimeType),
|
||||
[presentingDocument.semantic_identifier, mimeType]
|
||||
);
|
||||
|
||||
// Derived values -----------------------------------------------------------
|
||||
|
||||
const language = useMemo(
|
||||
() =>
|
||||
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
|
||||
"plaintext",
|
||||
[presentingDocument.semantic_identifier]
|
||||
);
|
||||
|
||||
const lineCount = useMemo(() => {
|
||||
if (!fileContent) return 0;
|
||||
return fileContent.split("\n").length;
|
||||
}, [fileContent]);
|
||||
|
||||
const fileSize = useMemo(() => {
|
||||
if (!fileContent) return "";
|
||||
const bytes = new TextEncoder().encode(fileContent).length;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(2)} KB`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}, [fileContent]);
|
||||
|
||||
// File fetching ------------------------------------------------------------
|
||||
|
||||
const fetchFile = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
setFileContent("");
|
||||
const fileIdLocal =
|
||||
presentingDocument.document_id.split("__")[1] ||
|
||||
presentingDocument.document_id;
|
||||
|
||||
try {
|
||||
const response = await fetchChatFile(fileIdLocal);
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
setFileUrl((prev) => {
|
||||
if (prev) window.URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
|
||||
const originalFileName =
|
||||
presentingDocument.semantic_identifier || "document";
|
||||
setFileName(originalFileName);
|
||||
|
||||
const rawContentType =
|
||||
response.headers.get("Content-Type") || "application/octet-stream";
|
||||
const resolvedMime = resolveMimeType(rawContentType, originalFileName);
|
||||
setMimeType(resolvedMime);
|
||||
|
||||
const resolved = resolveVariant(
|
||||
presentingDocument.semantic_identifier,
|
||||
resolvedMime
|
||||
);
|
||||
if (resolved.needsTextContent) {
|
||||
setFileContent(await blob.text());
|
||||
}
|
||||
} catch {
|
||||
setLoadError("Failed to load document.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [presentingDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFile();
|
||||
}, [fetchFile]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fileUrl) window.URL.revokeObjectURL(fileUrl);
|
||||
};
|
||||
}, [fileUrl]);
|
||||
|
||||
// Actions ------------------------------------------------------------------
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const link = document.createElement("a");
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || presentingDocument.document_id;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, [fileUrl, fileName, presentingDocument.document_id]);
|
||||
|
||||
const handleZoomIn = useCallback(
|
||||
() => setZoom((prev) => Math.min(prev + 25, 200)),
|
||||
[]
|
||||
);
|
||||
const handleZoomOut = useCallback(
|
||||
() => setZoom((prev) => Math.max(prev - 25, 25)),
|
||||
[]
|
||||
);
|
||||
|
||||
// Build context ------------------------------------------------------------
|
||||
|
||||
const ctx: PreviewContext = useMemo(
|
||||
() => ({
|
||||
fileContent,
|
||||
fileUrl,
|
||||
fileName,
|
||||
language,
|
||||
lineCount,
|
||||
fileSize,
|
||||
zoom,
|
||||
onZoomIn: handleZoomIn,
|
||||
onZoomOut: handleZoomOut,
|
||||
onDownload: handleDownload,
|
||||
}),
|
||||
[
|
||||
fileContent,
|
||||
fileUrl,
|
||||
fileName,
|
||||
language,
|
||||
lineCount,
|
||||
fileSize,
|
||||
zoom,
|
||||
handleZoomIn,
|
||||
handleZoomOut,
|
||||
handleDownload,
|
||||
]
|
||||
);
|
||||
|
||||
// Render -------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<Modal.Content
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
preventAccidentalClose={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Modal.Header
|
||||
title={fileName || "Document"}
|
||||
description={variant.headerDescription(ctx)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Body + floating footer wrapper */}
|
||||
<Modal.Body padding={0} gap={0}>
|
||||
<Section padding={0} gap={0}>
|
||||
{isLoading ? (
|
||||
<Section>
|
||||
<SimpleLoader className="h-8 w-8" />
|
||||
</Section>
|
||||
) : loadError ? (
|
||||
<Section padding={1}>
|
||||
<Text text03 mainUiBody>
|
||||
{loadError}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
variant.renderContent(ctx)
|
||||
)}
|
||||
</Section>
|
||||
</Modal.Body>
|
||||
|
||||
{/* Floating footer */}
|
||||
{!isLoading && !loadError && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0",
|
||||
"flex items-center justify-between",
|
||||
"p-4 pointer-events-none w-full"
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, var(--background-tint-01) 40%, transparent)",
|
||||
}}
|
||||
>
|
||||
{/* Left slot */}
|
||||
<div className="pointer-events-auto z-10">
|
||||
{variant.renderFooterLeft(ctx)}
|
||||
</div>
|
||||
|
||||
{/* Right slot */}
|
||||
<div className="pointer-events-auto z-10 rounded-12 bg-background-tint-00 p-1 shadow-lg">
|
||||
{variant.renderFooterRight(ctx)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgDownload, SvgZoomIn, SvgZoomOut } from "@opal/icons";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import TextSeparator from "@/refresh-components/TextSeparator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { getCodeLanguage } from "@/lib/languages";
|
||||
import { CodeBlock } from "@/app/app/message/CodeBlock";
|
||||
import { extractCodeText } from "@/app/app/message/codeUtils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PreviewContext — shared data bag passed into every variant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PreviewContext {
|
||||
fileContent: string;
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
language: string;
|
||||
lineCount: number;
|
||||
fileSize: string;
|
||||
zoom: number;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onDownload: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PreviewVariant — self-contained definition for a file-type view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PreviewVariant {
|
||||
/** Return true if this variant should handle the given file. */
|
||||
matches: (semanticIdentifier: string | null, mimeType: string) => boolean;
|
||||
/** Modal width. */
|
||||
width: "lg" | "md" | "md-sm" | "sm";
|
||||
/** Modal height. */
|
||||
height: "fit" | "sm" | "lg" | "full";
|
||||
/** Whether the fetcher should read the blob as text. */
|
||||
needsTextContent: boolean;
|
||||
/** String shown below the title in the modal header. */
|
||||
headerDescription: (ctx: PreviewContext) => string;
|
||||
/** Body content. */
|
||||
renderContent: (ctx: PreviewContext) => React.ReactNode;
|
||||
/** Left side of the floating footer (e.g. line count text, zoom controls). Return null for nothing. */
|
||||
renderFooterLeft: (ctx: PreviewContext) => React.ReactNode;
|
||||
/** Right side of the floating footer (e.g. copy + download buttons). */
|
||||
renderFooterRight: (ctx: PreviewContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared footer building blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DownloadButton({ onDownload }: { onDownload: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgDownload}
|
||||
onClick={onDownload}
|
||||
tooltip="Download"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ getText }: { getText: () => string }) {
|
||||
return (
|
||||
<CopyIconButton getCopyText={getText} tooltip="Copy content" size="sm" />
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomControls({
|
||||
zoom,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
}: {
|
||||
zoom: number;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-12 bg-background-tint-00 p-1 shadow-lg">
|
||||
<Section flexDirection="row" width="fit">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgZoomOut}
|
||||
onClick={onZoomOut}
|
||||
tooltip="Zoom Out"
|
||||
/>
|
||||
<Text mainUiMono text03>
|
||||
{zoom}%
|
||||
</Text>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgZoomIn}
|
||||
onClick={onZoomIn}
|
||||
tooltip="Zoom In"
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MARKDOWN_MIMES = [
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/plain",
|
||||
"text/x-rst",
|
||||
"text/x-org",
|
||||
];
|
||||
|
||||
const codeVariant: PreviewVariant = {
|
||||
matches: (name) => !!getCodeLanguage(name || ""),
|
||||
width: "md",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
|
||||
headerDescription: (ctx) =>
|
||||
ctx.fileContent
|
||||
? `${ctx.language} - ${ctx.lineCount} ${
|
||||
ctx.lineCount === 1 ? "line" : "lines"
|
||||
} · ${ctx.fileSize}`
|
||||
: "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<MinimalMarkdown
|
||||
content={`\`\`\`${ctx.language}${ctx.fileContent}\n\n\`\`\``}
|
||||
className="w-full break-words h-full"
|
||||
components={{
|
||||
code: ({ node, children }: any) => {
|
||||
const codeText = extractCodeText(node, ctx.fileContent, children);
|
||||
return (
|
||||
<CodeBlock className="" codeText={codeText}>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
<Text text03 mainUiBody className="select-none">
|
||||
{ctx.lineCount} {ctx.lineCount === 1 ? "line" : "lines"}
|
||||
</Text>
|
||||
),
|
||||
|
||||
renderFooterRight: (ctx) => (
|
||||
<Section flexDirection="row" width="fit">
|
||||
<CopyButton getText={() => ctx.fileContent} />
|
||||
<DownloadButton onDownload={ctx.onDownload} />
|
||||
</Section>
|
||||
),
|
||||
};
|
||||
|
||||
const imageVariant: PreviewVariant = {
|
||||
matches: (_name, mime) => mime.startsWith("image/"),
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<div
|
||||
className="flex flex-1 min-h-0 items-center justify-center p-4 transition-transform duration-300 ease-in-out"
|
||||
style={{
|
||||
transform: `scale(${ctx.zoom / 100})`,
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={ctx.fileUrl}
|
||||
alt={ctx.fileName}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
<ZoomControls
|
||||
zoom={ctx.zoom}
|
||||
onZoomIn={ctx.onZoomIn}
|
||||
onZoomOut={ctx.onZoomOut}
|
||||
/>
|
||||
),
|
||||
|
||||
renderFooterRight: (ctx) => (
|
||||
<Section flexDirection="row" width="fit">
|
||||
<CopyButton getText={() => ctx.fileContent} />
|
||||
<DownloadButton onDownload={ctx.onDownload} />
|
||||
</Section>
|
||||
),
|
||||
};
|
||||
|
||||
const pdfVariant: PreviewVariant = {
|
||||
matches: (_name, mime) => mime === "application/pdf",
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<iframe
|
||||
src={`${ctx.fileUrl}#toolbar=0`}
|
||||
className="w-full h-full flex-1 min-h-0 border-none"
|
||||
title="PDF Viewer"
|
||||
/>
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
<ZoomControls
|
||||
zoom={ctx.zoom}
|
||||
onZoomIn={ctx.onZoomIn}
|
||||
onZoomOut={ctx.onZoomOut}
|
||||
/>
|
||||
),
|
||||
renderFooterRight: (ctx) => (
|
||||
<Section flexDirection="row" width="fit">
|
||||
<CopyButton getText={() => ctx.fileContent} />
|
||||
<DownloadButton onDownload={ctx.onDownload} />
|
||||
</Section>
|
||||
),
|
||||
};
|
||||
|
||||
interface CsvData {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
function parseCsv(content: string): CsvData {
|
||||
const lines = content.split(/\r?\n/).filter((l) => l.length > 0);
|
||||
const headers = lines.length > 0 ? lines[0]?.split(",") ?? [] : [];
|
||||
const rows = lines.slice(1).map((line) => line.split(","));
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
const csvVariant: PreviewVariant = {
|
||||
matches: (name, mime) =>
|
||||
mime.startsWith("text/csv") || (name || "").toLowerCase().endsWith(".csv"),
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
headerDescription: (ctx) => {
|
||||
if (!ctx.fileContent) return "";
|
||||
const { rows } = parseCsv(ctx.fileContent);
|
||||
return `CSV - ${rows.length} rows · ${ctx.fileSize}`;
|
||||
},
|
||||
|
||||
renderContent: (ctx) => {
|
||||
if (!ctx.fileContent) return null;
|
||||
const { headers, rows } = parseCsv(ctx.fileContent);
|
||||
return (
|
||||
<Section justifyContent="start" alignItems="start" padding={1}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-sticky">
|
||||
<TableRow noHover>
|
||||
{headers.map((h: string, i: number) => (
|
||||
<TableHead key={i}>
|
||||
<Text as="p" className="line-clamp-2" text04 secondaryAction>
|
||||
{h}
|
||||
</Text>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row: string[], rIdx: number) => (
|
||||
<TableRow key={rIdx} noHover>
|
||||
{headers.map((_: string, cIdx: number) => (
|
||||
<TableCell
|
||||
key={cIdx}
|
||||
className={cn(
|
||||
cIdx === 0 && "sticky left-0",
|
||||
"py-3 px-4 whitespace-normal break-words"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
{...(cIdx === 0
|
||||
? { text04: true, secondaryAction: true }
|
||||
: { text03: true, secondaryBody: true })}
|
||||
>
|
||||
{row?.[cIdx] ?? ""}
|
||||
</Text>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TextSeparator
|
||||
count={rows.length}
|
||||
text={rows.length === 1 ? "row" : "rows"}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
},
|
||||
|
||||
renderFooterLeft: (ctx) => {
|
||||
if (!ctx.fileContent) return null;
|
||||
const { headers, rows } = parseCsv(ctx.fileContent);
|
||||
return (
|
||||
<Text text03 mainUiBody className="select-none">
|
||||
{headers.length} {headers.length === 1 ? "column" : "columns"} ·{" "}
|
||||
{rows.length} {rows.length === 1 ? "row" : "rows"}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
renderFooterRight: (ctx) => (
|
||||
<Section flexDirection="row" width="fit">
|
||||
<CopyButton getText={() => ctx.fileContent} />
|
||||
<DownloadButton onDownload={ctx.onDownload} />
|
||||
</Section>
|
||||
),
|
||||
};
|
||||
|
||||
const markdownVariant: PreviewVariant = {
|
||||
matches: (name, mime) => {
|
||||
if (MARKDOWN_MIMES.some((m) => mime.startsWith(m))) return true;
|
||||
const lower = (name || "").toLowerCase();
|
||||
return (
|
||||
lower.endsWith(".md") ||
|
||||
lower.endsWith(".markdown") ||
|
||||
lower.endsWith(".txt") ||
|
||||
lower.endsWith(".rst") ||
|
||||
lower.endsWith(".org")
|
||||
);
|
||||
},
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<ScrollIndicatorDiv className="flex-1 min-h-0 p-4" variant="shadow">
|
||||
<MinimalMarkdown
|
||||
content={ctx.fileContent}
|
||||
className="w-full pb-4 h-full text-lg break-words"
|
||||
/>
|
||||
</ScrollIndicatorDiv>
|
||||
),
|
||||
|
||||
renderFooterLeft: () => null,
|
||||
|
||||
renderFooterRight: (ctx) => (
|
||||
<Section flexDirection="row" width="fit">
|
||||
<CopyButton getText={() => ctx.fileContent} />
|
||||
<DownloadButton onDownload={ctx.onDownload} />
|
||||
</Section>
|
||||
),
|
||||
};
|
||||
|
||||
const unsupportedVariant: PreviewVariant = {
|
||||
matches: () => true,
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<div className="flex flex-col items-center justify-center flex-1 min-h-0 gap-4 p-6">
|
||||
<Text as="p" text03 mainUiBody>
|
||||
This file format is not supported for preview.
|
||||
</Text>
|
||||
<Button onClick={ctx.onDownload}>Download File</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
renderFooterLeft: () => null,
|
||||
renderFooterRight: (ctx) => <DownloadButton onDownload={ctx.onDownload} />,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant registry — first match wins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PREVIEW_VARIANTS: PreviewVariant[] = [
|
||||
codeVariant,
|
||||
imageVariant,
|
||||
pdfVariant,
|
||||
csvVariant,
|
||||
markdownVariant,
|
||||
];
|
||||
|
||||
export function resolveVariant(
|
||||
semanticIdentifier: string | null,
|
||||
mimeType: string
|
||||
): PreviewVariant {
|
||||
return (
|
||||
PREVIEW_VARIANTS.find((v) => v.matches(semanticIdentifier, mimeType)) ??
|
||||
unsupportedVariant
|
||||
);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ test.describe("File preview modal from chat file links", () => {
|
||||
await expect(modal.getByText("Hello from the mock file!")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking a code file link opens the PreviewModal with syntax highlighting", async ({
|
||||
test("clicking a code file link opens the CodeViewModal with syntax highlighting", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mockContent = `Here is your script: [app.py](/api/chat/file/${MOCK_FILE_ID})`;
|
||||
@@ -173,7 +173,7 @@ test.describe("File preview modal from chat file links", () => {
|
||||
await expect(fileLink).toBeVisible({ timeout: 5000 });
|
||||
await fileLink.click();
|
||||
|
||||
// Verify the PreviewModal opens
|
||||
// Verify the CodeViewModal opens
|
||||
const modal = page.getByRole("dialog");
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user