Browse Source

Keep web projects on the operator device

The hosted app should not save each operator's project into the shared test server filesystem when the browser can grant local folder access. Project creation now requires a browser-selected local folder in supported browsers, remembers that folder as the project browser root, and the workflow browser can list and load projects from File System Access handles. Server filesystem APIs remain as a fallback for unsupported browsers.

Constraint: Hosted users need per-operator local project storage instead of shared server paths.

Rejected: Keep manual absolute path entry on supported browsers | it can silently target the server filesystem in the deployed app.

Confidence: high

Scope-risk: moderate

Directive: Do not re-enable server path entry for hosted browsers without adding per-user isolation and auth boundaries.

Tested: npm test -- --run src/components/__tests__/ProjectSetupModal.test.tsx src/components/__tests__/QuickstartTemplatesView.test.tsx src/store/utils/__tests__/localStorage.test.ts

Tested: npx tsc --noEmit --pretty false filtered to changed files
feature/canvas-chatbot-copilot
jiajia 2 months ago
parent
commit
6365b511d6
  1. 12
      src/components/ProjectSetupModal.tsx
  2. 44
      src/components/quickstart/WorkflowBrowserView.tsx
  3. 8
      src/i18n/index.tsx
  4. 138
      src/utils/browserFileSystem.ts

12
src/components/ProjectSetupModal.tsx

@ -5,7 +5,7 @@ import { generateWorkflowId, useWorkflowStore } from "@/store/workflowStore";
import { ProviderType, ProviderSettings, NodeDefaultsConfig, LLMProvider, LLMModelType } from "@/types";
import { CanvasNavigationSettings, PanMode, ZoomMode, SelectionMode } from "@/types/canvas";
import { EnvStatusResponse } from "@/app/api/env-status/route";
import { loadNodeDefaults, saveNodeDefaults, getLastProjectBaseDir, setLastProjectBaseDir } from "@/store/utils/localStorage";
import { loadNodeDefaults, saveNodeDefaults, getLastProjectBaseDir, setLastProjectBaseDir, setWorkflowsDirectory } from "@/store/utils/localStorage";
import { ProviderModel } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useInlineParameters } from "@/hooks/useInlineParameters";
@ -154,6 +154,7 @@ export function ProjectSetupModal({
const [isValidating, setIsValidating] = useState(false);
const [isBrowsing, setIsBrowsing] = useState(false);
const [error, setError] = useState<string | null>(null);
const browserLocalProjectsEnabled = isBrowserDirectoryPickerSupported();
// Provider tab state
const [localProviders, setLocalProviders] = useState<ProviderSettings>(providerSettings);
@ -288,6 +289,11 @@ export function ProjectSetupModal({
const fullProjectPath = ensureProjectSubfolderPath(directoryPath, name);
const isBrowserPath = isBrowserFileSystemPath(fullProjectPath);
if (browserLocalProjectsEnabled && !isBrowserPath) {
setError(t("project.errorChooseLocalFolder"));
return;
}
if (!isBrowserPath && !(fullProjectPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(fullProjectPath) || fullProjectPath.startsWith("\\\\"))) {
setError(t("project.errorAbsolutePath"));
return;
@ -301,6 +307,7 @@ export function ProjectSetupModal({
const id = mode === "new" ? generateWorkflowId() : useWorkflowStore.getState().workflowId || generateWorkflowId();
setUseExternalImageStorage(false);
setLastProjectBaseDir(directoryPath);
setWorkflowsDirectory(directoryPath);
onSave(id, name.trim(), fullProjectPath);
setIsValidating(false);
return;
@ -482,7 +489,8 @@ export function ProjectSetupModal({
type="text"
value={directoryPath}
onChange={(e) => setDirectoryPath(e.target.value)}
placeholder="/Users/username/projects/my-project"
readOnly={browserLocalProjectsEnabled}
placeholder={browserLocalProjectsEnabled ? t("project.localFolderPlaceholder") : "/Users/username/projects/my-project"}
className="flex-1 px-3 py-2 bg-neutral-900 border border-neutral-600 rounded-lg text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
/>
<button

44
src/components/quickstart/WorkflowBrowserView.tsx

@ -8,6 +8,13 @@ import {
setWorkflowsDirectory,
} from "@/store/utils/localStorage";
import { useI18n } from "@/i18n";
import {
isBrowserDirectoryPickerSupported,
isBrowserFileSystemPath,
listBrowserWorkflows,
loadBrowserWorkflowFromDirectory,
pickBrowserWorkflowDirectory,
} from "@/utils/browserFileSystem";
interface WorkflowListEntry {
name: string;
@ -59,6 +66,15 @@ export function WorkflowBrowserView({
setIsLoading(true);
setError(null);
try {
if (isBrowserFileSystemPath(dirPath)) {
const browserWorkflows = await listBrowserWorkflows(dirPath);
setWorkflows(browserWorkflows);
if (browserWorkflows.length === 0) {
setError(t("workflowBrowser.errorNoWorkflows"));
}
return;
}
const res = await fetch(
`/api/list-workflows?path=${encodeURIComponent(dirPath)}`
);
@ -86,6 +102,15 @@ export function WorkflowBrowserView({
const browseAndSetDir = useCallback(async () => {
try {
if (isBrowserDirectoryPickerSupported()) {
const browserPath = await pickBrowserWorkflowDirectory();
if (browserPath) {
setWorkflowsDirectory(browserPath);
setDefaultDir(browserPath);
}
return;
}
const res = await fetch("/api/browse-directory");
const result = await res.json();
if (result.success && !result.cancelled && result.path) {
@ -99,6 +124,18 @@ export function WorkflowBrowserView({
const handleBrowseOther = useCallback(async () => {
try {
if (isBrowserDirectoryPickerSupported()) {
const dirPath = await pickBrowserWorkflowDirectory();
if (!dirPath) return;
setLoadingWorkflow(dirPath);
const workflow = await loadBrowserWorkflowFromDirectory(dirPath);
setLoadingWorkflow(null);
onWorkflowLoaded(workflow, dirPath);
onClose?.();
return;
}
const browseRes = await fetch("/api/browse-directory");
const browseResult = await browseRes.json();
@ -140,6 +177,13 @@ export function WorkflowBrowserView({
setLoadingWorkflow(entry.directoryPath);
setError(null);
try {
if (isBrowserFileSystemPath(entry.directoryPath)) {
const workflow = await loadBrowserWorkflowFromDirectory(entry.directoryPath);
onWorkflowLoaded(workflow, entry.directoryPath);
onClose?.();
return;
}
const res = await fetch(
`/api/workflow?path=${encodeURIComponent(entry.directoryPath)}&load=true`
);

8
src/i18n/index.tsx

@ -291,6 +291,7 @@ const en = {
"project.tabCanvas": "Canvas",
"project.name": "Project Name",
"project.directory": "Project Directory",
"project.localFolderPlaceholder": "Choose a local folder on this device",
"project.directoryHelp": "Workflow files and images will be saved here. Subfolders for inputs and generations will be auto-created.",
"project.embedImages": "Embed images as base64",
"project.embedImagesDesc": "Embeds all images in workflow, larger workflow files. Can hit memory limits on very large workflows.",
@ -338,6 +339,7 @@ const en = {
"project.errorOpenDirWithReason": "Failed to open directory picker: {error}",
"project.errorNameRequired": "Project name is required",
"project.errorDirectoryRequired": "Project directory is required",
"project.errorChooseLocalFolder": "Choose a local folder from this browser so the project is saved on this device.",
"project.errorAbsolutePath": "Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)",
"project.errorNotDirectory": "Project path is not a directory",
"project.errorValidateDir": "Failed to validate directory: {error}",
@ -664,6 +666,7 @@ const zhCN: Dictionary = {
"project.tabCanvas": "画布",
"project.name": "项目名称",
"project.directory": "项目目录",
"project.localFolderPlaceholder": "选择这台设备上的本地文件夹",
"project.directoryHelp": "工作流文件和图片会保存在这里,并自动创建 inputs 与 generations 子文件夹。",
"project.embedImages": "将图片嵌入为 base64",
"project.embedImagesDesc": "把所有图片嵌入工作流文件,文件会变大;超大工作流可能触达内存限制。",
@ -711,6 +714,7 @@ const zhCN: Dictionary = {
"project.errorOpenDirWithReason": "打开目录选择器失败:{error}",
"project.errorNameRequired": "项目名称必填",
"project.errorDirectoryRequired": "项目目录必填",
"project.errorChooseLocalFolder": "请从浏览器选择本地文件夹,这样项目会保存在当前操作者的设备上。",
"project.errorAbsolutePath": "项目目录必须是绝对路径(以 /、盘符或 UNC 路径开头)",
"project.errorNotDirectory": "项目路径不是目录",
"project.errorValidateDir": "验证目录失败:{error}",
@ -1005,6 +1009,7 @@ const zhTW: Dictionary = {
"project.tabNodeDefaults": "節點預設值",
"project.name": "專案名稱",
"project.directory": "專案目錄",
"project.localFolderPlaceholder": "選擇這台裝置上的本機資料夾",
"project.directoryHelp": "工作流程檔案和圖片會儲存在這裡,並自動建立 inputs 與 generations 子資料夾。",
"project.embedImages": "將圖片嵌入為 base64",
"project.inlineSettings": "在節點上顯示模型設定",
@ -1019,6 +1024,7 @@ const zhTW: Dictionary = {
"project.zoomMode": "縮放模式",
"project.selectionMode": "選擇模式",
"project.validating": "驗證中...",
"project.errorChooseLocalFolder": "請從瀏覽器選擇本機資料夾,這樣專案會儲存在目前操作者的裝置上。",
"modelSearch.search": "搜尋模型...",
"modelSearch.allProviders": "所有服務商",
"modelSearch.all": "全部",
@ -1325,6 +1331,7 @@ const ja: Dictionary = {
"project.tabCanvas": "キャンバス",
"project.name": "プロジェクト名",
"project.directory": "プロジェクトディレクトリ",
"project.localFolderPlaceholder": "このデバイス上のローカルフォルダを選択",
"project.directoryHelp": "ワークフローファイルと画像はここに保存されます。inputs と generations のサブフォルダは自動作成されます。",
"project.embedImages": "画像を base64 として埋め込む",
"project.embedImagesDesc": "すべての画像をワークフローに埋め込みます。ファイルは大きくなり、非常に大きなワークフローではメモリ制限に達する場合があります。",
@ -1372,6 +1379,7 @@ const ja: Dictionary = {
"project.errorOpenDirWithReason": "ディレクトリ選択を開けませんでした: {error}",
"project.errorNameRequired": "プロジェクト名は必須です",
"project.errorDirectoryRequired": "プロジェクトディレクトリは必須です",
"project.errorChooseLocalFolder": "このデバイスに保存するため、ブラウザからローカルフォルダを選択してください。",
"project.errorAbsolutePath": "プロジェクトディレクトリは絶対パスである必要があります(/、ドライブ文字、UNC パスで始まる形式)",
"project.errorNotDirectory": "プロジェクトパスはディレクトリではありません",
"project.errorValidateDir": "ディレクトリ検証に失敗しました: {error}",

138
src/utils/browserFileSystem.ts

@ -6,6 +6,15 @@ const PREFIX = "browserfs://";
const DB_NAME = "popiart-browser-file-system";
const DB_VERSION = 1;
const STORE_NAME = "directory-handles";
const MAX_WORKFLOW_SCAN_DEPTH = 3;
const SKIP_DIRS = new Set([".git", "node_modules", "__pycache__", ".next"]);
export interface BrowserWorkflowListEntry {
name: string;
directoryPath: string;
relativePath: string;
lastModified: number;
}
type BrowserDirectoryRecord = {
id: string;
@ -75,6 +84,135 @@ export async function writeBrowserWorkflowFile(
return { filePath: `${directoryPath}/${fileName}` };
}
export async function listBrowserWorkflows(parentPath: string): Promise<BrowserWorkflowListEntry[]> {
const rootHandle = await resolveDirectoryHandle(parentPath, false);
await ensurePermission(rootHandle, "readwrite");
const dirs = await collectBrowserDirectories(parentPath, rootHandle, "", 0);
const results = await Promise.all(
dirs.map((dir) => probeBrowserWorkflow(dir.path, dir.handle, dir.relativePath))
);
return results
.filter((entry): entry is BrowserWorkflowListEntry => entry !== null)
.sort((a, b) => b.lastModified - a.lastModified);
}
export async function loadBrowserWorkflowFromDirectory(directoryPath: string): Promise<WorkflowFile> {
const directoryHandle = await resolveDirectoryHandle(directoryPath, false);
await ensurePermission(directoryHandle, "readwrite");
const jsonFiles: { name: string; file: File }[] = [];
for await (const [name, handle] of directoryEntries(directoryHandle)) {
if (handle.kind !== "file" || !name.endsWith(".json")) continue;
const file = await (handle as FileSystemFileHandle).getFile();
jsonFiles.push({ name, file });
}
jsonFiles.sort((a, b) => b.file.lastModified - a.file.lastModified);
for (const { file } of jsonFiles) {
try {
const parsed = JSON.parse(await file.text()) as WorkflowFile;
if (
typeof parsed.version === "number" &&
Array.isArray(parsed.nodes) &&
Array.isArray(parsed.edges)
) {
return parsed;
}
} catch {
continue;
}
}
throw new Error("No workflow file found in directory");
}
async function collectBrowserDirectories(
rootPath: string,
currentHandle: FileSystemDirectoryHandle,
relativePath: string,
depth: number
): Promise<Array<{ path: string; handle: FileSystemDirectoryHandle; relativePath: string }>> {
if (depth > MAX_WORKFLOW_SCAN_DEPTH) return [];
const dirs: Array<{ path: string; handle: FileSystemDirectoryHandle; relativePath: string }> = [];
for await (const [name, handle] of directoryEntries(currentHandle)) {
if (handle.kind !== "directory") continue;
if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
const childHandle = handle as FileSystemDirectoryHandle;
const childRelativePath = relativePath ? `${relativePath}/${name}` : name;
const childPath = joinBrowserFileSystemPath(rootPath, childRelativePath);
dirs.push({ path: childPath, handle: childHandle, relativePath: childRelativePath });
if (depth < MAX_WORKFLOW_SCAN_DEPTH) {
try {
dirs.push(
...(await collectBrowserDirectories(rootPath, childHandle, childRelativePath, depth + 1))
);
} catch {
continue;
}
}
}
return dirs;
}
async function probeBrowserWorkflow(
directoryPath: string,
directoryHandle: FileSystemDirectoryHandle,
relativePath: string
): Promise<BrowserWorkflowListEntry | null> {
const jsonFiles: { file: File }[] = [];
for await (const [name, handle] of directoryEntries(directoryHandle)) {
if (handle.kind !== "file" || !name.endsWith(".json")) continue;
try {
jsonFiles.push({ file: await (handle as FileSystemFileHandle).getFile() });
} catch {
continue;
}
}
jsonFiles.sort((a, b) => b.file.lastModified - a.file.lastModified);
for (const { file } of jsonFiles) {
try {
const parsed = JSON.parse(await file.text()) as Partial<WorkflowFile>;
if (
typeof parsed.version === "number" &&
Array.isArray(parsed.nodes) &&
Array.isArray(parsed.edges)
) {
return {
name: typeof parsed.name === "string" && parsed.name ? parsed.name : dirBasename(relativePath),
directoryPath,
relativePath,
lastModified: file.lastModified,
};
}
} catch {
continue;
}
}
return null;
}
function dirBasename(dirPath: string): string {
return dirPath.split("/").filter(Boolean).pop() || dirPath;
}
function directoryEntries(
handle: FileSystemDirectoryHandle
): AsyncIterable<[string, FileSystemHandle]> {
return (handle as FileSystemDirectoryHandle & {
entries: () => AsyncIterable<[string, FileSystemHandle]>;
}).entries();
}
async function resolveDirectoryHandle(path: string, create: boolean): Promise<FileSystemDirectoryHandle> {
const parsed = parseBrowserFileSystemPath(path);
let handle = await getDirectoryHandleById(parsed.id);

Loading…
Cancel
Save