Browse Source

Merge branch 'master' into sync-test-s

feature/canvas-chatbot-copilot
jiajia 2 months ago
parent
commit
82eabad82f
  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

@ -292,6 +292,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.",
@ -339,6 +340,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}",
@ -666,6 +668,7 @@ const zhCN: Dictionary = {
"project.tabCanvas": "画布",
"project.name": "项目名称",
"project.directory": "项目目录",
"project.localFolderPlaceholder": "选择这台设备上的本地文件夹",
"project.directoryHelp": "工作流文件和图片会保存在这里,并自动创建 inputs 与 generations 子文件夹。",
"project.embedImages": "将图片嵌入为 base64",
"project.embedImagesDesc": "把所有图片嵌入工作流文件,文件会变大;超大工作流可能触达内存限制。",
@ -713,6 +716,7 @@ const zhCN: Dictionary = {
"project.errorOpenDirWithReason": "打开目录选择器失败:{error}",
"project.errorNameRequired": "项目名称必填",
"project.errorDirectoryRequired": "项目目录必填",
"project.errorChooseLocalFolder": "请从浏览器选择本地文件夹,这样项目会保存在当前操作者的设备上。",
"project.errorAbsolutePath": "项目目录必须是绝对路径(以 /、盘符或 UNC 路径开头)",
"project.errorNotDirectory": "项目路径不是目录",
"project.errorValidateDir": "验证目录失败:{error}",
@ -1008,6 +1012,7 @@ const zhTW: Dictionary = {
"project.tabNodeDefaults": "節點預設值",
"project.name": "專案名稱",
"project.directory": "專案目錄",
"project.localFolderPlaceholder": "選擇這台裝置上的本機資料夾",
"project.directoryHelp": "工作流程檔案和圖片會儲存在這裡,並自動建立 inputs 與 generations 子資料夾。",
"project.embedImages": "將圖片嵌入為 base64",
"project.inlineSettings": "在節點上顯示模型設定",
@ -1022,6 +1027,7 @@ const zhTW: Dictionary = {
"project.zoomMode": "縮放模式",
"project.selectionMode": "選擇模式",
"project.validating": "驗證中...",
"project.errorChooseLocalFolder": "請從瀏覽器選擇本機資料夾,這樣專案會儲存在目前操作者的裝置上。",
"modelSearch.search": "搜尋模型...",
"modelSearch.allProviders": "所有服務商",
"modelSearch.all": "全部",
@ -1329,6 +1335,7 @@ const ja: Dictionary = {
"project.tabCanvas": "キャンバス",
"project.name": "プロジェクト名",
"project.directory": "プロジェクトディレクトリ",
"project.localFolderPlaceholder": "このデバイス上のローカルフォルダを選択",
"project.directoryHelp": "ワークフローファイルと画像はここに保存されます。inputs と generations のサブフォルダは自動作成されます。",
"project.embedImages": "画像を base64 として埋め込む",
"project.embedImagesDesc": "すべての画像をワークフローに埋め込みます。ファイルは大きくなり、非常に大きなワークフローではメモリ制限に達する場合があります。",
@ -1376,6 +1383,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