chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.download
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js';
+23
View File
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { defineFlatConfig } from '@flowgram.ai/eslint-config';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
rules: {
'no-console': 'off',
},
settings: {
react: {
version: '18',
},
},
});
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@flowgram.ai/cli",
"version": "0.1.8",
"description": "A CLI tool to create demo projects or sync materials",
"repository": "https://github.com/bytedance/flowgram.ai",
"bin": {
"flowgram-cli": "./bin/index.js"
},
"type": "module",
"files": [
"bin",
"src",
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --out-dir dist",
"watch": "tsup src/index.ts --format esm,cjs --out-dir dist --watch",
"start": "node bin/index.js",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix"
},
"dependencies": {
"fs-extra": "^9.1.0",
"commander": "^11.0.0",
"chalk": "^5.3.0",
"tar": "7.4.3",
"inquirer": "^12.9.4",
"ignore": "~7.0.5"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@types/download": "8.0.5",
"@types/fs-extra": "11.0.4",
"@types/node": "^18",
"@types/inquirer": "^9.0.9",
"tsup": "^8.0.1",
"eslint": "^9.0.0",
"@typescript-eslint/parser": "^8.0.0",
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import https from 'https';
import { execSync } from 'child_process';
import * as tar from 'tar';
import inquirer from 'inquirer';
import fs from 'fs-extra';
import chalk from 'chalk';
const updateFlowGramVersions = (dependencies: any[], latestVersion: string) => {
for (const packageName in dependencies) {
if (packageName.startsWith('@flowgram.ai')) {
dependencies[packageName] = latestVersion;
}
}
};
// 使用 https 下载文件
function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
https
.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
})
.on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
file.on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
});
}
export const createApp = async (projectName?: string) => {
console.log(chalk.green('Welcome to @flowgram.ai/create-app CLI!'));
const latest = execSync('npm view @flowgram.ai/demo-fixed-layout version --tag=latest latest')
.toString()
.trim();
let folderName = '';
if (!projectName) {
// 询问用户选择 demo 项目
const { repo } = await inquirer.prompt([
{
type: 'list',
name: 'repo',
message: 'Select a demo to create:',
choices: [
{ name: 'Fixed Layout Demo', value: 'demo-fixed-layout' },
{ name: 'Free Layout Demo', value: 'demo-free-layout' },
{ name: 'Fixed Layout Demo Simple', value: 'demo-fixed-layout-simple' },
{ name: 'Free Layout Demo Simple', value: 'demo-free-layout-simple' },
{ name: 'Free Layout Nextjs Demo', value: 'demo-nextjs' },
{ name: 'Free Layout Vite Demo Simple', value: 'demo-vite' },
{ name: 'Demo Playground for infinite canvas', value: 'demo-playground' },
],
},
]);
folderName = repo;
} else {
if (
[
'fixed-layout',
'free-layout',
'fixed-layout-simple',
'free-layout-simple',
'playground',
'nextjs',
].includes(projectName)
) {
folderName = `demo-${projectName}`;
} else {
console.error('Invalid projectName. Please run "npx create-app" to choose demo.');
return;
}
}
try {
const targetDir = path.join(process.cwd());
// 下载 npm 包的 tarball
const downloadPackage = async () => {
try {
const url = `https://registry.npmjs.org/@flowgram.ai/${folderName}/-/${folderName}-${latest}.tgz`;
const tempTarballPath = path.join(process.cwd(), `${folderName}.tgz`);
console.log(chalk.blue(`Downloading ${url} ...`));
await downloadFile(url, tempTarballPath);
fs.ensureDirSync(targetDir);
await tar.x({
file: tempTarballPath,
C: targetDir,
});
fs.renameSync(path.join(targetDir, 'package'), path.join(targetDir, folderName));
fs.unlinkSync(tempTarballPath);
return true;
} catch (error) {
console.error(`Error downloading or extracting package`);
console.error(error);
return false;
}
};
const res = await downloadPackage();
// 替换 package.json 内部的 @flowgram.ai 包版本为 latest
const pkgJsonPath = path.join(targetDir, folderName, 'package.json');
const data = fs.readFileSync(pkgJsonPath, 'utf-8');
const packageLatestVersion = execSync('npm view @flowgram.ai/core version --tag=latest latest')
.toString()
.trim();
const jsonData = JSON.parse(data);
if (jsonData.dependencies) {
updateFlowGramVersions(jsonData.dependencies, packageLatestVersion);
}
if (jsonData.devDependencies) {
updateFlowGramVersions(jsonData.devDependencies, packageLatestVersion);
}
fs.writeFileSync(pkgJsonPath, JSON.stringify(jsonData, null, 2), 'utf-8');
if (res) {
console.log(chalk.green(`${folderName} Demo project created successfully!`));
console.log(chalk.yellow('Run the following commands to start:'));
console.log(chalk.cyan(` cd ${folderName}`));
console.log(chalk.cyan(' npm install'));
console.log(chalk.cyan(' npm start'));
} else {
console.log(chalk.red('Download failed'));
}
} catch (error) {
console.error('Error downloading repo:', error);
return;
}
};
+75
View File
@@ -0,0 +1,75 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import chalk from 'chalk';
import { traverseRecursiveTsFiles } from '../utils/ts-file';
import { Project } from '../utils/project';
import { loadNpm } from '../utils/npm';
import { Material } from '../materials/material';
export async function findUsedMaterials() {
// materialName can be undefined
console.log(chalk.bold('🚀 Welcome to @flowgram.ai form-materials CLI!'));
const project = await Project.getSingleton();
project.printInfo();
const formMaterialPkg = await loadNpm('@flowgram.ai/form-materials');
const materials: Material[] = Material.listAll(formMaterialPkg);
const allUsedMaterials = new Set<Material>();
const exportName2Material = new Map<string, Material>();
for (const material of materials) {
if (!material.indexFile) {
console.warn(`Material ${material.name} not found`);
return;
}
console.log(`👀 The exports of ${material.name} is ${material.allExportNames.join(',')}`);
material.allExportNames.forEach((exportName) => {
exportName2Material.set(exportName, material);
});
}
for (const tsFile of traverseRecursiveTsFiles(project.srcPath)) {
const fileMaterials = new Set<Material>();
let fileImportPrinted = false;
for (const importDeclaration of tsFile.imports) {
if (
!importDeclaration.source.startsWith('@flowgram.ai/form-materials') ||
!importDeclaration.namedImports?.length
) {
continue;
}
if (!fileImportPrinted) {
fileImportPrinted = true;
console.log(chalk.bold(`\n👀 Searching ${tsFile.path}`));
}
console.log(`🔍 ${importDeclaration.statement}`);
if (importDeclaration.namedImports) {
importDeclaration.namedImports.forEach((namedImport) => {
const material = exportName2Material.get(namedImport.imported);
if (material) {
fileMaterials.add(material);
allUsedMaterials.add(material);
console.log(`import ${chalk.bold(material.fullName)} by ${namedImport.imported}`);
}
});
}
}
}
console.log(chalk.bold('\n📦 All used materials:'));
console.log([...allUsedMaterials].map((_material) => _material.fullName).join(','));
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import { Command } from 'commander';
import { updateFlowgramVersion } from './update-version';
import { syncMaterial } from './materials';
import { findUsedMaterials } from './find-materials';
import { createApp } from './create-app';
const program = new Command();
program.name('flowgram-cli').version('1.0.0').description('Flowgram CLI');
program
.command('create-app')
.description('Create a new flowgram project')
.argument('[string]', 'Project name')
.action(async (projectName) => {
await createApp(projectName);
});
program
.command('materials')
.description('Sync materials to the project')
.argument(
'[string]',
'Material name or names\nExample 1: components/variable-selector \nExample2: components/variable-selector,effect/provideJsonSchemaOutputs'
)
.option('--refresh-project-imports', 'Refresh project imports to copied materials', false)
.option('--target-material-root-dir <string>', 'Target directory to copy materials')
.option('--select-multiple', 'Select multiple materials', false)
.action(async (materialName, options) => {
await syncMaterial({
materialName,
refreshProjectImports: options.refreshProjectImports,
targetMaterialRootDir: options.targetMaterialRootDir
? path.join(process.cwd(), options.targetMaterialRootDir)
: undefined,
selectMultiple: options.selectMultiple,
});
});
program
.command('find-used-materials')
.description('Find used materials in the project')
.action(async () => {
await findUsedMaterials();
});
program
.command('update-version')
.description('Update flowgram version in the project')
.argument('[string]', 'Flowgram version')
.action(async (version) => {
await updateFlowgramVersion(version);
});
program.parse(process.argv);
+60
View File
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import fs from 'fs';
import { traverseRecursiveTsFiles } from '../utils/ts-file';
import { SyncMaterialContext } from './types';
interface CopyMaterialReturn {
packagesToInstall: string[];
}
export const copyMaterials = (ctx: SyncMaterialContext): CopyMaterialReturn => {
const { selectedMaterials, project, formMaterialPkg, targetFormMaterialRoot } = ctx;
const formMaterialDependencies = formMaterialPkg.dependencies;
const packagesToInstall: Set<string> = new Set();
for (const material of selectedMaterials) {
const sourceDir: string = material.sourceDir;
const targetDir: string = path.join(targetFormMaterialRoot, material.type, material.name);
fs.cpSync(sourceDir, targetDir, { recursive: true });
for (const file of traverseRecursiveTsFiles(targetDir)) {
for (const importDeclaration of file.imports) {
const { source } = importDeclaration;
if (source.startsWith('@/')) {
// is inner import
console.log(`Replace Import from ${source} to @flowgram.ai/form-materials`);
file.replaceImport(
[importDeclaration],
[{ ...importDeclaration, source: '@flowgram.ai/form-materials' }]
);
packagesToInstall.add(`@flowgram.ai/form-materials@${project.flowgramVersion}`);
} else if (!source.startsWith('.') && !source.startsWith('react')) {
// check if is in form material dependencies
const [dep, version] =
Object.entries(formMaterialDependencies).find(([_key]) => source.startsWith(_key)) ||
[];
if (!dep) {
continue;
}
if (dep.startsWith('@flowgram.ai/')) {
packagesToInstall.add(`${dep}@${project.flowgramVersion}`);
} else {
packagesToInstall.add(`${dep}@${version}`);
}
}
}
}
}
return {
packagesToInstall: [...packagesToInstall],
};
};
+78
View File
@@ -0,0 +1,78 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import chalk from 'chalk';
import { Project } from '../utils/project';
import { loadNpm } from '../utils/npm';
import { MaterialCliOptions, SyncMaterialContext } from './types';
import { getSelectedMaterials } from './select';
import { executeRefreshProjectImport } from './refresh-project-import';
import { Material } from './material';
import { copyMaterials } from './copy';
export async function syncMaterial(cliOpts: MaterialCliOptions) {
const { refreshProjectImports, targetMaterialRootDir } = cliOpts;
// materialName can be undefined
console.log(chalk.bold('🚀 Welcome to @flowgram.ai form-materials CLI!'));
const project = await Project.getSingleton();
project.printInfo();
// where to place all material in target project
const targetFormMaterialRoot =
targetMaterialRootDir || path.join(project.projectPath, 'src', 'form-materials');
console.log(chalk.black(` - Target material root: ${targetFormMaterialRoot}`));
if (!project.flowgramVersion) {
throw new Error(
chalk.red(
'❌ Please install @flowgram.ai/fixed-layout-editor or @flowgram.ai/free-layout-editor'
)
);
}
const formMaterialPkg = await loadNpm('@flowgram.ai/form-materials');
let selectedMaterials: Material[] = await getSelectedMaterials(cliOpts, formMaterialPkg);
// Ensure material is defined before proceeding
if (!selectedMaterials.length) {
console.error(chalk.red('No material selected. Exiting.'));
process.exit(1);
}
const context: SyncMaterialContext = {
selectedMaterials: selectedMaterials,
project,
formMaterialPkg,
cliOpts,
targetFormMaterialRoot,
};
// Copy the materials to the project
console.log(chalk.bold('🚀 The following materials will be added to your project'));
console.log(selectedMaterials.map((material) => `📦 ${material.fullName}`).join('\n'));
console.log('\n');
let { packagesToInstall } = copyMaterials(context);
// Refresh project imports
if (refreshProjectImports) {
console.log(chalk.bold('🚀 Refresh imports in your project'));
executeRefreshProjectImport(context);
}
// Install the dependencies
await project.addDependencies(packagesToInstall);
console.log(chalk.bold('\n✅ These npm dependencies is added to your package.json'));
packagesToInstall.forEach((_package) => {
console.log(`- ${_package}`);
});
console.log(chalk.bold(chalk.bold('\n➡️ Please run npm install to install dependencies\n')));
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import { readdirSync } from 'fs';
import { getIndexTsFile } from '../utils/ts-file';
import { LoadedNpmPkg } from '../utils/npm';
export class Material {
protected static _all_materials_cache: Material[] = [];
static ALL_TYPES = [
'components',
'effects',
'plugins',
'shared',
'validate',
'form-plugins',
'hooks',
];
constructor(public type: string, public name: string, public formMaterialPkg: LoadedNpmPkg) {}
get fullName() {
return `${this.type}/${this.name}`;
}
get sourceDir() {
return path.join(this.formMaterialPkg.srcPath, this.type, this.name);
}
get indexFile() {
return getIndexTsFile(this.sourceDir);
}
get allExportNames() {
return this.indexFile?.allExportNames || [];
}
static listAll(formMaterialPkg: LoadedNpmPkg): Material[] {
if (!this._all_materials_cache.length) {
this._all_materials_cache = Material.ALL_TYPES.map((type) => {
const materialsPath: string = path.join(formMaterialPkg.srcPath, type);
return readdirSync(materialsPath)
.map((_path: string) => {
if (_path === 'index.ts') {
return null;
}
return new Material(type, _path, formMaterialPkg);
})
.filter((material): material is Material => material !== null);
}).flat();
}
return this._all_materials_cache;
}
}
@@ -0,0 +1,87 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import chalk from 'chalk';
import { traverseRecursiveTsFiles } from '../utils/ts-file';
import { ImportDeclaration, NamedImport } from '../utils/import';
import { SyncMaterialContext } from './types';
import { Material } from './material';
export function executeRefreshProjectImport(context: SyncMaterialContext) {
const { selectedMaterials, project, targetFormMaterialRoot } = context;
const exportName2Material = new Map<string, Material>();
const targetModule = `@/${path.relative(project.srcPath, targetFormMaterialRoot)}`;
for (const material of selectedMaterials) {
if (!material.indexFile) {
console.warn(`Material ${material.name} not found`);
return;
}
console.log(`👀 The exports of ${material.name} is ${material.allExportNames.join(',')}`);
material.allExportNames.forEach((exportName) => {
exportName2Material.set(exportName, material);
});
}
for (const tsFile of traverseRecursiveTsFiles(project.srcPath)) {
for (const importDeclaration of tsFile.imports) {
if (importDeclaration.source.startsWith('@flowgram.ai/form-materials')) {
// Import Module and its related named Imported
const restImports: NamedImport[] = [];
const importMap: Record<string, NamedImport[]> = {};
if (!importDeclaration.namedImports) {
continue;
}
for (const nameImport of importDeclaration.namedImports) {
const material = exportName2Material.get(nameImport.imported);
if (material) {
const importModule = `${targetModule}/${material.fullName}`;
importMap[importModule] = importMap[importModule] || [];
importMap[importModule].push(nameImport);
} else {
restImports.push(nameImport);
}
}
if (Object.keys(importMap).length === 0) {
continue;
}
const nextImports: ImportDeclaration[] = Object.entries(importMap).map(
([importModule, namedImports]) => ({
...importDeclaration,
namedImports,
source: importModule,
})
);
if (restImports?.length) {
nextImports.unshift({
...importDeclaration,
namedImports: restImports,
});
}
tsFile.replaceImport([importDeclaration], nextImports);
console.log(chalk.green(`🔄 Refresh Imports In: ${tsFile.path}`));
console.log(
`From:\n${importDeclaration.statement}\nTo:\n${nextImports
.map((item) => item.statement)
.join('\n')}`
);
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import inquirer from 'inquirer';
import chalk from 'chalk';
import { LoadedNpmPkg } from '../utils/npm';
import { MaterialCliOptions } from './types';
import { Material } from './material';
export const getSelectedMaterials = async (
cliOpts: MaterialCliOptions,
formMaterialPkg: LoadedNpmPkg
) => {
const { materialName, selectMultiple } = cliOpts;
const materials: Material[] = Material.listAll(formMaterialPkg);
let selectedMaterials: Material[] = [];
// 1. Check if materialName is provided and exists in materials
if (materialName) {
selectedMaterials = materialName
.split(',')
.map((_name) => materials.find((_m) => _m.fullName === _name.trim())!)
.filter(Boolean);
}
// 2. If material not found or materialName not provided, prompt user to select
if (!selectedMaterials.length) {
console.log(chalk.yellow(`Material "${materialName}" not found. Please select from the list:`));
const choices = materials.map((_material) => ({
name: _material.fullName,
value: _material,
}));
if (selectMultiple) {
// User select one component
const result = await inquirer.prompt<{
material: Material[]; // Specify type for prompt result
}>([
{
type: 'checkbox',
name: 'material',
message: 'Select multiple materials to add:',
choices: choices,
},
]);
selectedMaterials = result.material;
} else {
// User select one component
const result = await inquirer.prompt<{
material: Material; // Specify type for prompt result
}>([
{
type: 'list',
name: 'material',
message: 'Select one material to add:',
choices: choices,
},
]);
selectedMaterials = [result.material];
}
}
return selectedMaterials;
};
+23
View File
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Project } from '../utils/project';
import { LoadedNpmPkg } from '../utils/npm';
import { Material } from './material';
export interface MaterialCliOptions {
materialName?: string;
refreshProjectImports?: boolean;
targetMaterialRootDir?: string;
selectMultiple?: boolean;
}
export interface SyncMaterialContext {
selectedMaterials: Material[];
project: Project;
formMaterialPkg: LoadedNpmPkg;
cliOpts: MaterialCliOptions;
targetFormMaterialRoot: string;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import chalk from 'chalk';
import { getLatestVersion } from '../utils/npm';
import { traverseRecursiveFiles } from '../utils/file';
export async function updateFlowgramVersion(inputVersion?: string) {
console.log(chalk.bold('🚀 Welcome to @flowgram.ai update-version helper'));
// Get latest version
const latestVersion = await getLatestVersion('@flowgram.ai/editor');
const currentPath = process.cwd();
console.log('- Latest flowgram version: ', latestVersion);
console.log('- Current Path: ', currentPath);
// User Input flowgram version, default is latestVersion
const flowgramVersion: string = inputVersion || latestVersion;
for (const file of traverseRecursiveFiles(currentPath)) {
if (file.path.endsWith('package.json')) {
console.log('👀 Find package.json: ', file.path);
let updated = false;
const json = JSON.parse(file.content);
if (json.dependencies) {
for (const key in json.dependencies) {
if (key.startsWith('@flowgram.ai/')) {
updated = true;
json.dependencies[key] = flowgramVersion;
console.log(`- Update ${key} to ${flowgramVersion}`);
}
}
}
if (json.devDependencies) {
for (const key in json.devDependencies) {
if (key.startsWith('@flowgram.ai/')) {
updated = true;
json.devDependencies[key] = flowgramVersion;
console.log(`- Update ${key} to ${flowgramVersion}`);
}
}
}
if (updated) {
file.write(JSON.stringify(json, null, 2));
console.log(`${file.path} Updated`);
}
}
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export function extractNamedExports(content: string) {
const valueExports = [];
const typeExports = [];
// Collect all type definition names
const typeDefinitions = new Set();
const typePatterns = [/\b(?:type|interface)\s+(\w+)/g, /\bexport\s+(?:type|interface)\s+(\w+)/g];
let match;
for (const pattern of typePatterns) {
while ((match = pattern.exec(content)) !== null) {
typeDefinitions.add(match[1]);
}
}
// Match various export patterns
const exportPatterns = [
// export const/var/let/function/class/type/interface
/\bexport\s+(const|var|let|function|class|type|interface)\s+(\w+)/g,
// export { name1, name2 }
/\bexport\s*\{([^}]+)\}/g,
// export { name as alias }
/\bexport\s*\{[^}]*\b(\w+)\s+as\s+(\w+)[^}]*\}/g,
// export default function name()
/\bexport\s+default\s+(?:function|class)\s+(\w+)/g,
// export type { Type1, Type2 }
/\bexport\s+type\s*\{([^}]+)\}/g,
// export type { Original as Alias }
/\bexport\s+type\s*\{[^}]*\b(\w+)\s+as\s+(\w+)[^}]*\}/g,
];
// Handle first pattern: export const/var/let/function/class/type/interface
exportPatterns[0].lastIndex = 0;
while ((match = exportPatterns[0].exec(content)) !== null) {
const [, kind, name] = match;
if (kind === 'type' || kind === 'interface' || typeDefinitions.has(name)) {
typeExports.push(name);
} else {
valueExports.push(name);
}
}
// Handle second pattern: export { name1, name2 }
exportPatterns[1].lastIndex = 0;
while ((match = exportPatterns[1].exec(content)) !== null) {
const exportsList = match[1]
.split(',')
.map((item) => item.trim())
.filter((item) => item && !item.includes(' as '));
for (const name of exportsList) {
if (name.startsWith('type ')) {
typeExports.push(name.replace('type ', '').trim());
} else if (typeDefinitions.has(name)) {
typeExports.push(name);
} else {
valueExports.push(name);
}
}
}
// Handle third pattern: export { name as alias }
exportPatterns[2].lastIndex = 0;
while ((match = exportPatterns[2].exec(content)) !== null) {
const [, original, alias] = match;
if (typeDefinitions.has(original)) {
typeExports.push(alias);
} else {
valueExports.push(alias);
}
}
// Handle fourth pattern: export default function name()
exportPatterns[3].lastIndex = 0;
while ((match = exportPatterns[3].exec(content)) !== null) {
const name = match[1];
if (typeDefinitions.has(name)) {
typeExports.push(name);
} else {
valueExports.push(name);
}
}
// Handle fifth pattern: export type { Type1, Type2 }
exportPatterns[4].lastIndex = 0;
while ((match = exportPatterns[4].exec(content)) !== null) {
const exportsList = match[1]
.split(',')
.map((item) => item.trim())
.filter((item) => item && !item.includes(' as '));
for (const name of exportsList) {
typeExports.push(name);
}
}
// Handle sixth pattern: export type { Original as Alias }
exportPatterns[5].lastIndex = 0;
while ((match = exportPatterns[5].exec(content)) !== null) {
const [, original, alias] = match;
typeExports.push(alias);
}
// Deduplicate and sort
return {
values: [...new Set(valueExports)].sort(),
types: [...new Set(typeExports)].sort(),
};
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import fs from 'fs';
import ignore, { Ignore } from 'ignore';
export class File {
content: string;
isUtf8: boolean;
relativePath: string;
path: string;
suffix: string;
constructor(filePath: string, public root: string = '/') {
this.path = filePath;
this.relativePath = path.relative(this.root, this.path);
this.suffix = path.extname(this.path);
// Check if exists
if (!fs.existsSync(this.path)) {
throw Error(`File ${path} Not Exists`);
}
// If no utf-8, skip
try {
this.content = fs.readFileSync(this.path, 'utf-8');
this.isUtf8 = true;
} catch (e) {
this.isUtf8 = false;
return;
}
}
replace(updater: (content: string) => string) {
if (!this.isUtf8) {
console.warn('Not UTF-8 file skipped: ', this.path);
return;
}
this.content = updater(this.content);
fs.writeFileSync(this.path, this.content, 'utf-8');
}
write(nextContent: string) {
this.content = nextContent;
fs.writeFileSync(this.path, this.content, 'utf-8');
}
}
export function* traverseRecursiveFilePaths(
folder: string,
ig: Ignore = ignore().add('.git'),
root: string = folder
): Generator<string> {
const files = fs.readdirSync(folder);
// add .gitignore to ignore if exists
if (fs.existsSync(path.join(folder, '.gitignore'))) {
ig.add(fs.readFileSync(path.join(folder, '.gitignore'), 'utf-8'));
}
for (const file of files) {
const filePath = path.join(folder, file);
if (ig.ignores(path.relative(root, filePath))) {
continue;
}
if (fs.statSync(filePath).isDirectory()) {
yield* traverseRecursiveFilePaths(filePath, ig, root);
} else {
yield filePath;
}
}
}
export function* traverseRecursiveFiles(folder: string): Generator<File> {
for (const filePath of traverseRecursiveFilePaths(folder)) {
yield new File(filePath, folder);
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export interface NamedImport {
local?: string;
imported: string;
typeOnly?: boolean;
}
/**
* Cases
* import { A, B } from 'module';
* import A from 'module';
* import * as C from 'module';
* import D, { type E, F } from 'module';
* import A, { B as B1 } from 'module';
*/
export interface ImportDeclaration {
// origin statement
statement: string;
// import { A, B } from 'module';
namedImports?: NamedImport[];
// import A from 'module';
defaultImport?: string;
// import * as C from 'module';
namespaceImport?: string;
source: string;
}
export function assembleImport(declaration: ImportDeclaration): string {
const { namedImports, defaultImport, namespaceImport, source } = declaration;
const importClauses = [];
if (namedImports) {
importClauses.push(
`{ ${namedImports
.map(
({ local, imported, typeOnly }) =>
`${typeOnly ? 'type ' : ''}${imported}${local ? ` as ${local}` : ''}`
)
.join(', ')} }`
);
}
if (defaultImport) {
importClauses.push(defaultImport);
}
if (namespaceImport) {
importClauses.push(`* as ${namespaceImport}`);
}
return `import ${importClauses.join(', ')} from '${source}';`;
}
export function replaceImport(
fileContent: string,
origin: ImportDeclaration,
replaceTo: ImportDeclaration[]
): string {
const replaceImportStatements = replaceTo.map(assembleImport);
// replace origin statement
return fileContent.replace(origin.statement, replaceImportStatements.join('\n'));
}
export function* traverseFileImports(fileContent: string): Generator<ImportDeclaration> {
// 匹配所有 import 语句的正则表达式
const importRegex =
/import\s+([^{}*,]*?)?(?:\s*\*\s*as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,?)?(?:\s*\{([^}]*)\}\s*,?)?(?:\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,?)?\s*from\s*['"`]([^'"`]+)['"`]\;?/g;
let match;
while ((match = importRegex.exec(fileContent)) !== null) {
const [fullMatch, defaultPart, namespacePart, namedPart, defaultPart2, source] = match;
const declaration: ImportDeclaration = {
statement: fullMatch,
source: source,
};
// 处理默认导入
const defaultImport = defaultPart?.trim() || defaultPart2?.trim();
if (defaultImport && !namespacePart && !namedPart) {
declaration.defaultImport = defaultImport;
} else if (defaultImport && (namespacePart || namedPart)) {
declaration.defaultImport = defaultImport;
}
// 处理命名空间导入 (* as)
if (namespacePart) {
declaration.namespaceImport = namespacePart.trim();
}
// 处理命名导入
if (namedPart) {
const namedImports = [];
const namedItems = namedPart
.split(',')
.map((item) => item.trim())
.filter(Boolean);
for (const item of namedItems) {
const typeOnly = item.startsWith('type ');
const cleanItem = typeOnly ? item.slice(5).trim() : item;
if (cleanItem.includes(' as ')) {
const [imported, local] = cleanItem.split(' as ').map((s) => s.trim());
namedImports.push({
imported,
local,
typeOnly,
});
} else {
namedImports.push({
imported: cleanItem,
typeOnly,
});
}
}
if (namedImports.length > 0) {
declaration.namedImports = namedImports;
}
}
yield declaration;
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import https from 'https';
import { existsSync, readFileSync } from 'fs';
import { execSync } from 'child_process';
import * as tar from 'tar';
import fs from 'fs-extra';
export class LoadedNpmPkg {
constructor(public name: string, public version: string, public path: string) {}
get srcPath() {
return path.join(this.path, 'src');
}
get distPath() {
return path.join(this.path, 'dist');
}
protected _packageJson: Record<string, any>;
get packageJson() {
if (!this._packageJson) {
this._packageJson = JSON.parse(readFileSync(path.join(this.path, 'package.json'), 'utf8'));
}
return this._packageJson;
}
get dependencies(): Record<string, string> {
return this.packageJson.dependencies;
}
}
// 使用 https 下载文件
function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
https
.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
})
.on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
file.on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
});
}
export async function getLatestVersion(packageName: string): Promise<string> {
return execSync(`npm view ${packageName} version --tag=latest`).toString().trim();
}
export async function loadNpm(packageName: string): Promise<LoadedNpmPkg> {
const packageLatestVersion = await getLatestVersion(packageName);
const packagePath = path.join(__dirname, `./.download/${packageName}-${packageLatestVersion}`);
if (existsSync(packagePath)) {
return new LoadedNpmPkg(packageName, packageLatestVersion, packagePath);
}
try {
// 获取 tarball 地址
const tarballUrl = execSync(`npm view ${packageName}@${packageLatestVersion} dist.tarball`)
.toString()
.trim();
// 临时 tarball 路径
const tempTarballPath = path.join(
__dirname,
`./.download/${packageName}-${packageLatestVersion}.tgz`
);
// 确保目录存在
fs.ensureDirSync(path.dirname(tempTarballPath));
// 下载 tarball
await downloadFile(tarballUrl, tempTarballPath);
fs.ensureDirSync(packagePath);
// 解压到目标目录
await tar.x({
file: tempTarballPath,
cwd: packagePath,
strip: 1,
});
// 删除临时文件
fs.unlinkSync(tempTarballPath);
return new LoadedNpmPkg(packageName, packageLatestVersion, packagePath);
} catch (error) {
console.error(`Error downloading or extracting package`);
console.error(error);
throw error;
}
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import chalk from 'chalk';
import { getLatestVersion } from './npm';
interface PackageJson {
dependencies: { [key: string]: string };
devDependencies?: { [key: string]: string };
peerDependencies?: { [key: string]: string };
[key: string]: any;
}
export class Project {
flowgramVersion?: string;
projectPath: string;
packageJsonPath: string;
packageJson: PackageJson;
srcPath: string;
protected constructor() {}
async init() {
// get nearest package.json
let projectPath: string = process.cwd();
while (projectPath !== '/' && !existsSync(path.join(projectPath, 'package.json'))) {
projectPath = path.join(projectPath, '..');
}
if (projectPath === '/') {
throw new Error('Please run this command in a valid project');
}
this.projectPath = projectPath;
this.srcPath = path.join(projectPath, 'src');
this.packageJsonPath = path.join(projectPath, 'package.json');
this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf8'));
this.flowgramVersion =
this.packageJson.dependencies['@flowgram.ai/fixed-layout-editor'] ||
this.packageJson.dependencies['@flowgram.ai/free-layout-editor'] ||
this.packageJson.dependencies['@flowgram.ai/editor'];
}
async addDependency(dependency: string) {
let name: string;
let version: string;
// 处理作用域包(如 @types/react@1.0.0
const lastAtIndex = dependency.lastIndexOf('@');
if (lastAtIndex <= 0) {
// 没有@符号 或者@在开头(如 @types/react
name = dependency;
version = await getLatestVersion(name);
} else {
// 正常分割包名和版本
name = dependency.substring(0, lastAtIndex);
version = dependency.substring(lastAtIndex + 1);
// 如果版本部分为空,获取最新版本
if (!version.trim()) {
version = await getLatestVersion(name);
}
}
this.packageJson.dependencies[name] = version;
writeFileSync(this.packageJsonPath, JSON.stringify(this.packageJson, null, 2));
}
async addDependencies(dependencies: string[]) {
for (const dependency of dependencies) {
await this.addDependency(dependency);
}
}
writeToPackageJsonFile() {
writeFileSync(this.packageJsonPath, JSON.stringify(this.packageJson, null, 2));
}
printInfo() {
console.log(chalk.bold('Project Info:'));
console.log(chalk.black(` - Flowgram Version: ${this.flowgramVersion}`));
console.log(chalk.black(` - Project Path: ${this.projectPath}`));
}
static async getSingleton() {
const info = new Project();
await info.init();
return info;
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path, { join } from 'path';
import fs from 'fs';
import { assembleImport, ImportDeclaration, traverseFileImports } from './import';
import { File, traverseRecursiveFilePaths } from './file';
import { extractNamedExports } from './export';
class TsFile extends File {
exports: {
values: string[];
types: string[];
} = {
values: [],
types: [],
};
imports: ImportDeclaration[] = [];
get allExportNames() {
return [...this.exports.values, ...this.exports.types];
}
constructor(filePath: string, root?: string) {
super(filePath, root);
this.exports = extractNamedExports(fs.readFileSync(filePath, 'utf-8'));
this.imports = Array.from(traverseFileImports(fs.readFileSync(filePath, 'utf-8')));
}
addImport(importDeclarations: ImportDeclaration[]) {
importDeclarations.forEach((importDeclaration) => {
importDeclaration.statement = assembleImport(importDeclaration);
});
// add in last import statement
this.replace((content) => {
const lastImportStatement = this.imports[this.imports.length - 1];
return content.replace(
lastImportStatement.statement,
`${lastImportStatement?.statement}\n${importDeclarations.map((item) => item.statement)}\n`
);
});
this.imports.push(...importDeclarations);
}
removeImport(importDeclarations: ImportDeclaration[]) {
this.replace((content) =>
importDeclarations.reduce((prev, cur) => prev.replace(cur.statement, ''), content)
);
this.imports = this.imports.filter((item) => !importDeclarations.includes(item));
}
replaceImport(oldImports: ImportDeclaration[], newImports: ImportDeclaration[]) {
newImports.forEach((importDeclaration) => {
importDeclaration.statement = assembleImport(importDeclaration);
});
this.replace((content) => {
oldImports.forEach((oldImport, idx) => {
const replaceTo = newImports[idx];
if (replaceTo) {
content = content.replace(oldImport.statement, replaceTo.statement);
this.imports.map((_import) => {
if (_import.statement === oldImport.statement) {
_import = replaceTo;
}
});
} else {
content = content.replace(oldImport.statement, '');
this.imports = this.imports.filter(
(_import) => _import.statement !== oldImport.statement
);
}
});
const restNewImports = newImports.slice(oldImports.length);
if (restNewImports.length > 0) {
const lastImportStatement = newImports[oldImports.length - 1].statement;
content = content.replace(
lastImportStatement,
`${lastImportStatement}
${restNewImports.map((item) => item.statement).join('\n')}
`
);
}
this.imports.push(...restNewImports);
return content;
});
}
}
export function* traverseRecursiveTsFiles(folder: string): Generator<TsFile> {
for (const filePath of traverseRecursiveFilePaths(folder)) {
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
yield new TsFile(filePath, folder);
}
}
}
export function getIndexTsFile(folder: string): TsFile | undefined {
// ts or tsx
const files = fs.readdirSync(folder);
for (const file of files) {
if (file === 'index.ts' || file === 'index.tsx') {
return new TsFile(path.join(folder, file), folder);
}
}
return undefined;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"target": "es2020",
"module": "esnext",
"strictPropertyInitialization": false,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"allowJs": true,
"resolveJsonModule": true,
"types": ["node"],
"jsx": "react-jsx",
"lib": ["es6", "dom", "es2020", "es2019.Array"]
},
"include": ["./src"],
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { defineConfig } from "tsup";
export default defineConfig({
shims: true,
})
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js';
+64
View File
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { defineFlatConfig } from '@flowgram.ai/eslint-config';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineFlatConfig({
preset: 'node',
packageRoot: __dirname,
ignore: [
'**/*.d.ts',
'**/__mocks__',
'**/node_modules',
'**/build',
'**/dist',
'**/es',
'**/lib',
'**/.codebase',
'**/.changeset',
'**/config',
'**/common/scripts',
'**/output',
'error-log-str.js',
'*.bundle.js',
'*.min.js',
'*.js.map',
'**/*.log',
'**/tsconfig.tsbuildinfo',
'**/vitest.config.ts',
'package.json',
'*.json',
],
rules: {
'no-console': 'off',
'import/prefer-default-export': 'off',
'lines-between-class-members': 'warn',
'no-unused-vars': 'off',
'no-redeclare': 'off',
'no-empty-function': 'off',
'prefer-destructuring': 'off',
'no-underscore-dangle': 'off',
'no-multi-assign': 'off',
'arrow-body-style': 'warn',
'no-useless-constructor': 'off',
'no-param-reassign': 'off',
'max-classes-per-file': 'off',
'grouped-accessor-pairs': 'off',
'no-plusplus': 'off',
'no-restricted-syntax': 'off',
'import/extensions': 'off',
'consistent-return': 'off',
'no-use-before-define': 'off',
'no-bitwise': 'off',
'no-case-declarations': 'off',
'no-dupe-class-members': 'off',
'class-methods-use-this': 'off',
'default-param-last': 'off',
},
});
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@flowgram.ai/create-app",
"version": "0.1.8",
"description": "A CLI tool to create demo projects",
"repository": "https://github.com/bytedance/flowgram.ai",
"bin": {
"create-app": "./bin/index.js"
},
"type": "module",
"files": [
"bin",
"src",
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --out-dir dist",
"start": "node bin/index.js",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix"
},
"dependencies": {
"fs-extra": "^9.1.0",
"commander": "^11.0.0",
"chalk": "^5.3.0",
"tar": "7.4.3",
"inquirer": "^12.9.4"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@types/fs-extra": "11.0.4",
"@types/node": "^18",
"@types/inquirer": "^9.0.9",
"tsup": "^8.0.1",
"eslint": "^9.0.0",
"@typescript-eslint/parser": "^8.0.0",
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import path from 'path';
import https from 'https';
import http from 'http';
import { execSync } from 'child_process';
import * as tar from 'tar';
import inquirer from 'inquirer';
import fs from 'fs-extra';
import { Command } from 'commander';
import chalk from 'chalk';
const program = new Command();
const args = process.argv.slice(2);
const updateFlowGramVersions = (dependencies: any[], latestVersion: string) => {
for (const packageName in dependencies) {
if (packageName.startsWith('@flowgram.ai')) {
dependencies[packageName] = latestVersion;
}
}
};
// 使用 http/https 下载文件
function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(dest);
const request = lib.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
});
request.on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
file.on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
});
}
const main = async () => {
console.log(chalk.green('Welcome to @flowgram.ai/create-app CLI!123123'));
const latest = execSync('npm view @flowgram.ai/demo-fixed-layout version --tag=latest latest')
.toString()
.trim();
let folderName = '';
if (!args?.length) {
const { repo } = await inquirer.prompt([
{
type: 'list',
name: 'repo',
message: 'Select a demo to create:',
choices: [
{ name: 'Fixed Layout Demo', value: 'demo-fixed-layout' },
{ name: 'Free Layout Demo', value: 'demo-free-layout' },
{ name: 'Fixed Layout Demo Simple', value: 'demo-fixed-layout-simple' },
{ name: 'Free Layout Demo Simple', value: 'demo-free-layout-simple' },
{ name: 'Free Layout Nextjs Demo', value: 'demo-nextjs' },
{ name: 'Free Layout Vite Demo Simple', value: 'demo-vite' },
{ name: 'Demo Playground for infinite canvas', value: 'demo-playground' },
],
},
]);
folderName = repo;
} else {
if (
[
'fixed-layout',
'free-layout',
'fixed-layout-simple',
'free-layout-simple',
'playground',
'nextjs',
].includes(args[0])
) {
folderName = `demo-${args[0]}`;
} else {
console.error('Invalid argument. Please run "npx create-app" to choose demo.');
return;
}
}
try {
const targetDir = path.join(process.cwd());
const downloadPackage = async () => {
try {
const tempTarballPath = path.join(process.cwd(), `${folderName}.tgz`);
const url = `https://registry.npmjs.org/@flowgram.ai/${folderName}/-/${folderName}-${latest}.tgz`;
console.log(chalk.blue(`Downloading ${url} ...`));
await downloadFile(url, tempTarballPath);
fs.ensureDirSync(targetDir);
await tar.x({
file: tempTarballPath,
C: targetDir,
});
fs.renameSync(path.join(targetDir, 'package'), path.join(targetDir, folderName));
fs.unlinkSync(tempTarballPath);
return true;
} catch (error) {
console.error(`Error downloading or extracting package`);
console.error(error);
return false;
}
};
const res = await downloadPackage();
const pkgJsonPath = path.join(targetDir, folderName, 'package.json');
const data = fs.readFileSync(pkgJsonPath, 'utf-8');
const packageLatestVersion = execSync('npm view @flowgram.ai/core version --tag=latest latest')
.toString()
.trim();
const jsonData = JSON.parse(data);
if (jsonData.dependencies) {
updateFlowGramVersions(jsonData.dependencies, packageLatestVersion);
}
if (jsonData.devDependencies) {
updateFlowGramVersions(jsonData.devDependencies, packageLatestVersion);
}
fs.writeFileSync(pkgJsonPath, JSON.stringify(jsonData, null, 2), 'utf-8');
if (res) {
console.log(chalk.green(`${folderName} Demo project created successfully!`));
console.log(chalk.yellow('Run the following commands to start:'));
console.log(chalk.cyan(` cd ${folderName}`));
console.log(chalk.cyan(' npm install'));
console.log(chalk.cyan(' npm start'));
} else {
console.log(chalk.red('Download failed'));
}
} catch (error) {
console.error('Error downloading repo:', error);
return;
}
};
program.version('1.0.0').description('Create a demo project');
program.parse(process.argv);
main();
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"target": "es2020",
"module": "esnext",
"strictPropertyInitialization": false,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"allowJs": true,
"resolveJsonModule": true,
"types": ["node"],
"jsx": "react-jsx",
"lib": ["es6", "dom", "es2020", "es2019.Array"]
},
"include": ["./src"],
}
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
rules: {
'no-console': 'off',
'react/prop-types': 'off',
},
settings: {
react: {
version: 'detect', // 自动检测 React 版本
},
},
});
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" data-bundler="rspack">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flow FixedLayoutEditor Demo</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,65 @@
{
"name": "@flowgram.ai/demo-fixed-layout-animation",
"version": "0.1.0",
"description": "",
"keywords": [],
"license": "MIT",
"main": "./src/app.ts",
"files": [
"src/",
"eslint.config.js",
".gitignore",
"index.html",
"package.json",
"rsbuild.config.ts",
"tsconfig.json"
],
"scripts": {
"build": "exit 0",
"build:fast": "exit 0",
"build:watch": "exit 0",
"build:prod": "cross-env MODE=app NODE_ENV=production rsbuild build",
"clean": "rimraf dist",
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix",
"ts-check": "tsc --noEmit",
"start": "cross-env NODE_ENV=development rsbuild dev --open",
"test": "exit",
"test:cov": "exit",
"watch": "exit 0"
},
"dependencies": {
"@flowgram.ai/fixed-layout-editor": "workspace:*",
"@flowgram.ai/fixed-semi-materials": "workspace:*",
"@flowgram.ai/minimap-plugin": "workspace:*",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9",
"react": "^18",
"react-dom": "^18",
"classnames": "^2.5.1",
"styled-components": "^5"
},
"devDependencies": {
"@flowgram.ai/ts-config": "workspace:*",
"@flowgram.ai/eslint-config": "workspace:*",
"@rsbuild/core": "^1.2.16",
"@rsbuild/plugin-react": "^1.1.1",
"@rsbuild/plugin-less": "^1.1.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/styled-components": "^5",
"@typescript-eslint/parser": "^8.0.0",
"typescript": "^5.8.3",
"eslint": "^9.0.0",
"less": "^4.1.2",
"less-loader": "^6",
"cross-env": "~7.0.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginLess } from '@rsbuild/plugin-less';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [pluginReact(), pluginLess()],
source: {
entry: {
index: './src/app.tsx',
},
/**
* support inversify @injectable() and @inject decorators
*/
decorators: {
version: 'legacy',
},
},
html: {
title: 'demo-fixed-layout-animation',
},
tools: {
rspack: {
/**
* ignore warnings from @coze-editor/editor/language-typescript
*/
ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/],
},
},
});
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import '@flowgram.ai/fixed-layout-editor/index.css';
import { createRoot } from 'react-dom/client';
import { FixedLayoutEditorProvider, EditorRenderer } from '@flowgram.ai/fixed-layout-editor';
import { useEditorProps } from '@/hooks/use-editor-props';
import { UpdateSchema } from '@/components/update-schema';
import { Tools } from '@/components/tools';
const FlowGramApp = () => {
const editorProps = useEditorProps();
return (
<FixedLayoutEditorProvider {...editorProps}>
<EditorRenderer />
<Tools />
<UpdateSchema />
</FixedLayoutEditorProvider>
);
};
const app = createRoot(document.getElementById('root')!);
app.render(<FlowGramApp />);
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { TitleField } from '@/fields/title-field';
import { ContentField } from '@/fields/content-field';
export const FormRender = () => (
<>
<TitleField />
<ContentField />
</>
);
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.loading-dots {
display: flex;
gap: 4px;
.dot {
width: 6px;
height: 6px;
background: #3b82f6;
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
&:nth-child(3) {
animation-delay: 0s;
}
}
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import './index.less';
export const LoadingDots = () => (
<div className="loading-dots">
<span className="dot"></span>
<span className="dot"></span>
<span className="dot"></span>
</div>
);
@@ -0,0 +1,118 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.node-render {
background: #fff;
border: 1px solid rgba(6, 7, 9, 0.15);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
cursor: pointer;
padding: 16px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: auto;
min-width: 200px;
// Activated state - when node is selected/focused
&-activated {
border-color: #82a7fc;
}
// Dragging state - when node is being dragged
&-dragging {
opacity: 0.3;
}
// Block icon states - when showing order or regular block icons
&-block-icon,
&-block-order-icon {
width: 260px;
}
// Hover effects for better UX
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
// Focus state for accessibility
&:focus-within {
outline: 2px solid #82a7fc;
outline-offset: 2px;
}
.node-form {
transition: opacity 1s ease-in-out;
}
}
.node-render-before-render {
max-height: 1px;
.node-form {
opacity: 0;
}
}
.node-render-rendered {
max-height: 1px;
animation: node-rendered-transition 1s ease-out forwards;
.node-form {
opacity: 1;
}
}
@keyframes node-rendered-transition {
0% {
max-height: 1px;
}
100% {
max-height: 500px;
}
}
.node-render-removed {
max-height: 500px;
animation: node-removed-transition 0.3s ease-out forwards;
overflow: hidden;
padding: 0 16px;
transition: opacity 0.3s ease-out;
opacity: 0;
}
@keyframes node-removed-transition {
0% {
max-height: 500px;
padding: 16px;
}
100% {
max-height: 1px;
padding: 0 16px;
}
}
.node-render-border-transition {
outline: 2px solid transparent;
animation: node-border-appear-hide 0.8s ease-in-out forwards;
}
@keyframes node-border-appear-hide {
0% {
outline: 2px solid transparent;
}
50% {
outline: 2px solid #82a7fc;
}
100% {
outline: 2px solid transparent;
}
}
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import '@flowgram.ai/fixed-layout-editor/index.css';
import './index.less';
import classNames from 'classnames';
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/fixed-layout-editor';
import { useNodeStatus } from '@/hooks/use-node-loading';
export const NodeRender = ({ node }: { node: FlowNodeEntity }) => {
const { onMouseEnter, onMouseLeave, form, dragging, isBlockOrderIcon, isBlockIcon, activated } =
useNodeRender();
const { className } = useNodeStatus();
return (
<div
className={classNames('node-render', className, {
'node-render-activated': activated,
'node-render-dragging': dragging,
'node-render-block-order-icon': isBlockOrderIcon,
'node-render-block-icon': isBlockIcon,
})}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div className="node-form">{form?.render()}</div>
</div>
);
};
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.thinking-node {
background: #fff;
border: 1px solid oklch(80.9% .105 251.813);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
cursor: pointer;
padding: 16px;
background-color: oklch(97% .014 254.604);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: auto;
min-width: 200px;
.node-form {
transition: opacity 1s ease-in-out;
}
}
.thinking-node-loading {
&::before {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
background: linear-gradient(90deg,
transparent,
transparent,
transparent,
transparent,
rgba(59, 130, 246, 0.6),
rgba(96, 165, 250, 0.6),
transparent,
transparent,
transparent,
transparent,
transparent,
transparent);
background-size: 400% 100%;
border-radius: 8px;
z-index: -1;
animation: flowing-border 5s linear infinite;
pointer-events: none;
}
&::after {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
background: linear-gradient(90deg,
transparent,
transparent,
transparent,
transparent,
rgba(59, 130, 246, 0.08),
rgba(96, 165, 250, 0.08),
transparent,
transparent,
transparent,
transparent,
transparent,
transparent);
background-size: 400% 100%;
border-radius: 8px;
z-index: 1;
animation: flowing-border 5s linear infinite;
pointer-events: none;
}
}
@keyframes flowing-border {
0% {
background-position: 0% 0;
}
100% {
background-position: 400% 0;
}
}
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import classNames from 'classnames';
import { useNodeRender } from '@flowgram.ai/fixed-layout-editor';
import './index.less';
import { useNodeStatus } from '@/hooks/use-node-loading';
export const ThinkingNode = () => {
const { form } = useNodeRender();
const { className } = useNodeStatus();
return (
<div className={classNames('thinking-node', 'thinking-node-loading', className)}>
<div className="node-form">{form?.render()}</div>
</div>
);
};
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, useEffect, useState } from 'react';
import { usePlaygroundTools, useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { Minimap } from './minimap';
export const Tools = () => {
const { history } = useClientContext();
const tools = usePlaygroundTools();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const buttonStyle: CSSProperties = {
border: '1px solid #e0e0e0',
borderRadius: '4px',
cursor: 'pointer',
padding: '4px',
color: '#141414',
background: '#e1e3e4',
};
useEffect(() => {
const disposable = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
});
return () => disposable.dispose();
}, [history]);
return (
<>
<Minimap />
<div
style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 16, display: 'flex', gap: 8 }}
>
<button style={buttonStyle} onClick={() => tools.zoomin()}>
ZoomIn
</button>
<button style={buttonStyle} onClick={() => tools.zoomout()}>
ZoomOut
</button>
<span
style={{
...buttonStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
width: 40,
}}
>
{Math.floor(tools.zoom * 100)}%
</span>
<button style={buttonStyle} onClick={() => tools.fitView()}>
FitView
</button>
<button style={buttonStyle} onClick={() => tools.changeLayout()}>
ChangeLayout
</button>
<button
style={{
...buttonStyle,
cursor: canUndo ? 'pointer' : 'not-allowed',
color: canUndo ? '#141414' : '#b1b1b1',
}}
onClick={() => history.undo()}
disabled={!canUndo}
>
Undo
</button>
<button
style={{
...buttonStyle,
cursor: canRedo ? 'pointer' : 'not-allowed',
color: canRedo ? '#141414' : '#b1b1b1',
}}
onClick={() => history.redo()}
disabled={!canRedo}
>
Redo
</button>
</div>
</>
);
};
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MinimapRender } from '@flowgram.ai/minimap-plugin';
export const Minimap = () => (
<div
style={{
position: 'absolute',
left: 16,
bottom: 58,
zIndex: 100,
width: 218,
}}
>
<MinimapRender
containerStyles={{
pointerEvents: 'auto',
position: 'relative',
top: 'unset',
right: 'unset',
bottom: 'unset',
left: 'unset',
}}
inactiveStyle={{
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
}}
/>
</div>
);
@@ -0,0 +1,294 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
const initSchema = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: '开始',
},
},
],
};
const processStartSchema = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: '开始',
content: '天气穿衣建议工作流',
},
},
{
id: 'thinking_0',
type: 'thinking',
data: {
text: '正在生成天气穿衣建议工作流...业务流程:1.进行输入处理 2.获取天气数据 3.生成穿衣建议 4.整理输出。我需要根据这些步骤来生成天气穿衣建议工作流核心节点...',
},
},
],
};
const addCoreNodesSchema = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: '开始',
content: '天气穿衣建议工作流',
},
},
{
id: 'validate_input_0',
type: 'custom',
data: {
title: '输入处理节点',
content: '验证并清理城市名称输入 - validate_city_input()',
},
},
{
id: 'thinking_1',
type: 'thinking',
data: {
text: '正在生成错误检查节点与天气检查节点...',
},
},
{
id: 'fetch_weather_0',
type: 'custom',
data: {
title: '天气数据获取',
content: '调用wttr.in API获取天气信息 - fetch_weather_data()',
},
},
{
id: 'generate_suggestion_0',
type: 'custom',
data: {
title: '穿衣建议生成',
content: '基于天气数据生成穿衣建议 - generate_clothing_suggestion()',
},
},
{
id: 'format_response_0',
type: 'custom',
data: {
title: '输出整理节点',
content: '格式化最终回答 - format_final_response()',
},
},
{
id: 'end_0',
type: 'end',
data: {
title: '结束',
content: '返回格式化的穿衣建议',
},
},
],
};
const completeWorkflowLoadingSchema = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: '开始',
content: '天气穿衣建议工作流',
},
},
{
id: 'validate_input_0',
type: 'custom',
data: {
title: '输入处理节点',
content: '验证并清理城市名称输入 - validate_city_input()',
},
},
{
id: 'condition_0',
type: 'condition',
data: {
title: '输入验证',
content: '检查输入验证是否有错误',
},
blocks: [
{
id: 'thinking_2',
type: 'thinking',
data: {
text: '天气数据获取节点生成中',
},
},
{
id: 'thinking_3',
type: 'thinking',
data: {
text: '格式化错误节点生成中',
},
},
],
},
{
id: 'condition_1',
type: 'condition',
data: {
title: '天气数据检查',
content: '检查天气数据获取是否成功',
},
blocks: [
{
id: 'thinking_4',
type: 'thinking',
data: {
text: '穿衣建议生成节点生成中',
},
},
{
id: 'thinking_5',
type: 'thinking',
data: {
text: '格式化错误节点生成中',
},
},
],
},
{
id: 'format_response_0',
type: 'custom',
data: {
title: '输出整理节点',
content: '格式化最终回答 - format_final_response()',
},
},
{
id: 'end_0',
type: 'end',
data: {
title: '结束',
content: '返回格式化的穿衣建议',
},
},
],
};
const completeWorkflowSchema = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: '开始',
content: '天气穿衣建议工作流',
},
},
{
id: 'validate_input_0',
type: 'custom',
data: {
title: '输入处理节点',
content: '验证并清理城市名称输入 - validate_city_input()',
},
},
{
id: 'condition_0',
type: 'condition',
data: {
title: '输入验证',
content: '检查输入验证是否有错误',
},
blocks: [
{
id: 'block_0',
type: 'block',
blocks: [
{
id: 'fetch_weather_0',
type: 'custom',
data: {
title: '天气数据获取',
content: '调用wttr.in API获取天气信息 - fetch_weather_data()',
},
},
{
id: 'format_data_0',
type: 'custom',
data: {
title: '格式化数据',
content: '天气数据提取并进行处理格式化',
},
},
],
},
{
id: 'format_error_0',
type: 'custom',
data: {
title: '格式化错误',
content: '直接跳转到输出格式化',
},
},
],
},
{
id: 'condition_1',
type: 'condition',
data: {
title: '天气数据检查',
content: '检查天气数据获取是否成功',
},
blocks: [
{
id: 'generate_suggestion_0',
type: 'custom',
data: {
title: '穿衣建议生成',
content: '基于天气数据生成穿衣建议 - generate_clothing_suggestion()',
},
},
{
id: 'format_error_1',
type: 'custom',
data: {
title: '格式化错误',
content: '跳转到输出格式化',
},
},
],
},
{
id: 'format_response_0',
type: 'custom',
data: {
title: '输出整理节点',
content: '格式化最终回答 - format_final_response()',
},
},
{
id: 'end_0',
type: 'end',
data: {
title: '结束',
content: '返回格式化的穿衣建议',
},
},
],
};
export const exampleSchemas: FlowDocumentJSON[] = [
initSchema,
processStartSchema,
addCoreNodesSchema,
completeWorkflowLoadingSchema,
completeWorkflowSchema,
];
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
Weather-based Clothing Advisor using LangGraph
A workflow that fetches weather data and provides clothing recommendations.
"""
import json
import re
import requests
from typing import Dict, Any, Optional, TypedDict
from dataclasses import dataclass
from langgraph.graph import Graph, StateGraph, END
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
import os
# State definition for the workflow
class WorkflowState(TypedDict):
"""State structure for the weather clothing advisor workflow"""
city_name: str
validated_city: str
weather_data: Dict[str, Any]
temperature: float
weather_condition: str
clothing_suggestion: str
final_response: str
error_message: Optional[str]
@dataclass
class WeatherInfo:
"""Weather information data structure"""
temperature: float
condition: str
humidity: int
wind_speed: float
description: str
class WeatherClothingAdvisor:
"""Main class for the weather-based clothing advisor workflow"""
def __init__(self, openai_api_key: Optional[str] = None):
"""
Initialize the advisor with OpenAI API key
Args:
openai_api_key: OpenAI API key for LLM calls
"""
self.openai_api_key = openai_api_key or os.getenv("OPENAI_API_KEY")
if self.openai_api_key:
self.llm = ChatOpenAI(
api_key=self.openai_api_key,
model="gpt-3.5-turbo",
temperature=0.7
)
else:
self.llm = None
print("Warning: No OpenAI API key provided. Using rule-based suggestions.")
def validate_city_input(self, state: WorkflowState) -> WorkflowState:
"""
Node 1: Input processing and validation
Validates and cleans the city name input
Args:
state: Current workflow state
Returns:
Updated state with validated city name
"""
city_name = state.get("city_name", "").strip()
# Basic validation
if not city_name:
state["error_message"] = "城市名称不能为空"
return state
# Remove special characters and normalize
validated_city = re.sub(r'[^\w\s-]', '', city_name)
validated_city = validated_city.strip()
if len(validated_city) < 2:
state["error_message"] = "请输入有效的城市名称"
return state
state["validated_city"] = validated_city
state["error_message"] = None
print(f"✓ 城市名称验证通过: {validated_city}")
return state
def fetch_weather_data(self, state: WorkflowState) -> WorkflowState:
"""
Node 2: Weather data retrieval
Fetches weather information from wttr.in API
Args:
state: Current workflow state
Returns:
Updated state with weather data
"""
if state.get("error_message"):
return state
validated_city = state["validated_city"]
try:
# Use wttr.in API for weather data
url = f"http://wttr.in/{validated_city}?format=j1"
headers = {
'User-Agent': 'WeatherClothingAdvisor/1.0'
}
print(f"🌤️ 正在获取 {validated_city} 的天气数据...")
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
weather_data = response.json()
# Extract current weather information
current_condition = weather_data["current_condition"][0]
temperature_c = float(current_condition["temp_C"])
weather_desc = current_condition["weatherDesc"][0]["value"]
humidity = int(current_condition["humidity"])
wind_speed = float(current_condition["windspeedKmph"])
state["weather_data"] = weather_data
state["temperature"] = temperature_c
state["weather_condition"] = weather_desc
weather_info = WeatherInfo(
temperature=temperature_c,
condition=weather_desc,
humidity=humidity,
wind_speed=wind_speed,
description=f"{temperature_c}°C, {weather_desc}, 湿度 {humidity}%, 风速 {wind_speed}km/h"
)
print(f"✓ 天气数据获取成功: {weather_info.description}")
except requests.exceptions.RequestException as e:
state["error_message"] = f"获取天气数据失败: {str(e)}"
print(f"❌ 天气数据获取失败: {str(e)}")
except (KeyError, ValueError, IndexError) as e:
state["error_message"] = f"天气数据解析失败: {str(e)}"
print(f"❌ 天气数据解析失败: {str(e)}")
return state
def generate_clothing_suggestion(self, state: WorkflowState) -> WorkflowState:
"""
Node 3: Clothing suggestion generation
Generates clothing recommendations based on weather data
Args:
state: Current workflow state
Returns:
Updated state with clothing suggestions
"""
if state.get("error_message"):
return state
temperature = state["temperature"]
weather_condition = state["weather_condition"]
city_name = state["validated_city"]
print(f"🧥 正在生成穿衣建议...")
if self.llm:
# Use LLM for intelligent suggestions
try:
prompt = f"""
作为一个专业的穿衣顾问,请根据以下天气信息为用户提供详细的穿衣建议:
城市:{city_name}
温度:{temperature}°C
天气状况:{weather_condition}
请提供:
1. 上身穿着建议
2. 下身穿着建议
3. 外套建议
4. 配饰建议(如帽子、围巾等)
5. 鞋子建议
6. 特别注意事项
请用简洁明了的中文回答,语气友好自然。
"""
messages = [
SystemMessage(content="你是一个专业的穿衣顾问,擅长根据天气情况提供实用的穿衣建议。"),
HumanMessage(content=prompt)
]
response = self.llm.invoke(messages)
state["clothing_suggestion"] = response.content
except Exception as e:
print(f"⚠️ LLM调用失败,使用规则建议: {str(e)}")
state["clothing_suggestion"] = self._get_rule_based_suggestion(temperature, weather_condition)
else:
# Use rule-based suggestions
state["clothing_suggestion"] = self._get_rule_based_suggestion(temperature, weather_condition)
print("✓ 穿衣建议生成完成")
return state
def _get_rule_based_suggestion(self, temperature: float, weather_condition: str) -> str:
"""
Generate rule-based clothing suggestions
Args:
temperature: Temperature in Celsius
weather_condition: Weather condition description
Returns:
Clothing suggestion string
"""
suggestions = []
# Temperature-based suggestions
if temperature < 0:
suggestions.append("🧥 上身:保暖内衣 + 毛衣 + 厚外套")
suggestions.append("👖 下身:保暖裤 + 厚裤子")
suggestions.append("🧤 配饰:帽子、围巾、手套必备")
elif temperature < 10:
suggestions.append("🧥 上身:长袖衬衫 + 毛衣 + 外套")
suggestions.append("👖 下身:长裤")
suggestions.append("🧣 配饰:围巾、帽子")
elif temperature < 20:
suggestions.append("👔 上身:长袖衬衫 + 薄外套")
suggestions.append("👖 下身:长裤或牛仔裤")
suggestions.append("🧢 配饰:可选择轻薄围巾")
elif temperature < 25:
suggestions.append("👕 上身:长袖T恤或薄衬衫")
suggestions.append("👖 下身:长裤或休闲裤")
else:
suggestions.append("👕 上身:短袖T恤或薄衬衫")
suggestions.append("🩳 下身:短裤或薄长裤")
suggestions.append("🧴 注意:防晒和补水")
# Weather condition adjustments
weather_lower = weather_condition.lower()
if any(word in weather_lower for word in ['rain', 'shower', '', '阵雨']):
suggestions.append("☔ 特别提醒:携带雨伞或穿防水外套")
elif any(word in weather_lower for word in ['snow', '']):
suggestions.append("❄️ 特别提醒:穿防滑鞋,注意保暖")
elif any(word in weather_lower for word in ['wind', '']):
suggestions.append("💨 特别提醒:选择防风外套")
# Shoe suggestions
if temperature < 5:
suggestions.append("👢 鞋子:保暖靴子或厚底鞋")
elif temperature > 25:
suggestions.append("👟 鞋子:透气运动鞋或凉鞋")
else:
suggestions.append("👟 鞋子:舒适的运动鞋或休闲鞋")
return "\n".join(suggestions)
def format_final_response(self, state: WorkflowState) -> WorkflowState:
"""
Node 4: Output formatting
Formats the final response with weather info and clothing suggestions
Args:
state: Current workflow state
Returns:
Updated state with formatted final response
"""
if state.get("error_message"):
state["final_response"] = f"❌ 错误:{state['error_message']}"
return state
city_name = state["validated_city"]
temperature = state["temperature"]
weather_condition = state["weather_condition"]
clothing_suggestion = state["clothing_suggestion"]
final_response = f"""
🌍 {city_name} 天气穿衣建议
📊 当前天气情况:
• 温度:{temperature}°C
• 天气:{weather_condition}
👔 穿衣建议:
{clothing_suggestion}
💡 温馨提示:
建议出门前再次确认天气变化,根据个人体感适当调整穿着。
""".strip()
state["final_response"] = final_response
print("✓ 最终回答格式化完成")
return state
def create_workflow(self) -> StateGraph:
"""
Create and configure the LangGraph workflow
Returns:
Configured StateGraph workflow
"""
# Create the graph
workflow = StateGraph(WorkflowState)
# Add nodes
workflow.add_node("validate_input", self.validate_city_input)
workflow.add_node("fetch_weather", self.fetch_weather_data)
workflow.add_node("generate_suggestion", self.generate_clothing_suggestion)
workflow.add_node("format_response", self.format_final_response)
# Define the flow
workflow.set_entry_point("validate_input")
# Add conditional edges
workflow.add_conditional_edges(
"validate_input",
lambda state: "fetch_weather" if not state.get("error_message") else "format_response"
)
workflow.add_conditional_edges(
"fetch_weather",
lambda state: "generate_suggestion" if not state.get("error_message") else "format_response"
)
workflow.add_conditional_edges(
"generate_suggestion",
lambda state: "format_response" if not state.get("error_message") else "format_response"
)
workflow.add_edge("format_response", END)
return workflow.compile()
def get_clothing_advice(self, city_name: str) -> str:
"""
Main method to get clothing advice for a city
Args:
city_name: Name of the city to get weather and clothing advice for
Returns:
Formatted clothing advice string
"""
print(f"🚀 开始为 '{city_name}' 生成穿衣建议...")
# Create and run the workflow
workflow = self.create_workflow()
# Initial state
initial_state = WorkflowState(
city_name=city_name,
validated_city="",
weather_data={},
temperature=0.0,
weather_condition="",
clothing_suggestion="",
final_response="",
error_message=None
)
# Execute the workflow
result = workflow.invoke(initial_state)
return result["final_response"]
def main():
"""Main function to demonstrate the weather clothing advisor"""
print("🌤️ 天气穿衣建议助手")
print("=" * 50)
# Initialize the advisor
advisor = WeatherClothingAdvisor()
# Example usage
cities = ["北京", "上海", "广州", "深圳"]
for city in cities:
print(f"\n{'='*20} {city} {'='*20}")
try:
advice = advisor.get_clothing_advice(city)
print(advice)
except Exception as e:
print(f"❌ 处理 {city} 时出错: {str(e)}")
print("\n" + "-" * 60)
# Interactive mode
print("\n🎯 交互模式 (输入 'quit' 退出)")
while True:
try:
city_input = input("\n请输入城市名称: ").strip()
if city_input.lower() in ['quit', 'exit', '退出', 'q']:
print("👋 再见!")
break
if city_input:
advice = advisor.get_clothing_advice(city_input)
print(f"\n{advice}")
else:
print("❌ 请输入有效的城市名称")
except KeyboardInterrupt:
print("\n👋 再见!")
break
except Exception as e:
print(f"❌ 出现错误: {str(e)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.update-schema-button-container {
// Position and layout
position: absolute;
top: 50px;
right: 50px;
z-index: 100;
display: flex;
flex-direction: column;
gap: 12px;
}
.update-schema-button {
// Size and spacing
width: auto;
min-width: 120px;
height: 44px;
padding: 12px 24px;
// Typography
font-size: 14px;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #ffffff;
// Background and borders
background: #667eea;
border: none;
border-radius: 8px;
// Shadow and effects
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1);
// Interaction states
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
// Prevent text selection
user-select: none;
-webkit-user-select: none;
// Hover state - subtle enhancement
&:hover {
background: #5a6fd8;
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5), 0 3px 6px rgba(0, 0, 0, 0.15);
}
// Active/Click state - gentle press effect
&:active {
background: #4c5bc4;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3), 0 1px 2px rgba(0, 0, 0, 0.1);
}
// Button content styling
.button-content {
display: flex;
align-items: center;
gap: 8px;
}
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState } from 'react';
import { FlowDocumentJSON, useService } from '@flowgram.ai/fixed-layout-editor';
import './index.less';
import { WorkflowLoadSchemaService } from '@/services';
import { exampleSchemas } from './example-schemas';
export const UpdateSchema = () => {
const loadSchemaService = useService(WorkflowLoadSchemaService);
const [currentSchemaIndex, setCurrentSchemaIndex] = useState<number>(0);
const handleUpdateSchema = (): void => {
const currentSchema: FlowDocumentJSON = exampleSchemas[currentSchemaIndex];
// Update the document with current schema
loadSchemaService.load(currentSchema);
// Move to next schema index, cycle back to 0 when reaching the end
setCurrentSchemaIndex((currentSchemaIndex + 1) % exampleSchemas.length);
};
const handleForceUpdateSchema = (): void => {
const currentSchema: FlowDocumentJSON = exampleSchemas[currentSchemaIndex];
// Update the document with current schema
loadSchemaService.forceLoad(currentSchema);
// Move to next schema index, cycle back to 0 when reaching the end
setCurrentSchemaIndex((currentSchemaIndex + 1) % exampleSchemas.length);
};
return (
<div className="update-schema-button-container">
<button onClick={handleUpdateSchema} className="update-schema-button">
<span className="button-content">{`更新 ${currentSchemaIndex}/${exampleSchemas.length}`}</span>
</button>
<button onClick={handleForceUpdateSchema} className="update-schema-button">
<span className="button-content">{`强制更新 ${currentSchemaIndex}/${exampleSchemas.length}`}</span>
</button>
</div>
);
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.form-render-content {
font-size: 14px;
line-height: 1.6;
color: #666666;
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/fixed-layout-editor';
import './index.less';
export const ContentField = () => (
<Field<string> name="content">
{({ field }) => <div className="form-render-content">{field.value}</div>}
</Field>
);
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.thinking-title {
font-size: 14px;
font-weight: 500;
color: #8d8d8d;
}
.thinking-text {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 4px;
border-radius: 8px;
line-height: 1.5;
font-size: 14px;
.thinking-content {
flex: 1;
word-break: break-word;
color: #8d8d8d;
}
.cursor {
font-weight: bold;
animation: blink 1s infinite;
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState, useEffect } from 'react';
import { Field } from '@flowgram.ai/fixed-layout-editor';
import './index.less';
interface ThinkingTextProps {
thinking?: string;
}
// ThinkingText component with typewriter effect
const ThinkingText: React.FC<ThinkingTextProps> = ({ thinking }) => {
const [displayedText, setDisplayedText] = useState<string>('');
const [currentIndex, setCurrentIndex] = useState<number>(0);
// Reset animation when thinking text changes
useEffect(() => {
setDisplayedText('');
setCurrentIndex(0);
}, [thinking]);
// Typewriter effect for thinking text
useEffect(() => {
if (!thinking || currentIndex >= thinking.length) {
return;
}
const timer = setTimeout(() => {
setDisplayedText((prev: string) => prev + (thinking?.[currentIndex] || ''));
setCurrentIndex((prev: number) => prev + 1);
}, 50); // 50ms delay between each character
return () => clearTimeout(timer);
}, [thinking, currentIndex]);
if (!thinking) {
return null;
}
return (
<div className="thinking-text">
<div className="thinking-title">:</div>
<div>
<span className="thinking-content">{displayedText}</span>
<span className="cursor">
{currentIndex < (thinking?.length || 0) && <span className="cursor">|</span>}
</span>
</div>
</div>
);
};
export const ThinkingTextField = () => (
<Field<string> name="text">{({ field }) => <ThinkingText thinking={field.value} />}</Field>
);
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.form-render-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 12px;
color: #333333;
display: flex;
gap: 8px;
justify-content: flex-start;
align-items: center;
}
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/fixed-layout-editor';
import { useNodeStatus } from '@/hooks/use-node-loading';
import { LoadingDots } from '@/components/loading-dots';
import './index.less';
export const TitleField = () => {
const { loading } = useNodeStatus();
return (
<Field<string> name="title">
{({ field }) => (
<div className="form-render-title">
<span>{field.value}</span>
{loading && (
<span>
<LoadingDots />
</span>
)}
</div>
)}
</Field>
);
};
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import '@flowgram.ai/fixed-layout-editor/index.css';
import { useMemo } from 'react';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { defaultFixedSemiMaterials } from '@flowgram.ai/fixed-semi-materials';
import { FixedLayoutProps, FlowRendererKey } from '@flowgram.ai/fixed-layout-editor';
import { WorkflowLoadSchemaService } from '@/services';
import { nodeRegistries } from '@/nodes';
import { ThinkingNode } from '@/components/thinking-node';
import { NodeRender } from '@/components/node-render';
import { FormRender } from '@/components/form-render';
export function useEditorProps(): FixedLayoutProps {
return useMemo<FixedLayoutProps>(
() => ({
plugins: () => [
createMinimapPlugin({
disableLayer: true,
enableDisplayAllNodes: true,
canvasStyle: {
canvasWidth: 200,
canvasHeight: 100,
canvasPadding: 50,
},
}),
],
nodeRegistries,
initialData: {
nodes: [],
},
materials: {
renderDefaultNode: NodeRender,
components: {
...defaultFixedSemiMaterials,
[FlowRendererKey.DRAG_NODE]: () => <></>,
[FlowRendererKey.BRANCH_ADDER]: () => <></>,
[FlowRendererKey.ADDER]: () => <></>,
},
renderNodes: {
ThinkingNode,
},
},
onAllLayersRendered: (ctx) => {
setTimeout(() => {
ctx.playground.config.fitView(ctx.document.root.bounds.pad(30));
}, 10);
},
onBind: ({ bind }) => {
bind(WorkflowLoadSchemaService).toSelf().inSingletonScope();
},
/**
* Get the default node registry, which will be merged with the 'nodeRegistries'
* 提供默认的节点注册,这个会和 nodeRegistries 做合并
*/
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
formMeta: {
/**
* Render form
*/
render: FormRender,
},
};
},
/**
* Redo/Undo enable
*/
history: {
enable: true,
enableChangeNode: true, // Listen Node engine data change
onApply: (ctx) => {
if (ctx.document.disposed) return;
// Listen change to trigger auto save
// console.log('auto save: ', ctx.document.toJSON());
},
},
/**
* Node engine enable, you can configure formMeta in the FlowNodeRegistry
*/ nodeEngine: {
enable: true,
},
}),
[]
);
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useState } from 'react';
import { FlowNodeFormData, FormModelV2, useNodeRender } from '@flowgram.ai/fixed-layout-editor';
interface NodeStatus {
loading: boolean;
className: string;
}
const NodeStatusKey = 'status';
export const useNodeStatus = () => {
const { node } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formStatus = formModel.getValueIn<NodeStatus>(NodeStatusKey);
const [loading, setLoading] = useState(formStatus?.loading ?? false);
const [className, setClassName] = useState(formStatus?.className ?? '');
// 初始化表单值
useEffect(() => {
const initSize = formModel.getValueIn<{ width: number; height: number }>(NodeStatusKey);
if (!initSize) {
formModel.setValueIn(NodeStatusKey, {
loading: false,
});
}
}, [formModel]);
// 同步表单外部值变化:初始化/undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== NodeStatusKey && name !== '') {
return;
}
const newStatus = formModel.getValueIn<NodeStatus>(NodeStatusKey);
if (!newStatus) {
return;
}
setLoading(newStatus.loading);
setClassName(newStatus.className);
});
return () => disposer.dispose();
}, [formModel]);
return {
loading,
className,
};
};
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
FlowNodeBaseType,
FlowNodeEntity,
FlowNodeJSON,
FlowNodeMeta,
FlowNodeRegistry,
FlowNodeSplitType,
} from '@flowgram.ai/fixed-layout-editor';
export const ConditionNodeRegistry: FlowNodeRegistry<FlowNodeMeta> = {
type: 'condition',
extend: FlowNodeSplitType.DYNAMIC_SPLIT,
onBlockChildCreate(
originParent: FlowNodeEntity,
blockData: FlowNodeJSON,
addedNodes: FlowNodeEntity[] = [] // 新创建的节点都要存在这里
) {
const { document } = originParent;
const parent = document.getNode(`$inlineBlocks$${originParent.id}`);
const blockNode = document.addNode(
{
id: `$block$${blockData.id}`,
type: FlowNodeBaseType.BLOCK,
parent,
},
addedNodes
);
const createdNode = document.addNode(
{
...blockData,
type: blockData.type || FlowNodeBaseType.BLOCK,
parent: blockNode,
},
addedNodes
);
addedNodes.push(blockNode, createdNode);
return createdNode;
},
};
@@ -0,0 +1,21 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeMeta, FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
export const CustomNodeRegistry: FlowNodeRegistry<FlowNodeMeta> = {
type: 'custom',
meta: {},
// onAdd() {
// return {
// id: `custom_${nanoid(5)}`,
// type: 'custom',
// data: {
// title: 'Custom',
// content: 'this is custom content',
// },
// };
// },
};
@@ -0,0 +1,21 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* Copyright (c) 202 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { FlowNodeMeta, FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
import { ThinkingNodeRegistry } from './thinking';
import { CustomNodeRegistry } from './custom';
import { ConditionNodeRegistry } from './condition';
export const nodeRegistries: FlowNodeRegistry<FlowNodeMeta>[] = [
ConditionNodeRegistry,
CustomNodeRegistry,
ThinkingNodeRegistry,
];
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeMeta, FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
import { ThinkingTextField } from '@/fields/thinking-text-field';
import { LoadingDots } from '@/components/loading-dots';
export const ThinkingNodeRegistry: FlowNodeRegistry<FlowNodeMeta> = {
type: 'thinking',
meta: {
renderKey: 'ThinkingNode',
},
formMeta: {
render: () => (
<>
<div
style={{
marginBottom: 16,
}}
>
<LoadingDots />
</div>
<ThinkingTextField />
</>
),
},
};
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { WorkflowLoadSchemaService } from './load-schema-service';
@@ -0,0 +1,170 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
delay,
EntityManager,
FlowDocument,
FlowDocumentJSON,
FlowNodeBaseType,
FlowNodeEntity,
FlowNodeFormData,
FlowOperationBaseService,
FormModelV2,
inject,
injectable,
Playground,
} from '@flowgram.ai/fixed-layout-editor';
import { WorkflowLoadSchemaUtils } from './utils';
import { SchemaPatch, SchemaPatchData } from './type';
@injectable()
export class WorkflowLoadSchemaService {
@inject(FlowDocument) private document: FlowDocument;
@inject(EntityManager) private entityManager: EntityManager;
@inject(FlowOperationBaseService) private operationService: FlowOperationBaseService;
@inject(Playground) private playground: Playground;
private currentSchema: FlowDocumentJSON = {
nodes: [],
};
// constructor() {
// (window as any).WorkflowLoadSchemaService = this;
// }
public async load(schema: FlowDocumentJSON): Promise<void> {
const schemaPatch: SchemaPatch = WorkflowLoadSchemaUtils.createSchemaPatch(
this.currentSchema,
schema
);
this.currentSchema = schema;
await this.applySchemaPatch(schemaPatch);
this.document.fromJSON(schema);
}
public forceLoad(schema: FlowDocumentJSON): void {
this.currentSchema = schema;
this.document.fromJSON(schema);
}
private async applySchemaPatch(schemaPatch: SchemaPatch): Promise<void> {
await this.applyRemovePatch(schemaPatch.remove);
await delay(300);
await this.applyCreatePatch(schemaPatch.create);
await this.playground.config.fitView(this.document.root.bounds.pad(30));
}
private async applyCreatePatch(createSchemaPatchData: SchemaPatchData[]): Promise<void> {
const skipNodeIDs: Set<string> = new Set();
for (const nodePatchData of createSchemaPatchData) {
// 跳过 block 节点
if (skipNodeIDs.has(nodePatchData.nodeID)) {
continue;
}
const parentNode = this.getNode(nodePatchData.parentID);
// 特殊处理 condition 节点
if (parentNode?.flowNodeType === 'condition') {
const blocksSchema = createSchemaPatchData
.filter((item) => item.parentID === parentNode.id)
.map((item) => {
skipNodeIDs.add(item.nodeID);
return item.schema;
});
const blocks = this.document.addInlineBlocks(parentNode, blocksSchema);
await Promise.all(blocks.map((block) => this.createNodeMotion(block)));
continue;
}
// 更新节点数据
const isExist = Boolean(this.getNode(nodePatchData.nodeID));
const node = this.createNode(nodePatchData);
if (!isExist) {
// 新增节点动画
await this.createNodeMotion(node);
}
}
}
private createNode(patchData: SchemaPatchData): FlowNodeEntity {
const parent = this.getNode(patchData.parentID) ?? this.document.root;
if (parent?.flowNodeType === 'condition') {
// 特殊处理 condition 节点
const blocks = this.document.addInlineBlocks(parent, [patchData.schema]);
return blocks.find((block) => block.flowNodeType === patchData.schema.type) ?? blocks[0];
} else if (patchData.fromNodeID) {
return this.operationService.addFromNode(patchData.fromNodeID, patchData.schema);
} else {
return this.document.addNode({
...patchData.schema,
parent,
});
}
}
private getNode(id?: string): FlowNodeEntity | undefined {
if (!id) {
return undefined;
}
return this.document.getNode(id);
}
private async createNodeMotion(node: FlowNodeEntity): Promise<void> {
// 隐藏节点
this.setNodeStatus(node, { loading: true, className: 'node-render-before-render' });
this.document.fireRender();
await delay(20);
// 展示节点动画
this.setNodeStatus(node, { loading: true, className: 'node-render-rendered' });
await delay(180);
// 滚动到节点位置
this.playground.scrollToView({
bounds: node.bounds,
scrollToCenter: true,
});
// 高亮节点边框
this.setNodeStatus(node, { loading: true, className: 'node-render-border-transition' });
await delay(800);
// 移除节点边框高亮
this.setNodeStatus(node, { loading: false, className: '' });
}
private async removeNodeMotion(node: FlowNodeEntity): Promise<void> {
// 隐藏节点
this.setNodeStatus(node, { loading: false, className: 'node-render-removed' });
this.document.fireRender();
await delay(300);
}
private async applyRemovePatch(removeNodeIDs: string[]): Promise<void> {
await Promise.all(
removeNodeIDs.map(async (nodeID) => {
const node = this.entityManager.getEntityById<FlowNodeEntity>(nodeID);
const parent = node?.parent;
if (node) {
await this.removeNodeMotion(node);
node.dispose();
}
if (parent?.flowNodeType === FlowNodeBaseType.BLOCK && !parent.blocks.length) {
parent.dispose();
}
})
);
}
private setNodeStatus(
node: FlowNodeEntity,
status: {
loading: boolean;
className: string;
}
): void {
const formModel = node.getData(FlowNodeFormData)?.getFormModel<FormModelV2>();
formModel?.setValueIn('status', status);
}
}
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeJSON } from '@flowgram.ai/fixed-layout-editor';
export interface SchemaPatchData {
nodeID: string;
schema: FlowNodeJSON;
parentID?: string;
index?: number;
fromNodeID?: string;
}
export interface SchemaPatch {
create: SchemaPatchData[];
remove: string[];
}
@@ -0,0 +1,92 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON, FlowNodeJSON } from '@flowgram.ai/fixed-layout-editor';
import { SchemaPatch, SchemaPatchData } from './type';
export namespace WorkflowLoadSchemaUtils {
const createSchemaPatchDataMap = (params: {
nodeSchemas: FlowNodeJSON[];
parentID?: string;
schemaPatchDataMap?: Map<string, SchemaPatchData>;
}): Map<string, SchemaPatchData> => {
const { nodeSchemas, parentID, schemaPatchDataMap = new Map() } = params;
nodeSchemas.forEach((nodeSchema: FlowNodeJSON, index: number) => {
const prevNodeSchema = nodeSchemas[index - 1];
const processedSchema: FlowNodeJSON = {
...nodeSchema,
blocks: [],
};
const schemaPatchData: SchemaPatchData = {
nodeID: nodeSchema.id,
schema: processedSchema,
parentID,
index,
fromNodeID: prevNodeSchema?.id,
};
schemaPatchDataMap.set(nodeSchema.id, schemaPatchData);
if (nodeSchema.blocks) {
createSchemaPatchDataMap({
nodeSchemas: nodeSchema.blocks,
parentID: nodeSchema.id,
schemaPatchDataMap,
});
}
});
return schemaPatchDataMap;
};
export const createSchemaPatch = (
prevSchema: FlowDocumentJSON,
schema: FlowDocumentJSON
): SchemaPatch => {
const prevSchemaPatchDataMap = createSchemaPatchDataMap({
nodeSchemas: prevSchema.nodes,
});
const currentSchemaPatchDataMap = createSchemaPatchDataMap({
nodeSchemas: schema.nodes,
});
const prevNodeIDs: string[] = Array.from(prevSchemaPatchDataMap.keys());
const currentNodeIDs: string[] = Array.from(currentSchemaPatchDataMap.keys());
const createNodeIDs: string[] = currentNodeIDs.filter((id) => {
if (!prevSchemaPatchDataMap.has(id)) {
return true;
}
const prevSchemaPatchData = prevSchemaPatchDataMap.get(id)!;
const currentSchemaPatchData = currentSchemaPatchDataMap.get(id)!;
return (
prevSchemaPatchData.parentID !== currentSchemaPatchData.parentID ||
prevSchemaPatchData.fromNodeID !== currentSchemaPatchData.fromNodeID
);
});
const removeNodeIDs: string[] = prevNodeIDs.filter((id) => {
if (!currentSchemaPatchDataMap.has(id)) {
return true;
}
const prevSchemaPatchData = prevSchemaPatchDataMap.get(id)!;
const currentSchemaPatchData = currentSchemaPatchDataMap.get(id)!;
return (
prevSchemaPatchData.parentID !== currentSchemaPatchData.parentID ||
prevSchemaPatchData.fromNodeID !== currentSchemaPatchData.fromNodeID
);
});
const createSchemaPatches: SchemaPatchData[] = createNodeIDs
.map((id) => currentSchemaPatchDataMap.get(id)!)
.filter(Boolean);
const schemaPatch: SchemaPatch = {
create: createSchemaPatches,
remove: removeNodeIDs,
};
console.log('@debug schemaPatch', schemaPatch);
return schemaPatch;
};
}
@@ -0,0 +1,38 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"experimentalDecorators": true,
"target": "es2020",
"module": "esnext",
"strictPropertyInitialization": false,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"allowJs": true,
"resolveJsonModule": true,
"types": [
"node"
],
"jsx": "react-jsx",
"lib": [
"es6",
"dom",
"es2020",
"es2019.Array"
],
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
],
}
},
"include": [
"./src"
],
}
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
rules: {
'no-console': 'off',
'react/prop-types': 'off',
},
settings: {
react: {
version: 'detect', // 自动检测 React 版本
},
},
});
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" data-bundler="rspack">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flow FixedLayoutEditor Demo</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,58 @@
{
"name": "@flowgram.ai/demo-fixed-layout-simple",
"version": "0.1.0",
"description": "",
"keywords": [],
"license": "MIT",
"main": "./src/index.ts",
"files": [
"src/",
"eslint.config.js",
".gitignore",
"index.html",
"package.json",
"rsbuild.config.ts",
"tsconfig.json"
],
"scripts": {
"build": "exit 0",
"build:fast": "exit 0",
"build:watch": "exit 0",
"build:prod": "cross-env MODE=app NODE_ENV=production rsbuild build",
"clean": "rimraf dist",
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix",
"ts-check": "tsc --noEmit",
"start": "cross-env NODE_ENV=development rsbuild dev --open",
"test": "exit",
"test:cov": "exit",
"watch": "exit 0"
},
"dependencies": {
"@flowgram.ai/ts-config": "workspace:*",
"@flowgram.ai/eslint-config": "workspace:*",
"@douyinfe/semi-icons": "^2.80.0",
"@douyinfe/semi-ui": "^2.80.0",
"@flowgram.ai/fixed-layout-editor": "workspace:*",
"@flowgram.ai/fixed-semi-materials": "workspace:*",
"@flowgram.ai/minimap-plugin": "workspace:*",
"nanoid": "^5.0.9",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@rsbuild/core": "^1.2.16",
"@rsbuild/plugin-react": "^1.1.1",
"@types/node": "^18",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5.8.3",
"eslint": "^9.0.0",
"cross-env": "~7.0.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [pluginReact()],
source: {
entry: {
index: './src/app.tsx',
},
},
html: {
title: 'demo-fixed-layout-simple',
},
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { createRoot } from 'react-dom/client';
import { Editor } from './editor';
const app = createRoot(document.getElementById('root')!);
app.render(<Editor />);
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeEntity, useNodeRender, useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { IconDeleteStroked } from '@douyinfe/semi-icons';
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
const ctx = useClientContext();
/**
* Provides methods related to node rendering
* 提供节点渲染相关的方法
*/
const nodeRender = useNodeRender();
/**
* It can only be used when nodeEngine is enabled
* 只有在节点引擎开启时候才能使用表单
*/
const form = nodeRender.form;
return (
<div
className="demo-fixed-node"
/*
* onMouseEnter is added to a fixed layout node primarily to listen for hover highlighting of branch lines
* onMouseEnter 加到固定布局节点主要是为了监听 分支线条的 hover 高亮
**/
onMouseEnter={nodeRender.onMouseEnter}
onMouseLeave={nodeRender.onMouseLeave}
onMouseDown={(e) => {
// trigger drag node
nodeRender.startDrag(e);
e.stopPropagation();
}}
style={{
/**
* Lets you precisely control the style of branch nodes
* 用于精确控制分支节点的样式
* isBlockIcon: 整个 condition 分支的 头部节点
* isBlockOrderIcon: 分支的第一个节点
*/
opacity: nodeRender.dragging ? 0.3 : 1,
...(nodeRender.isBlockOrderIcon || nodeRender.isBlockIcon ? { width: 260 } : {}),
}}
>
{!nodeRender.readonly && (
<IconDeleteStroked
style={{ position: 'absolute', right: 4, top: 4 }}
onClick={() => ctx.operation.deleteNode(nodeRender.node)}
/>
)}
{form?.render()}
</div>
);
};
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import { type FlowNodeEntity, useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { IconPlus } from '@douyinfe/semi-icons';
interface PropsType {
activated?: boolean;
node: FlowNodeEntity;
}
export function BranchAdder(props: PropsType) {
const { activated, node } = props;
const nodeData = node.firstChild!.renderData;
const ctx = useClientContext();
const { operation, playground } = ctx;
const { isVertical } = node;
function addBranch() {
let block: FlowNodeEntity;
if (node.flowNodeType === 'multiOutputs') {
block = operation.addBlock(node, {
id: `output_${nanoid(5)}`,
type: 'output',
data: {
title: 'New Ouput',
content: '',
},
});
} else if (node.flowNodeType === 'multiInputs') {
block = operation.addBlock(node, {
id: `input_${nanoid(5)}`,
type: 'input',
data: {
title: 'New Input',
content: '',
},
});
} else {
block = operation.addBlock(node, {
id: `branch_${nanoid(5)}`,
type: 'block',
data: {
title: 'New Branch',
content: '',
},
});
}
setTimeout(() => {
playground.scrollToView({
bounds: block.bounds,
scrollToCenter: true,
});
}, 10);
}
if (playground.config.readonlyOrDisabled) return null;
const className = [
'demo-fixed-adder',
isVertical ? '' : 'isHorizontal',
activated ? 'activated' : '',
].join(' ');
return (
<div
className={className}
onMouseEnter={() => nodeData?.toggleMouseEnter()}
onMouseLeave={() => nodeData?.toggleMouseLeave()}
>
<div
onClick={() => {
addBranch();
}}
aria-hidden="true"
style={{ flexGrow: 1, textAlign: 'center' }}
>
<IconPlus />
</div>
</div>
);
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useState } from 'react';
import { useClientContext, FlowLayoutDefault } from '@flowgram.ai/fixed-layout-editor';
import { FLOW_LIST } from '../data';
const url = new window.URL(window.location.href);
export function FlowSelect() {
const [demoKey, updateDemoKey] = useState(url.searchParams.get('demo') ?? 'condition');
const clientContext = useClientContext();
useEffect(() => {
const targetDemoJSON = FLOW_LIST[demoKey];
if (targetDemoJSON) {
clientContext.history.stop(); // Stop redo/undo
clientContext.history.clear(); // Clear redo/undo
clientContext.document.fromJSON(targetDemoJSON); // Reload Data
console.log(clientContext.document.toString(true)); // Print the document tree
clientContext.history.start(); // Restart redo/undo
clientContext.document.setLayout(
targetDemoJSON.defaultLayout || FlowLayoutDefault.VERTICAL_FIXED_LAYOUT
);
// Update URL
if (url.searchParams.get('demo')) {
url.searchParams.set('demo', demoKey);
window.history.pushState({}, '', `/?${url.searchParams.toString()}`);
}
// Fit View
setTimeout(() => {
clientContext.playground.config.fitView(clientContext.document.root.bounds, true, 40);
}, 20);
}
}, [demoKey]);
return (
<div style={{ position: 'absolute', zIndex: 100 }}>
<label style={{ marginRight: 12 }}>Select Demo:</label>
<select
style={{ width: '180px', height: '32px', fontSize: 16 }}
onChange={(e) => updateDemoKey(e.target.value)}
value={demoKey}
>
{Object.keys(FLOW_LIST).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
);
}
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MinimapRender } from '@flowgram.ai/minimap-plugin';
export const Minimap = () => (
<div
style={{
position: 'absolute',
left: 16,
bottom: 51,
zIndex: 100,
width: 198,
}}
>
<MinimapRender
containerStyles={{
pointerEvents: 'auto',
position: 'relative',
top: 'unset',
right: 'unset',
bottom: 'unset',
left: 'unset',
}}
inactiveStyle={{
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
}}
/>
</div>
);
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { nanoid } from 'nanoid';
import { useStartDragNode } from '@flowgram.ai/fixed-layout-editor';
import { nodeRegistries } from '../node-registries';
import { useAddNode } from '../hooks/use-add-node';
export const NodeAddPanel: React.FC = (props) => {
const { startDrag } = useStartDragNode();
const { handleAdd, handleAddBranch } = useAddNode();
return (
<div className="demo-fixed-sidebar">
{nodeRegistries.map((registry) => {
const nodeType = registry.type;
return (
<div
key={nodeType}
className="demo-fixed-card"
onMouseDown={(e) => {
e.stopPropagation();
const nodeAddData = registry.onAdd();
return startDrag(
e,
{
dragJSON: nodeAddData,
onCreateNode: async (json, dropNode) => handleAdd(json, dropNode),
},
{
disableDragScroll: true,
}
);
}}
>
{nodeType}
</div>
);
})}
<div
key={'branch'}
className="demo-fixed-card"
onMouseDown={(e) => {
e.stopPropagation();
return startDrag(
e,
{
dragJSON: {
id: `branch_${nanoid(5)}`,
type: 'block',
data: {
title: 'New Branch',
content: '',
},
},
isBranch: true,
onCreateNode: async (json, dropNode) => handleAddBranch(json, dropNode),
},
{
disableDragScroll: true,
}
);
}}
>
branch
</div>
</div>
);
};
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeEntity, useClientContext, usePlayground } from '@flowgram.ai/fixed-layout-editor';
import { Dropdown } from '@douyinfe/semi-ui';
import { IconPlusCircle } from '@douyinfe/semi-icons';
import { nodeRegistries } from '../node-registries';
import { useAddNode } from '../hooks/use-add-node';
export const NodeAdder = (props: {
from: FlowNodeEntity;
to?: FlowNodeEntity;
hoverActivated: boolean;
}) => {
const { from, hoverActivated } = props;
const playground = usePlayground();
const context = useClientContext();
const { handleAdd } = useAddNode();
if (playground.config.readonlyOrDisabled) return null;
return (
<Dropdown
render={
<Dropdown.Menu>
{nodeRegistries.map((registry) => (
<Dropdown.Item
key={registry.type}
onClick={() => {
const props = registry?.onAdd(context, from);
handleAdd(props, from);
}}
>
{registry.type}
</Dropdown.Item>
))}
</Dropdown.Menu>
}
>
<div
style={{
width: hoverActivated ? 15 : 6,
height: hoverActivated ? 15 : 6,
backgroundColor: 'rgb(143, 149, 158)',
color: '#fff',
borderRadius: '50%',
cursor: 'pointer',
}}
>
{hoverActivated ? (
<IconPlusCircle
style={{
color: '#3370ff',
backgroundColor: '#fff',
borderRadius: 15,
}}
/>
) : null}
</div>
</Dropdown>
);
};
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import {
type FlowNodeEntity,
FlowNodeRenderData,
FlowDocument,
useService,
} from '@flowgram.ai/fixed-layout-editor';
import { Button } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
interface PropsType {
node: FlowNodeEntity;
}
export function SlotAdder(props: PropsType) {
const { node } = props;
const nodeData = node.firstChild?.getData<FlowNodeRenderData>(FlowNodeRenderData);
const document = useService(FlowDocument) as FlowDocument;
async function addPort() {
document.addNode({
id: nanoid(5),
type: 'custom',
parent: node,
data: {
title: 'Custom',
content: 'custom content',
},
});
}
return (
<div
style={{
display: 'flex',
background: 'var(--semi-color-bg-0)',
}}
onMouseEnter={() => nodeData?.toggleMouseEnter()}
onMouseLeave={() => nodeData?.toggleMouseLeave()}
>
<Button
onClick={() => {
addPort();
}}
size="small"
icon={<IconPlus />}
/>
</div>
);
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useState, useCallback } from 'react';
import { usePlaygroundTools, useClientContext, useRefresh } from '@flowgram.ai/fixed-layout-editor';
import { IconButton, Space } from '@douyinfe/semi-ui';
import { IconUnlock, IconLock } from '@douyinfe/semi-icons';
export function Tools() {
const { history, playground } = useClientContext();
const tools = usePlaygroundTools();
const refresh = useRefresh();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const toggleReadonly = useCallback(() => {
playground.config.readonly = !playground.config.readonly;
}, [playground]);
useEffect(() => {
const disposable = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
});
return () => disposable.dispose();
}, [history]);
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());
return () => disposable.dispose();
}, [playground]);
return (
<Space
style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 16, display: 'flex', gap: 8 }}
>
<button onClick={() => tools.zoomin()}>ZoomIn</button>
<button onClick={() => tools.zoomout()}>ZoomOut</button>
<button onClick={() => tools.fitView()}>Fitview</button>
<button onClick={() => tools.changeLayout()}>ChangeLayout</button>
<button onClick={() => history.undo()} disabled={!canUndo}>
Undo
</button>
<button onClick={() => history.redo()} disabled={!canRedo}>
Redo
</button>
{playground.config.readonly ? (
<IconButton
theme="borderless"
type="tertiary"
icon={<IconLock />}
onClick={toggleReadonly}
/>
) : (
<IconButton
theme="borderless"
type="tertiary"
icon={<IconUnlock />}
onClick={toggleReadonly}
/>
)}
<span>{Math.floor(tools.zoom * 100)}%</span>
</Space>
);
}
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const condition: FlowDocumentJSON = {
nodes: [
// 开始节点
{
id: 'start_0',
type: 'start',
data: {
title: 'Start',
content: 'start content',
},
blocks: [],
},
// 分支节点
{
id: 'condition_0',
type: 'condition',
data: {
title: 'Condition',
content: 'condition content',
},
blocks: [
{
id: 'branch_0',
type: 'block',
data: {
title: 'Branch 0',
content: 'branch 1 content',
},
blocks: [
{
id: 'custom_0',
type: 'custom',
data: {
title: 'Custom',
content: 'custom content',
},
},
],
},
{
id: 'branch_1',
type: 'block',
data: {
title: 'Branch 1',
content: 'branch 1 content',
},
blocks: [
{
id: 'break_0',
type: 'break',
data: {
title: 'Break',
content: 'Break content',
},
},
],
},
{
id: 'branch_2',
type: 'block',
data: {
title: 'Branch 2',
content: 'branch 2 content',
},
blocks: [],
},
],
},
// 结束节点
{
id: 'end_0',
type: 'end',
data: {
title: 'End',
content: 'end content',
},
},
],
};
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const dynamicSplit: FlowDocumentJSON = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: 'Start',
},
blocks: [],
},
{
id: 'dynamicSplit_0',
type: 'dynamicSplit',
data: {
title: 'DynamicSplit',
},
blocks: [
{
id: 'branch_0',
type: 'block',
data: {
title: 'Branch 0',
},
},
{
id: 'branch_1',
type: 'block',
data: {
title: 'Branch 1',
},
blocks: [],
},
],
},
{
id: 'end_0',
type: 'end',
data: {
title: 'End',
},
},
],
};
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON, FlowLayoutDefault } from '@flowgram.ai/fixed-layout-editor';
import { tryCatch } from './tryCatch';
import { slot } from './slot';
import { multiOutputs } from './multiOutputs';
import { multiInputs } from './multiInputs';
import { mindmap } from './mindmap';
import { loop } from './loop';
import { dynamicSplit } from './dynamicSplit';
import { condition } from './condition';
export const FLOW_LIST: Record<string, FlowDocumentJSON & { defaultLayout?: FlowLayoutDefault }> = {
condition,
mindmap: { ...mindmap, defaultLayout: FlowLayoutDefault.HORIZONTAL_FIXED_LAYOUT },
tryCatch,
dynamicSplit,
loop,
multiInputs,
multiOutputs,
slot,
};
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const loop: FlowDocumentJSON = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: 'Start',
},
blocks: [],
},
{
id: 'loop_0',
type: 'loop',
data: {
title: 'Loop',
},
blocks: [
{
id: 'branch_0',
type: 'block',
data: {
title: 'Branch 0',
},
blocks: [
{
id: 'custom',
type: 'custom',
data: {
title: 'Custom',
},
},
],
},
],
},
{
id: 'end_0',
type: 'end',
data: {
title: 'End',
},
},
],
};
@@ -0,0 +1,91 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const mindmap: FlowDocumentJSON = {
nodes: [
{
id: 'multiInputs_0',
type: 'multiInputs',
blocks: [
{
id: 'input_0',
type: 'input',
data: {
title: 'input_0',
},
},
{
id: 'input_1',
type: 'input',
data: {
title: 'input_1',
},
},
{
id: 'input_3',
type: 'input',
data: {
title: 'input_3',
},
},
],
},
{
id: 'multiOutputs_0',
type: 'multiOutputs',
data: {
title: 'mindNode_0',
},
blocks: [
{
id: 'output_0',
type: 'output',
data: {
title: 'output_0',
},
},
{
id: 'multiOutputs_1',
type: 'multiOutputs',
data: {
title: 'mindNode_1',
},
blocks: [
{
id: 'output_1',
type: 'output',
data: {
title: 'output_1',
},
},
{
id: 'output_2',
type: 'output',
data: {
title: 'output_2',
},
},
{
id: 'output_3',
type: 'output',
data: {
title: 'output_3',
},
},
],
},
{
id: 'output_4',
type: 'output',
data: {
title: 'output_4',
},
},
],
},
],
};
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const multiInputs: FlowDocumentJSON = {
nodes: [
{
id: 'multiInputs_0',
type: 'multiInputs',
blocks: [
{
id: 'input_0',
type: 'input',
data: {
title: 'input_0',
},
},
{
id: 'input_1',
type: 'input',
data: {
title: 'input_1',
},
},
{
id: 'input_3',
type: 'input',
data: {
title: 'input_3',
},
},
],
},
{
id: 'end_0',
type: 'end',
data: {
title: 'End',
},
},
],
};
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const multiOutputs: FlowDocumentJSON = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: 'Start',
},
blocks: [],
},
{
id: 'multiOutputs_0',
type: 'multiOutputs',
data: {
title: 'MultiOutputs',
},
blocks: [
{
id: 'output_0',
type: 'output',
data: {
title: 'output_0',
},
},
{
id: 'output_1',
type: 'output',
data: {
title: 'output_1',
},
},
{
id: 'output_2',
type: 'output',
data: {
title: 'output_2',
},
},
],
},
],
};
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const slot: FlowDocumentJSON = {
nodes: [
// 开始节点
{
id: 'start_0',
type: 'start',
data: {
title: 'Start',
content: 'start content',
},
blocks: [],
},
{
id: 'slot_0',
type: 'slot',
data: {
title: 'Slot',
content: 'Slot content',
},
blocks: [
{
id: 'slot_port_1',
type: 'slotBlock',
data: {
title: 'Slot 1',
content: 'slot 1 content',
},
blocks: [
{
id: 'custom_1',
type: 'custom',
data: {
title: 'Custom',
content: 'custom content',
},
},
],
},
{
id: 'slot_port_2',
type: 'slotBlock',
data: {
title: 'Slot 2',
content: 'slot 2 content',
},
blocks: [
{
id: 'custom_2',
type: 'custom',
data: {
title: 'Custom',
content: 'custom content',
},
},
],
},
{
id: 'slot_port_3',
type: 'slotBlock',
data: {
title: 'Slot 3',
content: 'slot 3 content',
},
blocks: [
{
id: 'custom_3',
type: 'custom',
data: {
title: 'Custom',
content: 'custom content',
},
},
{
id: 'custom_4',
type: 'custom',
data: {
title: 'Custom',
content: 'custom content',
},
},
],
},
],
},
// 结束节点
{
id: 'end_0',
type: 'end',
data: {
title: 'End',
content: 'end content',
},
},
],
};
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
export const tryCatch: FlowDocumentJSON = {
nodes: [
{
id: 'start_0',
type: 'start',
data: {
title: 'Start',
},
blocks: [],
},
{
id: 'tryCatch_0',
type: 'tryCatch',
data: {
title: 'TryCatch',
},
blocks: [
{
id: 'tryBlock_0',
type: 'tryBlock',
blocks: [],
},
{
id: 'catchBlock_0',
type: 'catchBlock',
data: {
title: 'Catch Block 1',
},
blocks: [],
},
{
id: 'catchBlock_1',
type: 'catchBlock',
data: {
title: 'Catch Block 2',
},
blocks: [],
},
],
},
{
id: 'end_0',
type: 'end',
data: {
title: 'End',
},
},
],
};
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FixedLayoutEditorProvider, EditorRenderer } from '@flowgram.ai/fixed-layout-editor';
import '@flowgram.ai/fixed-layout-editor/index.css';
import './index.css';
import { nodeRegistries } from './node-registries';
import { initialData } from './initial-data';
import { useEditorProps } from './hooks/use-editor-props';
import { FLOW_LIST } from './data';
import { Tools } from './components/tools';
import { NodeAddPanel } from './components/node-add-panel';
import { Minimap } from './components/minimap';
import { FlowSelect } from './components/flow-select';
export const Editor = (props: { demo?: string; hideTools?: boolean }) => {
const editorProps = useEditorProps(
props.demo ? FLOW_LIST[props.demo] : initialData,
nodeRegistries
);
return (
<FixedLayoutEditorProvider {...editorProps}>
<div className="demo-fixed-container">
<div className="demo-fixed-layout">
{!props.hideTools ? <NodeAddPanel /> : null}
<EditorRenderer className="demo-fixed-editor">
{/* add child panel here */}
</EditorRenderer>
</div>
</div>
{!props.hideTools ? (
<>
<Tools />
<FlowSelect />
<Minimap />
</>
) : null}
</FixedLayoutEditorProvider>
);
};
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
FlowNodeEntity,
FlowNodeJSON,
FlowOperationService,
usePlayground,
useService,
} from '@flowgram.ai/fixed-layout-editor';
export const useAddNode = () => {
const playground = usePlayground();
const flowOperationService = useService(FlowOperationService) as FlowOperationService;
const handleAdd = (addProps: FlowNodeJSON, dropNode: FlowNodeEntity) => {
const blocks = addProps.blocks ? addProps.blocks : undefined;
const entity = flowOperationService.addFromNode(dropNode, {
...addProps,
blocks,
});
setTimeout(() => {
playground.scrollToView({
bounds: entity.bounds,
scrollToCenter: true,
});
}, 10);
return entity;
};
const handleAddBranch = (addProps: FlowNodeJSON, dropNode: FlowNodeEntity) => {
const index = dropNode.index + 1;
const entity = flowOperationService.addBlock(dropNode.originParent!, addProps, {
index,
});
return entity;
};
return {
handleAdd,
handleAddBranch,
};
};
@@ -0,0 +1,210 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useMemo } from 'react';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { defaultFixedSemiMaterials } from '@flowgram.ai/fixed-semi-materials';
import {
Field,
type FixedLayoutProps,
FlowDocumentJSON,
FlowNodeRegistry,
FlowRendererKey,
FlowTextKey,
} from '@flowgram.ai/fixed-layout-editor';
import { SlotAdder } from '../components/slot-adder';
import { NodeAdder } from '../components/node-adder';
import { BranchAdder } from '../components/branch-adder';
import { BaseNode } from '../components/base-node';
/** semi materials */
export function useEditorProps(
initialData: FlowDocumentJSON, // 初始化数据
nodeRegistries: FlowNodeRegistry[] // 节点定义
): FixedLayoutProps {
return useMemo<FixedLayoutProps>(
() => ({
/**
* Whether to enable the background
*/
background: true,
/**
* Whether it is read-only or not, the node cannot be dragged in read-only mode
*/
readonly: false,
/**
* Initial data
* 初始化数据
*/
initialData,
/**
* 画布节点定义
*/
nodeRegistries,
/**
* Get the default node registry, which will be merged with the 'nodeRegistries'
* 提供默认的节点注册,这个会和 nodeRegistries 做合并
*/
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
formMeta: {
/**
* Render form
*/
render: () => (
<>
<Field<string> name="title">
{({ field }) => <div className="demo-fixed-node-title">{field.value}</div>}
</Field>
<div className="demo-fixed-node-content">
<Field<string> name="content">
<input />
</Field>
</div>
</>
),
},
};
},
/**
* Materials, components can be customized based on the key
* @see https://github.com/bytedance/flowgram.ai/blob/main/packages/materials/fixed-semi-materials/src/components/index.tsx
* 可以通过 key 自定义 UI 组件
*/
materials: {
components: {
...defaultFixedSemiMaterials,
/**
* Components can be customized based on key business-side requirements.
* 这里可以根据 key 业务侧定制组件
*/
[FlowRendererKey.ADDER]: NodeAdder,
[FlowRendererKey.BRANCH_ADDER]: BranchAdder,
[FlowRendererKey.SLOT_ADDER]: SlotAdder,
// [FlowRendererKey.DRAG_NODE]: DragNode,
},
renderDefaultNode: BaseNode, // 节点渲染
renderTexts: {
[FlowTextKey.LOOP_END_TEXT]: 'loop end',
[FlowTextKey.LOOP_TRAVERSE_TEXT]: 'looping',
},
},
/**
* Drag/Drop config
*/
dragdrop: {
/**
* Callback when drag drop
*/
onDrop: (ctx, dropData) => {
// console.log(
// '>>> onDrop: ',
// dropData.dropNode.id,
// dropData.dragNodes.map(n => n.id),
// );
},
canDrop: (ctx, dropData) => {
// dropData.dragjson
console.log('>>> canDrop: ', dropData.isBranch, dropData.dropNode.id, dropData.dragNodes);
return true;
},
},
/**
* Node engine enable, you can configure formMeta in the FlowNodeRegistry
*/
nodeEngine: {
enable: true,
},
history: {
enable: true,
enableChangeNode: true, // Listen Node engine data change
onApply(ctx, opt) {
// Listen change to trigger auto save
console.log('auto save: ', ctx.document.toJSON(), opt);
},
},
/**
* Playground init
* 画布初始化
*/
onInit: (ctx) => {
/**
* Data can also be dynamically loaded via fromJSON
* 也可以通过 fromJSON 动态加载数据
*/
// ctx.document.fromJSON(initialData)
console.log('---- Playground Init ----');
},
/**
* Playground render
*/
onAllLayersRendered: (ctx) => {
setTimeout(() => {
ctx.playground.config.fitView(ctx.document.root.bounds.pad(30));
}, 10);
},
/**
* Playground dispose
* 画布销毁
*/
onDispose: () => {
console.log('---- Playground Dispose ----');
},
/**
* 节点数据转换, 由 ctx.document.fromJSON 调用
* Node data transformation, called by ctx.document.fromJSON
* @param node
* @param json
*/
fromNodeJSON(node, json) {
return json;
},
/**
* 节点数据转换, 由 ctx.document.toJSON 调用
* Node data transformation, called by ctx.document.toJSON
* @param node
* @param json
*/
toNodeJSON(node, json) {
return json;
},
plugins: () => [
/**
* Minimap plugin
* 缩略图插件
*/
createMinimapPlugin({
disableLayer: true,
enableDisplayAllNodes: true,
canvasStyle: {
canvasWidth: 182,
canvasHeight: 102,
canvasPadding: 50,
canvasBackground: 'rgba(245, 245, 245, 1)',
canvasBorderRadius: 10,
viewportBackground: 'rgba(235, 235, 235, 1)',
viewportBorderRadius: 4,
viewportBorderColor: 'rgba(201, 201, 201, 1)',
viewportBorderWidth: 1,
viewportBorderDashLength: 2,
nodeColor: 'rgba(255, 255, 255, 1)',
nodeBorderRadius: 2,
nodeBorderWidth: 0.145,
nodeBorderColor: 'rgba(6, 7, 9, 0.10)',
overlayColor: 'rgba(255, 255, 255, 0)',
},
}),
],
}),
[]
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.demo-fixed-node {
align-items: flex-start;
background-color: #fff;
border: 1px solid rgba(6, 7, 9, 0.15);
border-radius: 8px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
width: 240px;
transition: all 0.3s ease;
}
.demo-fixed-sidebar {
height: 100%;
overflow-y: auto;
padding: 50px 16px 12px 16px;
box-sizing: border-box;
background: #f7f7fa;
border-right: 1px solid rgba(29, 28, 35, 0.08);
}
.demo-fixed-layout {
display: flex;
flex-direction: row;
flex-grow: 1;
}
.demo-fixed-editor {
flex-grow: 1;
position: relative;
height: 100%;
}
.demo-fixed-node-title {
background-color: #93bfe2;
width: 100%;
border-radius: 8px 8px 0 0;
padding: 4px 12px;
}
.demo-fixed-node-content {
padding: 16px;
flex-grow: 1;
width: 100%;
}
input {
color: black;
background-color: white;
}
.demo-fixed-adder {
width: 28px;
height: 18px;
background: rgb(187, 191, 196);
display: flex;
border-radius: 9px;
justify-content: space-evenly;
align-items: center;
color: #fff;
font-size: 10px;
font-weight: bold;
div {
display: flex;
justify-content: center;
align-items: center;
svg {
width: 12px;
height: 12px;
}
}
}
.demo-fixed-adder.activated {
background: #82A7FC
}
.demo-fixed-adder.isHorizontal {
transform: rotate(90deg);
}
.gedit-playground * {
box-sizing: border-box;
}
.demo-fixed-container {
position: absolute;
left: 0;
top: 0;
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
}
.demo-fixed-card {
width: 140px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 8px 0 rgba(28, 31, 35, 0.03);
cursor: -webkit-grab;
cursor: grab;
line-height: 16px;
margin-bottom: 12px;
overflow: hidden;
padding: 16px;
position: relative;
color: black;
user-select: none;
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { Editor as DemoFixedLayout } from './editor';
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
import { condition as conditionDemo } from './data/condition';
/**
* Initial Data
*/
export const initialData: FlowDocumentJSON = conditionDemo;
@@ -0,0 +1,81 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
/**
* 自定义节点注册
*/
export const nodeRegistries: FlowNodeRegistry[] = [
{
/**
* 自定义节点类型
*/
type: 'condition',
/**
* 自定义节点扩展:
* - loop: 扩展为循环节点
* - start: 扩展为开始节点
* - dynamicSplit: 扩展为分支节点
* - end: 扩展为结束节点
* - tryCatch: 扩展为 tryCatch 节点
* - break: 分支断开
* - default: 扩展为普通节点 (默认)
*/
extend: 'dynamicSplit',
/**
* 节点配置信息
*/
meta: {
// isStart: false, // 是否为开始节点
// isNodeEnd: false, // 是否为结束节点,结束节点后边无法再添加节点
// draggable: false, // 是否可拖拽,如开始节点和结束节点无法拖拽
// selectable: false, // 触发器等开始节点不能被框选
// deleteDisable: true, // 禁止删除
// copyDisable: true, // 禁止copy
// addDisable: true, // 禁止添加
},
onAdd() {
return {
id: `condition_${nanoid(5)}`,
type: 'condition',
data: {
title: 'Condition',
},
blocks: [
{
id: nanoid(5),
type: 'block',
data: {
title: 'If_0',
},
},
{
id: nanoid(5),
type: 'block',
data: {
title: 'If_1',
},
},
],
};
},
},
{
type: 'custom',
meta: {},
onAdd() {
return {
id: `custom_${nanoid(5)}`,
type: 'custom',
data: {
title: 'Custom',
content: 'this is custom content',
},
};
},
},
];
@@ -0,0 +1,22 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
"rootDir": "./src",
"experimentalDecorators": true,
"target": "es2020",
"module": "esnext",
"strictPropertyInitialization": false,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"allowJs": true,
"resolveJsonModule": true,
"types": ["node"],
"jsx": "react-jsx",
"lib": ["es6", "dom", "es2020", "es2019.Array"]
},
"include": ["./src"],
}
+373
View File
@@ -0,0 +1,373 @@
# FlowGram.AI - Demo Fixed Layout
Best practices demo for fixed layout
## Installation
```shell
npx @flowgram.ai/create-app@latest fixed-layout
```
## Project Overview
### Core Tech Stack
- Frontend Framework: React 18 + TypeScript
- Build Tool: Rsbuild (a modern build tool based on Rspack)
- Styling: Less + Styled Components + CSS Variables
- UI Component Library: Semi Design (@douyinfe/semi-ui)
- State Management: Editor framework developed in-house by Flowgram
- Dependency Injection: Inversify
### Core Dependencies
- @flowgram.ai/fixed-layout-editor: Core dependency for the fixed-layout editor
- @flowgram.ai/fixed-semi-materials: Semi Design materials library
- @flowgram.ai/form-materials: Form materials library
- @flowgram.ai/group-plugin: Group plugin
- @flowgram.ai/minimap-plugin: Minimap plugin
- @flowgram.ai/export-plugin: Download/export plugin
## Code Overview
```
src/
├── app.tsx # Application entry component
├── editor.tsx # Main editor component
├── index.ts # Module export entry
├── initial-data.ts # Initial data configuration
├── type.d.ts # Global type declarations
├── assets/ # Static assets
│ ├── icon-mouse.tsx # Mouse icon component
│ └── icon-pad.tsx # Trackpad icon component
├── components/ # Common components library
│ ├── index.ts # Components export entry
│ ├── node-list.tsx # Node list component
│ │
│ ├── agent-adder/ # Agent adder component
│ │ └── index.tsx
│ ├── agent-label/ # Agent label component
│ │ └── index.tsx
│ ├── base-node/ # Base node component
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── branch-adder/ # Branch adder component
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── drag-node/ # Draggable node component
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── node-adder/ # Node adder component
│ │ ├── index.tsx
│ │ ├── styles.tsx
│ │ └── utils.ts
│ ├── selector-box-popover/ # Selection box popover component
│ │ └── index.tsx
│ ├── sidebar/ # Sidebar components
│ │ ├── index.tsx
│ │ ├── sidebar-node-renderer.tsx
│ │ ├── sidebar-provider.tsx
│ │ └── sidebar-renderer.tsx
│ └── tools/ # Toolbar components
│ ├── index.tsx
│ ├── styles.tsx
│ ├── fit-view.tsx # Fit view tool
│ ├── minimap-switch.tsx # Minimap toggle
│ ├── minimap.tsx # Minimap component
│ ├── readonly.tsx # Readonly mode toggle
│ ├── run.tsx # Run tool
│ ├── save.tsx # Save tool
│ ├── switch-vertical.tsx # Vertical layout toggle
│ └── zoom-select.tsx # Zoom selector
├── context/ # React Context state management
│ ├── index.ts # Context export entry
│ ├── node-render-context.ts # Node render context
│ └── sidebar-context.ts # Sidebar context
├── form-components/ # Form components library
│ ├── index.ts # Export entry for form components
│ ├── feedback.tsx # Feedback component
│ │
│ ├── form-content/ # Form content components
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── form-header/ # Form header components
│ │ ├── index.tsx
│ │ ├── styles.tsx
│ │ ├── title-input.tsx
│ │ └── utils.tsx
│ ├── form-inputs/ # Form input components
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── form-item/ # Form item component
│ │ ├── index.css
│ │ └── index.tsx
│ ├── form-outputs/ # Form output components
│ │ ├── index.tsx
│ │ └── styles.tsx
│ └── properties-edit/ # Property editing components
│ ├── index.tsx
│ ├── property-edit.tsx
│ └── styles.tsx
├── hooks/ # Custom React hooks
│ ├── index.ts # Hooks export entry
│ ├── use-editor-props.ts # Hook for editor properties
│ ├── use-is-sidebar.ts # Hook for sidebar state
│ └── use-node-render-context.ts # Hook for node render context
├── nodes/ # Flow node definitions
│ ├── index.ts # Node registry
│ ├── default-form-meta.tsx # Default form metadata
│ │
│ ├── agent/ # Agent node type
│ │ ├── index.ts
│ │ ├── agent.ts
│ │ ├── agent-llm.ts
│ │ ├── agent-memory.ts
│ │ ├── agent-tools.ts
│ │ ├── memory.ts
│ │ └── tool.ts
│ ├── break-loop/ # Break loop node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── case/ # Case branch node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── case-default/ # Default case node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── catch-block/ # Exception catch block node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── end/ # End node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── if/ # Conditional node
│ │ └── index.ts
│ ├── if-block/ # Conditional block node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── llm/ # LLM node
│ │ └── index.ts
│ ├── loop/ # Loop node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── start/ # Start node
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── switch/ # Switch branch node
│ │ └── index.ts
│ └── trycatch/ # Try-Catch node
│ ├── index.ts
│ └── form-meta.tsx
├── plugins/ # Plugin system
│ ├── index.ts # Plugins export entry
│ │
│ ├── clipboard-plugin/ # Clipboard plugin
│ │ └── create-clipboard-plugin.ts
│ ├── group-plugin/ # Group plugin
│ │ ├── index.ts
│ │ ├── group-box-header.tsx
│ │ ├── group-node.tsx
│ │ ├── group-note.tsx
│ │ ├── group-tools.tsx
│ │ ├── icons/
│ │ │ └── index.tsx
│ │ └── multilang-textarea-editor/ # Multi-language textarea editor
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── base-textarea.tsx
│ └── variable-panel-plugin/ # Variable panel plugin
│ ├── index.ts
│ ├── variable-panel-layer.tsx
│ ├── variable-panel-plugin.ts
│ └── components/
│ ├── full-variable-list.tsx
│ ├── global-variable-editor.tsx
│ └── variable-panel.tsx
├── services/ # Services layer
│ ├── index.ts
│ └── custom-service.ts # Custom service
├── shortcuts/ # Shortcuts system
│ ├── index.ts
│ ├── constants.ts # Shortcut constants
│ └── utils.ts # Shortcut utilities
└── typings/ # Type definitions
├── index.ts # Types export entry
├── json-schema.ts # JSON Schema types
└── node.ts # Node type definitions
```
## Architecture Design Analysis
### Overall Architecture Pattern
This project adopts a layered architecture combined with modular design:
1. Presentation Layer
- Component layer: responsible for UI rendering and user interactions
- Tools layer: provides editor tool features
2. Business Logic Layer
- Node system: defines the behavior and properties of various flow nodes
- Plugin system: provides extensible functional modules
- Services layer: handles business logic and data operations
3. Data Layer
- Context state management: manages global application state
- Type system: ensures consistency of data structures
### Key Design Patterns
#### 1. Provider Pattern
```typescript
// The main editor component uses multiple nested Providers
<FixedLayoutEditorProvider {...editorProps}>
<SidebarProvider>
<EditorRenderer />
<DemoTools />
<SidebarRenderer />
</SidebarProvider>
</FixedLayoutEditorProvider>
```
Use cases:
- `FixedLayoutEditorProvider`: provides core editor features and state
- `SidebarProvider`: manages sidebar visibility and the selected node
#### 2. Registry Pattern
```typescript
export const FlowNodeRegistries: FlowNodeRegistry[] = [
StartNodeRegistry,
EndNodeRegistry,
SwitchNodeRegistry,
LLMNodeRegistry,
// ... more node types
];
```
Advantages:
- Supports dynamic registration of node types
- Easy to extend with new node types
- Decouples node type definitions
#### 3. Plugin Pattern
```typescript
plugins: () => [
createMinimapPlugin({...}),
createGroupPlugin({...}),
createClipboardPlugin(),
createVariablePanelPlugin({}),
]
```
Plugin system highlights:
- Minimap plugin: provides a canvas minimap
- Group plugin: supports node grouping and management
- Clipboard plugin: enables copy and paste
- Variable panel plugin: provides a UI for variable management
#### 4. Factory Pattern
Widely used in node creation and configuration:
```typescript
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
};
}
```
#### 5. Observer Pattern
Implemented via the history system:
```typescript
history: {
enable: true,
enableChangeNode: true,
onApply: debounce((ctx, opt) => {
console.log('auto save: ', ctx.document.toJSON());
}, 100),
}
```
#### 6. Strategy Pattern
Reflected in the materials system:
```typescript
materials: {
components: {
...defaultFixedSemiMaterials,
[FlowRendererKey.ADDER]: NodeAdder,
[FlowRendererKey.BRANCH_ADDER]: BranchAdder,
// Different render strategies can be swapped by key
}
}
```
### State Management Architecture
#### Context System Design
The project uses multiple dedicated Contexts to manage different domains of state:
1. SidebarContext: manages sidebar state
```typescript
export const SidebarContext = React.createContext<{
visible: boolean;
nodeId?: string;
setNodeId: (node: string | undefined) => void;
}>({ visible: false, setNodeId: () => {} });
```
2. NodeRenderContext: manages state related to node rendering
3. IsSidebarContext: simple boolean state
#### Custom Hooks
- `useEditorProps`: centralizes all editor configuration props
- `useIsSidebar`: determines whether the current environment is the sidebar
- `useNodeRenderContext`: gets the node render context
### Component Architecture
#### Component Layering
1. Base components
- `BaseNode`: base rendering component for all nodes
- `DragNode`: node component in drag state
2. Functional components
- Adders: `NodeAdder`, `BranchAdder`, `AgentAdder`
- Tools: zoom, save, run, and other utilities
3. Container components
- `Sidebar`: sidebar container and its subcomponents
- `Tools`: toolbar container
### Data Flow Architecture
#### Initial Data Structure
The project defines a complete initial flow dataset, including examples of multiple node types:
- Start node: entry point of the flow, defines output parameters
- Agent node: contains LLM, Memory, and Tools subcomponents
- LLM node: large language model processing node
- Switch node: conditional branch node
- Loop node: loop processing node
- TryCatch node: exception handling node
- End node: end of the flow
#### Data Transformation Mechanism
```typescript
fromNodeJSON(node, json) {
return json; // Transform logic on data import
},
toNodeJSON(node, json) {
return json; // Transform logic on data export
}
```
+373
View File
@@ -0,0 +1,373 @@
# FlowGram.AI - Demo Fixed Layout
固定布局最佳实践 demo
## 安装
```shell
npx @flowgram.ai/create-app@latest fixed-layout
```
## 项目概览
### 核心技术栈
- **前端框架**: React 18 + TypeScript
- **构建工具**: Rsbuild (基于 Rspack 的现代构建工具)
- **样式方案**: Less + Styled Components + CSS Variables
- **UI 组件库**: Semi Design (@douyinfe/semi-ui)
- **状态管理**: 基于 Flowgram 自研的编辑器框架
- **依赖注入**: Inversify
### 核心依赖包
- **@flowgram.ai/fixed-layout-editor**: 固定布局编辑器核心依赖
- **@flowgram.ai/fixed-semi-materials**: Semi Design 物料库
- **@flowgram.ai/form-materials**: 表单物料库
- **@flowgram.ai/group-plugin**: 分组插件
- **@flowgram.ai/minimap-plugin**: 缩略图插件
- **@flowgram.ai/export-plugin**: 下载/导出插件
## 代码说明
```
src/
├── app.tsx # 应用入口组件
├── editor.tsx # 主编辑器组件
├── index.ts # 模块导出入口
├── initial-data.ts # 初始化数据配置
├── type.d.ts # 全局类型声明
├── assets/ # 静态资源
│ ├── icon-mouse.tsx # 鼠标图标组件
│ └── icon-pad.tsx # 触控板图标组件
├── components/ # 通用组件库
│ ├── index.ts # 组件导出入口
│ ├── node-list.tsx # 节点列表组件
│ │
│ ├── agent-adder/ # Agent 添加器组件
│ │ └── index.tsx
│ ├── agent-label/ # Agent 标签组件
│ │ └── index.tsx
│ ├── base-node/ # 基础节点组件
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── branch-adder/ # 分支添加器组件
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── drag-node/ # 拖拽节点组件
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── node-adder/ # 节点添加器组件
│ │ ├── index.tsx
│ │ ├── styles.tsx
│ │ └── utils.ts
│ ├── selector-box-popover/ # 选择框弹出层组件
│ │ └── index.tsx
│ ├── sidebar/ # 侧边栏组件
│ │ ├── index.tsx
│ │ ├── sidebar-node-renderer.tsx
│ │ ├── sidebar-provider.tsx
│ │ └── sidebar-renderer.tsx
│ └── tools/ # 工具栏组件群
│ ├── index.tsx
│ ├── styles.tsx
│ ├── fit-view.tsx # 适应视图工具
│ ├── minimap-switch.tsx # 缩略图开关
│ ├── minimap.tsx # 缩略图组件
│ ├── readonly.tsx # 只读模式切换
│ ├── run.tsx # 运行工具
│ ├── save.tsx # 保存工具
│ ├── switch-vertical.tsx # 垂直布局切换
│ └── zoom-select.tsx # 缩放选择器
├── context/ # React Context 状态管理
│ ├── index.ts # Context 导出入口
│ ├── node-render-context.ts # 节点渲染上下文
│ └── sidebar-context.ts # 侧边栏上下文
├── form-components/ # 表单组件库
│ ├── index.ts # 表单组件导出入口
│ ├── feedback.tsx # 反馈组件
│ │
│ ├── form-content/ # 表单内容组件
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── form-header/ # 表单头部组件
│ │ ├── index.tsx
│ │ ├── styles.tsx
│ │ ├── title-input.tsx
│ │ └── utils.tsx
│ ├── form-inputs/ # 表单输入组件
│ │ ├── index.tsx
│ │ └── styles.tsx
│ ├── form-item/ # 表单项组件
│ │ ├── index.css
│ │ └── index.tsx
│ ├── form-outputs/ # 表单输出组件
│ │ ├── index.tsx
│ │ └── styles.tsx
│ └── properties-edit/ # 属性编辑组件
│ ├── index.tsx
│ ├── property-edit.tsx
│ └── styles.tsx
├── hooks/ # 自定义 React Hooks
│ ├── index.ts # Hooks 导出入口
│ ├── use-editor-props.ts # 编辑器属性 Hook
│ ├── use-is-sidebar.ts # 侧边栏状态 Hook
│ └── use-node-render-context.ts # 节点渲染上下文 Hook
├── nodes/ # 流程节点定义
│ ├── index.ts # 节点注册表
│ ├── default-form-meta.tsx # 默认表单元数据
│ │
│ ├── agent/ # Agent 节点类型
│ │ ├── index.ts
│ │ ├── agent.ts
│ │ ├── agent-llm.ts
│ │ ├── agent-memory.ts
│ │ ├── agent-tools.ts
│ │ ├── memory.ts
│ │ └── tool.ts
│ ├── break-loop/ # 跳出循环节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── case/ # Case 分支节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── case-default/ # 默认 Case 节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── catch-block/ # 异常捕获块节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── end/ # 结束节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── if/ # 条件判断节点
│ │ └── index.ts
│ ├── if-block/ # 条件块节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── llm/ # LLM 节点
│ │ └── index.ts
│ ├── loop/ # 循环节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── start/ # 开始节点
│ │ ├── index.ts
│ │ └── form-meta.tsx
│ ├── switch/ # Switch 分支节点
│ │ └── index.ts
│ └── trycatch/ # Try-Catch 节点
│ ├── index.ts
│ └── form-meta.tsx
├── plugins/ # 插件系统
│ ├── index.ts # 插件导出入口
│ │
│ ├── clipboard-plugin/ # 剪贴板插件
│ │ └── create-clipboard-plugin.ts
│ ├── group-plugin/ # 分组插件
│ │ ├── index.ts
│ │ ├── group-box-header.tsx
│ │ ├── group-node.tsx
│ │ ├── group-note.tsx
│ │ ├── group-tools.tsx
│ │ ├── icons/
│ │ │ └── index.tsx
│ │ └── multilang-textarea-editor/
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── base-textarea.tsx
│ └── variable-panel-plugin/ # 变量面板插件
│ ├── index.ts
│ ├── variable-panel-layer.tsx
│ ├── variable-panel-plugin.ts
│ └── components/
│ ├── full-variable-list.tsx
│ ├── global-variable-editor.tsx
│ └── variable-panel.tsx
├── services/ # 服务层
│ ├── index.ts
│ └── custom-service.ts # 自定义服务
├── shortcuts/ # 快捷键系统
│ ├── index.ts
│ ├── constants.ts # 快捷键常量
│ └── utils.ts # 快捷键工具函数
└── typings/ # 类型定义
├── index.ts # 类型导出入口
├── json-schema.ts # JSON Schema 类型
└── node.ts # 节点类型定义
```
## 架构设计分析
### 整体架构模式
该项目采用了**分层架构**和**模块化设计**相结合的架构模式:
1. **表现层 (Presentation Layer)**
- 组件层:负责 UI 渲染和用户交互
- 工具层:提供编辑器工具功能
2. **业务逻辑层 (Business Logic Layer)**
- 节点系统:定义各种流程节点的行为和属性
- 插件系统:提供可扩展的功能模块
- 服务层:处理业务逻辑和数据操作
3. **数据层 (Data Layer)**
- Context 状态管理:管理应用全局状态
- 类型系统:确保数据结构的一致性
### 核心设计模式
#### 1. 提供者模式 (Provider Pattern)
```typescript
// 主编辑器组件使用多层 Provider 嵌套
<FixedLayoutEditorProvider {...editorProps}>
<SidebarProvider>
<EditorRenderer />
<DemoTools />
<SidebarRenderer />
</SidebarProvider>
</FixedLayoutEditorProvider>
```
**应用场景**:
- `FixedLayoutEditorProvider`: 提供编辑器核心功能和状态
- `SidebarProvider`: 管理侧边栏的显示状态和选中节点
#### 2. 注册表模式 (Registry Pattern)
```typescript
export const FlowNodeRegistries: FlowNodeRegistry[] = [
StartNodeRegistry,
EndNodeRegistry,
SwitchNodeRegistry,
LLMNodeRegistry,
// ... 更多节点类型
];
```
**设计优势**:
- 支持动态节点类型注册
- 易于扩展新的节点类型
- 实现了节点类型的解耦
#### 3. 插件模式 (Plugin Pattern)
```typescript
plugins: () => [
createMinimapPlugin({...}),
createGroupPlugin({...}),
createClipboardPlugin(),
createVariablePanelPlugin({}),
]
```
**插件系统特点**:
- **缩略图插件**: 提供画布缩略图功能
- **分组插件**: 支持节点分组管理
- **剪贴板插件**: 实现复制粘贴功能
- **变量面板插件**: 提供变量管理界面
#### 4. 工厂模式 (Factory Pattern)
在节点创建和配置中广泛使用:
```typescript
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
};
}
```
#### 5. 观察者模式 (Observer Pattern)
通过历史记录系统实现:
```typescript
history: {
enable: true,
enableChangeNode: true,
onApply: debounce((ctx, opt) => {
console.log('auto save: ', ctx.document.toJSON());
}, 100),
}
```
#### 6. 策略模式 (Strategy Pattern)
在材料系统中体现:
```typescript
materials: {
components: {
...defaultFixedSemiMaterials,
[FlowRendererKey.ADDER]: NodeAdder,
[FlowRendererKey.BRANCH_ADDER]: BranchAdder,
// 可根据 key 替换不同的渲染策略
}
}
```
### 状态管理架构
#### Context 系统设计
项目采用了多个专用的 Context 来管理不同领域的状态:
1. **SidebarContext**: 管理侧边栏状态
```typescript
export const SidebarContext = React.createContext<{
visible: boolean;
nodeId?: string;
setNodeId: (node: string | undefined) => void;
}>({ visible: false, setNodeId: () => {} });
```
2. **NodeRenderContext**: 管理节点渲染相关状态
3. **IsSidebarContext**: 简单的布尔状态管理
#### 自定义 Hooks 设计
- `useEditorProps`: 集中管理编辑器的所有配置属性
- `useIsSidebar`: 判断当前是否在侧边栏环境中
- `useNodeRenderContext`: 获取节点渲染上下文
### 组件架构设计
#### 组件分层结构
1. **基础组件层**
- `BaseNode`: 所有节点的基础渲染组件
- `DragNode`: 拖拽状态下的节点组件
2. **功能组件层**
- 添加器组件: `NodeAdder`, `BranchAdder`, `AgentAdder`
- 工具组件: 缩放、保存、运行等功能组件
3. **容器组件层**
- `Sidebar`: 侧边栏容器及其子组件
- `Tools`: 工具栏容器
### 数据流架构
#### 初始数据结构
项目定义了完整的初始流程数据,包含多种节点类型的示例:
- **Start 节点**: 流程起始点,定义输出参数
- **Agent 节点**: 包含 LLM、Memory、Tools 子组件
- **LLM 节点**: 大语言模型处理节点
- **Switch 节点**: 条件分支节点
- **Loop 节点**: 循环处理节点
- **TryCatch 节点**: 异常处理节点
- **End 节点**: 流程结束点
#### 数据转换机制
```typescript
fromNodeJSON(node, json) {
return json; // 数据导入时的转换逻辑
},
toNodeJSON(node, json) {
return json; // 数据导出时的转换逻辑
}
```
+20
View File
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
rules: {
'no-console': 'off',
'react/prop-types': 'off',
},
settings: {
react: {
version: 'detect',
},
},
});
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" data-bundler="rspack">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flow FixedLayoutEditor Demo</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
{
"name": "@flowgram.ai/demo-fixed-layout",
"version": "0.1.0",
"description": "",
"keywords": [],
"license": "MIT",
"main": "./src/index.ts",
"files": [
"src/",
"eslint.config.js",
".gitignore",
"index.html",
"package.json",
"rsbuild.config.ts",
"tsconfig.json",
"README.md",
"README.zh_CN.md"
],
"scripts": {
"build": "exit 0",
"build:fast": "exit 0",
"build:watch": "exit 0",
"build:prod": "cross-env MODE=app NODE_ENV=production rsbuild build",
"clean": "rimraf dist",
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix",
"ts-check": "tsc --noEmit",
"start": "cross-env NODE_ENV=development rsbuild dev --open",
"test": "exit",
"test:cov": "exit",
"watch": "exit 0"
},
"dependencies": {
"@douyinfe/semi-icons": "^2.80.0",
"@douyinfe/semi-ui": "^2.80.0",
"@flowgram.ai/fixed-layout-editor": "workspace:*",
"@flowgram.ai/fixed-semi-materials": "workspace:*",
"@flowgram.ai/form-materials": "workspace:*",
"@flowgram.ai/group-plugin": "workspace:*",
"@flowgram.ai/minimap-plugin": "workspace:*",
"@flowgram.ai/export-plugin": "workspace:*",
"@flowgram.ai/panel-manager-plugin": "workspace:*",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9",
"react": "^18",
"react-dom": "^18",
"styled-components": "^5"
},
"devDependencies": {
"@flowgram.ai/ts-config": "workspace:*",
"@flowgram.ai/eslint-config": "workspace:*",
"@rsbuild/core": "^1.2.16",
"@rsbuild/plugin-react": "^1.1.1",
"@rsbuild/plugin-less": "^1.1.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/styled-components": "^5",
"@typescript-eslint/parser": "^8.0.0",
"typescript": "^5.8.3",
"eslint": "^9.0.0",
"less": "^4.1.2",
"less-loader": "^6",
"cross-env": "~7.0.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginLess } from '@rsbuild/plugin-less';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [pluginReact(), pluginLess()],
source: {
entry: {
index: './src/app.tsx',
},
/**
* support inversify @injectable() and @inject decorators
*/
decorators: {
version: 'legacy',
},
},
html: {
title: 'demo-fixed-layout',
},
tools: {
rspack: {
/**
* ignore warnings from @coze-editor/editor/language-typescript
*/
ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/],
},
},
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { createRoot } from 'react-dom/client';
import { Editor } from './editor';
const app = createRoot(document.getElementById('root')!);
app.render(<Editor />);
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" focusable="false" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M9.56066 2.43934C10.1464 3.02513 10.1464 3.97487 9.56066 4.56066L7.12132 7H14.75C18.8353 7 22 10.5796 22 14.5C22 18.4204 18.8353 22 14.75 22H11.5C10.6716 22 10 21.3284 10 20.5C10 19.6716 10.6716 19 11.5 19H14.75C17.016 19 19 16.9308 19 14.5C19 12.0692 17.016 10 14.75 10H7.12132L9.56066 12.4393C10.1464 13.0251 10.1464 13.9749 9.56066 14.5607C8.97487 15.1464 8.02513 15.1464 7.43934 14.5607L2.43934 9.56066C1.85355 8.97487 1.85355 8.02513 2.43934 7.43934L7.43934 2.43934C8.02513 1.85355 8.97487 1.85355 9.56066 2.43934Z" fill="#54A9FF"></path></svg>

After

Width:  |  Height:  |  Size: 733 B

Some files were not shown because too many files have changed in this diff Show More