Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fbe43b5
feat: Iterator container node with child node management, expose para…
Leeon-Tang Mar 12, 2026
d0e970f
feat: directory-import node, iterator capsule fix, execution yield, r…
Leeon-Tang Mar 13, 2026
a8f491a
feat: add iterator icon + hover hints for iterator & directory-import…
Leeon-Tang Mar 13, 2026
f084bcd
merge: sync dev into iterator - named migrations, node dedup, keyboar…
Leeon-Tang Mar 17, 2026
f8d18cd
feat: add iterator DB migration (parent_node_id, is_internal), fix br…
Leeon-Tang Mar 17, 2026
095d6fa
feat: replace iterator with subgraph groups, add trigger nodes and HT…
linqiquan Mar 18, 2026
ea0fe68
feat: add multi-run (gacha) per node and output selection
linqiquan Mar 18, 2026
c117ae2
feat: improve import workflow dialog with error handling and self-imp…
linqiquan Mar 19, 2026
8846c66
fix: exit subgraph on tab close; remove directory-import node
Leeon-Tang Mar 19, 2026
81d6123
refactor: unify node UI with shadcn components; fix results spacing
Leeon-Tang Mar 19, 2026
842a387
fix: group hint z-index, subgraph node run, results carousel animatio…
Leeon-Tang Mar 19, 2026
8ef3168
feat: improve Models filter-by-type button style, layout, state persi…
Leeon-Tang Mar 19, 2026
c174592
feat: sync all i18n locales for trigger/directory, trigger/http, outp…
Leeon-Tang Mar 20, 2026
e6bdeaf
fix: connection line offset via useUpdateNodeInternals, sync connecte…
Leeon-Tang Mar 20, 2026
87ae29f
fix: group node i18n label bug + update workflow editor screenshot
Leeon-Tang Mar 24, 2026
35393fb
docs: add HTTP server API details and OpenClaw skill server integration
Leeon-Tang Mar 24, 2026
dfac20a
docs: update Creative Studio screenshot
Leeon-Tang Mar 24, 2026
82b14d9
merge: sync iterator branch into dev
Leeon-Tang Mar 24, 2026
d1daf56
chore: prettier formatting across entire codebase
Leeon-Tang Mar 24, 2026
fd95bfd
chore: bump version to 2.1.0
linqiquan Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
{
}
{}
170 changes: 85 additions & 85 deletions README.md

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,49 @@ ipcMain.handle("select-directory", async () => {
return { success: true, path: result.filePaths[0] };
});

// Directory Import node — pick a directory for media scanning
ipcMain.handle("pick-directory", async () => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (!focusedWindow) return { success: false, error: "No focused window" };

const result = await dialog.showOpenDialog(focusedWindow, {
properties: ["openDirectory"],
title: "Select Media Directory",
});

if (result.canceled || !result.filePaths[0]) {
return { success: false, canceled: true };
}

return { success: true, path: result.filePaths[0] };
});

// Directory Import node — scan a directory for media files
ipcMain.handle(
"scan-directory",
async (_, dirPath: string, allowedExts: string[]) => {
const { readdirSync } = require("fs");
const { join, extname } = require("path");

const extSet = new Set(allowedExts.map((e: string) => e.toLowerCase()));
const results: string[] = [];
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) {
const ext = extname(entry.name).toLowerCase();
if (extSet.has(ext)) {
results.push(join(dirPath, entry.name));
}
}
}
} catch {
// Skip unreadable
}
return results;
},
);

ipcMain.handle(
"save-asset",
async (_, url: string, _type: string, fileName: string, subDir: string) => {
Expand Down
4 changes: 4 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const electronAPI = {
ipcRenderer.invoke("get-zimage-output-path"),
selectDirectory: (): Promise<SelectDirectoryResult> =>
ipcRenderer.invoke("select-directory"),
pickDirectory: (): Promise<SelectDirectoryResult> =>
ipcRenderer.invoke("pick-directory"),
scanDirectory: (dirPath: string, allowedExts: string[]): Promise<string[]> =>
ipcRenderer.invoke("scan-directory", dirPath, allowedExts),
saveAsset: (
url: string,
type: string,
Expand Down
12 changes: 11 additions & 1 deletion electron/workflow/db/edge.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ function rowToEdge(row: unknown[]): WorkflowEdge {
sourceOutputKey: row[3] as string,
targetNodeId: row[4] as string,
targetInputKey: row[5] as string,
isInternal: row[6] === 1,
};
}

const EDGE_COLS =
"id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key";
"id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key, is_internal";

export function getEdgesByWorkflowId(workflowId: string): WorkflowEdge[] {
const db = getDatabase();
Expand All @@ -43,3 +44,12 @@ export function deleteEdge(edgeId: string): void {
db.run("DELETE FROM edges WHERE id = ?", [edgeId]);
persistDatabase();
}
export function getInternalEdges(workflowId: string): WorkflowEdge[] {
const db = getDatabase();
const result = db.exec(
`SELECT ${EDGE_COLS} FROM edges WHERE workflow_id = ? AND is_internal = 1`,
[workflowId],
);
if (!result.length) return [];
return result[0].values.map(rowToEdge);
}
36 changes: 27 additions & 9 deletions electron/workflow/db/node.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,39 @@
import { getDatabase, persistDatabase } from "./connection";
import type { WorkflowNode } from "../../../src/workflow/types/workflow";

export function getNodesByWorkflowId(workflowId: string): WorkflowNode[] {
const db = getDatabase();
const result = db.exec(
"SELECT id, workflow_id, node_type, position_x, position_y, params, current_output_id FROM nodes WHERE workflow_id = ?",
[workflowId],
);
if (!result.length) return [];
return result[0].values.map((row) => ({
const NODE_COLS =
"id, workflow_id, node_type, position_x, position_y, params, current_output_id, parent_node_id";

function rowToNode(row: unknown[]): WorkflowNode {
return {
id: row[0] as string,
workflowId: row[1] as string,
nodeType: row[2] as string,
position: { x: row[3] as number, y: row[4] as number },
params: JSON.parse(row[5] as string),
currentOutputId: row[6] as string | null,
}));
parentNodeId: (row[7] as string | null) ?? null,
};
}

export function getNodesByWorkflowId(workflowId: string): WorkflowNode[] {
const db = getDatabase();
const result = db.exec(
`SELECT ${NODE_COLS} FROM nodes WHERE workflow_id = ?`,
[workflowId],
);
if (!result.length) return [];
return result[0].values.map(rowToNode);
}

export function getChildNodes(parentNodeId: string): WorkflowNode[] {
const db = getDatabase();
const result = db.exec(
`SELECT ${NODE_COLS} FROM nodes WHERE parent_node_id = ?`,
[parentNodeId],
);
if (!result.length) return [];
return result[0].values.map(rowToNode);
}

export function updateNodeParams(
Expand Down
38 changes: 38 additions & 0 deletions electron/workflow/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ const migrations: NamedMigration[] = [
}
},
},
{
id: "004_add_iterator_support",
apply: (db: SqlJsDatabase) => {
console.log("[Schema] Applying migration: 004_add_iterator_support");
// Add parent_node_id to nodes
const nodeCols = db.exec("PRAGMA table_info(nodes)");
const hasParentNodeId = nodeCols[0]?.values?.some(
(row) => row[1] === "parent_node_id",
);
if (!hasParentNodeId) {
db.run(
"ALTER TABLE nodes ADD COLUMN parent_node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)",
);
}
// Add is_internal to edges
const edgeCols = db.exec("PRAGMA table_info(edges)");
const hasIsInternal = edgeCols[0]?.values?.some(
(row) => row[1] === "is_internal",
);
if (!hasIsInternal) {
db.run(
"ALTER TABLE edges ADD COLUMN is_internal INTEGER NOT NULL DEFAULT 0 CHECK (is_internal IN (0, 1))",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)",
);
}
},
},
];

export function initializeSchema(db: SqlJsDatabase): void {
Expand All @@ -112,6 +144,7 @@ export function initializeSchema(db: SqlJsDatabase): void {
position_y REAL NOT NULL,
params TEXT NOT NULL DEFAULT '{}',
current_output_id TEXT,
parent_node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL,
FOREIGN KEY (current_output_id) REFERENCES node_executions(id) ON DELETE SET NULL
)`);

Expand All @@ -138,6 +171,7 @@ export function initializeSchema(db: SqlJsDatabase): void {
source_output_key TEXT NOT NULL,
target_node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
target_input_key TEXT NOT NULL,
is_internal INTEGER NOT NULL DEFAULT 0 CHECK (is_internal IN (0, 1)),
UNIQUE(source_node_id, source_output_key, target_node_id, target_input_key)
)`);

Expand Down Expand Up @@ -202,6 +236,10 @@ export function initializeSchema(db: SqlJsDatabase): void {
db.run(
"CREATE INDEX IF NOT EXISTS idx_wf_edges_target ON edges(target_node_id)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)",
);
db.run("CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)");
db.run(
"CREATE INDEX IF NOT EXISTS idx_wf_daily_spend_date ON daily_spend(date)",
);
Expand Down
9 changes: 7 additions & 2 deletions electron/workflow/db/workflow.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,27 +173,29 @@ export function updateWorkflow(

for (const node of uniqueNodes) {
db.run(
`INSERT INTO nodes (id, workflow_id, node_type, position_x, position_y, params, current_output_id) VALUES (?, ?, ?, ?, ?, ?, NULL)`,
`INSERT INTO nodes (id, workflow_id, node_type, position_x, position_y, params, current_output_id, parent_node_id) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`,
[
node.id,
id,
node.nodeType,
node.position.x,
node.position.y,
JSON.stringify(node.params),
node.parentNodeId ?? null,
],
);
}
for (const edge of uniqueEdges) {
db.run(
`INSERT INTO edges (id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key) VALUES (?, ?, ?, ?, ?, ?)`,
`INSERT INTO edges (id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key, is_internal) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
edge.id,
id,
edge.sourceNodeId,
edge.sourceOutputKey,
edge.targetNodeId,
edge.targetInputKey,
edge.isInternal ? 1 : 0,
],
);
}
Expand Down Expand Up @@ -284,6 +286,9 @@ export function duplicateWorkflow(sourceId: string): Workflow {
id: nodeIdMap.get(n.id)!,
workflowId: newWf.id,
currentOutputId: null,
parentNodeId: n.parentNodeId
? (nodeIdMap.get(n.parentNodeId) ?? null)
: null,
}),
);

Expand Down
88 changes: 87 additions & 1 deletion electron/workflow/engine/dag-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* DAG validation — cycle detection using DFS.
*/
interface SimpleEdge {
export interface SimpleEdge {
sourceNodeId: string;
targetNodeId: string;
}
Expand Down Expand Up @@ -38,3 +38,89 @@ export function wouldCreateCycle(
): boolean {
return hasCycle(nodeIds, [...edges, newEdge]);
}

/**
* Check whether adding `newEdge` would create a cycle within a sub-workflow
* defined by `subNodeIds` and `internalEdges`. Only the sub-node scope is
* considered — edges outside the sub-workflow are ignored.
*/
export function wouldCreateCycleInSubWorkflow(
subNodeIds: string[],
internalEdges: SimpleEdge[],
newEdge: SimpleEdge,
): boolean {
return hasCycle(subNodeIds, [...internalEdges, newEdge]);
}

/**
* Return edges that cross the iterator boundary (one endpoint inside, one
* outside), remapped so the inside endpoint is replaced with `iteratorNodeId`.
* This lets the outer DAG treat the iterator as a single node.
*/
export function getExternalEdges(
allEdges: SimpleEdge[],
iteratorNodeId: string,
childNodeIds: string[],
): SimpleEdge[] {
const childSet = new Set(childNodeIds);
const result: SimpleEdge[] = [];

for (const edge of allEdges) {
const srcInside = childSet.has(edge.sourceNodeId);
const tgtInside = childSet.has(edge.targetNodeId);

if (srcInside && !tgtInside) {
// Edge going from inside the iterator to outside — remap source
result.push({
sourceNodeId: iteratorNodeId,
targetNodeId: edge.targetNodeId,
});
} else if (!srcInside && tgtInside) {
// Edge going from outside into the iterator — remap target
result.push({
sourceNodeId: edge.sourceNodeId,
targetNodeId: iteratorNodeId,
});
}
// Both inside → internal edge, skip
// Both outside → not related to this iterator, skip
}

return result;
}

/**
* Build the node list and edge list for outer-DAG validation. Iterator child
* nodes are removed and their boundary-crossing edges are remapped onto the
* iterator node ID. Fully internal edges are dropped.
*
* `iterators` is an array of `{ iteratorNodeId, childNodeIds }` — one entry
* per iterator node in the workflow.
*/
export function buildOuterDAGView(
allNodeIds: string[],
allEdges: SimpleEdge[],
iterators: { iteratorNodeId: string; childNodeIds: string[] }[],
): { nodeIds: string[]; edges: SimpleEdge[] } {
// Collect all child node IDs across every iterator
const allChildIds = new Set<string>();
for (const it of iterators) {
for (const cid of it.childNodeIds) allChildIds.add(cid);
}

// Outer node list: exclude child nodes (iterators themselves stay)
const nodeIds = allNodeIds.filter((id) => !allChildIds.has(id));

// Start with edges that don't touch any child node at all
const edges: SimpleEdge[] = allEdges.filter(
(e) => !allChildIds.has(e.sourceNodeId) && !allChildIds.has(e.targetNodeId),
);

// Add remapped external edges for each iterator
for (const it of iterators) {
const ext = getExternalEdges(allEdges, it.iteratorNodeId, it.childNodeIds);
edges.push(...ext);
}

return { nodeIds, edges };
}
Loading
Loading