Compare commits

...

10 Commits

Author SHA1 Message Date
Space-Banane f73577da4f feat. Clear Import & AI Gen Flags Button in UpdateFunctionModal
/ Build Frontend (push) Has been cancelled
/ Test Backend (push) Has been cancelled
/ Generate OpenAPI Release (push) Has been cancelled
/ Validate Docker Compose (push) Has been cancelled
/ Build Backend (push) Has been cancelled
2026-05-26 17:46:18 +02:00
Space-Banane f9c821efac fix(backend): add ai_kicked_off to function update schema 2026-05-20 13:35:10 +02:00
Space-Banane ac28570784 improv. AI UI Improvements 2026-05-20 13:20:30 +02:00
Space-Banane c6199d79fe feat: navigate to function details after successful kickoff creation 2026-05-20 13:18:27 +02:00
Space-Banane f87bff377d feat: add ai_kicked_off property to Function model and related components 2026-05-20 13:18:20 +02:00
Space-Banane f9e7ec9511 feat: add support for uploading zip files and auto-unzip functionality 2026-05-19 23:20:27 +02:00
Space-Banane b46db8b99e feat: Multi File Select (deletion and upload) 2026-05-19 23:10:56 +02:00
Space-Banane bb744e02d5 feat: UI disable AI features when API Key not configured 2026-05-19 23:01:15 +02:00
Space b7d73f3639 Merge pull request #28 from Space-Banane/feat-l/zero-net
feat: per-function network restriction via Docker network none
2026-05-17 14:09:34 +02:00
luna fc2b886e7a fix(ui): allow editing network restriction in update modal 2026-05-17 14:02:17 +02:00
15 changed files with 896 additions and 113 deletions
+1
View File
@@ -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)
+10 -4
View File
@@ -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,
});
}),
);
+2 -1
View File
@@ -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,
}),
+1
View File
@@ -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",
+35
View File
@@ -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:
+314 -15
View File
@@ -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>
);
+35 -3
View File
@@ -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
View File
@@ -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>
+3 -3
View File
@@ -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>
+240 -60
View File
@@ -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) => {
+22 -4
View File
@@ -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
+3
View File
@@ -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;
+2 -1
View File
@@ -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;