Compare commits

..

18 Commits

Author SHA1 Message Date
6f8e09cb92 chore(i18n): sync translations with en-US
- Synced translation files based on en-US changes
- Mode: incremental
- Files: app

🤖 Generated with Claude Code GitHub Action
2026-01-07 10:27:11 +00:00
yyh
4c8c6cfba1 Merge 0eca1f671b into 187bfafe8b 2026-01-07 10:23:09 +00:00
yyh
0eca1f671b ci: Update i18n translation workflow using Claude. 2026-01-07 18:21:53 +08:00
yyh
ba0409bab2 Reapply "test(i18n): trigger CI with test translation key"
This reverts commit 228dc7d005.
2026-01-07 18:08:28 +08:00
yyh
33e7cfb9f7 chore(i18n): update GitHub Actions versions
- Update actions/checkout from v4 to v6
- Update actions/setup-node from v4 to v6

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:43:03 +08:00
yyh
228dc7d005 Revert "test(i18n): trigger CI with test translation key"
Revert "test(i18n): add pull_request trigger for CI testing"

This reverts commits 7130bbebfe and 7051d96841.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:42:42 +08:00
yyh
7130bbebfe test(i18n): trigger CI with test translation key
- Update actions/checkout to v6
- Update actions/setup-node to v6
- Add test key to en-US/app.json to trigger workflow
- Will be reverted after testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:28:31 +08:00
yyh
7051d96841 test(i18n): add pull_request trigger for CI testing
Temporary change to test workflow in PR CI.
Will be reverted after testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:26:09 +08:00
yyh
e948e5a4e0 fix(i18n): remove invalid model name from claude_args
Remove --model claude-sonnet-4-5-20250929 as it's not a valid model identifier.
Let Claude Code Action use its default model instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:25:32 +08:00
yyh
38d3a45258 fix(i18n): add pnpm and Node.js setup steps
pnpm is not pre-installed on GitHub ubuntu runners.
Add pnpm/action-setup and setup-node before Claude Code action.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:23:28 +08:00
yyh
713233d23a fix(i18n): use official github_token input parameter
Replace custom GH_TOKEN env with official github_token input:
- github_token is the official parameter per action.yml
- Action automatically sets GITHUB_TOKEN and GH_TOKEN internally
- Remove unnecessary use_oauth parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:14:15 +08:00
yyh
b7ef9791bf fix(i18n): add GH_TOKEN env for gh CLI authentication
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:10:08 +08:00
yyh
e5d105d966 fix(i18n): add --allowedTools to enable git/gh commands
Allow Claude to use git and gh commands for PR creation:
- Bash(git:*) - git operations
- Bash(gh:*) - GitHub CLI for PR creation
- Bash(pnpm:*) - package manager commands
- Read,Write,Edit,Glob,Grep - file operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:08:46 +08:00
yyh
7d1bb1445f fix(i18n): add PR creation step to Claude Code workflow
- Add PHASE 4: COMMIT AND CREATE PR instructions
- Configure git user for commits
- Add timeout_minutes parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:06:52 +08:00
4f6f3dada1 [autofix.ci] apply automated fixes 2026-01-07 09:05:52 +00:00
yyh
fd2f20b678 Update web/i18n-config/README.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-07 17:04:07 +08:00
3b9de6837b [autofix.ci] apply automated fixes 2026-01-07 08:59:14 +00:00
yyh
521d19e036 feat(i18n): migrate translation workflow to Claude Code GitHub Actions
- Replace Bing translate with Claude Code Action
- Support three change scenarios: ADD, UPDATE, DELETE
- Include lint:fix step for JSON key sorting
- Update pnpm-lock.yaml after removing bing-translate-api

Deleted:
- web/scripts/auto-gen-i18n.js
- .github/workflows/translate-i18n-base-on-english.yml

Added:
- .github/workflows/translate-i18n-claude.yml

Modified:
- web/package.json (removed i18n:gen, bing-translate-api)
- web/i18n-config/README.md
- web/pnpm-lock.yaml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 16:55:06 +08:00
67 changed files with 678 additions and 7977 deletions

View File

@ -1,94 +0,0 @@
name: Translate i18n Files Based on English
on:
push:
branches: [main]
paths:
- 'web/i18n/en-US/*.json'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
check-and-update:
if: github.repository == 'langgenius/dify'
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
# Keep use old checkout action version for https://github.com/peter-evans/create-pull-request/issues/4272
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check for file changes in i18n/en-US
id: check_files
run: |
# Skip check for manual trigger, translate all files
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "FILES_CHANGED=true" >> $GITHUB_ENV
echo "FILE_ARGS=" >> $GITHUB_ENV
echo "Manual trigger: translating all files"
else
git fetch origin "${{ github.event.before }}" || true
git fetch origin "${{ github.sha }}" || true
changed_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'i18n/en-US/*.json')
echo "Changed files: $changed_files"
if [ -n "$changed_files" ]; then
echo "FILES_CHANGED=true" >> $GITHUB_ENV
file_args=""
for file in $changed_files; do
filename=$(basename "$file" .json)
file_args="$file_args --file $filename"
done
echo "FILE_ARGS=$file_args" >> $GITHUB_ENV
echo "File arguments: $file_args"
else
echo "FILES_CHANGED=false" >> $GITHUB_ENV
fi
fi
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
run_install: false
- name: Set up Node.js
if: env.FILES_CHANGED == 'true'
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Install dependencies
if: env.FILES_CHANGED == 'true'
working-directory: ./web
run: pnpm install --frozen-lockfile
- name: Generate i18n translations
if: env.FILES_CHANGED == 'true'
working-directory: ./web
run: pnpm run i18n:gen ${{ env.FILE_ARGS }}
- name: Create Pull Request
if: env.FILES_CHANGED == 'true'
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore(i18n): update translations based on en-US changes'
title: 'chore(i18n): translate i18n files based on en-US changes'
body: |
This PR was automatically created to update i18n translation files based on changes in en-US locale.
**Triggered by:** ${{ github.sha }}
**Changes included:**
- Updated translation files for all locales
branch: chore/automated-i18n-updates-${{ github.sha }}
delete-branch: true

View File

@ -0,0 +1,367 @@
name: Translate i18n Files with Claude Code
on:
push:
branches: [main]
paths:
- 'web/i18n/en-US/*.json'
pull_request:
branches: [main]
paths:
- '.github/workflows/translate-i18n-claude.yml'
- 'web/i18n/en-US/*.json'
workflow_dispatch:
inputs:
files:
description: 'Specific files to translate (space-separated, e.g., "app common"). Leave empty for all files.'
required: false
type: string
languages:
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported languages.'
required: false
type: string
mode:
description: 'Sync mode: incremental (only changes) or full (re-check all keys)'
required: false
default: 'incremental'
type: choice
options:
- incremental
- full
permissions:
contents: write
pull-requests: write
jobs:
translate:
if: github.repository == 'langgenius/dify'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Detect changed files and generate diff
id: detect_changes
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
# Manual trigger
if [ -n "${{ github.event.inputs.files }}" ]; then
echo "CHANGED_FILES=${{ github.event.inputs.files }}" >> $GITHUB_OUTPUT
else
# Get all JSON files in en-US directory
files=$(ls web/i18n/en-US/*.json 2>/dev/null | xargs -n1 basename | sed 's/.json$//' | tr '\n' ' ')
echo "CHANGED_FILES=$files" >> $GITHUB_OUTPUT
fi
echo "TARGET_LANGS=${{ github.event.inputs.languages }}" >> $GITHUB_OUTPUT
echo "SYNC_MODE=${{ github.event.inputs.mode || 'incremental' }}" >> $GITHUB_OUTPUT
# For manual trigger with incremental mode, get diff from last commit
# For full mode, we'll do a complete check anyway
if [ "${{ github.event.inputs.mode }}" == "full" ]; then
echo "Full mode: will check all keys" > /tmp/i18n-diff.txt
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
else
git diff HEAD~1..HEAD -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt
if [ -s /tmp/i18n-diff.txt ]; then
echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT
else
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
fi
fi
elif [ "${{ github.event_name }}" == "pull_request" ]; then
# PR trigger - detect changed files in the PR
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.sha }}"
changed=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/.json$//' | tr '\n' ' ' || echo "")
echo "CHANGED_FILES=$changed" >> $GITHUB_OUTPUT
echo "TARGET_LANGS=" >> $GITHUB_OUTPUT
echo "SYNC_MODE=incremental" >> $GITHUB_OUTPUT
# Generate detailed diff for the PR
git diff "$BASE_SHA".."$HEAD_SHA" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt
if [ -s /tmp/i18n-diff.txt ]; then
echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT
else
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
fi
else
# Push trigger - detect changed files from the push
changed=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} -- 'web/i18n/en-US/*.json' 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/.json$//' | tr '\n' ' ' || echo "")
echo "CHANGED_FILES=$changed" >> $GITHUB_OUTPUT
echo "TARGET_LANGS=" >> $GITHUB_OUTPUT
echo "SYNC_MODE=incremental" >> $GITHUB_OUTPUT
# Generate detailed diff for the push
git diff ${{ github.event.before }}..${{ github.sha }} -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt
if [ -s /tmp/i18n-diff.txt ]; then
echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT
else
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
fi
fi
# Truncate diff if too large (keep first 50KB)
if [ -f /tmp/i18n-diff.txt ]; then
head -c 50000 /tmp/i18n-diff.txt > /tmp/i18n-diff-truncated.txt
mv /tmp/i18n-diff-truncated.txt /tmp/i18n-diff.txt
fi
echo "Detected files: $(cat $GITHUB_OUTPUT | grep CHANGED_FILES || echo 'none')"
- name: Run Claude Code for Translation Sync
if: steps.detect_changes.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 55
prompt: |
You are a professional i18n synchronization engineer for the Dify project.
Your task is to keep all language translations in sync with the English source (en-US).
## Context
- Changed/target files: ${{ steps.detect_changes.outputs.CHANGED_FILES }}
- Target languages (empty means all supported): ${{ steps.detect_changes.outputs.TARGET_LANGS }}
- Sync mode: ${{ steps.detect_changes.outputs.SYNC_MODE }}
- Translation files are located in: web/i18n/{locale}/{filename}.json
- Language configuration is in: web/i18n-config/languages.ts
- Git diff is available: ${{ steps.detect_changes.outputs.DIFF_AVAILABLE }}
## CRITICAL DESIGN: Verify First, Then Sync
You MUST follow this three-phase approach:
═══════════════════════════════════════════════════════════════
║ PHASE 1: VERIFY - Analyze and Generate Change Report ║
═══════════════════════════════════════════════════════════════
### Step 1.1: Analyze Git Diff (for incremental mode)
```bash
cat /tmp/i18n-diff.txt 2>/dev/null || echo "No diff available"
```
Parse the diff to categorize changes:
- Lines with `+` (not `+++`): Added or modified values
- Lines with `-` (not `---`): Removed or old values
- Identify specific keys for each category:
* ADD: Keys that appear only in `+` lines (new keys)
* UPDATE: Keys that appear in both `-` and `+` lines (value changed)
* DELETE: Keys that appear only in `-` lines (removed keys)
### Step 1.2: Read Language Configuration
```bash
cat web/i18n-config/languages.ts
```
Extract all languages with `supported: true`.
### Step 1.3: Run i18n:check for Each Language
```bash
cd web && pnpm install --frozen-lockfile
pnpm run i18n:check
```
This will report:
- Missing keys (need to ADD)
- Extra keys (need to DELETE)
### Step 1.4: Generate Change Report
Create a structured report identifying:
```
╔══════════════════════════════════════════════════════════════╗
║ I18N SYNC CHANGE REPORT ║
╠══════════════════════════════════════════════════════════════╣
║ Files to process: [list] ║
║ Languages to sync: [list] ║
╠══════════════════════════════════════════════════════════════╣
║ ADD (New Keys): ║
║ - [filename].[key]: "English value" ║
║ ... ║
╠══════════════════════════════════════════════════════════════╣
║ UPDATE (Modified Keys - MUST re-translate): ║
║ - [filename].[key]: "Old value" → "New value" ║
║ ... ║
╠══════════════════════════════════════════════════════════════╣
║ DELETE (Extra Keys): ║
║ - [language]/[filename].[key] ║
║ ... ║
╚══════════════════════════════════════════════════════════════╝
```
**IMPORTANT**: For UPDATE detection, compare git diff to find keys where
the English value changed. These MUST be re-translated even if target
language already has a translation (it's now stale!).
═══════════════════════════════════════════════════════════════
║ PHASE 2: SYNC - Execute Changes Based on Report ║
═══════════════════════════════════════════════════════════════
### Step 2.1: Process ADD Operations
For each key in the ADD list:
1. Get the English value from en-US file
2. Translate to target language
3. Add to target language JSON file (maintain alphabetical order if file uses it)
### Step 2.2: Process UPDATE Operations (CRITICAL!)
For each key in the UPDATE list:
1. Get the NEW English value
2. Translate to target language
3. **OVERWRITE** the existing translation (old one is stale!)
### Step 2.3: Process DELETE Operations
For extra keys reported by i18n:check:
- Run: `cd web && pnpm run i18n:check --auto-remove`
- Or manually remove from target language JSON files
## Translation Guidelines
- PRESERVE all placeholders exactly as-is:
- `{{variable}}` - Mustache interpolation
- `${variable}` - Template literal
- `<tag>content</tag>` - HTML tags
- `_one`, `_other` - Pluralization suffixes (these are KEY suffixes, not values)
- Use appropriate language register (formal/informal) based on existing translations
- Match existing translation style in each language
- Technical terms: check existing conventions per language
- For CJK languages: no spaces between characters unless necessary
- For RTL languages (ar-TN, fa-IR): ensure proper text handling
## Output Format Requirements
- Alphabetical key ordering (if original file uses it)
- 2-space indentation
- Trailing newline at end of file
- Valid JSON (use proper escaping for special characters)
═══════════════════════════════════════════════════════════════
║ PHASE 3: RE-VERIFY - Confirm All Issues Resolved ║
═══════════════════════════════════════════════════════════════
### Step 3.1: Run Lint Fix (IMPORTANT!)
```bash
cd web && pnpm lint:fix --quiet -- 'i18n/**/*.json'
```
This ensures:
- JSON keys are sorted alphabetically (jsonc/sort-keys rule)
- Valid i18n keys (dify-i18n/valid-i18n-keys rule)
- No extra keys (dify-i18n/no-extra-keys rule)
### Step 3.2: Run Final i18n Check
```bash
cd web && pnpm run i18n:check
```
### Step 3.3: Fix Any Remaining Issues
If check reports issues:
- Go back to PHASE 2 for unresolved items
- Repeat until check passes
### Step 3.4: Generate Final Summary
```
╔══════════════════════════════════════════════════════════════╗
║ SYNC COMPLETED SUMMARY ║
╠══════════════════════════════════════════════════════════════╣
║ Language │ Added │ Updated │ Deleted │ Status ║
╠══════════════════════════════════════════════════════════════╣
║ zh-Hans │ 5 │ 2 │ 1 │ ✓ Complete ║
║ ja-JP │ 5 │ 2 │ 1 │ ✓ Complete ║
║ ... │ ... │ ... │ ... │ ... ║
╠══════════════════════════════════════════════════════════════╣
║ i18n:check │ PASSED - All keys in sync ║
╚══════════════════════════════════════════════════════════════╝
```
## Mode-Specific Behavior
**SYNC_MODE = "incremental"** (default):
- Focus on keys identified from git diff
- Also check i18n:check output for any missing/extra keys
- Efficient for small changes
**SYNC_MODE = "full"**:
- Compare ALL keys between en-US and each language
- Run i18n:check to identify all discrepancies
- Use for first-time sync or fixing historical issues
## Important Notes
1. Always run i18n:check BEFORE and AFTER making changes
2. The check script is the source of truth for missing/extra keys
3. For UPDATE scenario: git diff is the source of truth for changed values
4. Create a single commit with all translation changes
5. If any translation fails, continue with others and report failures
═══════════════════════════════════════════════════════════════
║ PHASE 4: COMMIT AND CREATE PR ║
═══════════════════════════════════════════════════════════════
After all translations are complete and verified:
### Step 4.1: Check for changes
```bash
git status --porcelain
```
If there are changes:
### Step 4.2: Create a new branch and commit
```bash
BRANCH_NAME="chore/i18n-sync-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH_NAME"
git add web/i18n/
git commit -m "chore(i18n): sync translations with en-US
- Synced translation files based on en-US changes
- Mode: ${{ steps.detect_changes.outputs.SYNC_MODE }}
- Files: ${{ steps.detect_changes.outputs.CHANGED_FILES }}
🤖 Generated with Claude Code GitHub Action"
git push origin "$BRANCH_NAME"
```
### Step 4.3: Create Pull Request
```bash
gh pr create \
--title "chore(i18n): sync translations with en-US" \
--body "## Summary
This PR was automatically generated to sync i18n translation files.
### Changes
- Mode: ${{ steps.detect_changes.outputs.SYNC_MODE }}
- Files processed: ${{ steps.detect_changes.outputs.CHANGED_FILES }}
### Verification
- [x] \`i18n:check\` passed
- [x] \`lint:fix\` applied
🤖 Generated with Claude Code GitHub Action" \
--base main
```
claude_args: |
--max-turns 100
--allowedTools "Read,Write,Edit,Bash(git:*),Bash(gh:*),Bash(cd:*),Bash(pnpm:*),Bash(cat:*),Bash(ls:*),Glob,Grep"

1
.gitignore vendored
View File

@ -209,7 +209,6 @@ api/.vscode
.history
.idea/
web/migration/
# pnpm
/.pnpm-store

View File

@ -63,7 +63,6 @@ class NodeType(StrEnum):
TRIGGER_SCHEDULE = "trigger-schedule"
TRIGGER_PLUGIN = "trigger-plugin"
HUMAN_INPUT = "human-input"
GROUP = "group"
@property
def is_trigger_node(self) -> bool:

View File

@ -307,14 +307,7 @@ class Graph:
if not node_configs:
raise ValueError("Graph must have at least one node")
# Filter out UI-only node types:
# - custom-note: top-level type (node_config.type == "custom-note")
# - group: data-level type (node_config.data.type == "group")
node_configs = [
node_config for node_config in node_configs
if node_config.get("type", "") != "custom-note"
and node_config.get("data", {}).get("type", "") != "group"
]
node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"]
# Parse node configurations
node_configs_map = cls._parse_node_configs(node_configs)

View File

@ -1,7 +1,6 @@
import type { FC } from 'react'
import { memo } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { Folder as FolderLine } from '@/app/components/base/icons/src/vender/line/files'
import {
Agent,
Answer,
@ -55,7 +54,6 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin
[BlockEnum.TemplateTransform]: TemplatingTransform,
[BlockEnum.VariableAssigner]: VariableX,
[BlockEnum.VariableAggregator]: VariableX,
[BlockEnum.Group]: FolderLine,
[BlockEnum.Assigner]: Assigner,
[BlockEnum.Tool]: VariableX,
[BlockEnum.IterationStart]: VariableX,
@ -99,7 +97,6 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[BlockEnum.VariableAssigner]: 'bg-util-colors-blue-blue-500',
[BlockEnum.VariableAggregator]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Tool]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Group]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Assigner]: 'bg-util-colors-blue-blue-500',
[BlockEnum.ParameterExtractor]: 'bg-util-colors-blue-blue-500',
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',

View File

@ -25,7 +25,7 @@ import {
useAvailableBlocks,
useNodesInteractions,
} from './hooks'
import { BlockEnum, NodeRunningStatus } from './types'
import { NodeRunningStatus } from './types'
import { getEdgeColor } from './utils'
const CustomEdge = ({
@ -136,7 +136,7 @@ const CustomEdge = ({
stroke,
strokeWidth: 2,
opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1),
strokeDasharray: (data._isTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined,
strokeDasharray: data._isTemp ? '8 8' : undefined,
}}
/>
<EdgeLabelRenderer>

View File

@ -1,11 +0,0 @@
export const CUSTOM_GROUP_NODE = 'custom-group'
export const CUSTOM_GROUP_INPUT_NODE = 'custom-group-input'
export const CUSTOM_GROUP_EXIT_PORT_NODE = 'custom-group-exit-port'
export const GROUP_CHILDREN_Z_INDEX = 1002
export const UI_ONLY_GROUP_NODE_TYPES = new Set([
CUSTOM_GROUP_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_EXIT_PORT_NODE,
])

View File

@ -1,54 +0,0 @@
'use client'
import type { FC } from 'react'
import type { CustomGroupExitPortNodeData } from './types'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import { cn } from '@/utils/classnames'
type CustomGroupExitPortNodeProps = {
id: string
data: CustomGroupExitPortNodeData
}
const CustomGroupExitPortNode: FC<CustomGroupExitPortNodeProps> = ({ id: _id, data }) => {
return (
<div
className={cn(
'flex items-center justify-center',
'h-8 w-8 rounded-full',
'bg-util-colors-green-green-500 shadow-md',
data.selected && 'ring-2 ring-primary-400',
)}
>
{/* Target handle - receives internal connections from leaf nodes */}
<Handle
id="target"
type="target"
position={Position.Left}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Source handle - connects to external nodes */}
<Handle
id="source"
type="source"
position={Position.Right}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Icon */}
<svg
className="h-4 w-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
)
}
export default memo(CustomGroupExitPortNode)

View File

@ -1,55 +0,0 @@
'use client'
import type { FC } from 'react'
import type { CustomGroupInputNodeData } from './types'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import { cn } from '@/utils/classnames'
type CustomGroupInputNodeProps = {
id: string
data: CustomGroupInputNodeData
}
const CustomGroupInputNode: FC<CustomGroupInputNodeProps> = ({ id: _id, data }) => {
return (
<div
className={cn(
'flex items-center justify-center',
'h-8 w-8 rounded-full',
'bg-util-colors-blue-blue-500 shadow-md',
data.selected && 'ring-2 ring-primary-400',
)}
>
{/* Target handle - receives external connections */}
<Handle
id="target"
type="target"
position={Position.Left}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Source handle - connects to entry nodes */}
<Handle
id="source"
type="source"
position={Position.Right}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Icon */}
<svg
className="h-4 w-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
</div>
)
}
export default memo(CustomGroupInputNode)

View File

@ -1,94 +0,0 @@
'use client'
import type { FC } from 'react'
import type { CustomGroupNodeData } from './types'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import { Plus02 } from '@/app/components/base/icons/src/vender/line/general'
import { cn } from '@/utils/classnames'
type CustomGroupNodeProps = {
id: string
data: CustomGroupNodeData
}
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ data }) => {
const { group } = data
const exitPorts = group.exitPorts ?? []
const connectedSourceHandleIds = data._connectedSourceHandleIds ?? []
return (
<div
className={cn(
'bg-workflow-block-parma-bg/50 group relative rounded-2xl border-2 border-dashed border-components-panel-border',
data.selected && 'border-primary-400',
)}
style={{
width: data.width || 280,
height: data.height || 200,
}}
>
{/* Group Header */}
<div className="absolute -top-7 left-0 flex items-center gap-1 px-2">
<span className="text-xs font-medium text-text-tertiary">
{group.title}
</span>
</div>
{/* Target handle for incoming connections */}
<Handle
id="target"
type="target"
position={Position.Left}
className={cn(
'!h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
)}
style={{ top: '50%' }}
/>
<div className="px-3 pt-3">
{exitPorts.map((port, index) => {
const connected = connectedSourceHandleIds.includes(port.portNodeId)
return (
<div key={port.portNodeId} className="relative flex h-6 items-center px-1">
<div className="w-full text-right text-xs font-semibold text-text-secondary">
{port.name}
</div>
<Handle
id={port.portNodeId}
type="source"
position={Position.Right}
className={cn(
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
!connected && 'after:opacity-0',
'!-right-[21px] !top-1/2 !-translate-y-1/2',
)}
isConnectable
/>
{/* Visual "+" indicator (styling aligned with existing branch handles) */}
<div
className={cn(
'pointer-events-none absolute z-10 hidden h-4 w-4 items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface',
'-right-[21px] top-1/2 -translate-y-1/2',
'group-hover:flex',
data.selected && '!flex',
)}
>
<Plus02 className="h-2.5 w-2.5" />
</div>
</div>
)
})}
</div>
</div>
)
}
export default memo(CustomGroupNode)

View File

@ -1,19 +0,0 @@
export {
CUSTOM_GROUP_EXIT_PORT_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_NODE,
GROUP_CHILDREN_Z_INDEX,
UI_ONLY_GROUP_NODE_TYPES,
} from './constants'
export { default as CustomGroupExitPortNode } from './custom-group-exit-port-node'
export { default as CustomGroupInputNode } from './custom-group-input-node'
export { default as CustomGroupNode } from './custom-group-node'
export type {
CustomGroupExitPortNodeData,
CustomGroupInputNodeData,
CustomGroupNodeData,
ExitPortInfo,
GroupMember,
} from './types'

View File

@ -1,82 +0,0 @@
import type { BlockEnum } from '../types'
/**
* Exit port info stored in Group node
*/
export type ExitPortInfo = {
portNodeId: string
leafNodeId: string
sourceHandle: string
name: string
}
/**
* Group node data structure
* node.type = 'custom-group'
* node.data.type = '' (empty string to bypass backend NodeType validation)
*/
export type CustomGroupNodeData = {
type: '' // Empty string bypasses backend NodeType validation
title: string
desc?: string
_connectedSourceHandleIds?: string[]
_connectedTargetHandleIds?: string[]
group: {
groupId: string
title: string
memberNodeIds: string[]
entryNodeIds: string[]
inputNodeId: string
exitPorts: ExitPortInfo[]
collapsed: boolean
}
width?: number
height?: number
selected?: boolean
_isTempNode?: boolean
}
/**
* Group Input node data structure
* node.type = 'custom-group-input'
* node.data.type = ''
*/
export type CustomGroupInputNodeData = {
type: ''
title: string
desc?: string
groupInput: {
groupId: string
title: string
}
selected?: boolean
_isTempNode?: boolean
}
/**
* Exit Port node data structure
* node.type = 'custom-group-exit-port'
* node.data.type = ''
*/
export type CustomGroupExitPortNodeData = {
type: ''
title: string
desc?: string
exitPort: {
groupId: string
leafNodeId: string
sourceHandle: string
name: string
}
selected?: boolean
_isTempNode?: boolean
}
/**
* Member node info for display
*/
export type GroupMember = {
id: string
type: BlockEnum
label?: string
}

View File

@ -10,7 +10,6 @@ import { useCallback } from 'react'
import {
useStoreApi,
} from 'reactflow'
import { BlockEnum } from '../types'
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
@ -109,50 +108,6 @@ export const useEdgesInteractions = () => {
return
const currentEdge = edges[currentEdgeIndex]
const nodes = getNodes()
// collect edges to delete (including corresponding real edges for temp edges)
const edgesToDelete: Set<string> = new Set([currentEdge.id])
// if deleting a temp edge connected to a group, also delete the corresponding real hidden edge
if (currentEdge.data?._isTemp) {
const groupNode = nodes.find(n =>
n.data.type === BlockEnum.Group
&& (n.id === currentEdge.source || n.id === currentEdge.target),
)
if (groupNode) {
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
if (currentEdge.target === groupNode.id) {
// inbound temp edge: find real edge with same source, target is a head node
edges.forEach((edge) => {
if (edge.source === currentEdge.source
&& memberIds.has(edge.target)
&& edge.sourceHandle === currentEdge.sourceHandle) {
edgesToDelete.add(edge.id)
}
})
}
else if (currentEdge.source === groupNode.id) {
// outbound temp edge: sourceHandle format is "leafNodeId-originalHandle"
const sourceHandle = currentEdge.sourceHandle || ''
const lastDashIndex = sourceHandle.lastIndexOf('-')
if (lastDashIndex > 0) {
const leafNodeId = sourceHandle.substring(0, lastDashIndex)
const originalHandle = sourceHandle.substring(lastDashIndex + 1)
edges.forEach((edge) => {
if (edge.source === leafNodeId
&& edge.target === currentEdge.target
&& (edge.sourceHandle || 'source') === originalHandle) {
edgesToDelete.add(edge.id)
}
})
}
}
}
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
{ type: 'remove', edge: currentEdge },
@ -171,10 +126,7 @@ export const useEdgesInteractions = () => {
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
for (let i = draft.length - 1; i >= 0; i--) {
if (edgesToDelete.has(draft[i].id))
draft.splice(i, 1)
}
draft.splice(currentEdgeIndex, 1)
})
setEdges(newEdges)
handleSyncWorkflowDraft()

View File

@ -1,138 +0,0 @@
import type { PredecessorHandle } from '../utils'
import { useMemo } from 'react'
import { useStore as useReactFlowStore } from 'reactflow'
import { shallow } from 'zustand/shallow'
import { BlockEnum } from '../types'
import { getCommonPredecessorHandles } from '../utils'
export type MakeGroupAvailability = {
canMakeGroup: boolean
branchEntryNodeIds: string[]
commonPredecessorHandle?: PredecessorHandle
}
type MinimalEdge = {
id: string
source: string
sourceHandle: string
target: string
}
/**
* Pure function to check if the selected nodes can be grouped.
* Can be called both from React hooks and imperatively.
*/
export const checkMakeGroupAvailability = (
selectedNodeIds: string[],
edges: MinimalEdge[],
hasGroupNode = false,
): MakeGroupAvailability => {
if (selectedNodeIds.length <= 1 || hasGroupNode) {
return {
canMakeGroup: false,
branchEntryNodeIds: [],
commonPredecessorHandle: undefined,
}
}
const selectedNodeIdSet = new Set(selectedNodeIds)
const inboundFromOutsideTargets = new Set<string>()
const incomingEdgeCounts = new Map<string, number>()
const incomingFromSelectedTargets = new Set<string>()
edges.forEach((edge) => {
// Only consider edges whose target is inside the selected subgraph.
if (!selectedNodeIdSet.has(edge.target))
return
incomingEdgeCounts.set(edge.target, (incomingEdgeCounts.get(edge.target) ?? 0) + 1)
if (selectedNodeIdSet.has(edge.source))
incomingFromSelectedTargets.add(edge.target)
else
inboundFromOutsideTargets.add(edge.target)
})
// Branch head (entry) definition:
// - has at least one incoming edge
// - and all its incoming edges come from outside the selected subgraph
const branchEntryNodeIds = selectedNodeIds.filter((nodeId) => {
const incomingEdgeCount = incomingEdgeCounts.get(nodeId) ?? 0
if (incomingEdgeCount === 0)
return false
return !incomingFromSelectedTargets.has(nodeId)
})
// No branch head means we cannot tell how many branches are represented by this selection.
if (branchEntryNodeIds.length === 0) {
return {
canMakeGroup: false,
branchEntryNodeIds,
commonPredecessorHandle: undefined,
}
}
// Guardrail: disallow side entrances into the selected subgraph.
// If an outside node connects to a non-entry node inside the selection, the grouping boundary is ambiguous.
const branchEntryNodeIdSet = new Set(branchEntryNodeIds)
const hasInboundToNonEntryNode = Array.from(inboundFromOutsideTargets).some(nodeId => !branchEntryNodeIdSet.has(nodeId))
if (hasInboundToNonEntryNode) {
return {
canMakeGroup: false,
branchEntryNodeIds,
commonPredecessorHandle: undefined,
}
}
// Compare the branch heads by their common predecessor "handler" (source node + sourceHandle).
// This is required for multi-handle nodes like If-Else / Classifier where different branches use different handles.
const commonPredecessorHandles = getCommonPredecessorHandles(
branchEntryNodeIds,
// Only look at edges coming from outside the selected subgraph when determining the "pre" handler.
edges.filter(edge => !selectedNodeIdSet.has(edge.source)),
)
if (commonPredecessorHandles.length !== 1) {
return {
canMakeGroup: false,
branchEntryNodeIds,
commonPredecessorHandle: undefined,
}
}
return {
canMakeGroup: true,
branchEntryNodeIds,
commonPredecessorHandle: commonPredecessorHandles[0],
}
}
export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAvailability => {
const edgeKeys = useReactFlowStore((state) => {
const delimiter = '\u0000'
const keys = state.edges.map(edge => `${edge.source}${delimiter}${edge.sourceHandle || 'source'}${delimiter}${edge.target}`)
keys.sort()
return keys
}, shallow)
const hasGroupNode = useReactFlowStore((state) => {
return state.getNodes().some(node => node.selected && node.data.type === BlockEnum.Group)
})
return useMemo(() => {
const delimiter = '\u0000'
const edges = edgeKeys.map((key) => {
const [source, handleId, target] = key.split(delimiter)
return {
id: key,
source,
sourceHandle: handleId || 'source',
target,
}
})
return checkMakeGroupAvailability(selectedNodeIds, edges, hasGroupNode)
}, [edgeKeys, selectedNodeIds, hasGroupNode])
}

View File

@ -8,7 +8,6 @@ import type {
ResizeParamsWithDirection,
} from 'reactflow'
import type { PluginDefaultValue } from '../block-selector/types'
import type { GroupHandler, GroupMember, GroupNodeData } from '../nodes/group/types'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
@ -53,7 +52,6 @@ import { useWorkflowHistoryStore } from '../workflow-history-store'
import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url'
import { useHelpline } from './use-helpline'
import useInspectVarsCrud from './use-inspect-vars-crud'
import { checkMakeGroupAvailability } from './use-make-group'
import { useNodesMetaData } from './use-nodes-meta-data'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import {
@ -75,151 +73,6 @@ const ENTRY_NODE_WRAPPER_OFFSET = {
y: 21, // Adjusted based on visual testing feedback
} as const
/**
* Parse group handler id to get original node id and sourceHandle
* Handler id format: `${nodeId}-${sourceHandle}`
*/
function parseGroupHandlerId(handlerId: string): { originalNodeId: string, originalSourceHandle: string } {
const lastDashIndex = handlerId.lastIndexOf('-')
return {
originalNodeId: handlerId.substring(0, lastDashIndex),
originalSourceHandle: handlerId.substring(lastDashIndex + 1),
}
}
/**
* Create a pair of edges for group node connections:
* - realEdge: hidden edge from original node to target (persisted to backend)
* - uiEdge: visible temp edge from group to target (UI-only, not persisted)
*/
function createGroupEdgePair(params: {
groupNodeId: string
handlerId: string
targetNodeId: string
targetHandle: string
nodes: Node[]
baseEdgeData?: Partial<Edge['data']>
zIndex?: number
}): { realEdge: Edge, uiEdge: Edge } | null {
const { groupNodeId, handlerId, targetNodeId, targetHandle, nodes, baseEdgeData = {}, zIndex = 0 } = params
const groupNode = nodes.find(node => node.id === groupNodeId)
const groupData = groupNode?.data as GroupNodeData | undefined
const handler = groupData?.handlers?.find(h => h.id === handlerId)
let originalNodeId: string
let originalSourceHandle: string
if (handler?.nodeId && handler?.sourceHandle) {
originalNodeId = handler.nodeId
originalSourceHandle = handler.sourceHandle
}
else {
const parsed = parseGroupHandlerId(handlerId)
originalNodeId = parsed.originalNodeId
originalSourceHandle = parsed.originalSourceHandle
}
const originalNode = nodes.find(node => node.id === originalNodeId)
const targetNode = nodes.find(node => node.id === targetNodeId)
if (!originalNode || !targetNode)
return null
// Create the real edge (from original node to target) - hidden because original node is in group
const realEdge: Edge = {
id: `${originalNodeId}-${originalSourceHandle}-${targetNodeId}-${targetHandle}`,
type: CUSTOM_EDGE,
source: originalNodeId,
sourceHandle: originalSourceHandle,
target: targetNodeId,
targetHandle,
hidden: true,
data: {
...baseEdgeData,
sourceType: originalNode.data.type,
targetType: targetNode.data.type,
_hiddenInGroupId: groupNodeId,
},
zIndex,
}
// Create the UI edge (from group to target) - temporary, not persisted to backend
const uiEdge: Edge = {
id: `${groupNodeId}-${handlerId}-${targetNodeId}-${targetHandle}`,
type: CUSTOM_EDGE,
source: groupNodeId,
sourceHandle: handlerId,
target: targetNodeId,
targetHandle,
data: {
...baseEdgeData,
sourceType: BlockEnum.Group,
targetType: targetNode.data.type,
_isTemp: true,
},
zIndex,
}
return { realEdge, uiEdge }
}
function createGroupInboundEdges(params: {
sourceNodeId: string
sourceHandle: string
groupNodeId: string
groupData: GroupNodeData
nodes: Node[]
baseEdgeData?: Partial<Edge['data']>
zIndex?: number
}): { realEdges: Edge[], uiEdge: Edge } | null {
const { sourceNodeId, sourceHandle, groupNodeId, groupData, nodes, baseEdgeData = {}, zIndex = 0 } = params
const sourceNode = nodes.find(node => node.id === sourceNodeId)
const headNodeIds = groupData.headNodeIds || []
if (!sourceNode || headNodeIds.length === 0)
return null
const realEdges: Edge[] = headNodeIds.map((headNodeId) => {
const headNode = nodes.find(node => node.id === headNodeId)
return {
id: `${sourceNodeId}-${sourceHandle}-${headNodeId}-target`,
type: CUSTOM_EDGE,
source: sourceNodeId,
sourceHandle,
target: headNodeId,
targetHandle: 'target',
hidden: true,
data: {
...baseEdgeData,
sourceType: sourceNode.data.type,
targetType: headNode?.data.type,
_hiddenInGroupId: groupNodeId,
},
zIndex,
} as Edge
})
const uiEdge: Edge = {
id: `${sourceNodeId}-${sourceHandle}-${groupNodeId}-target`,
type: CUSTOM_EDGE,
source: sourceNodeId,
sourceHandle,
target: groupNodeId,
targetHandle: 'target',
data: {
...baseEdgeData,
sourceType: sourceNode.data.type,
targetType: BlockEnum.Group,
_isTemp: true,
},
zIndex,
}
return { realEdges, uiEdge }
}
export const useNodesInteractions = () => {
const { t } = useTranslation()
const store = useStoreApi()
@ -595,146 +448,6 @@ export const useNodesInteractions = () => {
return
}
// Check if source is a group node - need special handling
const isSourceGroup = sourceNode?.data.type === BlockEnum.Group
if (isSourceGroup && sourceHandle && target && targetHandle) {
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(sourceHandle)
// Check if real edge already exists
if (edges.find(edge =>
edge.source === originalNodeId
&& edge.sourceHandle === originalSourceHandle
&& edge.target === target
&& edge.targetHandle === targetHandle,
)) {
return
}
const parentNode = nodes.find(node => node.id === targetNode?.parentId)
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
const edgePair = createGroupEdgePair({
groupNodeId: source!,
handlerId: sourceHandle,
targetNodeId: target,
targetHandle,
nodes,
baseEdgeData: {
isInIteration,
iteration_id: isInIteration ? targetNode?.parentId : undefined,
isInLoop,
loop_id: isInLoop ? targetNode?.parentId : undefined,
},
})
if (!edgePair)
return
const { realEdge, uiEdge } = edgePair
// Update connected handle ids for the original node
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge: realEdge }],
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
const newEdges = produce(edges, (draft) => {
draft.push(realEdge)
draft.push(uiEdge)
})
setNodes(newNodes)
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
nodeId: targetNode?.id,
})
return
}
const isTargetGroup = targetNode?.data.type === BlockEnum.Group
if (isTargetGroup && source && sourceHandle) {
const groupData = targetNode.data as GroupNodeData
const headNodeIds = groupData.headNodeIds || []
if (edges.find(edge =>
edge.source === source
&& edge.sourceHandle === sourceHandle
&& edge.target === target
&& edge.targetHandle === targetHandle,
)) {
return
}
const parentNode = nodes.find(node => node.id === sourceNode?.parentId)
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
const inboundResult = createGroupInboundEdges({
sourceNodeId: source,
sourceHandle,
groupNodeId: target!,
groupData,
nodes,
baseEdgeData: {
isInIteration,
iteration_id: isInIteration ? sourceNode?.parentId : undefined,
isInLoop,
loop_id: isInLoop ? sourceNode?.parentId : undefined,
},
})
if (!inboundResult)
return
const { realEdges, uiEdge } = inboundResult
const edgeChanges = realEdges.map(edge => ({ type: 'add' as const, edge }))
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
const newEdges = produce(edges, (draft) => {
realEdges.forEach((edge) => {
draft.push(edge)
})
draft.push(uiEdge)
})
setNodes(newNodes)
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
nodeId: headNodeIds[0],
})
return
}
if (
edges.find(
edge =>
@ -1196,34 +909,8 @@ export const useNodesInteractions = () => {
}
}
// Check if prevNode is a group node - need special handling
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
let newEdge: Edge | null = null
let newUiEdge: Edge | null = null
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
const edgePair = createGroupEdgePair({
groupNodeId: prevNodeId,
handlerId: prevNodeSourceHandle,
targetNodeId: newNode.id,
targetHandle,
nodes: [...nodes, newNode],
baseEdgeData: {
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
})
if (edgePair) {
newEdge = edgePair.realEdge
newUiEdge = edgePair.uiEdge
}
}
else if (nodeType !== BlockEnum.DataSource) {
// Normal case: prevNode is not a group
let newEdge = null
if (nodeType !== BlockEnum.DataSource) {
newEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
@ -1248,10 +935,9 @@ export const useNodesInteractions = () => {
}
}
const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge: edge! }))
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
edgesToAdd,
(newEdge ? [{ type: 'add', edge: newEdge }] : []),
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
@ -1320,8 +1006,6 @@ export const useNodesInteractions = () => {
})
if (newEdge)
draft.push(newEdge)
if (newUiEdge)
draft.push(newUiEdge)
})
setNodes(newNodes)
@ -1406,7 +1090,7 @@ export const useNodesInteractions = () => {
const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!)
const afterNodesInSameBranchIds = afterNodesInSameBranch.map(
(node: Node) => node.id,
node => node.id,
)
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
@ -1516,113 +1200,37 @@ export const useNodesInteractions = () => {
}
}
// Check if prevNode is a group node - need special handling
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
let newPrevEdge: Edge | null = null
let newPrevUiEdge: Edge | null = null
const edgesToRemove: string[] = []
const currentEdgeIndex = edges.findIndex(
edge => edge.source === prevNodeId && edge.target === nextNodeId,
)
let newPrevEdge = null
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(prevNodeSourceHandle)
// Find edges to remove: both hidden real edge and UI temp edge from group to nextNode
const hiddenEdge = edges.find(
edge => edge.source === originalNodeId
&& edge.sourceHandle === originalSourceHandle
&& edge.target === nextNodeId,
)
const uiTempEdge = edges.find(
edge => edge.source === prevNodeId
&& edge.sourceHandle === prevNodeSourceHandle
&& edge.target === nextNodeId,
)
if (hiddenEdge)
edgesToRemove.push(hiddenEdge.id)
if (uiTempEdge)
edgesToRemove.push(uiTempEdge.id)
const edgePair = createGroupEdgePair({
groupNodeId: prevNodeId,
handlerId: prevNodeSourceHandle,
targetNodeId: newNode.id,
if (nodeType !== BlockEnum.DataSource) {
newPrevEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
source: prevNodeId,
sourceHandle: prevNodeSourceHandle,
target: newNode.id,
targetHandle,
nodes: [...nodes, newNode],
baseEdgeData: {
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
})
if (edgePair) {
newPrevEdge = edgePair.realEdge
newPrevUiEdge = edgePair.uiEdge
}
}
else {
const isNextNodeGroupForRemoval = nextNode.data.type === BlockEnum.Group
if (isNextNodeGroupForRemoval) {
const groupData = nextNode.data as GroupNodeData
const headNodeIds = groupData.headNodeIds || []
headNodeIds.forEach((headNodeId) => {
const realEdge = edges.find(
edge => edge.source === prevNodeId
&& edge.sourceHandle === prevNodeSourceHandle
&& edge.target === headNodeId,
)
if (realEdge)
edgesToRemove.push(realEdge.id)
})
const uiEdge = edges.find(
edge => edge.source === prevNodeId
&& edge.sourceHandle === prevNodeSourceHandle
&& edge.target === nextNodeId,
)
if (uiEdge)
edgesToRemove.push(uiEdge.id)
}
else {
const currentEdge = edges.find(
edge => edge.source === prevNodeId && edge.target === nextNodeId,
)
if (currentEdge)
edgesToRemove.push(currentEdge.id)
}
if (nodeType !== BlockEnum.DataSource) {
newPrevEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
source: prevNodeId,
sourceHandle: prevNodeSourceHandle,
target: newNode.id,
targetHandle,
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: prevNode.parentId
? isInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
zIndex: prevNode.parentId
? isInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
}
let newNextEdge: Edge | null = null
let newNextUiEdge: Edge | null = null
const newNextRealEdges: Edge[] = []
const nextNodeParentNode
= nodes.find(node => node.id === nextNode.parentId) || null
@ -1633,113 +1241,49 @@ export const useNodesInteractions = () => {
= !!nextNodeParentNode
&& nextNodeParentNode.data.type === BlockEnum.Loop
const isNextNodeGroup = nextNode.data.type === BlockEnum.Group
if (
nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier
&& nodeType !== BlockEnum.LoopEnd
) {
if (isNextNodeGroup) {
const groupData = nextNode.data as GroupNodeData
const headNodeIds = groupData.headNodeIds || []
headNodeIds.forEach((headNodeId) => {
const headNode = nodes.find(node => node.id === headNodeId)
newNextRealEdges.push({
id: `${newNode.id}-${sourceHandle}-${headNodeId}-target`,
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: headNodeId,
targetHandle: 'target',
hidden: true,
data: {
sourceType: newNode.data.type,
targetType: headNode?.data.type,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_hiddenInGroupId: nextNodeId,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId
? isNextNodeInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
} as Edge)
})
newNextUiEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-target`,
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: nextNodeId,
targetHandle: 'target',
data: {
sourceType: newNode.data.type,
targetType: BlockEnum.Group,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_isTemp: true,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId
? isNextNodeInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
}
else {
newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: nextNodeId,
targetHandle: nextNodeTargetHandle,
data: {
sourceType: newNode.data.type,
targetType: nextNode.data.type,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration
? nextNode.parentId
: undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId
? isNextNodeInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: nextNodeId,
targetHandle: nextNodeTargetHandle,
data: {
sourceType: newNode.data.type,
targetType: nextNode.data.type,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration
? nextNode.parentId
: undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId
? isNextNodeInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
}
const edgeChanges = [
...edgesToRemove.map(id => ({ type: 'remove' as const, edge: edges.find(e => e.id === id)! })).filter(c => c.edge),
...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []),
...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []),
...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []),
...newNextRealEdges.map(edge => ({ type: 'add' as const, edge })),
...(newNextUiEdge ? [{ type: 'add' as const, edge: newNextUiEdge }] : []),
]
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
edgeChanges,
[
{ type: 'remove', edge: edges[currentEdgeIndex] },
...(newPrevEdge ? [{ type: 'add', edge: newPrevEdge }] : []),
...(newNextEdge ? [{ type: 'add', edge: newNextEdge }] : []),
],
[...nodes, newNode],
)
const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!)
const afterNodesInSameBranchIds = afterNodesInSameBranch.map(
(node: Node) => node.id,
node => node.id,
)
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
@ -1798,10 +1342,7 @@ export const useNodesInteractions = () => {
})
}
const newEdges = produce(edges, (draft) => {
const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id))
draft.length = 0
draft.push(...filteredDraft)
draft.splice(currentEdgeIndex, 1)
draft.forEach((item) => {
item.data = {
...item.data,
@ -1810,15 +1351,9 @@ export const useNodesInteractions = () => {
})
if (newPrevEdge)
draft.push(newPrevEdge)
if (newPrevUiEdge)
draft.push(newPrevUiEdge)
if (newNextEdge)
draft.push(newNextEdge)
newNextRealEdges.forEach((edge) => {
draft.push(edge)
})
if (newNextUiEdge)
draft.push(newNextUiEdge)
})
setEdges(newEdges)
}
@ -2543,302 +2078,6 @@ export const useNodesInteractions = () => {
setEdges(newEdges)
}, [store])
// Check if there are any nodes selected via box selection
const hasBundledNodes = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
return nodes.some(node => node.data._isBundled)
}, [store])
const getCanMakeGroup = useCallback(() => {
const { getNodes, edges } = store.getState()
const nodes = getNodes()
const bundledNodes = nodes.filter(node => node.data._isBundled)
if (bundledNodes.length <= 1)
return false
const bundledNodeIds = bundledNodes.map(node => node.id)
const minimalEdges = edges.map(edge => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle || 'source',
target: edge.target,
}))
const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group)
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
return canMakeGroup
}, [store])
const handleMakeGroup = useCallback(() => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const bundledNodes = nodes.filter(node => node.data._isBundled)
if (bundledNodes.length <= 1)
return
const bundledNodeIds = bundledNodes.map(node => node.id)
const minimalEdges = edges.map(edge => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle || 'source',
target: edge.target,
}))
const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group)
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
if (!canMakeGroup)
return
const bundledNodeIdSet = new Set(bundledNodeIds)
const bundledNodeIdIsLeaf = new Set<string>()
const inboundEdges = edges.filter(edge => !bundledNodeIdSet.has(edge.source) && bundledNodeIdSet.has(edge.target))
const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target))
// leaf node: no outbound edges to other nodes in the selection
const handlers: GroupHandler[] = []
const leafNodeIdSet = new Set<string>()
bundledNodes.forEach((node: Node) => {
const targetBranches = node.data._targetBranches || [{ id: 'source', name: node.data.title }]
targetBranches.forEach((branch) => {
// A branch should be a handler if it's either:
// 1. Connected to a node OUTSIDE the group
// 2. NOT connected to any node INSIDE the group
const isConnectedInside = edges.some(edge =>
edge.source === node.id
&& (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source'))
&& bundledNodeIdSet.has(edge.target),
)
const isConnectedOutside = edges.some(edge =>
edge.source === node.id
&& (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source'))
&& !bundledNodeIdSet.has(edge.target),
)
if (isConnectedOutside || !isConnectedInside) {
const handlerId = `${node.id}-${branch.id}`
handlers.push({
id: handlerId,
label: branch.name || node.data.title || node.id,
nodeId: node.id,
sourceHandle: branch.id,
})
leafNodeIdSet.add(node.id)
}
})
})
const leafNodeIds = Array.from(leafNodeIdSet)
leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id))
const members: GroupMember[] = bundledNodes.map((node) => {
return {
id: node.id,
type: node.data.type,
label: node.data.title,
}
})
// head nodes: nodes that receive input from outside the group
const headNodeIds = [...new Set(inboundEdges.map(edge => edge.target))]
// put the group node at the top-left corner of the selection, slightly offset
const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes)
const groupNodeData: GroupNodeData = {
title: t('operator.makeGroup', { ns: 'workflow' }),
desc: '',
type: BlockEnum.Group,
members,
handlers,
headNodeIds,
leafNodeIds,
selected: true,
_targetBranches: handlers.map(handler => ({
id: handler.id,
name: handler.label || handler.id,
})),
}
const { newNode: groupNode } = generateNewNode({
data: groupNodeData,
position: {
x: minX - 20,
y: minY - 20,
},
})
const nodeTypeMap = new Map(nodes.map(node => [node.id, node.data.type]))
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (bundledNodeIdSet.has(node.id)) {
node.data._isBundled = false
node.selected = false
node.hidden = true
node.data._hiddenInGroupId = groupNode.id
}
else {
node.data._isBundled = false
}
})
draft.push(groupNode)
})
const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => {
if (bundledNodeIdSet.has(edge.source) || bundledNodeIdSet.has(edge.target)) {
edge.hidden = true
edge.data = {
...edge.data,
_hiddenInGroupId: groupNode.id,
_isBundled: false,
}
}
else if (edge.data?._isBundled) {
edge.data._isBundled = false
}
})
// re-add the external inbound edges to the group node as UI-only edges (not persisted to backend)
inboundEdges.forEach((edge) => {
draft.push({
id: `${edge.id}__to-${groupNode.id}`,
type: edge.type || CUSTOM_EDGE,
source: edge.source,
target: groupNode.id,
sourceHandle: edge.sourceHandle,
targetHandle: 'target',
data: {
...edge.data,
sourceType: nodeTypeMap.get(edge.source)!,
targetType: BlockEnum.Group,
_hiddenInGroupId: undefined,
_isBundled: false,
_isTemp: true, // UI-only edge, not persisted to backend
},
zIndex: edge.zIndex,
})
})
// outbound edges of the group node as UI-only edges (not persisted to backend)
outboundEdges.forEach((edge) => {
if (!bundledNodeIdIsLeaf.has(edge.source))
return
// Use the same handler id format: nodeId-sourceHandle
const originalSourceHandle = edge.sourceHandle || 'source'
const handlerId = `${edge.source}-${originalSourceHandle}`
draft.push({
id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`,
type: edge.type || CUSTOM_EDGE,
source: groupNode.id,
target: edge.target,
sourceHandle: handlerId,
targetHandle: edge.targetHandle,
data: {
...edge.data,
sourceType: BlockEnum.Group,
targetType: nodeTypeMap.get(edge.target)!,
_hiddenInGroupId: undefined,
_isBundled: false,
_isTemp: true,
},
zIndex: edge.zIndex,
})
})
})
setNodes(newNodes)
setEdges(newEdges)
workflowStore.setState({
selectionMenu: undefined,
})
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, {
nodeId: groupNode.id,
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore])
// check if the current selection can be ungrouped (single selected Group node)
const getCanUngroup = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
const selectedNodes = nodes.filter(node => node.selected)
if (selectedNodes.length !== 1)
return false
return selectedNodes[0].data.type === BlockEnum.Group
}, [store])
// get the selected group node id for ungroup operation
const getSelectedGroupId = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
const selectedNodes = nodes.filter(node => node.selected)
if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group)
return selectedNodes[0].id
return undefined
}, [store])
const handleUngroup = useCallback((groupId: string) => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const groupNode = nodes.find(n => n.id === groupId)
if (!groupNode || groupNode.data.type !== BlockEnum.Group)
return
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
// restore hidden member nodes
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (memberIds.has(node.id)) {
node.hidden = false
delete node.data._hiddenInGroupId
}
})
// remove group node
const groupIndex = draft.findIndex(n => n.id === groupId)
if (groupIndex !== -1)
draft.splice(groupIndex, 1)
})
// restore hidden edges and remove temp edges in single pass O(E)
const newEdges = produce(edges, (draft) => {
const indicesToRemove: number[] = []
for (let i = 0; i < draft.length; i++) {
const edge = draft[i]
// restore hidden edges that involve member nodes
if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target)))
edge.hidden = false
// collect temp edges connected to group for removal
if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId))
indicesToRemove.push(i)
}
// remove collected indices in reverse order to avoid index shift
for (let i = indicesToRemove.length - 1; i >= 0; i--)
draft.splice(indicesToRemove[i], 1)
})
setNodes(newNodes)
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeDelete, {
nodeId: groupId,
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store])
return {
handleNodeDragStart,
handleNodeDrag,
@ -2859,17 +2098,11 @@ export const useNodesInteractions = () => {
handleNodesPaste,
handleNodesDuplicate,
handleNodesDelete,
handleMakeGroup,
handleUngroup,
handleNodeResize,
handleNodeDisconnect,
handleHistoryBack,
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
hasBundledNodes,
getCanMakeGroup,
getCanUngroup,
getSelectedGroupId,
}
}

View File

@ -1,10 +1,8 @@
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store'
import type { Node } from '@/app/components/workflow/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { CollectionType } from '@/app/components/tools/types'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import GroupDefault from '@/app/components/workflow/nodes/group/default'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { useGetLanguage } from '@/context/i18n'
@ -27,7 +25,6 @@ export const useNodesMetaData = () => {
}
export const useNodeMetaData = (node: Node) => {
const { t } = useTranslation()
const language = useGetLanguage()
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
@ -37,9 +34,6 @@ export const useNodeMetaData = (node: Node) => {
const { data } = node
const nodeMetaData = availableNodesMetaData.nodesMap?.[data.type]
const author = useMemo(() => {
if (data.type === BlockEnum.Group)
return GroupDefault.metaData.author
if (data.type === BlockEnum.DataSource)
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.author
@ -54,9 +48,6 @@ export const useNodeMetaData = (node: Node) => {
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList])
const description = useMemo(() => {
if (data.type === BlockEnum.Group)
return t('blocksAbout.group', { ns: 'workflow' })
if (data.type === BlockEnum.DataSource)
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.description[language]
if (data.type === BlockEnum.Tool) {
@ -67,7 +58,7 @@ export const useNodeMetaData = (node: Node) => {
return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
}
return nodeMetaData?.metaData.description
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language, t])
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language])
return useMemo(() => {
return {

View File

@ -27,12 +27,6 @@ export const useShortcuts = (): void => {
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
hasBundledNodes,
getCanMakeGroup,
handleMakeGroup,
getCanUngroup,
getSelectedGroupId,
handleUngroup,
} = useNodesInteractions()
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
@ -84,8 +78,7 @@ export const useShortcuts = (): void => {
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
const { showDebugAndPreviewPanel } = workflowStore.getState()
// Only intercept when nodes are selected via box selection
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel && hasBundledNodes()) {
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) {
e.preventDefault()
handleNodesCopy()
}
@ -106,26 +99,6 @@ export const useShortcuts = (): void => {
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => {
// Only intercept when the selection can be grouped
if (shouldHandleShortcut(e) && getCanMakeGroup()) {
e.preventDefault()
// Close selection context menu if open
workflowStore.setState({ selectionMenu: undefined })
handleMakeGroup()
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.g`, (e) => {
// Only intercept when the selection can be ungrouped
if (shouldHandleShortcut(e) && getCanUngroup()) {
e.preventDefault()
const groupId = getSelectedGroupId()
if (groupId)
handleUngroup(groupId)
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()

View File

@ -1,10 +1,10 @@
import type {
Connection,
} from 'reactflow'
import type { GroupNodeData } from '../nodes/group/types'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import type {
BlockEnum,
Edge,
Node,
ValueSelector,
@ -28,12 +28,14 @@ import {
} from '../constants'
import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import {
useStore,
useWorkflowStore,
} from '../store'
import { BlockEnum, WorkflowRunningStatus } from '../types'
import {
WorkflowRunningStatus,
} from '../types'
import {
getWorkflowEntryNode,
isWorkflowEntryNode,
@ -379,7 +381,7 @@ export const useWorkflow = () => {
return startNodes
}, [nodesMap, getRootNodesById])
const isValidConnection = useCallback(({ source, sourceHandle, target }: Connection) => {
const isValidConnection = useCallback(({ source, sourceHandle: _sourceHandle, target }: Connection) => {
const {
edges,
getNodes,
@ -394,42 +396,15 @@ export const useWorkflow = () => {
if (sourceNode.parentId !== targetNode.parentId)
return false
// For Group nodes, use the leaf node's type for validation
// sourceHandle format: "${leafNodeId}-${originalSourceHandle}"
let actualSourceType = sourceNode.data.type
if (sourceNode.data.type === BlockEnum.Group && sourceHandle) {
const lastDashIndex = sourceHandle.lastIndexOf('-')
if (lastDashIndex > 0) {
const leafNodeId = sourceHandle.substring(0, lastDashIndex)
const leafNode = nodes.find(node => node.id === leafNodeId)
if (leafNode)
actualSourceType = leafNode.data.type
}
}
if (sourceNode && targetNode) {
const sourceNodeAvailableNextNodes = getAvailableBlocks(actualSourceType, !!sourceNode.parentId).availableNextBlocks
const sourceNodeAvailableNextNodes = getAvailableBlocks(sourceNode.data.type, !!sourceNode.parentId).availableNextBlocks
const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks
if (targetNode.data.type === BlockEnum.Group) {
const groupData = targetNode.data as GroupNodeData
const headNodeIds = groupData.headNodeIds || []
if (headNodeIds.length > 0) {
const headNode = nodes.find(node => node.id === headNodeIds[0])
if (headNode) {
const headNodeAvailablePrevNodes = getAvailableBlocks(headNode.data.type, !!targetNode.parentId).availablePrevBlocks
if (!headNodeAvailablePrevNodes.includes(actualSourceType))
return false
}
}
}
else {
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
return false
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
return false
if (!targetNodeAvailablePrevNodes.includes(actualSourceType))
return false
}
if (!targetNodeAvailablePrevNodes.includes(sourceNode.data.type))
return false
}
const hasCycle = (node: Node, visited = new Set()) => {
@ -550,7 +525,6 @@ export const useIsNodeInLoop = (loopId: string) => {
return false
if (node.parentId === loopId)
return true
return false

View File

@ -54,14 +54,6 @@ import {
} from './constants'
import CustomConnectionLine from './custom-connection-line'
import CustomEdge from './custom-edge'
import {
CUSTOM_GROUP_EXIT_PORT_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_NODE,
CustomGroupExitPortNode,
CustomGroupInputNode,
CustomGroupNode,
} from './custom-group-node'
import DatasetsDetailProvider from './datasets-detail-store/provider'
import HelpLine from './help-line'
import {
@ -120,9 +112,6 @@ const nodeTypes = {
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
[CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
[CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode,
[CUSTOM_GROUP_NODE]: CustomGroupNode,
[CUSTOM_GROUP_INPUT_NODE]: CustomGroupInputNode,
[CUSTOM_GROUP_EXIT_PORT_NODE]: CustomGroupExitPortNode,
}
const edgeTypes = {
[CUSTOM_EDGE]: CustomEdge,

View File

@ -41,14 +41,13 @@ const PanelOperatorPopup = ({
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
handleUngroup,
} = useNodesInteractions()
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { nodesReadOnly } = useNodesReadOnly()
const edge = edges.find(edge => edge.target === id)
const nodeMetaData = useNodeMetaData({ id, data } as Node)
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly && data.type !== BlockEnum.Group
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly
const isChildNode = !!(data.isInIteration || data.isInLoop)
const { data: workflowTools } = useAllWorkflowTools()
@ -62,25 +61,6 @@ const PanelOperatorPopup = ({
return (
<div className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
{
!nodesReadOnly && data.type === BlockEnum.Group && (
<>
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onClosePopup()
handleUngroup(id)
}}
>
{t('panel.ungroup', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'shift', 'g']} />
</div>
</div>
<div className="h-px bg-divider-regular"></div>
</>
)
}
{
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
<>

View File

@ -594,7 +594,7 @@ const BasePanel: FC<BasePanelProps> = ({
)
}
{
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && data.type !== BlockEnum.Group && (
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
<div className="flex items-center justify-between pl-4 pr-3">
<Tab
value={tabType}
@ -603,9 +603,9 @@ const BasePanel: FC<BasePanelProps> = ({
</div>
)
}
{data.type !== BlockEnum.Group && <Split />}
<Split />
</div>
{(tabType === TabType.settings || data.type === BlockEnum.Group) && (
{tabType === TabType.settings && (
<div className="flex flex-1 flex-col overflow-y-auto">
<div>
{cloneElement(children as any, {

View File

@ -56,7 +56,6 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = {
[BlockEnum.VariableAggregator]: useVariableAggregatorSingleRunFormParams,
[BlockEnum.Assigner]: useVariableAssignerSingleRunFormParams,
[BlockEnum.KnowledgeBase]: useKnowledgeBaseSingleRunFormParams,
[BlockEnum.Group]: undefined,
[BlockEnum.VariableAssigner]: undefined,
[BlockEnum.End]: undefined,
[BlockEnum.Answer]: undefined,
@ -104,7 +103,6 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = {
[BlockEnum.DataSource]: undefined,
[BlockEnum.DataSourceEmpty]: undefined,
[BlockEnum.KnowledgeBase]: undefined,
[BlockEnum.Group]: undefined,
[BlockEnum.TriggerWebhook]: undefined,
[BlockEnum.TriggerSchedule]: undefined,
[BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore,

View File

@ -221,7 +221,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
}
{
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.Group && !data._isCandidate && (
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
<NodeSourceHandle
id={id}
data={data}

View File

@ -14,8 +14,6 @@ import DocExtractorNode from './document-extractor/node'
import DocExtractorPanel from './document-extractor/panel'
import EndNode from './end/node'
import EndPanel from './end/panel'
import GroupNode from './group/node'
import GroupPanel from './group/panel'
import HttpNode from './http/node'
import HttpPanel from './http/panel'
import IfElseNode from './if-else/node'
@ -77,7 +75,6 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
[BlockEnum.Group]: GroupNode,
}
export const PanelComponentMap: Record<string, ComponentType<any>> = {
@ -106,5 +103,4 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
[BlockEnum.Group]: GroupPanel,
}

View File

@ -1,26 +0,0 @@
import type { NodeDefault } from '../../types'
import type { GroupNodeData } from './types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
const metaData = genNodeMetaData({
sort: 100,
type: BlockEnum.Group,
})
const nodeDefault: NodeDefault<GroupNodeData> = {
metaData,
defaultValue: {
members: [],
handlers: [],
headNodeIds: [],
leafNodeIds: [],
},
checkValid() {
return {
isValid: true,
}
},
}
export default nodeDefault

View File

@ -1,94 +0,0 @@
import type { GroupHandler, GroupMember, GroupNodeData } from './types'
import type { BlockEnum, NodeProps } from '@/app/components/workflow/types'
import { RiArrowRightSLine } from '@remixicon/react'
import { memo, useMemo } from 'react'
import BlockIcon from '@/app/components/workflow/block-icon'
import { cn } from '@/utils/classnames'
import { NodeSourceHandle } from '../_base/components/node-handle'
const MAX_MEMBER_ICONS = 12
const GroupNode = (props: NodeProps<GroupNodeData>) => {
const { data } = props
// show the explicitly passed members first; otherwise use the _children information to fill the type
const members: GroupMember[] = useMemo(() => (
data.members?.length
? data.members
: data._children?.length
? data._children.map(child => ({
id: child.nodeId,
type: child.nodeType as BlockEnum,
label: child.nodeType,
}))
: []
), [data._children, data.members])
const handlers: GroupHandler[] = useMemo(() => (
data.handlers?.length
? data.handlers
: members.length
? members.map(member => ({
id: `${member.id}-source`,
label: member.label || member.id,
nodeId: member.id,
sourceHandle: 'source',
}))
: []
), [data.handlers, members])
return (
<div className="space-y-2 px-3 pb-3">
{members.length > 0 && (
<div className="flex items-center gap-1 overflow-hidden">
<div className="flex flex-wrap items-center gap-1 overflow-hidden">
{members.slice(0, MAX_MEMBER_ICONS).map(member => (
<div
key={member.id}
className="flex h-7 items-center rounded-full bg-components-input-bg-normal px-1.5 shadow-xs"
>
<BlockIcon
type={member.type}
size="xs"
className="!shadow-none"
/>
</div>
))}
{members.length > MAX_MEMBER_ICONS && (
<div className="system-xs-medium rounded-full bg-components-input-bg-normal px-2 py-1 text-text-tertiary">
+
{members.length - MAX_MEMBER_ICONS}
</div>
)}
</div>
<RiArrowRightSLine className="ml-auto h-4 w-4 shrink-0 text-text-tertiary" />
</div>
)}
{handlers.length > 0 && (
<div className="space-y-1">
{handlers.map(handler => (
<div
key={handler.id}
className={cn(
'relative',
'system-sm-semibold uppercase',
'flex h-9 items-center rounded-md bg-components-panel-on-panel-item-bg px-3 text-text-primary shadow-xs',
)}
>
{handler.label || handler.id}
<NodeSourceHandle
{...props}
handleId={handler.id}
handleClassName="!top-1/2 !-translate-y-1/2 !-right-[21px]"
/>
</div>
))}
</div>
)}
</div>
)
}
GroupNode.displayName = 'GroupNode'
export default memo(GroupNode)

View File

@ -1,9 +0,0 @@
import { memo } from 'react'
const GroupPanel = () => {
return null
}
GroupPanel.displayName = 'GroupPanel'
export default memo(GroupPanel)

View File

@ -1,21 +0,0 @@
import type { BlockEnum, CommonNodeType } from '../../types'
export type GroupMember = {
id: string
type: BlockEnum
label?: string
}
export type GroupHandler = {
id: string
label?: string
nodeId?: string // leaf node id for multi-branch nodes
sourceHandle?: string // original sourceHandle (e.g., case_id for if-else)
}
export type GroupNodeData = CommonNodeType<{
members?: GroupMember[]
handlers?: GroupHandler[]
headNodeIds?: string[] // nodes that receive input from outside the group
leafNodeIds?: string[] // nodes that send output to outside the group
}>

View File

@ -1,5 +1,3 @@
import type { FC, ReactElement } from 'react'
import type { I18nKeysByPrefix } from '@/types/i18n'
import {
RiAlignBottom,
RiAlignCenter,
@ -19,13 +17,9 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import { shallow } from 'zustand/shallow'
import Tooltip from '@/app/components/base/tooltip'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useMakeGroupAvailability } from './hooks/use-make-group'
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import ShortcutsName from './shortcuts-name'
import { useStore, useWorkflowStore } from './store'
enum AlignType {
@ -39,67 +33,21 @@ enum AlignType {
DistributeVertical = 'distributeVertical',
}
type AlignButtonConfig = {
type: AlignType
icon: ReactElement
labelKey: I18nKeysByPrefix<'workflow', 'operator.'>
}
type AlignButtonProps = {
config: AlignButtonConfig
label: string
onClick: (type: AlignType) => void
position?: 'top' | 'bottom' | 'left' | 'right'
}
const AlignButton: FC<AlignButtonProps> = ({ config, label, onClick, position = 'bottom' }) => {
return (
<Tooltip position={position} popupContent={label}>
<div
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-text-secondary hover:bg-state-base-hover"
onClick={() => onClick(config.type)}
>
{config.icon}
</div>
</Tooltip>
)
}
const ALIGN_BUTTONS: AlignButtonConfig[] = [
{ type: AlignType.Left, icon: <RiAlignLeft className="h-4 w-4" />, labelKey: 'alignLeft' },
{ type: AlignType.Center, icon: <RiAlignCenter className="h-4 w-4" />, labelKey: 'alignCenter' },
{ type: AlignType.Right, icon: <RiAlignRight className="h-4 w-4" />, labelKey: 'alignRight' },
{ type: AlignType.DistributeHorizontal, icon: <RiAlignJustify className="h-4 w-4" />, labelKey: 'distributeHorizontal' },
{ type: AlignType.Top, icon: <RiAlignTop className="h-4 w-4" />, labelKey: 'alignTop' },
{ type: AlignType.Middle, icon: <RiAlignCenter className="h-4 w-4 rotate-90" />, labelKey: 'alignMiddle' },
{ type: AlignType.Bottom, icon: <RiAlignBottom className="h-4 w-4" />, labelKey: 'alignBottom' },
{ type: AlignType.DistributeVertical, icon: <RiAlignJustify className="h-4 w-4 rotate-90" />, labelKey: 'distributeVertical' },
]
const SelectionContextmenu = () => {
const { t } = useTranslation()
const ref = useRef(null)
const { getNodesReadOnly, nodesReadOnly } = useNodesReadOnly()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const {
handleNodesCopy,
handleNodesDuplicate,
handleNodesDelete,
handleMakeGroup,
} = useNodesInteractions()
const selectionMenu = useStore(s => s.selectionMenu)
// Access React Flow methods
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const selectedNodeIds = useReactFlowStore((state) => {
const ids = state.getNodes().filter(node => node.selected).map(node => node.id)
ids.sort()
return ids
}, shallow)
const { canMakeGroup } = useMakeGroupAvailability(selectedNodeIds)
// Get selected nodes for alignment logic
const selectedNodes = useReactFlowStore(state =>
state.getNodes().filter(node => node.selected),
)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
@ -117,9 +65,9 @@ const SelectionContextmenu = () => {
if (container) {
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect()
const menuWidth = 244
const menuWidth = 240
const estimatedMenuHeight = 203
const estimatedMenuHeight = 380
if (left + menuWidth > containerWidth)
left = left - menuWidth
@ -139,9 +87,9 @@ const SelectionContextmenu = () => {
}, ref)
useEffect(() => {
if (selectionMenu && selectedNodeIds.length <= 1)
if (selectionMenu && selectedNodes.length <= 1)
handleSelectionContextmenuCancel()
}, [selectionMenu, selectedNodeIds.length, handleSelectionContextmenuCancel])
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
// Handle align nodes logic
const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
@ -300,7 +248,7 @@ const SelectionContextmenu = () => {
}, [])
const handleAlignNodes = useCallback((alignType: AlignType) => {
if (getNodesReadOnly() || selectedNodeIds.length <= 1) {
if (getNodesReadOnly() || selectedNodes.length <= 1) {
handleSelectionContextmenuCancel()
return
}
@ -311,6 +259,9 @@ const SelectionContextmenu = () => {
// Get all current nodes
const nodes = store.getState().getNodes()
// Get all selected nodes
const selectedNodeIds = selectedNodes.map(node => node.id)
// Find container nodes and their children
// Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
// when the container is selected. This prevents child nodes from being moved outside their containers.
@ -416,7 +367,7 @@ const SelectionContextmenu = () => {
catch (err) {
console.error('Failed to update nodes:', err)
}
}, [getNodesReadOnly, handleAlignNode, handleDistributeNodes, handleSelectionContextmenuCancel, handleSyncWorkflowDraft, saveStateToHistory, selectedNodeIds, store, workflowStore])
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
if (!selectionMenu)
return null
@ -430,75 +381,73 @@ const SelectionContextmenu = () => {
}}
ref={ref}
>
<div ref={menuRef} className="w-[244px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
{!nodesReadOnly && (
<>
<div className="p-1">
<div
className={`flex h-8 items-center justify-between rounded-lg px-3 text-sm ${
canMakeGroup
? 'cursor-pointer text-text-secondary hover:bg-state-base-hover'
: 'cursor-not-allowed text-text-disabled'
}`}
onClick={() => {
if (!canMakeGroup)
return
handleMakeGroup()
handleSelectionContextmenuCancel()
}}
>
{t('operator.makeGroup', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'g']} className={!canMakeGroup ? 'opacity-50' : ''} />
</div>
</div>
<div className="h-px bg-divider-regular" />
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
handleNodesCopy()
handleSelectionContextmenuCancel()
}}
>
{t('common.copy', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'c']} />
</div>
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
handleNodesDuplicate()
handleSelectionContextmenuCancel()
}}
>
{t('common.duplicate', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'd']} />
</div>
</div>
<div className="h-px bg-divider-regular" />
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive"
onClick={() => {
handleNodesDelete()
handleSelectionContextmenuCancel()
}}
>
{t('operation.delete', { ns: 'common' })}
<ShortcutsName keys={['del']} />
</div>
</div>
<div className="h-px bg-divider-regular" />
</>
)}
<div className="flex items-center justify-between p-1">
{ALIGN_BUTTONS.map(config => (
<AlignButton
key={config.type}
config={config}
label={t(`operator.${config.labelKey}`, { ns: 'workflow' })}
onClick={handleAlignNodes}
/>
))}
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.vertical', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Top)}
>
<RiAlignTop className="h-4 w-4" />
{t('operator.alignTop', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Middle)}
>
<RiAlignCenter className="h-4 w-4 rotate-90" />
{t('operator.alignMiddle', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Bottom)}
>
<RiAlignBottom className="h-4 w-4" />
{t('operator.alignBottom', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
>
<RiAlignJustify className="h-4 w-4 rotate-90" />
{t('operator.distributeVertical', { ns: 'workflow' })}
</div>
</div>
<div className="h-px bg-divider-regular"></div>
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.horizontal', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Left)}
>
<RiAlignLeft className="h-4 w-4" />
{t('operator.alignLeft', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Center)}
>
<RiAlignCenter className="h-4 w-4" />
{t('operator.alignCenter', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Right)}
>
<RiAlignRight className="h-4 w-4" />
{t('operator.alignRight', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
>
<RiAlignJustify className="h-4 w-4" />
{t('operator.distributeHorizontal', { ns: 'workflow' })}
</div>
</div>
</div>
</div>

View File

@ -30,7 +30,6 @@ export enum BlockEnum {
Code = 'code',
TemplateTransform = 'template-transform',
HttpRequest = 'http-request',
Group = 'group',
VariableAssigner = 'variable-assigner',
VariableAggregator = 'variable-aggregator',
Tool = 'tool',
@ -80,7 +79,6 @@ export type CommonNodeType<T = {}> = {
_isEntering?: boolean
_showAddVariablePopup?: boolean
_holdAddVariablePopup?: boolean
_hiddenInGroupId?: string
_iterationLength?: number
_iterationIndex?: number
_waitingRun?: boolean
@ -115,7 +113,6 @@ export type CommonEdgeType = {
_connectedNodeIsHovering?: boolean
_connectedNodeIsSelected?: boolean
_isBundled?: boolean
_hiddenInGroupId?: string
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean

View File

@ -1,15 +1,21 @@
import type { CustomGroupNodeData } from '../custom-group-node'
import type { GroupNodeData } from '../nodes/group/types'
import type { IfElseNodeType } from '../nodes/if-else/types'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import type { QuestionClassifierNodeType } from '../nodes/question-classifier/types'
import type { ToolNodeType } from '../nodes/tool/types'
import type { Edge, Node } from '../types'
import type {
Edge,
Node,
} from '../types'
import { cloneDeep } from 'es-toolkit/object'
import { getConnectedEdges } from 'reactflow'
import { getIterationStartNode, getLoopStartNode } from '@/app/components/workflow/utils/node'
import {
getConnectedEdges,
} from 'reactflow'
import { correctModelProvider } from '@/utils'
import {
getIterationStartNode,
getLoopStartNode,
} from '.'
import {
CUSTOM_NODE,
DEFAULT_RETRY_INTERVAL,
@ -19,22 +25,18 @@ import {
NODE_WIDTH_X_OFFSET,
START_INITIAL_POSITION,
} from '../constants'
import { CUSTOM_GROUP_NODE, GROUP_CHILDREN_Z_INDEX } from '../custom-group-node'
import { branchNameCorrect } from '../nodes/if-else/utils'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import { BlockEnum, ErrorHandleMode } from '../types'
import {
BlockEnum,
ErrorHandleMode,
} from '../types'
const WHITE = 'WHITE'
const GRAY = 'GRAY'
const BLACK = 'BLACK'
const isCyclicUtil = (
nodeId: string,
color: Record<string, string>,
adjList: Record<string, string[]>,
stack: string[],
) => {
const isCyclicUtil = (nodeId: string, color: Record<string, string>, adjList: Record<string, string[]>, stack: string[]) => {
color[nodeId] = GRAY
stack.push(nodeId)
@ -45,12 +47,8 @@ const isCyclicUtil = (
stack.push(childId)
return true
}
if (
color[childId] === WHITE
&& isCyclicUtil(childId, color, adjList, stack)
) {
if (color[childId] === WHITE && isCyclicUtil(childId, color, adjList, stack))
return true
}
}
color[nodeId] = BLACK
if (stack.length > 0 && stack[stack.length - 1] === nodeId)
@ -68,7 +66,8 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
adjList[node.id] = []
}
for (const edge of edges) adjList[edge.source]?.push(edge.target)
for (const edge of edges)
adjList[edge.source]?.push(edge.target)
for (let i = 0; i < nodes.length; i++) {
if (color[nodes[i].id] === WHITE)
@ -88,34 +87,20 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
}
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
const hasIterationNode = nodes.some(
node => node.data.type === BlockEnum.Iteration,
)
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE)
const hasBusinessGroupNode = nodes.some(
node => node.data.type === BlockEnum.Group,
)
if (
!hasIterationNode
&& !hasLoopNode
&& !hasGroupNode
&& !hasBusinessGroupNode
) {
if (!hasIterationNode && !hasLoopNode) {
return {
nodes,
edges,
}
}
const nodesMap = nodes.reduce(
(prev, next) => {
prev[next.id] = next
return prev
},
{} as Record<string, Node>,
)
const nodesMap = nodes.reduce((prev, next) => {
prev[next.id] = next
return prev
}, {} as Record<string, Node>)
const iterationNodesWithStartNode = []
const iterationNodesWithoutStartNode = []
@ -127,12 +112,8 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
if (currentNode.data.type === BlockEnum.Iteration) {
if (currentNode.data.start_node_id) {
if (
nodesMap[currentNode.data.start_node_id]?.type
!== CUSTOM_ITERATION_START_NODE
) {
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_ITERATION_START_NODE)
iterationNodesWithStartNode.push(currentNode)
}
}
else {
iterationNodesWithoutStartNode.push(currentNode)
@ -141,12 +122,8 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
if (currentNode.data.type === BlockEnum.Loop) {
if (currentNode.data.start_node_id) {
if (
nodesMap[currentNode.data.start_node_id]?.type
!== CUSTOM_LOOP_START_NODE
) {
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE)
loopNodesWithStartNode.push(currentNode)
}
}
else {
loopNodesWithoutStartNode.push(currentNode)
@ -155,10 +132,7 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
}
const newIterationStartNodesMap = {} as Record<string, Node>
const newIterationStartNodes = [
...iterationNodesWithStartNode,
...iterationNodesWithoutStartNode,
].map((iterationNode, index) => {
const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => {
const newNode = getIterationStartNode(iterationNode.id)
newNode.id = newNode.id + index
newIterationStartNodesMap[iterationNode.id] = newNode
@ -166,34 +140,24 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
})
const newLoopStartNodesMap = {} as Record<string, Node>
const newLoopStartNodes = [
...loopNodesWithStartNode,
...loopNodesWithoutStartNode,
].map((loopNode, index) => {
const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map((loopNode, index) => {
const newNode = getLoopStartNode(loopNode.id)
newNode.id = newNode.id + index
newLoopStartNodesMap[loopNode.id] = newNode
return newNode
})
const newEdges = [
...iterationNodesWithStartNode,
...loopNodesWithStartNode,
].map((nodeItem) => {
const newEdges = [...iterationNodesWithStartNode, ...loopNodesWithStartNode].map((nodeItem) => {
const isIteration = nodeItem.data.type === BlockEnum.Iteration
const newNode = (
isIteration ? newIterationStartNodesMap : newLoopStartNodesMap
)[nodeItem.id]
const newNode = (isIteration ? newIterationStartNodesMap : newLoopStartNodesMap)[nodeItem.id]
const startNode = nodesMap[nodeItem.data.start_node_id]
const source = newNode.id
const sourceHandle = 'source'
const target = startNode.id
const targetHandle = 'target'
const parentNode
= nodes.find(node => node.id === startNode.parentId) || null
const isInIteration
= !!parentNode && parentNode.data.type === BlockEnum.Iteration
const parentNode = nodes.find(node => node.id === startNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
return {
@ -216,159 +180,21 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
}
})
nodes.forEach((node) => {
if (
node.data.type === BlockEnum.Iteration
&& newIterationStartNodesMap[node.id]
) {
(node.data as IterationNodeType).start_node_id
= newIterationStartNodesMap[node.id].id
}
if (node.data.type === BlockEnum.Iteration && newIterationStartNodesMap[node.id])
(node.data as IterationNodeType).start_node_id = newIterationStartNodesMap[node.id].id
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id]) {
(node.data as LoopNodeType).start_node_id
= newLoopStartNodesMap[node.id].id
}
})
// Derive Group internal edges (input → entries, leaves → exits)
const groupInternalEdges: Edge[] = []
const groupNodes = nodes.filter(node => node.type === CUSTOM_GROUP_NODE)
for (const groupNode of groupNodes) {
const groupData = groupNode.data as unknown as CustomGroupNodeData
const { group } = groupData
if (!group)
continue
const { inputNodeId, entryNodeIds, exitPorts } = group
// Derive edges: input → each entry node
for (const entryId of entryNodeIds) {
const entryNode = nodesMap[entryId]
if (entryNode) {
groupInternalEdges.push({
id: `group-internal-${inputNodeId}-source-${entryId}-target`,
type: 'custom',
source: inputNodeId,
sourceHandle: 'source',
target: entryId,
targetHandle: 'target',
data: {
sourceType: '' as any, // Group input has empty type
targetType: entryNode.data.type,
_isGroupInternal: true,
_groupId: groupNode.id,
},
zIndex: GROUP_CHILDREN_Z_INDEX,
} as Edge)
}
}
// Derive edges: each leaf node → exit port
for (const exitPort of exitPorts) {
const leafNode = nodesMap[exitPort.leafNodeId]
if (leafNode) {
groupInternalEdges.push({
id: `group-internal-${exitPort.leafNodeId}-${exitPort.sourceHandle}-${exitPort.portNodeId}-target`,
type: 'custom',
source: exitPort.leafNodeId,
sourceHandle: exitPort.sourceHandle,
target: exitPort.portNodeId,
targetHandle: 'target',
data: {
sourceType: leafNode.data.type,
targetType: '' as string, // Exit port has empty type
_isGroupInternal: true,
_groupId: groupNode.id,
},
zIndex: GROUP_CHILDREN_Z_INDEX,
} as Edge)
}
}
}
// Rebuild isTemp edges for business Group nodes (BlockEnum.Group)
// These edges connect the group node to external nodes for visual display
const groupTempEdges: Edge[] = []
const inboundEdgeIds = new Set<string>()
nodes.forEach((groupNode) => {
if (groupNode.data.type !== BlockEnum.Group)
return
const groupData = groupNode.data as GroupNodeData
const {
members = [],
headNodeIds = [],
leafNodeIds = [],
handlers = [],
} = groupData
const memberSet = new Set(members.map(m => m.id))
const headSet = new Set(headNodeIds)
const leafSet = new Set(leafNodeIds)
edges.forEach((edge) => {
// Inbound edge: source outside group, target is a head node
// Use Set to dedupe since multiple head nodes may share same external source
if (!memberSet.has(edge.source) && headSet.has(edge.target)) {
const sourceHandle = edge.sourceHandle || 'source'
const edgeId = `${edge.source}-${sourceHandle}-${groupNode.id}-target`
if (!inboundEdgeIds.has(edgeId)) {
inboundEdgeIds.add(edgeId)
groupTempEdges.push({
id: edgeId,
type: 'custom',
source: edge.source,
sourceHandle,
target: groupNode.id,
targetHandle: 'target',
data: {
sourceType: edge.data?.sourceType,
targetType: BlockEnum.Group,
_isTemp: true,
},
} as Edge)
}
}
// Outbound edge: source is a leaf node, target outside group
if (leafSet.has(edge.source) && !memberSet.has(edge.target)) {
const edgeSourceHandle = edge.sourceHandle || 'source'
const handler = handlers.find(
h =>
h.nodeId === edge.source && h.sourceHandle === edgeSourceHandle,
)
if (handler) {
groupTempEdges.push({
id: `${groupNode.id}-${handler.id}-${edge.target}-${edge.targetHandle}`,
type: 'custom',
source: groupNode.id,
sourceHandle: handler.id,
target: edge.target!,
targetHandle: edge.targetHandle,
data: {
sourceType: BlockEnum.Group,
targetType: edge.data?.targetType,
_isTemp: true,
},
} as Edge)
}
}
})
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id])
(node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id
})
return {
nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes],
edges: [...edges, ...newEdges, ...groupInternalEdges, ...groupTempEdges],
edges: [...edges, ...newEdges],
}
}
export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
const { nodes, edges } = preprocessNodesAndEdges(
cloneDeep(originNodes),
cloneDeep(originEdges),
)
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
const firstNode = nodes[0]
if (!firstNode?.position) {
@ -380,35 +206,23 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
})
}
const iterationOrLoopNodeMap = nodes.reduce(
(acc, node) => {
if (node.parentId) {
if (acc[node.parentId]) {
acc[node.parentId].push({
nodeId: node.id,
nodeType: node.data.type,
})
}
else {
acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }]
}
}
return acc
},
{} as Record<string, { nodeId: string, nodeType: BlockEnum }[]>,
)
const iterationOrLoopNodeMap = nodes.reduce((acc, node) => {
if (node.parentId) {
if (acc[node.parentId])
acc[node.parentId].push({ nodeId: node.id, nodeType: node.data.type })
else
acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }]
}
return acc
}, {} as Record<string, { nodeId: string, nodeType: BlockEnum }[]>)
return nodes.map((node) => {
if (!node.type)
node.type = CUSTOM_NODE
const connectedEdges = getConnectedEdges([node], edges)
node.data._connectedSourceHandleIds = connectedEdges
.filter(edge => edge.source === node.id)
.map(edge => edge.sourceHandle || 'source')
node.data._connectedTargetHandleIds = connectedEdges
.filter(edge => edge.target === node.id)
.map(edge => edge.targetHandle || 'target')
node.data._connectedSourceHandleIds = connectedEdges.filter(edge => edge.source === node.id).map(edge => edge.sourceHandle || 'source')
node.data._connectedTargetHandleIds = connectedEdges.filter(edge => edge.target === node.id).map(edge => edge.targetHandle || 'target')
if (node.data.type === BlockEnum.IfElse) {
const nodeData = node.data as IfElseNodeType
@ -423,86 +237,49 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
]
}
node.data._targetBranches = branchNameCorrect([
...(node.data as IfElseNodeType).cases.map(item => ({
id: item.case_id,
name: '',
})),
...(node.data as IfElseNodeType).cases.map(item => ({ id: item.case_id, name: '' })),
{ id: 'false', name: '' },
])
// delete conditions and logical_operator if cases is not empty
if (
nodeData.cases.length > 0
&& nodeData.conditions
&& nodeData.logical_operator
) {
if (nodeData.cases.length > 0 && nodeData.conditions && nodeData.logical_operator) {
delete nodeData.conditions
delete nodeData.logical_operator
}
}
if (node.data.type === BlockEnum.QuestionClassifier) {
node.data._targetBranches = (
node.data as QuestionClassifierNodeType
).classes.map((topic) => {
node.data._targetBranches = (node.data as QuestionClassifierNodeType).classes.map((topic) => {
return topic
})
}
if (node.data.type === BlockEnum.Group) {
const groupData = node.data as GroupNodeData
if (groupData.handlers?.length) {
node.data._targetBranches = groupData.handlers.map(handler => ({
id: handler.id,
name: handler.label || handler.id,
}))
}
}
if (node.data.type === BlockEnum.Iteration) {
const iterationNodeData = node.data as IterationNodeType
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
iterationNodeData.is_parallel = iterationNodeData.is_parallel || false
iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10
iterationNodeData.error_handle_mode
= iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
}
// TODO: loop error handle mode
if (node.data.type === BlockEnum.Loop) {
const loopNodeData = node.data as LoopNodeType
loopNodeData._children = iterationOrLoopNodeMap[node.id] || []
loopNodeData.error_handle_mode
= loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
loopNodeData.error_handle_mode = loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
}
// legacy provider handle
if (node.data.type === BlockEnum.LLM) {
(node as any).data.model.provider = correctModelProvider(
(node as any).data.model.provider,
)
}
if (node.data.type === BlockEnum.LLM)
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
if (
node.data.type === BlockEnum.KnowledgeRetrieval
&& (node as any).data.multiple_retrieval_config?.reranking_model
) {
(node as any).data.multiple_retrieval_config.reranking_model.provider
= correctModelProvider(
(node as any).data.multiple_retrieval_config?.reranking_model.provider,
)
}
if (node.data.type === BlockEnum.KnowledgeRetrieval && (node as any).data.multiple_retrieval_config?.reranking_model)
(node as any).data.multiple_retrieval_config.reranking_model.provider = correctModelProvider((node as any).data.multiple_retrieval_config?.reranking_model.provider)
if (node.data.type === BlockEnum.QuestionClassifier) {
(node as any).data.model.provider = correctModelProvider(
(node as any).data.model.provider,
)
}
if (node.data.type === BlockEnum.QuestionClassifier)
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
if (node.data.type === BlockEnum.ParameterExtractor) {
(node as any).data.model.provider = correctModelProvider(
(node as any).data.model.provider,
)
}
if (node.data.type === BlockEnum.ParameterExtractor)
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
if (node.data.type === BlockEnum.HttpRequest && !node.data.retry_config) {
node.data.retry_config = {
@ -512,21 +289,14 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
}
}
if (
node.data.type === BlockEnum.Tool
&& !(node as Node<ToolNodeType>).data.version
&& !(node as Node<ToolNodeType>).data.tool_node_version
) {
if (node.data.type === BlockEnum.Tool && !(node as Node<ToolNodeType>).data.version && !(node as Node<ToolNodeType>).data.tool_node_version) {
(node as Node<ToolNodeType>).data.tool_node_version = '2'
const toolConfigurations = (node as Node<ToolNodeType>).data.tool_configurations
if (toolConfigurations && Object.keys(toolConfigurations).length > 0) {
const newValues = { ...toolConfigurations }
Object.keys(toolConfigurations).forEach((key) => {
if (
typeof toolConfigurations[key] !== 'object'
|| toolConfigurations[key] === null
) {
if (typeof toolConfigurations[key] !== 'object' || toolConfigurations[key] === null) {
newValues[key] = {
type: 'constant',
value: toolConfigurations[key],
@ -542,62 +312,50 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
}
export const initialEdges = (originEdges: Edge[], originNodes: Node[]) => {
const { nodes, edges } = preprocessNodesAndEdges(
cloneDeep(originNodes),
cloneDeep(originEdges),
)
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
let selectedNode: Node | null = null
const nodesMap = nodes.reduce(
(acc, node) => {
acc[node.id] = node
const nodesMap = nodes.reduce((acc, node) => {
acc[node.id] = node
if (node.data?.selected)
selectedNode = node
if (node.data?.selected)
selectedNode = node
return acc
},
{} as Record<string, Node>,
)
return acc
}, {} as Record<string, Node>)
const cycleEdges = getCycleEdges(nodes, edges)
return edges
.filter((edge) => {
return !cycleEdges.find(
cycEdge =>
cycEdge.source === edge.source && cycEdge.target === edge.target,
)
})
.map((edge) => {
edge.type = 'custom'
return edges.filter((edge) => {
return !cycleEdges.find(cycEdge => cycEdge.source === edge.source && cycEdge.target === edge.target)
}).map((edge) => {
edge.type = 'custom'
if (!edge.sourceHandle)
edge.sourceHandle = 'source'
if (!edge.sourceHandle)
edge.sourceHandle = 'source'
if (!edge.targetHandle)
edge.targetHandle = 'target'
if (!edge.targetHandle)
edge.targetHandle = 'target'
if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) {
edge.data = {
...edge.data,
sourceType: nodesMap[edge.source].data.type!,
} as any
}
if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) {
edge.data = {
...edge.data,
sourceType: nodesMap[edge.source].data.type!,
} as any
}
if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) {
edge.data = {
...edge.data,
targetType: nodesMap[edge.target].data.type!,
} as any
}
if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) {
edge.data = {
...edge.data,
targetType: nodesMap[edge.target].data.type!,
} as any
}
if (selectedNode) {
edge.data = {
...edge.data,
_connectedNodeIsSelected:
edge.source === selectedNode.id || edge.target === selectedNode.id,
} as any
}
if (selectedNode) {
edge.data = {
...edge.data,
_connectedNodeIsSelected: edge.source === selectedNode.id || edge.target === selectedNode.id,
} as any
}
return edge
})
return edge
})
}

View File

@ -157,95 +157,6 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
}
}
export const getCommonPredecessorNodeIds = (selectedNodeIds: string[], edges: Edge[]) => {
const uniqSelectedNodeIds = Array.from(new Set(selectedNodeIds))
if (uniqSelectedNodeIds.length <= 1)
return []
const selectedNodeIdSet = new Set(uniqSelectedNodeIds)
const predecessorNodeIdsMap = new Map<string, Set<string>>()
edges.forEach((edge) => {
if (!selectedNodeIdSet.has(edge.target))
return
const predecessors = predecessorNodeIdsMap.get(edge.target) ?? new Set<string>()
predecessors.add(edge.source)
predecessorNodeIdsMap.set(edge.target, predecessors)
})
let commonPredecessorNodeIds: Set<string> | null = null
uniqSelectedNodeIds.forEach((nodeId) => {
const predecessors = predecessorNodeIdsMap.get(nodeId) ?? new Set<string>()
if (!commonPredecessorNodeIds) {
commonPredecessorNodeIds = new Set(predecessors)
return
}
Array.from(commonPredecessorNodeIds).forEach((predecessorNodeId) => {
if (!predecessors.has(predecessorNodeId))
commonPredecessorNodeIds!.delete(predecessorNodeId)
})
})
return Array.from(commonPredecessorNodeIds ?? []).sort()
}
export type PredecessorHandle = {
nodeId: string
handleId: string
}
export const getCommonPredecessorHandles = (targetNodeIds: string[], edges: Edge[]): PredecessorHandle[] => {
const uniqTargetNodeIds = Array.from(new Set(targetNodeIds))
if (uniqTargetNodeIds.length === 0)
return []
// Get the "direct predecessor handler", which is:
// - edge.source (predecessor node)
// - edge.sourceHandle (the specific output handle of the predecessor; defaults to 'source' if not set)
// Used to handle multi-handle branch scenarios like If-Else / Classifier.
const targetNodeIdSet = new Set(uniqTargetNodeIds)
const predecessorHandleMap = new Map<string, Set<string>>() // targetNodeId -> Set<`${source}\0${handleId}`>
const delimiter = '\u0000'
edges.forEach((edge) => {
if (!targetNodeIdSet.has(edge.target))
return
const predecessors = predecessorHandleMap.get(edge.target) ?? new Set<string>()
const handleId = edge.sourceHandle || 'source'
predecessors.add(`${edge.source}${delimiter}${handleId}`)
predecessorHandleMap.set(edge.target, predecessors)
})
// Intersect predecessor handlers of all targets, keeping only handlers common to all targets.
let commonKeys: Set<string> | null = null
uniqTargetNodeIds.forEach((nodeId) => {
const keys = predecessorHandleMap.get(nodeId) ?? new Set<string>()
if (!commonKeys) {
commonKeys = new Set(keys)
return
}
Array.from(commonKeys).forEach((key) => {
if (!keys.has(key))
commonKeys!.delete(key)
})
})
return Array.from<string>(commonKeys ?? [])
.map((key) => {
const [nodeId, handleId] = key.split(delimiter)
return { nodeId, handleId }
})
.sort((a, b) => a.nodeId.localeCompare(b.nodeId) || a.handleId.localeCompare(b.handleId))
}
export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => {
const idMap = nodes.reduce((acc, node) => {
acc[node.id] = uuid4()

View File

@ -158,10 +158,32 @@ We have a list of languages that we support in the `languages.ts` file. But some
## Utility scripts
- Auto-fill translations: `pnpm run i18n:gen --file app common --lang zh-Hans ja-JP [--dry-run]`
- Use space-separated values; repeat `--file` / `--lang` as needed. Defaults to all en-US files and all supported locales except en-US.
- Protects placeholders (`{{var}}`, `${var}`, `<tag>`) before translation and restores them after.
- Check missing/extra keys: `pnpm run i18n:check --file app billing --lang zh-Hans [--auto-remove]`
- Use space-separated values; repeat `--file` / `--lang` as needed. Returns non-zero on missing/extra keys; `--auto-remove` deletes extra keys automatically.
Workflows: `.github/workflows/translate-i18n-base-on-english.yml` auto-runs the translation generator on `web/i18n/en-US/*.json` changes to main. `i18n:check` is a manual script (not run in CI).
## Automatic Translation
Translation is handled automatically by Claude Code GitHub Actions. When changes are pushed to `web/i18n/en-US/*.json` on the main branch:
1. Claude Code analyzes the git diff to detect changes
1. Identifies three types of changes:
- **ADD**: New keys that need translation
- **UPDATE**: Modified keys that need re-translation (even if target language has existing translation)
- **DELETE**: Removed keys that need to be deleted from other languages
1. Runs `i18n:check` to verify the initial sync status.
1. Translates missing/updated keys while preserving placeholders (`{{var}}`, `${var}`, `<tag>`) and removes deleted keys.
1. Runs `lint:fix` to sort JSON keys and `i18n:check` again to ensure everything is synchronized.
1. Creates a PR with the translations.
### Manual Trigger
To manually trigger translation:
1. Go to Actions > "Translate i18n Files with Claude Code"
1. Click "Run workflow"
1. Optionally configure:
- **files**: Specific files to translate (space-separated, e.g., "app common")
- **languages**: Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP")
- **mode**: `incremental` (default, only changes) or `full` (check all keys)
Workflow: `.github/workflows/translate-i18n-claude.yml`

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "هذا مفتاح اختبار لمشغل CI - سيتم التراجع عنه",
"accessControl": "التحكم في الوصول إلى تطبيق الويب",
"accessControlDialog.accessItems.anyone": "أي شخص لديه الرابط",
"accessControlDialog.accessItems.external": "المستخدمون الخارجيون Authenticated",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Dies ist ein Testschlüssel für CI-Trigger - wird rückgängig gemacht",
"accessControl": "Zugriffskontrolle für Webanwendungen",
"accessControlDialog.accessItems.anyone": "Jeder mit dem Link",
"accessControlDialog.accessItems.external": "Authentifizierte externe Benutzer",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "This is a test key for CI trigger - will be reverted",
"accessControl": "Web App Access Control",
"accessControlDialog.accessItems.anyone": "Anyone with the link",
"accessControlDialog.accessItems.external": "Authenticated external users",

View File

@ -7,7 +7,6 @@
"blocks.datasource-empty": "Empty Data Source",
"blocks.document-extractor": "Doc Extractor",
"blocks.end": "Output",
"blocks.group": "Group",
"blocks.http-request": "HTTP Request",
"blocks.if-else": "IF/ELSE",
"blocks.iteration": "Iteration",
@ -38,7 +37,6 @@
"blocksAbout.datasource-empty": "Empty Data Source placeholder",
"blocksAbout.document-extractor": "Used to parse uploaded documents into text content that is easily understandable by LLM.",
"blocksAbout.end": "Define the output and result type of a workflow",
"blocksAbout.group": "Group multiple nodes together for better organization",
"blocksAbout.http-request": "Allow server requests to be sent over the HTTP protocol",
"blocksAbout.if-else": "Allows you to split the workflow into two branches based on if/else conditions",
"blocksAbout.iteration": "Perform multiple steps on a list object until all results are outputted.",
@ -938,7 +936,6 @@
"operator.distributeHorizontal": "Space Horizontally",
"operator.distributeVertical": "Space Vertically",
"operator.horizontal": "Horizontal",
"operator.makeGroup": "Make Group",
"operator.selectionAlignment": "Selection Alignment",
"operator.vertical": "Vertical",
"operator.zoomIn": "Zoom In",
@ -967,7 +964,6 @@
"panel.scrollToSelectedNode": "Scroll to selected node",
"panel.selectNextStep": "Select Next Step",
"panel.startNode": "Start Node",
"panel.ungroup": "Ungroup",
"panel.userInputField": "User Input Field",
"publishLimit.startNodeDesc": "Youve reached the limit of 2 triggers per workflow for this plan. Upgrade to publish this workflow.",
"publishLimit.startNodeTitlePrefix": "Upgrade to",

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Esta es una clave de prueba para el disparador de CI - será revertida",
"accessControl": "Control de Acceso a la Aplicación Web",
"accessControlDialog.accessItems.anyone": "Cualquiera con el enlace",
"accessControlDialog.accessItems.external": "Usuarios externos autenticados",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "این یک کلید آزمایشی برای تریگر CI است - بازگردانده خواهد شد",
"accessControl": "کنترل دسترسی به وب اپلیکیشن",
"accessControlDialog.accessItems.anyone": "هر کسی که لینک را داشته باشد",
"accessControlDialog.accessItems.external": "کاربران خارجی تأیید شده",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Ceci est une clé de test pour le déclencheur CI - sera annulée",
"accessControl": "Contrôle d'accès à l'application Web",
"accessControlDialog.accessItems.anyone": "Quiconque avec le lien",
"accessControlDialog.accessItems.external": "Utilisateurs externes authentifiés",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "यह CI ट्रिगर के लिए एक परीक्षण कुंजी है - वापस ले ली जाएगी",
"accessControl": "वेब एप्लिकेशन पहुँच नियंत्रण",
"accessControlDialog.accessItems.anyone": "लिंक के साथ कोई भी",
"accessControlDialog.accessItems.external": "प्रमाणित बाहरी उपयोगकर्ता",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Ini adalah kunci uji untuk pemicu CI - akan dikembalikan",
"accessControl": "Kontrol Akses Aplikasi Web",
"accessControlDialog.accessItems.anyone": "Siapa pun yang memiliki tautan",
"accessControlDialog.accessItems.external": "Pengguna eksternal yang diautentikasi",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Questa è una chiave di test per il trigger CI - verrà ripristinata",
"accessControl": "Controllo di accesso all'app web",
"accessControlDialog.accessItems.anyone": "Chiunque con il link",
"accessControlDialog.accessItems.external": "Utenti esterni autenticati",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "これはCIトリガーのテストキーです - 元に戻されます",
"accessControl": "Web アプリアクセス制御",
"accessControlDialog.accessItems.anyone": "リンクを知っているすべてのユーザー",
"accessControlDialog.accessItems.external": "認証済みの外部ユーザー",

View File

@ -964,7 +964,6 @@
"panel.scrollToSelectedNode": "選択したノードまでスクロール",
"panel.selectNextStep": "次ノード選択",
"panel.startNode": "開始ノード",
"panel.ungroup": "グループ解除",
"panel.userInputField": "ユーザー入力欄",
"publishLimit.startNodeDesc": "このプランでは、各ワークフローのトリガー数は最大 2 個まで設定できます。公開するにはアップグレードが必要です。",
"publishLimit.startNodeTitlePrefix": "アップグレードして、",

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "CI 트리거를 위한 테스트 키입니다 - 되돌려질 예정입니다",
"accessControl": "웹 애플리케이션 접근 제어",
"accessControlDialog.accessItems.anyone": "링크가 있는 누구나",
"accessControlDialog.accessItems.external": "인증된 외부 사용자",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "To jest klucz testowy dla wyzwalacza CI - zostanie przywrócony",
"accessControl": "Kontrola dostępu do aplikacji internetowej",
"accessControlDialog.accessItems.anyone": "Każdy z linkiem",
"accessControlDialog.accessItems.external": "Uwierzytelnieni użytkownicy zewnętrzni",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Esta é uma chave de teste para acionamento de CI - será revertida",
"accessControl": "Controle de Acesso do Aplicativo Web",
"accessControlDialog.accessItems.anyone": "Qualquer pessoa com o link",
"accessControlDialog.accessItems.external": "Usuários externos autenticados",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Aceasta este o cheie de test pentru declanșatorul CI - va fi inversată",
"accessControl": "Controlul Accesului la Aplicația Web",
"accessControlDialog.accessItems.anyone": "Oricine are linkul",
"accessControlDialog.accessItems.external": "Utilizatori extern autentificați",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Это тестовый ключ для триггера CI - будет отменен",
"accessControl": "Управление доступом к веб-приложению",
"accessControlDialog.accessItems.anyone": "Кто угодно с ссылкой",
"accessControlDialog.accessItems.external": "Аутентифицированные внешние пользователи",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "To je testni ključ za sprožitev CI - bo razveljavljen",
"accessControl": "Nadzor dostopa do spletne aplikacije",
"accessControlDialog.accessItems.anyone": "Kdorkoli s povezavo",
"accessControlDialog.accessItems.external": "Avtorizirani zunanji uporabniki",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "นี่คือคีย์ทดสอบสำหรับทริกเกอร์ CI - จะถูกย้อนกลับ",
"accessControl": "การควบคุมการเข้าถึงเว็บแอปพลิเคชัน",
"accessControlDialog.accessItems.anyone": "ใครก็ตามที่มีลิงก์",
"accessControlDialog.accessItems.external": "ผู้ใช้ภายนอกที่ได้รับการตรวจสอบแล้ว",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Bu, CI tetikleyici için bir test anahtarıdır - geri alınacaktır",
"accessControl": "Web Uygulaması Erişim Kontrolü",
"accessControlDialog.accessItems.anyone": "Bağlantıya sahip olan herkes",
"accessControlDialog.accessItems.external": "Kimliği onaylanmış harici kullanıcılar",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Це тестовий ключ для тригера CI - буде скасовано",
"accessControl": "Контроль доступу до веб-додатків",
"accessControlDialog.accessItems.anyone": "Кожен, у кого є посилання",
"accessControlDialog.accessItems.external": "Аутентифіковані зовнішні користувачі",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "Đây là khóa thử nghiệm cho trigger CI - sẽ được hoàn nguyên",
"accessControl": "Kiểm soát truy cập ứng dụng web",
"accessControlDialog.accessItems.anyone": "Ai có liên kết",
"accessControlDialog.accessItems.external": "Người dùng bên ngoài được xác thực",

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "这是用于 CI 触发的测试键 - 将被还原",
"accessControl": "Web 应用访问控制",
"accessControlDialog.accessItems.anyone": "任何人",
"accessControlDialog.accessItems.external": "经认证的外部用户",

View File

@ -936,7 +936,6 @@
"operator.distributeHorizontal": "水平等间距",
"operator.distributeVertical": "垂直等间距",
"operator.horizontal": "水平方向",
"operator.makeGroup": "建立群组",
"operator.selectionAlignment": "选择对齐",
"operator.vertical": "垂直方向",
"operator.zoomIn": "放大",
@ -965,7 +964,6 @@
"panel.scrollToSelectedNode": "滚动至选中节点",
"panel.selectNextStep": "选择下一个节点",
"panel.startNode": "开始节点",
"panel.ungroup": "取消编组",
"panel.userInputField": "用户输入字段",
"publishLimit.startNodeDesc": "您已达到此计划上每个工作流最多 2 个触发器的限制。请升级后再发布此工作流。",
"publishLimit.startNodeTitlePrefix": "升级以",

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
{
"_test_ci_trigger": "這是用於 CI 觸發的測試鍵 - 將被還原",
"accessControl": "網頁應用程式存取控制",
"accessControlDialog.accessItems.anyone": "擁有鏈接的人",
"accessControlDialog.accessItems.external": "經過驗證的外部用戶",

View File

@ -964,7 +964,6 @@
"panel.scrollToSelectedNode": "捲動至選取的節點",
"panel.selectNextStep": "選擇下一個節點",
"panel.startNode": "起始節點",
"panel.ungroup": "取消群組",
"panel.userInputField": "用戶輸入字段",
"publishLimit.startNodeDesc": "目前方案最多允許 2 個開始節點,升級後才能發布此工作流程。",
"publishLimit.startNodeTitlePrefix": "升級以",

File diff suppressed because it is too large Load Diff

View File

@ -40,7 +40,6 @@
"gen-icons": "node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
"uglify-embed": "node ./bin/uglify-embed",
"i18n:check": "tsx ./scripts/check-i18n.js",
"i18n:gen": "tsx ./scripts/auto-gen-i18n.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
@ -196,7 +195,6 @@
"@vitest/coverage-v8": "4.0.16",
"autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0",
"bing-translate-api": "^4.1.0",
"code-inspector-plugin": "1.2.9",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",

174
web/pnpm-lock.yaml generated
View File

@ -481,9 +481,6 @@ importers:
babel-loader:
specifier: ^10.0.0
version: 10.0.0(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3))
bing-translate-api:
specifier: ^4.1.0
version: 4.2.0
code-inspector-plugin:
specifier: 1.2.9
version: 1.2.9
@ -3160,10 +3157,6 @@ packages:
resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==}
engines: {node: '>=18'}
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
'@solid-primitives/event-listener@2.4.3':
resolution: {integrity: sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==}
peerDependencies:
@ -3324,10 +3317,6 @@ packages:
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
@ -3511,9 +3500,6 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -3652,9 +3638,6 @@ packages:
'@types/html-minifier-terser@6.1.0':
resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==}
'@types/http-cache-semantics@4.0.4':
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
@ -3667,9 +3650,6 @@ packages:
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@ -3732,9 +3712,6 @@ packages:
'@types/resolve@1.20.6':
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
@ -4338,9 +4315,6 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bing-translate-api@4.2.0:
resolution: {integrity: sha512-7a9yo1NbGcHPS8zXTdz8tCOymHZp2pvCuYOChCaXKjOX8EIwdV3SLd4D7RGIqZt1UhffypYBUcAV2gDcTgK0rA==}
bippy@0.3.34:
resolution: {integrity: sha512-vmptmU/20UdIWHHhq7qCSHhHzK7Ro3YJ1utU0fBG7ujUc58LEfTtilKxcF0IOgSjT5XLcm7CBzDjbv4lcKApGQ==}
peerDependencies:
@ -4427,14 +4401,6 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
cacheable-lookup@5.0.4:
resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
engines: {node: '>=10.6.0'}
cacheable-request@7.0.4:
resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==}
engines: {node: '>=8'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@ -4588,9 +4554,6 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clone-response@1.0.3:
resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
@ -4996,10 +4959,6 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
defer-to-connect@2.0.1:
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
engines: {node: '>=10'}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
@ -5712,10 +5671,6 @@ packages:
get-own-enumerable-property-symbols@3.0.2:
resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==}
get-stream@5.2.0:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
@ -5772,10 +5727,6 @@ packages:
peerDependencies:
csstype: ^3.0.10
got@11.8.6:
resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==}
engines: {node: '>=10.19.0'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@ -5920,17 +5871,10 @@ packages:
htmlparser2@6.1.0:
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
http2-wrapper@1.0.3:
resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
engines: {node: '>=10.19.0'}
https-browserify@1.0.0:
resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==}
@ -6453,10 +6397,6 @@ packages:
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
lowercase-keys@2.0.0:
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
engines: {node: '>=8'}
lowlight@1.20.0:
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
@ -6729,10 +6669,6 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
mimic-response@1.0.1:
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
engines: {node: '>=4'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
@ -6880,10 +6816,6 @@ packages:
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'}
normalize-url@6.1.0:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'}
normalize-wheel@1.0.1:
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
@ -6964,10 +6896,6 @@ packages:
oxc-resolver@11.15.0:
resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==}
p-cancelable@2.1.1:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@ -7359,10 +7287,6 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@ -7705,9 +7629,6 @@ packages:
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -7724,9 +7645,6 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
responselike@2.0.1:
resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
@ -11717,8 +11635,6 @@ snapshots:
'@sindresorhus/base62@1.0.0': {}
'@sindresorhus/is@4.6.0': {}
'@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)':
dependencies:
'@solid-primitives/utils': 6.3.2(solid-js@1.9.10)
@ -11971,10 +11887,6 @@ snapshots:
dependencies:
tslib: 2.8.1
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
postcss-selector-parser: 6.0.10
@ -12199,13 +12111,6 @@ snapshots:
dependencies:
'@babel/types': 7.28.5
'@types/cacheable-request@6.0.3':
dependencies:
'@types/http-cache-semantics': 4.0.4
'@types/keyv': 3.1.4
'@types/node': 18.15.0
'@types/responselike': 1.0.3
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -12373,8 +12278,6 @@ snapshots:
'@types/html-minifier-terser@6.1.0': {}
'@types/http-cache-semantics@4.0.4': {}
'@types/js-cookie@3.0.6': {}
'@types/js-yaml@4.0.9': {}
@ -12383,10 +12286,6 @@ snapshots:
'@types/katex@0.16.7': {}
'@types/keyv@3.1.4':
dependencies:
'@types/node': 18.15.0
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@ -12450,10 +12349,6 @@ snapshots:
'@types/resolve@1.20.6': {}
'@types/responselike@1.0.3':
dependencies:
'@types/node': 18.15.0
'@types/semver@7.7.1': {}
'@types/sortablejs@1.15.9': {}
@ -13160,10 +13055,6 @@ snapshots:
binary-extensions@2.3.0: {}
bing-translate-api@4.2.0:
dependencies:
got: 11.8.6
bippy@0.3.34(@types/react@19.2.7)(react@19.2.3):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@19.2.7)
@ -13273,18 +13164,6 @@ snapshots:
cac@6.7.14: {}
cacheable-lookup@5.0.4: {}
cacheable-request@7.0.4:
dependencies:
clone-response: 1.0.3
get-stream: 5.2.0
http-cache-semantics: 4.2.0
keyv: 4.5.4
lowercase-keys: 2.0.0
normalize-url: 6.1.0
responselike: 2.0.1
callsites@3.1.0: {}
camel-case@4.1.2:
@ -13425,10 +13304,6 @@ snapshots:
client-only@0.0.1: {}
clone-response@1.0.3:
dependencies:
mimic-response: 1.0.1
clsx@1.2.1: {}
clsx@2.1.1: {}
@ -13859,6 +13734,7 @@ snapshots:
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
optional: true
dedent@0.7.0: {}
@ -13871,8 +13747,6 @@ snapshots:
deepmerge@4.3.1: {}
defer-to-connect@2.0.1: {}
define-lazy-prop@2.0.0: {}
del@4.1.1:
@ -14013,6 +13887,7 @@ snapshots:
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
optional: true
endent@2.1.0:
dependencies:
@ -14799,10 +14674,6 @@ snapshots:
get-own-enumerable-property-symbols@3.0.2: {}
get-stream@5.2.0:
dependencies:
pump: 3.0.3
get-stream@8.0.1: {}
get-tsconfig@4.13.0:
@ -14862,20 +14733,6 @@ snapshots:
dependencies:
csstype: 3.2.3
got@11.8.6:
dependencies:
'@sindresorhus/is': 4.6.0
'@szmarczak/http-timer': 4.0.6
'@types/cacheable-request': 6.0.3
'@types/responselike': 1.0.3
cacheable-lookup: 5.0.4
cacheable-request: 7.0.4
decompress-response: 6.0.0
http2-wrapper: 1.0.3
lowercase-keys: 2.0.0
p-cancelable: 2.1.1
responselike: 2.0.1
graceful-fs@4.2.11: {}
graphemer@1.4.0: {}
@ -15120,8 +14977,6 @@ snapshots:
domutils: 2.8.0
entities: 2.2.0
http-cache-semantics@4.2.0: {}
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@ -15129,11 +14984,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
http2-wrapper@1.0.3:
dependencies:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
https-browserify@1.0.0: {}
https-proxy-agent@7.0.6:
@ -15604,8 +15454,6 @@ snapshots:
dependencies:
tslib: 2.8.1
lowercase-keys@2.0.0: {}
lowlight@1.20.0:
dependencies:
fault: 1.0.4
@ -16186,9 +16034,8 @@ snapshots:
mimic-function@5.0.1: {}
mimic-response@1.0.1: {}
mimic-response@3.1.0: {}
mimic-response@3.1.0:
optional: true
min-indent@1.0.1: {}
@ -16360,8 +16207,6 @@ snapshots:
normalize-range@0.1.2: {}
normalize-url@6.1.0: {}
normalize-wheel@1.0.1: {}
npm-run-path@5.3.0:
@ -16445,8 +16290,6 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.15.0
'@oxc-resolver/binding-win32-x64-msvc': 11.15.0
p-cancelable@2.1.1: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@ -16809,6 +16652,7 @@ snapshots:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
optional: true
punycode@1.4.1: {}
@ -16828,8 +16672,6 @@ snapshots:
queue-microtask@1.2.3: {}
quick-lru@5.1.1: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@ -17296,8 +17138,6 @@ snapshots:
resize-observer-polyfill@1.5.1: {}
resolve-alpn@1.2.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@ -17316,10 +17156,6 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
responselike@2.0.1:
dependencies:
lowercase-keys: 2.0.0
restore-cursor@5.1.0:
dependencies:
onetime: 7.0.0

View File

@ -1,336 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { translate } from 'bing-translate-api'
import data from '../i18n-config/languages'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const targetLanguage = 'en-US'
const i18nFolder = '../i18n' // Path to i18n folder relative to this script
// https://github.com/plainheart/bing-translate-api/blob/master/src/met/lang.json
const languageKeyMap = data.languages.reduce((map, language) => {
if (language.supported) {
if (language.value === 'zh-Hans' || language.value === 'zh-Hant')
map[language.value] = language.value
else
map[language.value] = language.value.split('-')[0]
}
return map
}, {})
const supportedLanguages = Object.keys(languageKeyMap)
function parseArgs(argv) {
const args = {
files: [],
languages: [],
isDryRun: false,
help: false,
errors: [],
}
const collectValues = (startIndex) => {
const values = []
let cursor = startIndex + 1
while (cursor < argv.length && !argv[cursor].startsWith('--')) {
const value = argv[cursor].trim()
if (value)
values.push(value)
cursor++
}
return { values, nextIndex: cursor - 1 }
}
const validateList = (values, flag) => {
if (!values.length) {
args.errors.push(`${flag} requires at least one value. Example: ${flag} app billing`)
return false
}
const invalid = values.find(value => value.includes(','))
if (invalid) {
args.errors.push(`${flag} expects space-separated values. Example: ${flag} app billing`)
return false
}
return true
}
for (let index = 2; index < argv.length; index++) {
const arg = argv[index]
if (arg === '--dry-run') {
args.isDryRun = true
continue
}
if (arg === '--help' || arg === '-h') {
args.help = true
break
}
if (arg.startsWith('--file=')) {
args.errors.push('--file expects space-separated values. Example: --file app billing')
continue
}
if (arg === '--file') {
const { values, nextIndex } = collectValues(index)
if (validateList(values, '--file'))
args.files.push(...values)
index = nextIndex
continue
}
if (arg.startsWith('--lang=')) {
args.errors.push('--lang expects space-separated values. Example: --lang zh-Hans ja-JP')
continue
}
if (arg === '--lang') {
const { values, nextIndex } = collectValues(index)
if (validateList(values, '--lang'))
args.languages.push(...values)
index = nextIndex
continue
}
}
return args
}
function printHelp() {
console.log(`Usage: pnpm run i18n:gen [options]
Options:
--file <name...> Process only specific files; provide space-separated names and repeat --file if needed
--lang <locale> Process only specific locales; provide space-separated locales and repeat --lang if needed (default: all supported except en-US)
--dry-run Preview changes without writing files
-h, --help Show help
Examples:
pnpm run i18n:gen --file app common --lang zh-Hans ja-JP
pnpm run i18n:gen --dry-run
`)
}
function protectPlaceholders(text) {
const placeholders = []
let safeText = text
const patterns = [
/\{\{[^{}]+\}\}/g, // mustache
/\$\{[^{}]+\}/g, // template expressions
/<[^>]+>/g, // html-like tags
]
patterns.forEach((pattern) => {
safeText = safeText.replace(pattern, (match) => {
const token = `__PH_${placeholders.length}__`
placeholders.push({ token, value: match })
return token
})
})
return {
safeText,
restore(translated) {
return placeholders.reduce((result, { token, value }) => result.replace(new RegExp(token, 'g'), value), translated)
},
}
}
async function translateText(source, toLanguage) {
if (typeof source !== 'string')
return { value: source, skipped: false }
const trimmed = source.trim()
if (!trimmed)
return { value: source, skipped: false }
const { safeText, restore } = protectPlaceholders(source)
try {
const { translation } = await translate(safeText, null, languageKeyMap[toLanguage])
return { value: restore(translation), skipped: false }
}
catch (error) {
console.error(`❌ Error translating to ${toLanguage}:`, error.message)
return { value: source, skipped: true, error: error.message }
}
}
async function translateMissingKeys(sourceObj, targetObject, toLanguage) {
const skippedKeys = []
const translatedKeys = []
for (const key of Object.keys(sourceObj)) {
const sourceValue = sourceObj[key]
const targetValue = targetObject[key]
// Skip if target already has this key
if (targetValue !== undefined)
continue
const translationResult = await translateText(sourceValue, toLanguage)
targetObject[key] = translationResult.value ?? ''
if (translationResult.skipped)
skippedKeys.push(`${key}: ${sourceValue}`)
else
translatedKeys.push(key)
}
return { skipped: skippedKeys, translated: translatedKeys }
}
async function autoGenTrans(fileName, toGenLanguage, isDryRun = false) {
const fullKeyFilePath = path.resolve(__dirname, i18nFolder, targetLanguage, `${fileName}.json`)
const toGenLanguageFilePath = path.resolve(__dirname, i18nFolder, toGenLanguage, `${fileName}.json`)
try {
const content = fs.readFileSync(fullKeyFilePath, 'utf8')
const fullKeyContent = JSON.parse(content)
if (!fullKeyContent || typeof fullKeyContent !== 'object')
throw new Error(`Failed to extract translation object from ${fullKeyFilePath}`)
// if toGenLanguageFilePath does not exist, create it with empty object
let toGenOutPut = {}
if (fs.existsSync(toGenLanguageFilePath)) {
const existingContent = fs.readFileSync(toGenLanguageFilePath, 'utf8')
toGenOutPut = JSON.parse(existingContent)
}
console.log(`\n🌍 Processing ${fileName} for ${toGenLanguage}...`)
const result = await translateMissingKeys(fullKeyContent, toGenOutPut, toGenLanguage)
// Generate summary report
console.log(`\n📊 Translation Summary for ${fileName} -> ${toGenLanguage}:`)
console.log(` ✅ Translated: ${result.translated.length} keys`)
console.log(` ⏭️ Skipped: ${result.skipped.length} keys`)
if (result.skipped.length > 0) {
console.log(`\n⚠️ Skipped keys in ${fileName} (${toGenLanguage}):`)
result.skipped.slice(0, 5).forEach(item => console.log(` - ${item}`))
if (result.skipped.length > 5)
console.log(` ... and ${result.skipped.length - 5} more`)
}
const res = `${JSON.stringify(toGenOutPut, null, 2)}\n`
if (!isDryRun) {
fs.writeFileSync(toGenLanguageFilePath, res)
console.log(`💾 Saved translations to ${toGenLanguageFilePath}`)
}
else {
console.log(`🔍 [DRY RUN] Would save translations to ${toGenLanguageFilePath}`)
}
return result
}
catch (error) {
console.error(`Error processing file ${fullKeyFilePath}:`, error.message)
throw error
}
}
// Add command line argument support
const args = parseArgs(process.argv)
const isDryRun = args.isDryRun
const targetFiles = args.files
const targetLangs = args.languages
// Rate limiting helper
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function main() {
if (args.help) {
printHelp()
return
}
if (args.errors.length) {
args.errors.forEach(message => console.error(`${message}`))
printHelp()
process.exit(1)
return
}
console.log('🚀 Starting i18n:gen script...')
console.log(`📋 Mode: ${isDryRun ? 'DRY RUN (no files will be modified)' : 'LIVE MODE'}`)
const filesInEn = fs
.readdirSync(path.resolve(__dirname, i18nFolder, targetLanguage))
.filter(file => /\.json$/.test(file)) // Only process .json files
.map(file => file.replace(/\.json$/, ''))
// Filter by target files if specified
const filesToProcess = targetFiles.length > 0 ? filesInEn.filter(f => targetFiles.includes(f)) : filesInEn
const languagesToProcess = Array.from(new Set((targetLangs.length > 0 ? targetLangs : supportedLanguages)
.filter(lang => lang !== targetLanguage)))
const unknownLangs = languagesToProcess.filter(lang => !languageKeyMap[lang])
if (unknownLangs.length) {
console.error(`❌ Unsupported languages: ${unknownLangs.join(', ')}`)
process.exit(1)
}
if (!filesToProcess.length) {
console.log(' No files to process based on provided arguments')
return
}
if (!languagesToProcess.length) {
console.log(' No languages to process (did you only specify en-US?)')
return
}
console.log(`📁 Files to process: ${filesToProcess.join(', ')}`)
console.log(`🌍 Languages to process: ${languagesToProcess.join(', ')}`)
let totalTranslated = 0
let totalSkipped = 0
let totalErrors = 0
// Process files sequentially to avoid API rate limits
for (const file of filesToProcess) {
console.log(`\n📄 Processing file: ${file}`)
// Process languages with rate limiting
for (const language of languagesToProcess) {
try {
const result = await autoGenTrans(file, language, isDryRun)
totalTranslated += result.translated.length
totalSkipped += result.skipped.length
// Rate limiting: wait 500ms between language processing
await delay(500)
}
catch (e) {
console.error(`❌ Error translating ${file} to ${language}:`, e.message)
totalErrors++
}
}
}
// Final summary
console.log('\n🎉 Auto-translation completed!')
console.log('📊 Final Summary:')
console.log(` ✅ Total keys translated: ${totalTranslated}`)
console.log(` ⏭️ Total keys skipped: ${totalSkipped}`)
console.log(` ❌ Total errors: ${totalErrors}`)
if (isDryRun)
console.log('\n💡 This was a dry run. To actually translate, run without --dry-run flag.')
if (totalErrors > 0)
process.exitCode = 1
}
main().catch((error) => {
console.error('❌ Unexpected error:', error.message)
process.exit(1)
})