Compare commits
10 Commits
7c2be08267
...
f73577da4f
| Author | SHA1 | Date | |
|---|---|---|---|
| f73577da4f | |||
| f9c821efac | |||
| ac28570784 | |||
| c6199d79fe | |||
| f87bff377d | |||
| f9e7ec9511 | |||
| b46db8b99e | |||
| bb744e02d5 | |||
| b7d73f3639 | |||
| fc2b886e7a |
@@ -98,6 +98,7 @@ model Function {
|
||||
// System Manged Data
|
||||
guest_access Boolean @default(false) // Whether guest users can access this function, will be kept in sync with GuestUser permissions, when changed
|
||||
imported Boolean @default(false) // Marks whether the function was created via import
|
||||
ai_kicked_off Boolean @default(false) // Marks whether the function was created via AI kickoff
|
||||
|
||||
// Caching
|
||||
cache_enabled Boolean @default(false)
|
||||
|
||||
@@ -9,7 +9,7 @@ export = new fileRouter.Path("/").http(
|
||||
http
|
||||
.document({
|
||||
description:
|
||||
"Get information about the authenticated user (session or API key).",
|
||||
"Get information about the authenticated user and AI availability.",
|
||||
tags: ["User"] as OpenAPITags[],
|
||||
operationId: "getUserInfo",
|
||||
responses: {
|
||||
@@ -21,9 +21,13 @@ export = new fileRouter.Path("/").http(
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string" },
|
||||
user: { type: "object" },
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKeyConfigured: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
session: { type: ["object", "null"] },
|
||||
apiKey: { type: ["object", "null"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -50,9 +54,11 @@ export = new fileRouter.Path("/").http(
|
||||
...authCheck.user,
|
||||
password: undefined,
|
||||
openRouterKey: undefined,
|
||||
apiKeyConfigured: Boolean(
|
||||
authCheck.user.openRouterKey || process.env.OPENROUTER_API_KEY,
|
||||
),
|
||||
},
|
||||
session: authCheck.method === "session" ? authCheck.session : null,
|
||||
apiKey: authCheck.method === "apiKey" ? authCheck.apiKey : null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -750,7 +750,8 @@ Platform Rules:
|
||||
return ctr.print({ status: 401, message: authCheck.message });
|
||||
}
|
||||
|
||||
const openRouterKey = authCheck.user.openRouterKey;
|
||||
const openRouterKey =
|
||||
authCheck.user.openRouterKey || process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!openRouterKey) {
|
||||
return ctr.status(ctr.$status.SERVICE_UNAVAILABLE).print({
|
||||
|
||||
@@ -137,6 +137,10 @@ export = new fileRouter.Path("/")
|
||||
type: "boolean",
|
||||
description: "Marks the function as imported",
|
||||
},
|
||||
ai_kicked_off: {
|
||||
type: "boolean",
|
||||
description: "Marks the function as created via AI kickoff",
|
||||
},
|
||||
settings: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -231,6 +235,7 @@ export = new fileRouter.Path("/")
|
||||
.regex(/^[a-zA-Z0-9-_]+$/)
|
||||
.optional(), // Only allow alphanumeric, hyphens, and underscores
|
||||
imported: z.boolean().optional(),
|
||||
ai_kicked_off: z.boolean().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
max_ram: z.number().min(128).max(1024).optional(),
|
||||
@@ -362,6 +367,7 @@ export = new fileRouter.Path("/")
|
||||
cache_enabled: data.settings?.cache_enabled ?? false,
|
||||
cache_ttl: data.settings?.cache_ttl ?? 60,
|
||||
imported: data.imported ?? false,
|
||||
ai_kicked_off: data.ai_kicked_off ?? false,
|
||||
env: data.environment
|
||||
? JSON.stringify(
|
||||
data.environment.map((env) => ({
|
||||
@@ -787,6 +793,11 @@ export = new fileRouter.Path("/")
|
||||
},
|
||||
ffmpeg_install: { type: "boolean", description: "Install ffmpeg" },
|
||||
opencv_install: { type: "boolean", description: "Install opencv" },
|
||||
imported: { type: "boolean", description: "Marks the function as imported" },
|
||||
ai_kicked_off: {
|
||||
type: "boolean",
|
||||
description: "Marks the function as created via AI kickoff",
|
||||
},
|
||||
executionAlias: { type: "string" },
|
||||
settings: {
|
||||
type: "object",
|
||||
@@ -881,6 +892,8 @@ export = new fileRouter.Path("/")
|
||||
network_restricted: z.boolean().optional(),
|
||||
ffmpeg_install: z.boolean().optional(),
|
||||
opencv_install: z.boolean().optional(),
|
||||
imported: z.boolean().optional(),
|
||||
ai_kicked_off: z.boolean().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
max_ram: z.number().min(128).max(1024).optional(),
|
||||
@@ -1060,6 +1073,12 @@ export = new fileRouter.Path("/")
|
||||
...(data.opencv_install !== undefined && {
|
||||
opencv_install: data.opencv_install,
|
||||
}),
|
||||
...(data.imported !== undefined && {
|
||||
imported: data.imported,
|
||||
}),
|
||||
...(data.ai_kicked_off !== undefined && {
|
||||
ai_kicked_off: data.ai_kicked_off,
|
||||
}),
|
||||
...(data.cors_origins !== undefined && {
|
||||
cors_origins: data.cors_origins,
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-toastify": "^11.0.5",
|
||||
"jszip": "^3.10.1",
|
||||
"serve": "^14.2.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^4.9.5",
|
||||
|
||||
Generated
+35
@@ -44,6 +44,9 @@ importers:
|
||||
'@types/react-syntax-highlighter':
|
||||
specifier: ^15.5.13
|
||||
version: 15.5.13
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
motion:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -3332,6 +3335,9 @@ packages:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@9.0.21:
|
||||
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
|
||||
|
||||
@@ -3824,6 +3830,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -3857,6 +3866,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -4307,6 +4319,9 @@ packages:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
param-case@3.0.4:
|
||||
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
|
||||
|
||||
@@ -5286,6 +5301,9 @@ packages:
|
||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
@@ -9916,6 +9934,8 @@ snapshots:
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@9.0.21: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -10660,6 +10680,13 @@ snapshots:
|
||||
object.assign: 4.1.7
|
||||
object.values: 1.2.1
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -10688,6 +10715,10 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -11085,6 +11116,8 @@ snapshots:
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
param-case@3.0.4:
|
||||
dependencies:
|
||||
dot-case: 3.0.4
|
||||
@@ -12206,6 +12239,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
|
||||
@@ -1,7 +1,60 @@
|
||||
import { useState, type DragEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { FunctionFile } from "../../types/Prisma";
|
||||
import { ActionButton } from "../buttons/ActionButton";
|
||||
|
||||
function SelectionToggle({
|
||||
checked,
|
||||
indeterminate = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: {
|
||||
checked: boolean;
|
||||
indeterminate?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
aria-label={indeterminate ? "Partially selected" : checked ? "Selected" : "Select"}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!disabled) onClick();
|
||||
}}
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-md border transition-all duration-200 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-white/10 bg-white/5 opacity-40"
|
||||
: checked || indeterminate
|
||||
? "border-primary/60 bg-primary text-white shadow-[0_0_0_1px_rgba(34,211,238,0.25)]"
|
||||
: "border-white/15 bg-white/5 text-white/30 hover:border-primary/40 hover:bg-primary/10 hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<span className="h-0.5 w-2.5 rounded-full bg-current" />
|
||||
) : checked ? (
|
||||
<svg viewBox="0 0 20 20" fill="none" className="h-3.5 w-3.5">
|
||||
<path
|
||||
d="M4.5 10.5L8.2 14.2L15.5 5.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileManagerCard({
|
||||
files,
|
||||
activeFile,
|
||||
@@ -10,10 +63,16 @@ export function FileManagerCard({
|
||||
onDownloadFile,
|
||||
onRenameFile,
|
||||
onDeleteFile,
|
||||
onDeleteSelectedFiles,
|
||||
nonSelectableOnSelectAllFileNames = [],
|
||||
onDropFiles,
|
||||
onAIGenerate,
|
||||
aiDisabled = false,
|
||||
aiDisabledReason,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
autoUnzipFiles,
|
||||
onAutoUnzipFilesChange,
|
||||
}: {
|
||||
files: FunctionFile[];
|
||||
activeFile: FunctionFile | null;
|
||||
@@ -22,12 +81,128 @@ export function FileManagerCard({
|
||||
onDownloadFile: (file: FunctionFile) => void;
|
||||
onRenameFile: (file: FunctionFile) => void;
|
||||
onDeleteFile: (file: FunctionFile) => void;
|
||||
onDropFiles?: (files: File[]) => void | Promise<void>;
|
||||
onDeleteSelectedFiles?: (files: FunctionFile[]) => boolean | Promise<boolean>;
|
||||
nonSelectableOnSelectAllFileNames?: string[];
|
||||
onDropFiles?: (
|
||||
files: File[],
|
||||
options?: { unzipArchives?: boolean },
|
||||
) => void | Promise<void>;
|
||||
onAIGenerate?: () => void;
|
||||
aiDisabled?: boolean;
|
||||
aiDisabledReason?: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
autoUnzipFiles: boolean;
|
||||
onAutoUnzipFilesChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const zipUploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const selectAllExcludedFileNames = useMemo(
|
||||
() => new Set(nonSelectableOnSelectAllFileNames),
|
||||
[nonSelectableOnSelectAllFileNames],
|
||||
);
|
||||
|
||||
const selectableFiles = useMemo(
|
||||
() => files.filter((file) => !selectAllExcludedFileNames.has(file.name)),
|
||||
[files, selectAllExcludedFileNames],
|
||||
);
|
||||
const selectableFileIds = useMemo(
|
||||
() => selectableFiles.map((file) => file.id),
|
||||
[selectableFiles],
|
||||
);
|
||||
|
||||
const selectedFiles = useMemo(
|
||||
() => files.filter((file) => selectedFileIds.has(file.id)),
|
||||
[files, selectedFileIds],
|
||||
);
|
||||
const selectedSelectableCount = selectedFiles.filter((file) =>
|
||||
selectableFileIds.includes(file.id),
|
||||
).length;
|
||||
const allSelectableFilesSelected =
|
||||
selectableFileIds.length > 0 && selectedSelectableCount === selectableFileIds.length;
|
||||
const hasPartialSelection =
|
||||
selectedSelectableCount > 0 && selectedSelectableCount < selectableFileIds.length;
|
||||
const hasSelectedFiles = selectedFiles.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFileIds((prev) => {
|
||||
const availableIds = new Set(files.map((file) => file.id));
|
||||
const next = new Set(Array.from(prev).filter((id) => availableIds.has(id)));
|
||||
if (
|
||||
next.size === prev.size &&
|
||||
Array.from(next).every((id) => prev.has(id))
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [files]);
|
||||
|
||||
const toggleFileSelection = (fileId: number) => {
|
||||
setSelectedFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fileId)) {
|
||||
next.delete(fileId);
|
||||
} else {
|
||||
next.add(fileId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allSelectableFilesSelected) {
|
||||
selectableFileIds.forEach((id) => next.delete(id));
|
||||
} else {
|
||||
selectableFileIds.forEach((id) => next.add(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedFileIds(new Set());
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleZipUploadClick = () => {
|
||||
zipUploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUploadChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!onDropFiles) return;
|
||||
const uploadedFiles = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
if (uploadedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
void onDropFiles(uploadedFiles, { unzipArchives: autoUnzipFiles });
|
||||
};
|
||||
|
||||
const handleZipUploadChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!onDropFiles) return;
|
||||
const uploadedFiles = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
if (uploadedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
void onDropFiles(uploadedFiles, { unzipArchives: true });
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (!onDeleteSelectedFiles || !hasSelectedFiles) return;
|
||||
const shouldDelete = await onDeleteSelectedFiles(selectedFiles);
|
||||
if (shouldDelete) {
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (!onDropFiles) return;
|
||||
@@ -60,7 +235,7 @@ export function FileManagerCard({
|
||||
if (droppedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
void onDropFiles(droppedFiles);
|
||||
void onDropFiles(droppedFiles, { unzipArchives: autoUnzipFiles });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -87,20 +262,70 @@ export function FileManagerCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex items-center justify-between gap-3 text-xs text-text/60">
|
||||
{files.length > 0 ? (
|
||||
<div className="flex items-center gap-2 select-none">
|
||||
<SelectionToggle
|
||||
checked={allSelectableFilesSelected}
|
||||
indeterminate={hasPartialSelection}
|
||||
disabled={selectableFileIds.length === 0}
|
||||
onClick={handleSelectAll}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectableFileIds.length === 0}
|
||||
onClick={handleSelectAll}
|
||||
className={`text-left transition-colors ${
|
||||
selectableFileIds.length === 0
|
||||
? "cursor-not-allowed text-text/35"
|
||||
: "text-text/70 hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{hasSelectedFiles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Clear {selectedFiles.length}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-3 max-h-64 overflow-y-auto scrollbar-thin scrollbar-track-gray-800 scrollbar-thumb-primary/30">
|
||||
{files.length > 0 ? (
|
||||
files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`bg-background/30 border rounded-lg p-2 cursor-pointer transition-all duration-200 ${
|
||||
activeFile?.id === file.id
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-primary/10 hover:border-primary/30 hover:bg-primary/5"
|
||||
selectedFileIds.has(file.id)
|
||||
? "border-primary/50 bg-primary/10"
|
||||
: activeFile?.id === file.id
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-primary/10 hover:border-primary/30 hover:bg-primary/5"
|
||||
}`}
|
||||
onClick={() => onFileSelect(file)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text text-sm truncate flex-1">{file.name}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<SelectionToggle
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onClick={() => {
|
||||
toggleFileSelection(file.id);
|
||||
}}
|
||||
/>
|
||||
<span className="text-text text-sm truncate flex-1">
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 ml-2 flex-shrink-0">
|
||||
<button
|
||||
className="p-1 text-blue-400 hover:bg-blue-400/10 rounded transition-all duration-200 text-xs"
|
||||
@@ -159,10 +384,81 @@ export function FileManagerCard({
|
||||
variant="primary"
|
||||
onClick={onCreateFile}
|
||||
/>
|
||||
{onDropFiles && (
|
||||
<>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUploadChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadClick}
|
||||
className="w-full mt-2 flex items-center justify-center gap-2 py-2 px-3 rounded-lg text-xs font-bold uppercase tracking-widest transition-all duration-200 bg-background/50 border border-primary/20 text-primary hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
📤 Upload Files
|
||||
</button>
|
||||
<input
|
||||
ref={zipUploadInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
onChange={handleZipUploadChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZipUploadClick}
|
||||
className="w-full mt-2 flex items-center justify-center gap-2 py-2 px-3 rounded-lg text-xs font-bold uppercase tracking-widest transition-all duration-200 bg-background/50 border border-primary/20 text-primary hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
🗜️ Upload From Zip
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onDropFiles && (
|
||||
<div className="mt-2 rounded-lg border border-primary/20 bg-background/30 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={autoUnzipFiles}
|
||||
onClick={() => onAutoUnzipFilesChange(!autoUnzipFiles)}
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
>
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-text/75">
|
||||
Auto Unzip Files
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-5 w-9 items-center rounded-full p-0.5 transition-colors ${
|
||||
autoUnzipFiles ? "bg-primary" : "bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
autoUnzipFiles ? "translate-x-4" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onDeleteSelectedFiles && hasSelectedFiles && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleDeleteSelected();
|
||||
}}
|
||||
className="w-full mt-2 flex items-center justify-center gap-2 py-2 px-3 rounded-lg text-xs font-bold uppercase tracking-widest transition-all duration-200 bg-red-600/20 border border-red-500/30 text-red-300 hover:bg-red-500/20 hover:border-red-400/40"
|
||||
>
|
||||
🗑️ Delete Selected ({selectedFiles.length})
|
||||
</button>
|
||||
)}
|
||||
{onAIGenerate && (
|
||||
<button
|
||||
disabled={aiDisabled}
|
||||
onClick={onAIGenerate}
|
||||
className="w-full mt-2 flex items-center justify-center gap-2 py-2 px-3 rounded-lg text-xs font-bold uppercase tracking-widest transition-all duration-200"
|
||||
className={`w-full mt-2 flex items-center justify-center gap-2 py-2 px-3 rounded-lg text-xs font-bold uppercase tracking-widest transition-all duration-200 ${
|
||||
aiDisabled ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg,rgba(99,102,241,0.18),rgba(139,92,246,0.12))",
|
||||
@@ -180,12 +476,15 @@ export function FileManagerCard({
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(99,102,241,0.3)";
|
||||
}}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-orange-400 fill-orange-400" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
KICKOFF
|
||||
</button>
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-orange-400 fill-orange-400" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
KICKOFF
|
||||
</button>
|
||||
)}
|
||||
{aiDisabled && aiDisabledReason && (
|
||||
<p className="mt-2 text-xs text-yellow-300/80">{aiDisabledReason}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
FunctionFile,
|
||||
getImageDisplayName,
|
||||
@@ -16,6 +17,8 @@ interface AIGenerateModalProps {
|
||||
namespaceId?: number; // Required for creation flow
|
||||
existingFiles?: FunctionFile[];
|
||||
onSuccess?: () => void; // called after files are written — triggers a file list refresh
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
function AIGenerateModal({
|
||||
@@ -25,7 +28,10 @@ function AIGenerateModal({
|
||||
namespaceId,
|
||||
existingFiles = [],
|
||||
onSuccess,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
}: AIGenerateModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<AIMode>(functionId ? "revision" : "kickoff");
|
||||
const [stage, setStage] = useState<"intake" | "review" | "generating">(
|
||||
functionId ? "generating" : "intake",
|
||||
@@ -103,6 +109,7 @@ function AIGenerateModal({
|
||||
namespaceId: namespaceId,
|
||||
startup_file: suggestedConfig.startup_file,
|
||||
docker_mount: dockerMount,
|
||||
ai_kicked_off: true,
|
||||
settings: {
|
||||
allow_http: allowHttp,
|
||||
},
|
||||
@@ -126,6 +133,10 @@ function AIGenerateModal({
|
||||
if (genRes.status === "OK" && "data" in genRes) {
|
||||
setResult(genRes.data);
|
||||
if (onSuccess) onSuccess();
|
||||
if (!functionId) {
|
||||
onClose();
|
||||
navigate(`/functions/${newFunctionId}`);
|
||||
}
|
||||
} else {
|
||||
setError((genRes as any).message || "Generation failed (function created)");
|
||||
}
|
||||
@@ -211,18 +222,39 @@ function AIGenerateModal({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={functionId ? "Code Generation" : "AI KICKOFF"}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 p-4 text-sm text-yellow-100">
|
||||
{disabledReason ??
|
||||
"AI features are disabled until an OpenRouter API key is configured."}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
Open Account Settings to add a key, then reopen this dialog.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={functionId ? "AI ASSISTANT" : "AI KICKOFF"}
|
||||
title={functionId ? "Code Generation" : "AI KICKOFF"}
|
||||
maxWidth="lg"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{/* Scrollable body */}
|
||||
<div className="space-y-5">
|
||||
<p className="text-xs text-gray-500 tracking-wider mt-0.5 uppercase">
|
||||
{stage === "intake" ? "Configure your function" : stage === "review" ? "Review configuration" : "Generating files..."}
|
||||
{stage === "intake" ? "Configure your function" : "Review configuration"}
|
||||
</p>
|
||||
|
||||
{stage === "intake" && !functionId && (
|
||||
@@ -628,7 +660,7 @@ function AIGenerateModal({
|
||||
{isLoading
|
||||
? "Processing..."
|
||||
: functionId
|
||||
? "Run Generation"
|
||||
? "✨ Generate"
|
||||
: stage === "intake"
|
||||
? "Next Step"
|
||||
: "Confirm & Kickoff"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import Modal from "../Modal";
|
||||
import { useConfirm } from "../ConfirmModal";
|
||||
import {
|
||||
@@ -58,10 +58,19 @@ function UpdateFunctionModal({
|
||||
const [cacheTtl, setCacheTtl] = useState<number>(60);
|
||||
const [isReinstallingFfmpeg, setIsReinstallingFfmpeg] = useState(false);
|
||||
const [isReinstallingOpencv, setIsReinstallingOpencv] = useState(false);
|
||||
const [isClearingSourceFlags, setIsClearingSourceFlags] = useState(false);
|
||||
const [isDeprecated, setIsDeprecated] = useState<boolean>(false);
|
||||
const [deprecatedImages, setDeprecatedImages] = useState<string[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<Namespace[]>([]);
|
||||
const [selectedNamespaceId, setSelectedNamespaceId] = useState<number>();
|
||||
const [sourceFlags, setSourceFlags] = useState<{
|
||||
imported: boolean;
|
||||
ai_kicked_off: boolean;
|
||||
}>({
|
||||
imported: false,
|
||||
ai_kicked_off: false,
|
||||
});
|
||||
const initializedFunctionIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDeprecation = async () => {
|
||||
@@ -109,7 +118,12 @@ function UpdateFunctionModal({
|
||||
|
||||
// Initialize form with existing function data
|
||||
useEffect(() => {
|
||||
if (functionData && isOpen) {
|
||||
if (!isOpen) {
|
||||
initializedFunctionIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (functionData && initializedFunctionIdRef.current !== functionData.id) {
|
||||
setName(functionData.name);
|
||||
setDescription(functionData.description || "");
|
||||
setImage(functionData.image as Image);
|
||||
@@ -127,6 +141,11 @@ function UpdateFunctionModal({
|
||||
setCacheEnabled(functionData.cache_enabled ?? false);
|
||||
setCacheTtl(functionData.cache_ttl ?? 60);
|
||||
setSelectedNamespaceId(functionData.namespaceId);
|
||||
setSourceFlags({
|
||||
imported: functionData.imported ?? false,
|
||||
ai_kicked_off: functionData.ai_kicked_off ?? false,
|
||||
});
|
||||
initializedFunctionIdRef.current = functionData.id;
|
||||
}
|
||||
}, [functionData, isOpen]);
|
||||
|
||||
@@ -144,6 +163,7 @@ function UpdateFunctionModal({
|
||||
const namespaceSelectValue =
|
||||
namespaces.length > 0 ? (selectedNamespaceId ?? "") : "";
|
||||
const isDotnetRuntime = isDotnetImage(image);
|
||||
const hasSourceFlags = sourceFlags.imported || sourceFlags.ai_kicked_off;
|
||||
|
||||
const addCorsOrigin = () => {
|
||||
const val = corsOriginInput.trim();
|
||||
@@ -274,6 +294,11 @@ function UpdateFunctionModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCorsOriginsArray = corsOrigins
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0);
|
||||
|
||||
const urls = lastLogs
|
||||
.flatMap((log) => {
|
||||
try {
|
||||
@@ -298,8 +323,8 @@ function UpdateFunctionModal({
|
||||
const filteredUrls = urls
|
||||
.filter(
|
||||
(url) =>
|
||||
!corsOriginsArray.includes(`https://${url}`) &&
|
||||
!corsOriginsArray.includes(`http://${url}`),
|
||||
!currentCorsOriginsArray.includes(`https://${url}`) &&
|
||||
!currentCorsOriginsArray.includes(`http://${url}`),
|
||||
)
|
||||
.slice(0, 5); // limit to 5 URLs
|
||||
|
||||
@@ -334,6 +359,46 @@ function UpdateFunctionModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSourceFlags = async () => {
|
||||
if (!functionData || !hasSourceFlags) return;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Clear Source Flags?",
|
||||
message:
|
||||
"This will remove the Imported and AI Kicked-Off labels from this function. It will not change the code, files, or runtime settings.",
|
||||
confirmText: "Clear Flags",
|
||||
cancelText: "Cancel",
|
||||
variant: "delete",
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsClearingSourceFlags(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await updateFunction(functionData.id, {
|
||||
imported: false,
|
||||
ai_kicked_off: false,
|
||||
});
|
||||
|
||||
if (response.status === "OK") {
|
||||
setSourceFlags({
|
||||
imported: false,
|
||||
ai_kicked_off: false,
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
setError("Error clearing source flags: " + response.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to clear source flags:", err);
|
||||
setError("An unexpected error occurred while clearing source flags");
|
||||
} finally {
|
||||
setIsClearingSourceFlags(false);
|
||||
}
|
||||
};
|
||||
|
||||
// console.log("Last logs passed to UpdateFunctionModal:", lastLogs); // Debug log
|
||||
// console.log("Logged URLs for CORS suggestions:", loggedUrls); // Debug log
|
||||
|
||||
@@ -355,6 +420,48 @@ function UpdateFunctionModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasSourceFlags && (
|
||||
<div className="rounded-lg border border-cyan-500/30 bg-gradient-to-r from-cyan-500/10 via-sky-500/10 to-indigo-500/10 p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 text-2xl">🏷️</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-cyan-200 text-sm font-semibold">
|
||||
Source flags enabled
|
||||
</h3>
|
||||
<p className="text-cyan-100/80 text-xs leading-relaxed">
|
||||
This function is marked as imported or AI generated. You can
|
||||
clear those labels without changing the function itself.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sourceFlags.imported && (
|
||||
<span className="rounded-full border border-blue-400/40 bg-blue-500/15 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-blue-100">
|
||||
Imported
|
||||
</span>
|
||||
)}
|
||||
{sourceFlags.ai_kicked_off && (
|
||||
<span className="rounded-full border border-emerald-400/40 bg-emerald-500/15 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-emerald-100">
|
||||
AI Kicked-Off
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleClearSourceFlags();
|
||||
}}
|
||||
disabled={isLoading || isClearingSourceFlags}
|
||||
className="shrink-0 rounded-lg border border-cyan-400/30 bg-cyan-500/15 px-4 py-2 text-xs font-semibold text-cyan-100 transition-all duration-300 hover:bg-cyan-500/25 hover:border-cyan-300/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isClearingSourceFlags ? "Clearing..." : "Clear Flags"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-yellow-900/30 border-l-4 border-yellow-500 p-4 rounded-lg flex items-start gap-4 mb-2">
|
||||
<span className="text-yellow-400 text-2xl mt-0.5">⚠️</span>
|
||||
<div>
|
||||
@@ -683,6 +790,51 @@ function UpdateFunctionModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`bg-gray-800/30 border border-cyan-600/50 rounded-lg p-4 ${
|
||||
isHtmlFunction ? "opacity-50 pointer-events-none" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">🔒</span>
|
||||
<div>
|
||||
<p className="text-cyan-300 font-medium text-sm">
|
||||
Restrict Network
|
||||
</p>
|
||||
<p className="text-cyan-400 text-xs">
|
||||
Run the container with Docker network disabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={networkRestricted}
|
||||
onChange={(e) => setNetworkRestricted(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
disabled={isLoading || isHtmlFunction}
|
||||
id="network-restricted-update"
|
||||
/>
|
||||
<label
|
||||
htmlFor="network-restricted-update"
|
||||
className="w-12 h-6 bg-gray-600 rounded-full peer-checked:bg-gradient-to-r peer-checked:from-cyan-500 peer-checked:to-blue-500 transition-all duration-300 cursor-pointer flex items-center relative"
|
||||
>
|
||||
<div
|
||||
className={`absolute w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ${
|
||||
networkRestricted ? "translate-x-6" : "translate-x-0.5"
|
||||
}`}
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{isHtmlFunction && (
|
||||
<p className="text-xs text-cyan-400 mt-1">
|
||||
Network restrictions are disabled for HTML startup files
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FFmpeg Install Toggle */}
|
||||
<div
|
||||
className={`bg-gray-800/30 border border-purple-600/50 rounded-lg p-4 ${
|
||||
@@ -974,9 +1126,9 @@ function UpdateFunctionModal({
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="text-sm">💾</span>
|
||||
Update Function
|
||||
</button>
|
||||
</div>
|
||||
Update Function
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+50
-15
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import { UserContext } from "../App";
|
||||
import { deleteAccount, exportAccountData, updateAccountSettings } from "../services/backend.account";
|
||||
|
||||
@@ -9,31 +9,33 @@ export const AccountPage = ({}) => {
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const aiFeaturesEnabled = Boolean(user?.apiKeyConfigured);
|
||||
|
||||
// AI Settings state
|
||||
const [openRouterKey, setOpenRouterKey] = useState<string>("");
|
||||
const [openRouterKeyInitialized, setOpenRouterKeyInitialized] = useState(false);
|
||||
const [aiSettingsSaving, setAiSettingsSaving] = useState(false);
|
||||
const [aiSettingsMessage, setAiSettingsMessage] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
// Sync key from user object when user is first loaded
|
||||
useEffect(() => {
|
||||
if (user && !openRouterKeyInitialized) {
|
||||
setOpenRouterKey(user.openRouterKey ?? "");
|
||||
setOpenRouterKeyInitialized(true);
|
||||
}
|
||||
}, [user, openRouterKeyInitialized]);
|
||||
|
||||
const handleSaveAiSettings = async () => {
|
||||
const trimmedKey = openRouterKey.trim();
|
||||
if (!trimmedKey) {
|
||||
setAiSettingsMessage({
|
||||
type: "err",
|
||||
text: "Enter an OpenRouter API key before saving.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAiSettingsSaving(true);
|
||||
setAiSettingsMessage(null);
|
||||
try {
|
||||
const result = await updateAccountSettings({
|
||||
openRouterKey: openRouterKey.trim() === "" ? null : openRouterKey.trim(),
|
||||
openRouterKey: trimmedKey,
|
||||
});
|
||||
if (result.status === "OK") {
|
||||
setAiSettingsMessage({ type: "ok", text: "API key saved successfully" });
|
||||
setOpenRouterKey("");
|
||||
refreshUser();
|
||||
} else {
|
||||
setAiSettingsMessage({ type: "err", text: result.message });
|
||||
@@ -45,6 +47,25 @@ export const AccountPage = ({}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAiSettings = async () => {
|
||||
setAiSettingsSaving(true);
|
||||
setAiSettingsMessage(null);
|
||||
try {
|
||||
const result = await updateAccountSettings({ openRouterKey: null });
|
||||
if (result.status === "OK") {
|
||||
setAiSettingsMessage({ type: "ok", text: "Saved API key removed successfully" });
|
||||
setOpenRouterKey("");
|
||||
refreshUser();
|
||||
} else {
|
||||
setAiSettingsMessage({ type: "err", text: result.message });
|
||||
}
|
||||
} catch {
|
||||
setAiSettingsMessage({ type: "err", text: "An error occurred while clearing the key" });
|
||||
} finally {
|
||||
setAiSettingsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteConfirmation !== "DELETE_MY_ACCOUNT") {
|
||||
setDeleteError("Please type 'DELETE_MY_ACCOUNT' to confirm");
|
||||
@@ -182,6 +203,11 @@ export const AccountPage = ({}) => {
|
||||
</div>
|
||||
AI Settings
|
||||
</h2>
|
||||
<div className="mb-4 rounded-lg border border-primary/15 bg-background/40 px-4 py-3 text-sm text-text/75">
|
||||
{aiFeaturesEnabled
|
||||
? "AI features are currently enabled."
|
||||
: "AI features are disabled until an OpenRouter API key is configured."}
|
||||
</div>
|
||||
<p className="text-text/50 text-sm mb-6">
|
||||
Provide your own{" "}
|
||||
<a
|
||||
@@ -191,7 +217,7 @@ export const AccountPage = ({}) => {
|
||||
className="text-primary/70 hover:text-primary underline underline-offset-2"
|
||||
>
|
||||
OpenRouter API key
|
||||
</a>{" "}
|
||||
</a>{" "}
|
||||
to enable AI-powered code generation for your functions. Your key is
|
||||
stored securely and used only for your requests.
|
||||
</p>
|
||||
@@ -206,7 +232,7 @@ export const AccountPage = ({}) => {
|
||||
type={showKey ? "text" : "password"}
|
||||
value={openRouterKey}
|
||||
onChange={(e) => setOpenRouterKey(e.target.value)}
|
||||
placeholder="sk-or-…"
|
||||
placeholder={aiFeaturesEnabled ? "Enter a new key to replace the saved one" : "sk-or-…"}
|
||||
className="flex-1 px-4 py-3 bg-background/50 border border-primary/20 rounded-lg text-text font-mono text-sm focus:border-primary/50 focus:outline-none placeholder:text-text/30"
|
||||
/>
|
||||
<button
|
||||
@@ -218,7 +244,7 @@ export const AccountPage = ({}) => {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-text/40 text-xs mt-1">
|
||||
Leave blank to remove the key. With no key set, AI features are disabled.
|
||||
Enter a new key to save it. The saved key is never shown back to the browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -239,8 +265,17 @@ export const AccountPage = ({}) => {
|
||||
disabled={aiSettingsSaving}
|
||||
className="px-6 py-2.5 bg-primary/20 border border-primary/30 rounded-lg text-primary font-semibold hover:bg-primary/30 hover:border-primary/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
|
||||
>
|
||||
{aiSettingsSaving ? "Saving…" : "Save AI Settings"}
|
||||
{aiSettingsSaving ? "Saving…" : "Save Key"}
|
||||
</button>
|
||||
{aiFeaturesEnabled && (
|
||||
<button
|
||||
onClick={handleClearAiSettings}
|
||||
disabled={aiSettingsSaving}
|
||||
className="px-6 py-2.5 bg-background/50 border border-primary/20 rounded-lg text-text/70 font-semibold hover:border-primary/40 hover:text-text disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
|
||||
>
|
||||
{aiSettingsSaving ? "Clearing…" : "Remove Saved Key"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,9 +41,9 @@ export const DOCSKICKOFF = () => {
|
||||
</p>
|
||||
<div className="mb-6 p-4 bg-yellow-900/20 border border-yellow-500/30 rounded-lg">
|
||||
<p className="text-sm text-yellow-300">
|
||||
<strong>Note:</strong> If no key is configured, the AI generate button will
|
||||
return a <code>503 Service Unavailable</code> error. Add your key in
|
||||
Account Settings to enable KICKOFF.
|
||||
<strong>Note:</strong> If no key is configured, the AI generate button is
|
||||
disabled in the dashboard. Add your key in Account Settings to enable
|
||||
KICKOFF.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { toast } from "react-toastify";
|
||||
import { useBeforeUnload, useBlocker, useParams } from "react-router-dom";
|
||||
import { SHSFExport } from "../../components/modals/functions/ImportFunctionModal";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useContext, useEffect, useState, useRef } from "react";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import JSZip from "jszip";
|
||||
import CreateFileModal from "../../components/modals/functionFiles/CreateFileModal";
|
||||
import RenameFileModal from "../../components/modals/functionFiles/RenameFileModal";
|
||||
import DeleteFileModal from "../../components/modals/functionFiles/DeleteFileModal";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
Namespace,
|
||||
TriggerLog,
|
||||
} from "../../types/Prisma";
|
||||
import { UserContext } from "../../App";
|
||||
import {
|
||||
getFunctionById,
|
||||
executeFunction,
|
||||
@@ -73,6 +75,7 @@ export interface TimingEntry {
|
||||
|
||||
function FunctionDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useContext(UserContext);
|
||||
const confirm = useConfirm();
|
||||
const [functionData, setFunctionData] = useState<XFunction | null>(null);
|
||||
const [nameSpace, setNamespace] = useState<Namespace | null>(null);
|
||||
@@ -156,11 +159,14 @@ function FunctionDetail() {
|
||||
const [showLoadDefaultModal, setShowLoadDefaultModal] = useState(false);
|
||||
const [showAIModal, setShowAIModal] = useState(false);
|
||||
const [showGitModal, setShowGitModal] = useState(false);
|
||||
const [autoUnzipFiles, setAutoUnzipFiles] = useState(true);
|
||||
const [stopShowingResult, setStopShowingResult] = useState(false);
|
||||
const navigationPromptOpenRef = useRef(false);
|
||||
const editorViewStatesRef = useRef<Map<number, any>>(new Map());
|
||||
const saveShortcutRef = useRef<() => void>(() => {});
|
||||
const resultModalsEnabled = !stopShowingResult;
|
||||
const aiEnabled = Boolean(user?.apiKeyConfigured);
|
||||
const isZipFilename = (filename: string) => filename.toLowerCase().endsWith(".zip");
|
||||
|
||||
const savedActiveFile =
|
||||
activeFile ? files.find((file) => file.id === activeFile.id) ?? activeFile : null;
|
||||
@@ -363,6 +369,87 @@ function FunctionDetail() {
|
||||
setCode(value || "");
|
||||
};
|
||||
|
||||
const normalizeZipEntryName = (entryName: string) => {
|
||||
const normalized = entryName.replace(/\\/g, "/").trim().replace(/^\/+/, "");
|
||||
if (!normalized || normalized.startsWith("/") || /^[a-zA-Z]:/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = normalized
|
||||
.split("/")
|
||||
.filter((segment) => segment.length > 0 && segment !== ".");
|
||||
if (segments.length === 0 || segments.some((segment) => segment === "..")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return segments.join("/");
|
||||
};
|
||||
|
||||
const extractZipFiles = async (zipFile: File) => {
|
||||
const zip = await JSZip.loadAsync(await zipFile.arrayBuffer());
|
||||
const extracted: Array<{ name: string; content: string }> = [];
|
||||
const skippedEntries: string[] = [];
|
||||
|
||||
const entryReads: Array<Promise<void>> = [];
|
||||
zip.forEach((relativePath, entry) => {
|
||||
if (entry.dir) {
|
||||
return;
|
||||
}
|
||||
|
||||
entryReads.push(
|
||||
(async () => {
|
||||
const normalizedName = normalizeZipEntryName(relativePath);
|
||||
if (!normalizedName) {
|
||||
skippedEntries.push(relativePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await entry.async("string");
|
||||
extracted.push({ name: normalizedName, content });
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(entryReads);
|
||||
return { extracted, skippedEntries };
|
||||
};
|
||||
|
||||
const persistFile = async (
|
||||
filename: string,
|
||||
content: string,
|
||||
): Promise<
|
||||
| { success: true; name: string }
|
||||
| { success: false; message: string }
|
||||
> => {
|
||||
if (!id) {
|
||||
return { success: false, message: "Function ID is missing." };
|
||||
}
|
||||
|
||||
if (serveHtmlOnly && !isHtmlFilename(filename)) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Only .html files are allowed for this function.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await createOrUpdateFile(parseInt(id), {
|
||||
filename,
|
||||
code: content,
|
||||
});
|
||||
|
||||
if (data.status === "OK") {
|
||||
setFiles((prev) => [...prev, { ...data.data, content }]);
|
||||
return { success: true, name: filename };
|
||||
}
|
||||
|
||||
return { success: false, message: data.message };
|
||||
} catch (error) {
|
||||
console.error("Error creating file:", error);
|
||||
return { success: false, message: "An error occurred while creating the file." };
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorDidMount = (editor: any, monaco: any) => {
|
||||
editorRef.current = editor;
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
@@ -783,67 +870,75 @@ function FunctionDetail() {
|
||||
filename: string,
|
||||
content: string,
|
||||
): Promise<boolean> => {
|
||||
if (!id) {
|
||||
toast.error("Function ID is missing.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (serveHtmlOnly && !isHtmlFilename(filename)) {
|
||||
toast.error("Only .html files are allowed for this function.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await createOrUpdateFile(parseInt(id), {
|
||||
filename,
|
||||
code: content,
|
||||
});
|
||||
if (data.status === "OK") {
|
||||
setFiles((prev) => [...prev, { ...data.data, content }]); // Ensure the new file has the correct content
|
||||
return true;
|
||||
} else {
|
||||
toast.error("Error creating file: " + data.message);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating file:", error);
|
||||
toast.error("An error occurred while creating the file.");
|
||||
const result = await persistFile(filename, content);
|
||||
if (!result.success) {
|
||||
toast.error(`Error creating file: ${result.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDropFiles = async (droppedFiles: File[]) => {
|
||||
const handleDropFiles = async (
|
||||
droppedFiles: File[],
|
||||
options?: { unzipArchives?: boolean },
|
||||
) => {
|
||||
if (!id) {
|
||||
toast.error("Function ID is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUnzipArchives = options?.unzipArchives ?? autoUnzipFiles;
|
||||
const existingNames = new Set(files.map((file) => file.name));
|
||||
const createdNames: string[] = [];
|
||||
const skippedExistingNames: string[] = [];
|
||||
const skippedInvalidTypeNames: string[] = [];
|
||||
const skippedZipEntries: string[] = [];
|
||||
const skippedZipFiles: string[] = [];
|
||||
|
||||
for (const droppedFile of droppedFiles) {
|
||||
if (serveHtmlOnly && !isHtmlFilename(droppedFile.name)) {
|
||||
skippedInvalidTypeNames.push(droppedFile.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingNames.has(droppedFile.name)) {
|
||||
skippedExistingNames.push(droppedFile.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await droppedFile.text();
|
||||
const created = await handleCreateFile(droppedFile.name, content);
|
||||
if (created) {
|
||||
existingNames.add(droppedFile.name);
|
||||
createdNames.push(droppedFile.name);
|
||||
let uploadedEntries: Array<{ name: string; content: string }>;
|
||||
if (isZipFilename(droppedFile.name) && shouldUnzipArchives) {
|
||||
const { extracted, skippedEntries } = await extractZipFiles(droppedFile);
|
||||
uploadedEntries = extracted;
|
||||
skippedZipEntries.push(...skippedEntries);
|
||||
if (uploadedEntries.length === 0) {
|
||||
toast.error(`${droppedFile.name} did not contain any files to upload.`);
|
||||
continue;
|
||||
}
|
||||
} else if (isZipFilename(droppedFile.name)) {
|
||||
skippedZipFiles.push(droppedFile.name);
|
||||
continue;
|
||||
} else {
|
||||
uploadedEntries = [{ name: droppedFile.name, content: await droppedFile.text() }];
|
||||
}
|
||||
|
||||
for (const uploadedFile of uploadedEntries) {
|
||||
if (serveHtmlOnly && !isHtmlFilename(uploadedFile.name)) {
|
||||
skippedInvalidTypeNames.push(uploadedFile.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingNames.has(uploadedFile.name)) {
|
||||
skippedExistingNames.push(uploadedFile.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await persistFile(uploadedFile.name, uploadedFile.content);
|
||||
if (result.success) {
|
||||
existingNames.add(uploadedFile.name);
|
||||
createdNames.push(uploadedFile.name);
|
||||
} else {
|
||||
toast.error(`Failed to create ${uploadedFile.name}: ${result.message}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading dropped file:", error);
|
||||
toast.error(`Failed to read ${droppedFile.name}.`);
|
||||
toast.error(
|
||||
isZipFilename(droppedFile.name)
|
||||
? `Failed to unpack ${droppedFile.name}. Make sure it is a valid zip archive.`
|
||||
: `Failed to read ${droppedFile.name}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,6 +965,22 @@ function FunctionDetail() {
|
||||
: `${skippedInvalidTypeNames.length} files were skipped because only .html files are allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (skippedZipEntries.length > 0) {
|
||||
toast.error(
|
||||
skippedZipEntries.length === 1
|
||||
? `${skippedZipEntries[0]} was skipped because it is not a valid zip path.`
|
||||
: `${skippedZipEntries.length} zip entries were skipped because they are not valid paths.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (skippedZipFiles.length > 0) {
|
||||
toast.error(
|
||||
skippedZipFiles.length === 1
|
||||
? `${skippedZipFiles[0]} was skipped because Auto Unzip Files is off.`
|
||||
: `${skippedZipFiles.length} zip files were skipped because Auto Unzip Files is off.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameFile = async (newFilename: string): Promise<boolean> => {
|
||||
@@ -900,28 +1011,87 @@ function FunctionDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (): Promise<boolean> => {
|
||||
if (!id || !selectedFile) return false;
|
||||
const removeFileFromState = (fileId: number) => {
|
||||
editorViewStatesRef.current.delete(fileId);
|
||||
setFiles((prev) => prev.filter((file) => file.id !== fileId));
|
||||
if (selectedFile?.id === fileId) {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
if (activeFile?.id === fileId) {
|
||||
setActiveFile(null);
|
||||
setCode(null);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await deleteFile(parseInt(id), selectedFile.id);
|
||||
if (data.status === "OK") {
|
||||
setFiles((prev) => prev.filter((file) => file.id !== selectedFile.id));
|
||||
if (activeFile?.id === selectedFile.id) {
|
||||
editorViewStatesRef.current.delete(selectedFile.id);
|
||||
setActiveFile(null);
|
||||
setCode(null);
|
||||
const deleteFunctionFiles = async (filesToDelete: FunctionFile[]) => {
|
||||
if (!id || filesToDelete.length === 0) return false;
|
||||
|
||||
let deletedCount = 0;
|
||||
let hadError = false;
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
const data = await deleteFile(parseInt(id), file.id);
|
||||
if (data.status === "OK") {
|
||||
removeFileFromState(file.id);
|
||||
deletedCount++;
|
||||
} else {
|
||||
hadError = true;
|
||||
toast.error(`Error deleting ${file.name}: ${data.message}`);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
toast.error("Error deleting file: " + data.message);
|
||||
return false;
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error("Error deleting file:", error);
|
||||
toast.error(`An error occurred while deleting ${file.name}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
toast.error("An error occurred while deleting the file.");
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
toast.success(
|
||||
deletedCount === 1
|
||||
? `Deleted ${filesToDelete[0].name}.`
|
||||
: `Deleted ${deletedCount} files.`,
|
||||
);
|
||||
}
|
||||
|
||||
return deletedCount > 0 && !hadError;
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (): Promise<boolean> => {
|
||||
if (!selectedFile) return false;
|
||||
return deleteFunctionFiles([selectedFile]);
|
||||
};
|
||||
|
||||
const handleDeleteSelectedFiles = async (
|
||||
filesToDelete: FunctionFile[],
|
||||
): Promise<boolean> => {
|
||||
if (!id || filesToDelete.length === 0) return false;
|
||||
|
||||
const previewNames = filesToDelete
|
||||
.slice(0, 5)
|
||||
.map((file) => file.name)
|
||||
.join(", ");
|
||||
const summary =
|
||||
filesToDelete.length === 1
|
||||
? `This will permanently delete "${filesToDelete[0].name}".`
|
||||
: `This will permanently delete ${filesToDelete.length} files: ${previewNames}${
|
||||
filesToDelete.length > 5
|
||||
? `, and ${filesToDelete.length - 5} more`
|
||||
: ""
|
||||
}.`;
|
||||
const shouldDelete = await confirm({
|
||||
title: "Delete Files",
|
||||
message: summary,
|
||||
confirmText: "Delete Files",
|
||||
cancelText: "Cancel",
|
||||
variant: "delete",
|
||||
});
|
||||
|
||||
if (!shouldDelete) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return deleteFunctionFiles(filesToDelete);
|
||||
};
|
||||
|
||||
const handleCreateTrigger = async (
|
||||
@@ -1486,8 +1656,16 @@ function FunctionDetail() {
|
||||
setSelectedFile(file);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
onDeleteSelectedFiles={handleDeleteSelectedFiles}
|
||||
nonSelectableOnSelectAllFileNames={
|
||||
functionData.startup_file ? [functionData.startup_file] : []
|
||||
}
|
||||
onDropFiles={handleDropFiles}
|
||||
autoUnzipFiles={autoUnzipFiles}
|
||||
onAutoUnzipFilesChange={setAutoUnzipFiles}
|
||||
onAIGenerate={() => setShowAIModal(true)}
|
||||
aiDisabled={!aiEnabled}
|
||||
aiDisabledReason="Enable AI in Account Settings to use AI KICKOFF."
|
||||
disabled={Boolean(functionData.git_url)}
|
||||
disabledReason="Git source active — file manager disabled. Use Version Control to manage files."
|
||||
/>
|
||||
@@ -1929,6 +2107,8 @@ function FunctionDetail() {
|
||||
onClose={() => setShowAIModal(false)}
|
||||
functionId={functionData?.id ?? 0}
|
||||
existingFiles={files}
|
||||
disabled={!aiEnabled}
|
||||
disabledReason="Enable AI in Account Settings to use AI KICKOFF."
|
||||
onSuccess={() => {
|
||||
if (id) {
|
||||
getFiles(parseInt(id)).then((filesData) => {
|
||||
|
||||
@@ -17,7 +17,8 @@ import AIGenerateModal from "../../components/modals/AIGenerateModal";
|
||||
function FunctionsList() {
|
||||
const [namespaces, setNamespaces] = useState<Namespace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { user, refreshUser } = useContext(UserContext);
|
||||
const { user } = useContext(UserContext);
|
||||
const aiEnabled = Boolean(user?.apiKeyConfigured);
|
||||
const [isNamespaceModalOpen, setNamespaceModalOpen] = useState(false);
|
||||
const [isRenameNamespaceModalOpen, setRenameNamespaceModalOpen] =
|
||||
useState(false);
|
||||
@@ -163,6 +164,7 @@ function FunctionsList() {
|
||||
icon="✨"
|
||||
label="AI KICKOFF"
|
||||
variant="primary"
|
||||
disabled={!aiEnabled}
|
||||
onClick={() => setAIModalOpen(true)}
|
||||
/>
|
||||
<ActionButton
|
||||
@@ -194,11 +196,17 @@ function FunctionsList() {
|
||||
} else {
|
||||
expandAllNamespaces();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!aiEnabled && (
|
||||
<p className="mb-6 text-sm text-yellow-300/80">
|
||||
Enable AI in Account Settings to use AI KICKOFF.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Functions Grid */}
|
||||
{namespaces.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -255,6 +263,8 @@ function FunctionsList() {
|
||||
onClose={() => setAIModalOpen(false)}
|
||||
onSuccess={refreshData}
|
||||
namespaceId={namespaces.length > 0 ? (selectedNamespace?.id || namespaces[0].id) : undefined}
|
||||
disabled={!aiEnabled}
|
||||
disabledReason="Enable AI in Account Settings to use AI KICKOFF."
|
||||
/>
|
||||
<CloneFunctionModal
|
||||
isOpen={isCloneFunctionModalOpen}
|
||||
@@ -311,14 +321,16 @@ function ActionButton({
|
||||
label,
|
||||
variant = "primary",
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
variant?: "primary" | "secondary";
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const baseClasses =
|
||||
"px-4 py-2 rounded-lg font-semibold transition-all duration-300 flex items-center gap-2 hover:scale-105";
|
||||
"px-4 py-2 rounded-lg font-semibold transition-all duration-300 flex items-center gap-2 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100";
|
||||
const variantClasses = {
|
||||
primary:
|
||||
"bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:shadow-[0_0_30px_rgba(124,131,253,0.3)] border border-transparent",
|
||||
@@ -329,6 +341,7 @@ function ActionButton({
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${baseClasses} ${variantClasses[variant]}`}
|
||||
>
|
||||
<span className="text-lg">{icon}</span>
|
||||
@@ -481,6 +494,11 @@ function FunctionCard({
|
||||
Imported
|
||||
</span>
|
||||
)}
|
||||
{func.ai_kicked_off && (
|
||||
<span className="uppercase tracking-wide px-2 py-0.5 rounded-full bg-gradient-to-r from-emerald-500/30 to-cyan-500/30 text-emerald-100 border border-emerald-500/40 shadow-sm text-xs font-bold">
|
||||
AI Kicked-Off
|
||||
</span>
|
||||
)}
|
||||
{func.startup_file.endsWith(".html") && (
|
||||
<span className="uppercase tracking-wide px-2 py-0.5 rounded-full bg-gradient-to-r from-blue-500/30 to-purple-500/30 text-blue-100 border border-blue-500/40 shadow-sm text-xs font-bold">
|
||||
HTML Only
|
||||
|
||||
@@ -88,6 +88,7 @@ async function createFunction(config: {
|
||||
cors_origins?: string;
|
||||
executionAlias?: string;
|
||||
imported?: boolean;
|
||||
ai_kicked_off?: boolean;
|
||||
}) {
|
||||
const response = await fetch(`${BASE_URL}/api/function`, {
|
||||
method: "POST",
|
||||
@@ -170,6 +171,8 @@ async function updateFunction(
|
||||
network_restricted?: boolean;
|
||||
ffmpeg_install?: boolean;
|
||||
opencv_install?: boolean;
|
||||
imported?: boolean;
|
||||
ai_kicked_off?: boolean;
|
||||
namespaceId?: number;
|
||||
settings?: {
|
||||
max_ram?: number;
|
||||
|
||||
@@ -36,6 +36,7 @@ interface XFunction {
|
||||
network_restricted: boolean;
|
||||
ffmpeg_install: boolean;
|
||||
imported: boolean;
|
||||
ai_kicked_off: boolean;
|
||||
|
||||
// Caching
|
||||
cache_enabled: boolean;
|
||||
@@ -76,7 +77,7 @@ interface User {
|
||||
role: UserRole;
|
||||
|
||||
password?: string;
|
||||
openRouterKey?: string | null;
|
||||
apiKeyConfigured?: boolean;
|
||||
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
|
||||
Reference in New Issue
Block a user