refactor: split backend into standalone server project

Remove embedded frontend and workflow editor assets, add standalone API deployment configuration, and retain the current backend service updates.
This commit is contained in:
t
2026-08-20 21:46:55 +08:00
parent 954a3fe8d9
commit 3d47227fbd
612 changed files with 515 additions and 97334 deletions
-225
View File
@@ -1,225 +0,0 @@
---
name: release-version
description: Use when preparing or publishing a new semantic version release, updating bilingual changelogs, creating Git tags, or publishing GitHub/Gitee release pages for this repository.
---
# Release Version
## Overview
Create releases with a strict `vx.y.z` tag, produce bilingual changelog entries from actual Git history, publish both the `docs` submodule update and repository tag, and create the GitHub and Gitee Release page entries in one controlled workflow.
Run the workflow from the repository root. Read [references/changelog-style.md](references/changelog-style.md) before drafting the human-facing update notes.
## Workflow
1. Validate the requested version.
2. Inspect the repository and determine the comparison range.
3. Draft bilingual changelog entries from the actual diff.
4. Commit and push the `docs` submodule.
5. Commit the parent repository update if the submodule pointer changed.
6. Create and push the annotated tag.
7. Create GitHub and Gitee Release page entries for the tag.
8. Verify both remote tags and both Release pages.
Do not skip the repository inspection step. Release notes must come from the real diff between tags, not from guesswork.
## Validate The Version
- Accept only tags that match `^v\d+\.\d+\.\d+$`.
- Reject date-style tags such as `v20260414`.
- Confirm the target tag does not already exist locally or on any configured remote.
- Prefer the latest reachable semver tag as the previous release tag.
- If no earlier semver tag exists, fall back to the latest reachable tag of any format and state that fallback in the changelog drafting notes.
Use the helper script first:
```bash
python3 .codex/skills/release-version/scripts/collect_release_context.py \
--repo . \
--tag v1.2.3
```
If the caller already specifies the previous tag, pass it explicitly:
```bash
python3 .codex/skills/release-version/scripts/collect_release_context.py \
--repo . \
--tag v1.2.3 \
--previous-tag v1.2.2
```
If the repository-local helper is unavailable, fall back to `~/.codex/skills/release-version/scripts/collect_release_context.py`.
## Inspect The Repository
- Check `git status --short` in the parent repo.
- Check `git -C docs status --short` in the `docs` submodule.
- Read the JSON output of `collect_release_context.py`.
- Use the commit list, changed files, and insertions/deletions to decide what is user-visible.
- Prioritize behavior changes, new features, fixes, migrations, API changes, configuration changes, and documentation changes that matter to adopters.
- Ignore pure formatting churn unless it changes usage.
If the working tree contains unrelated changes that would be risky to include in the release, stop and ask the user before proceeding.
## Draft The Changelog
Update these files:
- `docs/zh/docs/changelog.md`
- `docs/en/docs/changelog.md`
Prepend a new entry using this exact structure:
```md
## ${tag} (${yyyy-MM-dd})
### 更新内容
${content}
### 发布地址
- Github: <https://github.com/huabeitech/agent-desk/releases/tag/${tag}>
- Gitee: <https://gitee.com/huabeitech/agent-desk/releases/tag/${tag}>
```
For the English file, keep the same links and heading level, but translate the section heading and content naturally:
```md
## ${tag} (${yyyy-MM-dd})
### Updates
${content}
### Release Links
- Github: <https://github.com/huabeitech/agent-desk/releases/tag/${tag}>
- Gitee: <https://gitee.com/huabeitech/agent-desk/releases/tag/${tag}>
```
Changelog writing rules:
- Write concise, user-facing summaries instead of raw commit subjects.
- Keep Chinese and English entries semantically aligned.
- Prefer 3-6 bullets unless the release is extremely small.
- Group related changes into a single bullet when that reads better.
- Mention compatibility-sensitive changes explicitly.
- If the comparison baseline is a non-semver fallback tag, note that in your private reasoning, not in the public changelog unless the user asks for it.
## Commit And Push The Docs Submodule
After editing the changelog files:
1. Run `git -C docs status --short`.
2. Review the diff with `git -C docs diff -- zh/docs/changelog.md en/docs/changelog.md`.
3. Commit inside the `docs` submodule with a focused message such as `docs: update changelog for v1.2.3`.
4. Push the `docs` submodule commit to its remote branch.
Branch rule:
- If `docs` is on a local branch, push that branch.
- If `docs` is detached, push `HEAD` to `origin/main` unless the repository clearly uses another default branch.
## Commit The Parent Repository
If the `docs` submodule pointer changed in the parent repository, commit it before tagging. Otherwise the release tag will not reference the new changelog revision.
Recommended flow:
```bash
git status --short
git add docs
git commit -m "chore: update docs submodule for v1.2.3"
```
Only include unrelated parent-repo changes if the user explicitly wants them in the release commit.
## Create And Push The Tag
Create an annotated tag after the repository state is ready:
```bash
git tag -a v1.2.3 -m "Release v1.2.3"
```
Push the commit branch first if needed, then push the tag to every configured remote that should publish releases:
```bash
git push github HEAD
git push origin HEAD
git push github v1.2.3
git push origin v1.2.3
```
Adjust the branch name if `HEAD` is not tracking the intended release branch.
## Create GitHub And Gitee Releases
Pushing tags is not enough. The release is incomplete until both Release pages exist:
- GitHub: `https://github.com/huabeitech/agent-desk/releases/tag/${tag}`
- Gitee: `https://gitee.com/huabeitech/agent-desk/releases/tag/${tag}`
Use the same concise release notes derived from the changelog. Prefer a bilingual body with Chinese first and English second.
Required credentials:
- GitHub: `GITHUB_TOKEN` or `GH_TOKEN` with access to `huabeitech/agent-desk` and permission to create releases. For a fine-grained PAT, use an organization-allowed lifetime and grant the repository at least `Contents: Read and write` plus `Metadata: Read`.
- Gitee: `GITEE_ACCESS_TOKEN` or `GITEE_TOKEN` with release write access to `huabeitech/agent-desk`.
Never print tokens in command output or final responses. If the user pastes a token into the conversation, use it only for the requested release operation and recommend rotation after use.
Build the release body from the new changelog entry, for example:
```bash
mkdir -p /tmp/agent-desk-release
awk 'BEGIN{p=0} /^## v1\.2\.3 /{p=1; next} /^## v[0-9]/{if(p) exit} p{print}' \
docs/zh/docs/changelog.md | sed '/^### 发布地址/,$d' > /tmp/agent-desk-release/v1.2.3-zh.md
awk 'BEGIN{p=0} /^## v1\.2\.3 /{p=1; next} /^## v[0-9]/{if(p) exit} p{print}' \
docs/en/docs/changelog.md | sed '/^### Release Links/,$d' > /tmp/agent-desk-release/v1.2.3-en.md
{
printf '## 更新内容\n\n'
sed '1,/^### 更新内容$/d' /tmp/agent-desk-release/v1.2.3-zh.md
printf '\n## Updates\n\n'
sed '1,/^### Updates$/d' /tmp/agent-desk-release/v1.2.3-en.md
} > /tmp/agent-desk-release/v1.2.3-release-body.md
```
Create the GitHub Release:
```bash
token="${GITHUB_TOKEN:-$GH_TOKEN}"
curl -sS -o /tmp/github_release_v1.2.3.json -w '%{http_code}' \
-X POST https://api.github.com/repos/huabeitech/agent-desk/releases \
-H "Authorization: Bearer ${token}" \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
-H 'Content-Type: application/json' \
-d @<(jq -n --rawfile body /tmp/agent-desk-release/v1.2.3-release-body.md \
'{tag_name:"v1.2.3", target_commitish:"main", name:"v1.2.3", body:$body, draft:false, prerelease:false}')
```
Create the Gitee Release:
```bash
token="${GITEE_ACCESS_TOKEN:-$GITEE_TOKEN}"
curl -sS -o /tmp/gitee_release_v1.2.3.json -w '%{http_code}' \
-X POST https://gitee.com/api/v5/repos/huabeitech/agent-desk/releases \
-H 'Content-Type: application/json' \
-d @<(jq -n --rawfile body /tmp/agent-desk-release/v1.2.3-release-body.md --arg token "${token}" \
'{access_token:$token, tag_name:"v1.2.3", target_commitish:"main", name:"v1.2.3", body:$body, prerelease:false}')
```
If creation returns `422`/already exists, fetch the existing release and verify it references the target tag before treating it as complete. If GitHub returns `Resource not accessible by personal access token`, inspect the API message and ask for a token that satisfies the organization policy and repository permissions.
## Final Verification
- Confirm `git rev-parse v1.2.3^{tag}` succeeds.
- Confirm `git ls-remote --tags github v1.2.3` and `git ls-remote --tags origin v1.2.3` show the new tag.
- Confirm `curl -sS -o /tmp/github_release_verify.json -w '%{http_code}' https://api.github.com/repos/huabeitech/agent-desk/releases/tags/v1.2.3` returns `200`.
- Confirm `curl -sS -o /tmp/gitee_release_verify.json -w '%{http_code}' https://gitee.com/api/v5/repos/huabeitech/agent-desk/releases/tags/v1.2.3` returns `200`.
- Confirm the `docs` submodule remote contains the changelog commit.
- Confirm both parent and `docs` working trees are clean.
- Summarize the previous tag used for comparison, the files updated, the commit hashes created, the remotes pushed, and the GitHub/Gitee Release URLs.
@@ -1,4 +0,0 @@
interface:
display_name: "Release Version"
short_description: "Create semver tags and changelog drafts"
default_prompt: "Use $release-version to create a new vx.y.z release, update changelogs, and push docs plus tags."
@@ -1,61 +0,0 @@
# Changelog Style
Use this guide when drafting `docs/zh/changelog.md` and `docs/en/changelog.md`.
## Goal
Turn Git history into short release notes that explain what changed for adopters.
## Keep
- New user-facing features.
- Bug fixes with clear impact.
- Breaking or compatibility-sensitive changes.
- API, configuration, deployment, model, workflow, or schema changes that affect usage.
- Important documentation updates when they unlock new workflows.
## Drop Or Compress
- Pure refactors with no visible effect.
- Formatting-only changes.
- Internal rename churn.
- Mechanical dependency updates unless they fix a real issue.
## Chinese Style
- Use concise bullets.
- Prefer product or workflow language over commit jargon.
- Start with the effect, then mention the area if needed.
- Keep terms consistent across bullets.
Example:
```md
- 优化会话列表查询与筛选逻辑,减少后台定位问题时的人工排查成本。
- 修复消息发送链路中的异常处理,避免部分失败场景下页面状态不同步。
```
## English Style
- Mirror the Chinese meaning instead of translating word by word.
- Use direct release-note phrasing.
- Prefer active wording and concrete impact.
Example:
```md
- Improved conversation list querying and filtering so operators can locate problem cases faster.
- Fixed error handling in the message send flow to prevent UI state from drifting after partial failures.
```
## Grouping Heuristics
- Merge multiple commits into one bullet when they deliver one outcome.
- Separate bullets when the audience or impact differs.
- Keep both language versions aligned in bullet count when possible.
## Before Finalizing
- Re-check that every bullet is supported by the diff.
- Remove statements that depend on assumptions you cannot verify from code, tests, or commits.
- Keep the notes short enough to scan in under a minute.
@@ -1,221 +0,0 @@
#!/usr/bin/env python3
"""Collect release context between a target tag and its comparison baseline."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
def run_git(
repo: Path,
args: list[str],
allow_failure: bool = False,
timeout_seconds: int = 15,
) -> str:
try:
result = subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=False,
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired:
if allow_failure:
return ""
raise RuntimeError(f"git command timed out: git {' '.join(args)}")
if result.returncode != 0 and not allow_failure:
raise RuntimeError(result.stderr.strip() or "git command failed")
return result.stdout.strip()
def parse_semver(tag: str) -> tuple[int, int, int] | None:
match = SEMVER_TAG_RE.match(tag)
if not match:
return None
return tuple(int(part) for part in match.groups())
def list_reachable_tags(repo: Path) -> list[str]:
output = run_git(repo, ["tag", "--merged", "HEAD"])
return [line.strip() for line in output.splitlines() if line.strip()]
def choose_previous_tag(repo: Path, tags: list[str], target_tag: str | None) -> tuple[str | None, str | None]:
semver_tags: list[tuple[tuple[int, int, int], str]] = []
other_tags: list[str] = []
target_semver = parse_semver(target_tag) if target_tag else None
for tag in tags:
if target_tag and tag == target_tag:
continue
parsed = parse_semver(tag)
if parsed is None:
other_tags.append(tag)
continue
if target_semver is not None and parsed >= target_semver:
continue
semver_tags.append((parsed, tag))
if semver_tags:
semver_tags.sort(key=lambda item: item[0], reverse=True)
return semver_tags[0][1], "semver"
if not other_tags:
return None, None
candidates: list[tuple[int, str]] = []
for tag in other_tags:
ts = run_git(repo, ["log", "-1", "--format=%ct", tag], allow_failure=True)
try:
candidates.append((int(ts), tag))
except ValueError:
continue
if not candidates:
return other_tags[-1], "fallback"
candidates.sort(reverse=True)
return candidates[0][1], "fallback"
def ensure_tag_absent(repo: Path, tag: str) -> None:
local = run_git(repo, ["tag", "--list", tag])
if local:
raise ValueError(f"target tag already exists locally: {tag}")
remotes_output = run_git(repo, ["remote"])
for remote in [line.strip() for line in remotes_output.splitlines() if line.strip()]:
remote_hit = run_git(
repo,
["ls-remote", "--tags", remote, tag],
allow_failure=True,
timeout_seconds=8,
)
if remote_hit:
raise ValueError(f"target tag already exists on remote {remote}: {tag}")
def ensure_tag_exists(repo: Path, tag: str) -> None:
hit = run_git(repo, ["rev-parse", "--verify", "--quiet", tag], allow_failure=True)
if not hit:
raise ValueError(f"previous tag does not exist locally: {tag}")
def get_commit_list(repo: Path, rev_range: str) -> list[dict[str, str]]:
output = run_git(repo, ["log", "--reverse", "--date=short", "--pretty=format:%H%x09%ad%x09%s", rev_range])
commits: list[dict[str, str]] = []
for line in output.splitlines():
commit_hash, date_str, subject = line.split("\t", 2)
commits.append(
{
"hash": commit_hash,
"short_hash": commit_hash[:7],
"date": date_str,
"subject": subject,
}
)
return commits
def get_changed_files(repo: Path, rev_range: str) -> list[str]:
output = run_git(repo, ["diff", "--name-only", rev_range])
return [line.strip() for line in output.splitlines() if line.strip()]
def get_numstat(repo: Path, rev_range: str) -> dict[str, int]:
output = run_git(repo, ["diff", "--numstat", rev_range])
changed_files = 0
insertions = 0
deletions = 0
for line in output.splitlines():
parts = line.split("\t")
if len(parts) < 3:
continue
added, removed = parts[0], parts[1]
changed_files += 1
if added.isdigit():
insertions += int(added)
if removed.isdigit():
deletions += int(removed)
return {
"changed_files": changed_files,
"insertions": insertions,
"deletions": deletions,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", default=".", help="Repository root. Defaults to current directory.")
parser.add_argument("--tag", help="Target release tag to validate, for example v1.2.3.")
parser.add_argument("--previous-tag", help="Explicit comparison baseline.")
return parser
def main() -> int:
args = build_parser().parse_args()
repo_path = Path(args.repo).expanduser().resolve()
if not (repo_path / ".git").exists():
print(json.dumps({"error": f"not a git repository: {repo_path}"}))
return 1
if args.tag and parse_semver(args.tag) is None:
print(json.dumps({"error": f"invalid tag format: {args.tag}", "expected": "vx.y.z"}))
return 1
try:
if args.tag:
ensure_tag_absent(repo_path, args.tag)
if args.previous_tag:
ensure_tag_exists(repo_path, args.previous_tag)
except ValueError as exc:
print(json.dumps({"error": str(exc)}))
return 1
tags = list_reachable_tags(repo_path)
previous_tag = args.previous_tag
previous_tag_source = "explicit" if previous_tag else None
if previous_tag is None:
previous_tag, previous_tag_source = choose_previous_tag(repo_path, tags, args.tag)
rev_range = "HEAD"
if previous_tag:
rev_range = f"{previous_tag}..HEAD"
try:
commits = get_commit_list(repo_path, rev_range)
changed_files = get_changed_files(repo_path, rev_range)
stats = get_numstat(repo_path, rev_range)
head_commit = run_git(repo_path, ["rev-parse", "HEAD"])
except RuntimeError as exc:
print(json.dumps({"error": str(exc)}))
return 1
payload: dict[str, object] = {
"repo": str(repo_path),
"target_tag": args.tag,
"previous_tag": previous_tag,
"previous_tag_source": previous_tag_source,
"rev_range": rev_range,
"head_commit": head_commit,
"commit_count": len(commits),
"commits": commits,
"changed_files": changed_files,
}
payload.update(stats)
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
-9
View File
@@ -20,12 +20,3 @@ config/config.yaml
node_modules
.pnpm-store
web/node_modules
web/.next
web/out
web/coverage
web/tsconfig.tsbuildinfo
web/next-env.d.ts
web/.env*
!web/.env.example
-90
View File
@@ -1,90 +0,0 @@
name: Docker Image
on:
push:
tags:
- "v*"
permissions:
contents: read
env:
IMAGE_NAME: mlogclub/agent-desk
jobs:
build:
name: Build and push Docker image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate Docker Hub credentials
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
if [ -z "${DOCKERHUB_USERNAME}" ]; then
echo "Secret DOCKERHUB_USERNAME is required" >&2
exit 1
fi
if [ -z "${DOCKERHUB_TOKEN}" ]; then
echo "Secret DOCKERHUB_TOKEN is required" >&2
exit 1
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
- name: Extract LanceDB Docker metadata
id: meta-lancedb
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
flavor: |
suffix=-lancedb,onlatest=true
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: app
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push LanceDB
uses: docker/build-push-action@v6
with:
context: .
target: app-lancedb
platforms: linux/amd64
push: true
tags: ${{ steps.meta-lancedb.outputs.tags }}
labels: ${{ steps.meta-lancedb.outputs.labels }}
+10 -18
View File
@@ -1,23 +1,15 @@
.DS_Store
.worktrees
.idea
.pnpm-store
.idea/
.vscode/
.worktrees/
data/
config/config.yaml
/include/
/lib/
data/
dist/
include/
lib/
*.log
__debug_bin*
node_modules/
.next/
.task/
*.tsbuildinfo
.superpowers
agent-desk
test-reports
web/public/flowgram-editor/
test-reports/
/server
-6
View File
@@ -1,6 +0,0 @@
[submodule "qdrant"]
path = qdrant
url = git@github.com:huabeitech/agent-desk-qdrant.git
[submodule "docs"]
path = docs
url = git@github.com:huabeitech/agent-desk-docs.git
+2 -24
View File
@@ -2,39 +2,17 @@
"version": "0.2.0",
"configurations": [
{
"name": "server",
"name": "Agent Desk API",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/server/main.go",
"cwd": "${workspaceFolder}",
"buildFlags": "-tags=dev,lancedb",
"env": {
"CGO_ENABLED": "1",
"CGO_CFLAGS": "-I${workspaceFolder}/include",
"CGO_LDFLAGS": "${workspaceFolder}/lib/darwin_arm64/liblancedb_go.a -framework Security -framework CoreFoundation"
},
"buildFlags": "-tags=dev",
"args": [
"-config",
"${workspaceFolder}/config/config.yaml"
]
},
{
"name": "web",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"cwd": "${workspaceFolder}/web"
}
],
"compounds": [
{
"name": "server + web",
"configurations": [
"server",
"web"
],
"stopAll": true
}
]
}
+4 -19
View File
@@ -2,29 +2,14 @@
"version": "2.0.0",
"tasks": [
{
"label": "web:dev",
"label": "server:dev",
"type": "shell",
"command": "pnpm dev",
"command": "make dev",
"options": {
"cwd": "${workspaceFolder}/web"
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "web"
},
"problemMatcher": {
"owner": "custom",
"pattern": {
"regexp": "."
},
"background": {
"activeOnStart": true,
"beginsPattern": ".",
"endsPattern": "Ready in|Local:"
}
}
"problemMatcher": []
}
]
}
+13 -499
View File
@@ -1,505 +1,19 @@
# AGENTS.md
This file defines mandatory development rules for AI Agents in this project. Unless the user explicitly requests a deviation, these rules must be followed.
This repository contains the Agent Desk backend only.
## 1. Basic Principles
- Stack: Go + Gin + GORM + `github.com/mlogclub/simple`
- Database compatibility: SQLite and MySQL must remain supported; PostgreSQL is also supported.
- Layer direction: models -> repositories -> services -> handlers.
- Handlers must not call repositories directly or return GORM models.
- Services own business rules and transaction boundaries.
- Repositories own data access and accept `db *gorm.DB` consistently.
- Use request/response DTOs and the common `httpx.WriteJSON` response wrapper.
- Use `log/slog` for logging and `any` instead of `interface{}` in new code.
- Run `gofmt` and `go test ./...` after backend changes.
- Scope: the repository root and all subdirectories
- Priority: explicit user instructions > this file > default implementation habits
- If these rules conflict with the user's request: follow the user's request first, and note the deviation in the change summary
The sibling frontend project is normally `../agent-desk-web`. Shared frontend enums are generated with:
## 2. Fixed Technology Stack
- Backend: `Golang` + `Gin` + `GORM` + `github.com/mlogclub/simple`
- Database: must be compatible with both `SQLite` and `MySQL`
- Frontend: `Next.js(App Router)` + `React` + `shadcn/ui` + `Tailwind CSS`
- Frontend package manager: `pnpm`
## 3. Directory Conventions
```text
.
├── cmd/
│ ├── server/
│ ├── migration/
│ └── generator/
├── internal/
│ ├── bootstrap/
│ ├── builders/
│ ├── handlers/
│ │ ├── api/
│ │ ├── dashboard/
│ │ └── third/
│ ├── middleware/
│ ├── migration/
│ ├── models/
│ ├── repositories/
│ ├── services/
│ └── pkg/
│ ├── config/
│ ├── dto/
│ ├── enums/
│ ├── errorsx/
│ ├── httpx/
│ ├── logx/
│ └── utils/
├── web/
└── docs/
```bash
make enums FRONTEND_DIR=../agent-desk-web
```
## 4. Backend Layering
The backend must follow one-way dependencies: `models -> repositories -> services -> handlers`
- `models`: only define entities and table mappings
- `repositories`: only encapsulate data access
- `services`: handle business rules, transaction orchestration, and aggregation logic
- `handlers`: only parse parameters, check permissions, call services, and wrap responses
Forbidden:
- Handlers directly calling repositories
- Returning GORM models directly to the frontend
- Writing business orchestration in models or repositories
## 4.1 Full Layer Flow (`models -> repositories -> services -> handlers -> builders`)
This section is an executable refinement of the layering rules: each layer must do only what belongs to that layer. Data should flow around DTOs, GORM details should be concentrated in repositories, transaction boundaries should be concentrated in services, and response assembly should be concentrated in builders/handlers.
### 4.1.1 Dependency Direction (Required)
Only the following one-way dependencies are allowed:
- `models` -> must not depend on any business layer
- `repositories` -> may depend on `models` and base libraries (`gorm`/`simple/sqls`)
- `services` -> may depend on `repositories`, `models`, and `enums/errorsx/utils`; responsible for transactions and business orchestration
- `builders` -> may depend on `models` and `dto/response`; if necessary, may depend on a small number of `services` to supplement display fields, but aggregation in the service layer is preferred
- `handlers` -> may depend on `services`, `builders`, `pkg/dto/request`, `pkg/httpx/params`, and `pkg/httpx` response wrappers
Reverse dependencies are forbidden:
- `repositories` must not depend on `services/handlers/builders`
- `models` must not depend on `repositories/services/handlers/builders`
- `handlers` must not depend on `repositories` (they must go through services)
### 4.1.2 Data Shape and Flow (Recommended Standard)
A typical CRUD/business action data flow:
1. The **handler** reads parameters (`query/body/form/path`), performs permission checks, and calls the **service**
2. The **service** executes business rules (validation, idempotency, state machines, aggregation), starts a transaction when needed, and calls the **repository**
3. The **repository** only performs data reads/writes (`CRUD + queries`) and returns `models` or necessary aggregate structures
4. **builders** map `models`/aggregate results into `response DTO`
5. The **handler** returns `httpx.WriteJSON(...)`
Strong constraints:
- **Handler inputs use request DTOs**
- **Handler outputs use response DTOs**
- **Models must not be returned directly to the frontend**
### 4.1.3 Per-Layer Allow/Forbid Checklist
#### models (Entity Layer)
- **Allowed**
- Field definitions, table names, index/constraint tags, associations (GORM tags)
- Lightweight constants/enum field types (prefer `internal/pkg/enums`)
- **Forbidden**
- Business methods (for example, rule checks such as `CanDispatch()` belong in services)
- DB access, transactions, complex calculations
#### repositories (Data Access Layer)
- **Allowed**
- CRUD: `Get/Take/Find/FindOne/FindPageBy.../Create/Update/Updates/UpdateColumn/Delete`
- Reusable query-related methods: `FindByUserID`, `CountByStatus`, `FindActiveBy...`
- Anything that is a data-access detail belongs here (SQL conditions, sorting, pagination, locks)
- **Forbidden**
- Business orchestration (cross-table workflows, state transitions, event publishing, etc.)
- Permission checks or login-state checks
- Directly assembling response DTOs (DTO mapping belongs in builders/handlers)
Repository best practices:
- **Prefer unified primary-key read/write methods**: `Get/Updates/Delete`, avoiding repeated `id = ?` logic in services
- **Prefer query conditions through `sqls.Cnd` / `sqls.NewCnd()`**
- **Repository method signatures should consistently accept `db *gorm.DB`** (supporting both `sqls.DB()` and `ctx.Tx`)
#### services (Business Layer)
- **Allowed**
- Business rules: parameter normalization, cross-entity validation, state machines, idempotency, concurrency semantics
- Aggregation: combining results from multiple repositories when needed
- Transaction orchestration: `sqls.WithTransaction(func(ctx *sqls.TxContext) error { ... })`
- Domain-object preparation before calling builders (when builders stay cleaner as pure mappers)
- **Forbidden**
- Handler responsibilities: parameter parsing, HTTP details, response wrapping
- Repository responsibilities: scattered GORM queries (unless it is a one-off complex SQL query that is not worth extracting)
Service best practices:
- **Open transactions only where atomicity is required**, and ensure every DB operation inside the transaction uses `ctx.Tx`
- **Call repositories from services**; do not mix repository usage with direct GORM calls in a way that splits style and ownership
#### builders (Output Construction Layer)
Purpose: convert `models` (or service aggregate results) into `response DTO`, avoiding repetitive mapping boilerplate in handlers.
- **Allowed**
- Pure `Model -> ResponseDTO` mapping
- Time formatting and enum label filling when needed
- Batch builders: `BuildXxxList([]models.Xxx) []response.Xxx`
- **Forbidden**
- DB access (builders should not query the database)
- Permission checks, transactions, complex business processes
Recommended builder form:
- Location: `internal/builders/*_builder.go`
- Methods: `BuildXxx(item *models.Xxx) *response.Xxx` / `BuildXxxList(list []models.Xxx) []response.Xxx`
#### handlers (API Layer)
- **Allowed**
- Parameter parsing: `params.ReadJSON/ReadForm/NewPagedSqlCnd/GetInt64...`
- Permissions: `AuthService.GetAuthPrincipal/RequirePermission/HasPermission`
- Calling services, calling builders, and wrapping with `httpx.WriteJSON`
- **Forbidden**
- Direct repository calls
- Directly returning models
- Writing business orchestration inside handlers (for example, "write A, then write B")
### 4.1.4 Transaction Best Practices
Transaction boundaries should be decided by the service layer. Principles:
- **A transaction is required** (`sqls.WithTransaction`) for:
- Multiple write SQL statements (for example, updating the main table and writing a log/event/relation table)
- Read-modify-write flows that require consistency (must not break under concurrency)
- Writes across multiple repositories that must be atomic
- **A transaction is not required** for:
- A single write SQL statement (one `Create/Updates/UpdateColumn/Delete`)
- A single write SQL statement plus pure calculation/parameter cleanup
Rules inside a transaction:
- Inside a transaction, all DB calls must use `ctx.Tx` (pass `ctx.Tx` as the repository method's `db` argument)
- Do not mix in `sqls.DB()` inside a transaction (it escapes the transaction)
### 4.1.5 Standard Endpoint Skeleton (Example)
```go
// Handler: parameters/permissions/response
func XxxUpdate(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionXxxUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.UpdateXxxRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.XxxService.UpdateXxx(req, operator); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
```
```go
// Service: business rules + transaction orchestration + repository calls
func (s *xxxService) UpdateXxx(req request.UpdateXxxRequest, operator *dto.AuthPrincipal) error {
current := repositories.XxxRepository.Get(sqls.DB(), req.ID)
if current == nil {
return errorsx.InvalidParam("object does not exist")
}
// Single write SQL statement: no transaction required
return repositories.XxxRepository.Updates(sqls.DB(), req.ID, map[string]any{
"name": strings.TrimSpace(req.Name),
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}
```
```go
// Builder: Model -> ResponseDTO
func BuildXxx(item *models.Xxx) *response.Xxx {
if item == nil {
return nil
}
return &response.Xxx{
id: item.ID,
// ...
}
}
```
## 5. `simple` Usage Conventions
- Prefer `sqls.Cnd` for query conditions
- Prefer `internal/pkg/httpx/params` for parameter binding
- Use `internal/pkg/httpx.WriteJSON` for all HTTP responses
- Write transaction boundaries must follow **4.1.4 Transaction Best Practices** (avoid slogan-style rules such as "always open a transaction even for a single write SQL statement")
## 6. Database Compatibility Rules
- Use compatible field types: `varchar`, `text`, `int`, `bigint`, `datetime`
- Primary keys must consistently use `int64`
- Avoid database-private syntax and dialect-specific features
- Keep time storage and parsing strategies consistent; MySQL must use `parseTime=True`
## 7. Code Generation and Migration
### 7.1 Code Generation
- Entry point: `cmd/generator/generator.go`
- Command: `task generator`
- Generation library: `github.com/mlogclub/codegen`
- Registration method: `codegen.GetGenerateStruct(&models.XXX{})`
- Generated files should be placed in the `generated` directory and named `*_gen.go`
- Generated code is only responsible for basic CRUD; business logic must be written manually in services/handlers
Standard process:
1. Define or modify the model
2. Register it in the generator
3. Run `task generator`
4. Add business logic in the handwritten layers
5. Run tests and self-checks
### 7.2 Migration
- DDL changes do not go through `internal/migration/runner.go` by default
- New tables, table changes, and index changes are handled uniformly through `sqls.DB().AutoMigrate(models.Models...)`
- `internal/migration/runner.go` is only for DML: initial data, backfills, repairs, remapping, etc.
- Migrations must be idempotent, and `version` must increase monotonically
- Execution order: run `AutoMigrate` first, then `migration.Migrate(...)`
## 8. API Conventions
### 8.1 DTOs and Responses
- Separate DTOs: define `request` and `response` separately
- JSON fields must consistently use `camelCase`
- Do not leak underlying SQL errors directly
- Error code ranges:
- `1000-1999` parameter errors
- `2000-2999` business errors
- `3000-3999` authentication/authorization errors
- `5000-5999` system errors
### 8.2 Path Layers
- `/api/dashboard/*`: business dashboard APIs
- `/api/third/*`: third-party platform callback/call APIs
- `/api/*`: open APIs
Do not add version prefixes such as `/api/v1`.
### 8.3 Dashboard API Style
- Prefer flat resource paths, such as `/api/dashboard/project`
- Prefer `/list`, `/create`, `/update`, and `/delete` for list/create/update/delete
- Prefer passing query conditions through `query` or `body`
- Avoid path params except for detail endpoints
- Detail endpoints may use `GET /api/dashboard/project/{id}`
- Prefer filtering subordinate resources through ordinary parameters such as `projectId` and `episodeId`; deep nested routes are discouraged
### 8.4 Route Registration
- The Gin engine is created uniformly in `internal/bootstrap/server.go`, and middleware is also registered there in order
- Routes should be split into grouping functions in `internal/bootstrap/routes.go` and `internal/bootstrap/*_routes.go`
- Business dashboard routes are registered through `dashboardGroup := app.Group("/api/dashboard", middleware.AuthMiddleware)`
- Open APIs should be organized under `/api/*` groups by domain, and third-party callbacks under `/api/third/*`
- Inside groups, mount handlers explicitly through `group.GET/POST/PUT/DELETE/Any(...)`
- Do not create a separate top-level `app.Group("/api/dashboard/xxx")` for each resource
- Authentication and authorization middleware should preferably be mounted at the `/api/dashboard` or `/api/admin` layer
### 8.5 Gin Explicit Route Rules
This project uses explicit Gin routes and does not use framework automatic routing. Handler method names are only for code organization; final URLs are determined by the paths registered in `internal/bootstrap/*_routes.go`.
- Route mounting example:
```go
func registerDashboardQuickReplyRoutes(group *gin.RouterGroup) {
group.GET("/:id", dashboard.QuickReplyGetBy)
group.Any("/list", dashboard.QuickReplyList)
group.POST("/create", dashboard.QuickReplyPostCreate)
group.POST("/update", dashboard.QuickReplyPostUpdate)
group.POST("/delete", dashboard.QuickReplyPostDelete)
}
```
- With the registration above, the resource base path is determined by the outer `dashboardGroup.Group("/quick-reply")`; the final full paths are `/api/dashboard/quick-reply/list`, `/api/dashboard/quick-reply/{id}`, etc.
- Handler names should keep the existing readable prefixes: `XxxList`, `XxxGetBy`, `XxxPostCreate`, `XxxPostUpdate`, `XxxPostDelete`
- Handler names do not create routes; before adding an endpoint, the corresponding `register...Routes` function must be modified
- The HTTP method must be determined by the Gin registration method:
- List queries: prefer `group.Any("/list", XxxList)`, used by the frontend as `GET /list`
- Detail queries: prefer `group.GET("/:id", XxxGetBy)`
- Write APIs: consistently use `group.POST("/create|/update|/delete", XxxPost...)`
- Business actions: use explicit paths, such as `group.POST("/send_message", ConversationPostSend_message)`
- Path params should be used only for detail endpoints or strong path-semantics scenarios; ordinary filters should continue using query/body
Common correct mappings in the current project:
- `registerDashboardUserRoutes(dashboardGroup.Group("/user"))`
- `group.GET("/:id", dashboard.UserGetBy)` -> `GET /api/dashboard/user/{id}`
- `group.Any("/list", dashboard.UserList)` -> `ANY /api/dashboard/user/list`
- `group.POST("/create", dashboard.UserPostCreate)` -> `POST /api/dashboard/user/create`
- `group.POST("/update", dashboard.UserPostUpdate)` -> `POST /api/dashboard/user/update`
- `group.POST("/delete", dashboard.UserPostDelete)` -> `POST /api/dashboard/user/delete`
- `registerDashboardConversationRoutes(dashboardGroup.Group("/conversation"))`
- `group.GET("/:id", dashboard.ConversationGetBy)` -> `GET /api/dashboard/conversation/{id}`
- `group.Any("/list", dashboard.ConversationList)` -> `ANY /api/dashboard/conversation/list`
- `group.Any("/message/list", dashboard.ConversationMessage_list)` -> `ANY /api/dashboard/conversation/message/list`
- `group.POST("/send_message", dashboard.ConversationPostSend_message)` -> `POST /api/dashboard/conversation/send_message`
Easy mistakes:
- Do not assume adding an `XxxList` method automatically creates a `/list` route; it must be explicitly registered in the routes file
- Do not register detail endpoints as `/detail`; the current convention is `GET /:id`
- Do not casually add deeply nested routes; subordinate resources should preferably be filtered by ordinary parameters such as `projectId` and `conversationId`
- If an API contract requires an underscore path, write the underscore path directly in the Gin route, for example `group.POST("/send_message", ...)`
- Before adding a handler method, first write the corresponding Gin route registration and confirm that the final URL matches the frontend contract
### 8.6 Handler Conventions
- One handler file per resource, located at `internal/handlers/{api|dashboard|third}/*_handler.go`
- Handler functions should use the uniform form: `func XxxPostCreate(ctx *gin.Context)`
- Recommended method names:
- `XxxList(ctx *gin.Context)`
- `XxxGetBy(ctx *gin.Context)`
- `XxxPostCreate(ctx *gin.Context)`
- `XxxPostUpdate(ctx *gin.Context)`
- `XxxPostDelete(ctx *gin.Context)`
- Business actions may extend this pattern with names such as `XxxPostTest(ctx *gin.Context)` or `XxxPostGenerate(ctx *gin.Context)`
- Requirements for paginated `List` endpoints:
- Use `params.NewPagedSqlCnd(...)`
- Declare each filter field explicitly through `params.QueryFilter`
- Prefer default sorting with `.Desc("id")`; special sorting requires an explicit reason
- Prefer `FindPageByCnd(...)` in the service layer
- Perform DTO mapping in the handler layer; do not return model lists directly
- Paginated responses must use:
```go
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
```
- Paginated `data` structure must be:
- `data.results`
- `data.page.page`
- `data.page.limit`
- `data.page.total`
- Detail endpoints should preferably return `httpx.WriteJSON(ctx, dto)`
- Delete endpoints should preferably return `httpx.WriteJSON(ctx, nil)`
- JSON bodies should preferably be read with `params.ReadJSON`
- Form parameters should preferably be read with `params.ReadForm`
- Single parameters may be retrieved with `params.GetInt64`, `params.GetInt64Arr`, `params.Get`, etc.
- Pagination and query parameters should preferably use `params.NewPagedSqlCnd`
- Authenticated users should be retrieved through `services.AuthService.GetAuthPrincipal(ctx)` or `RequirePermission(ctx, ...)`
- Permission checks should consistently use `services.AuthService.HasPermission(...)` or `RequirePermission(...)`
- Authentication/authorization failures should consistently return `httpx.WriteJSON(ctx, err)`
- Errors such as `gorm.ErrRecordNotFound` should be converted into clear business messages
- When returning backend data, logic that converts data into response DTOs may be placed under `internal/builders`
### 8.7 Enum Definitions
- System constants should be defined uniformly under `/internal/pkg/enums`
- Model statuses should preferably use `Status` from `/internal/pkg/enums/enums.go`; only add a new status enum when it does not meet the requirement
- Enums shared by backend and frontend must follow [docs/design/specs/backend-frontend-enum-ast-spec.md](docs/design/specs/backend-frontend-enum-ast-spec.md)
- Shared backend/frontend enums may only be defined in the backend; the frontend must generate results with `task enums`, and handwritten duplicate business enums are forbidden
## 9. Go Code Standards
- Logs must consistently use the standard library `log/slog`
- New logs must not introduce other logging libraries
- Log fields should preferably use structured key-value pairs
- New Go code must consistently use `any`; do not add new `interface{}`
- Run `gofmt` after modifying Go code
## 10. Frontend Standards
### 10.1 Project Facts
- Frontend directory: `web`
- Framework: `Next.js 16` + App Router
- Page directory: `web/app/*`
- Component directory: `web/components/*`
- shadcn/ui base component directory: `web/components/ui/*`
- Utility directories: `web/lib/*`, `web/hooks/*`
- Alias: `@/*`
- Style entry: `web/app/globals.css`
- shadcn config: `web/components.json`
### 10.2 Components and Pages
- Prefer base components from `shadcn/ui`
- If an existing `shadcn/ui` component covers the use case, do not duplicate an equivalent base component
- If missing base components such as `dialog`, `textarea`, or `select` are truly needed for business logic, install them according to the standard process instead of hand-writing substitutes
- Do not modify `web/components/ui/*`
- Business components should live in `web/components/*` or the corresponding business directory
- API calls must be uniformly encapsulated in the service layer; do not scatter raw `fetch` calls in pages
- Frontend business APIs must be called through service methods under `web/lib/api/*`; raw `fetch` must not be used directly in `page.tsx`, business components, or stores
- `web/lib/api/client.ts` is the default request entry point; new business APIs should preferably reuse `request()` instead of implementing another request client
- When the backend returns the unified `JsonResult`, the frontend must handle `success`, `errorCode`, `message`, and `data` consistently; success must not be determined only by HTTP status
- Business code must not parse `JsonResult.data`, assemble generic error handling, or hand-write auth-refresh logic by itself; these concerns must be centralized in the common request wrapper
- Requests that require login state must reuse the unified wrapper with auth headers, `3000/3002` token refresh, and login-expiration cleanup; do not handle these separately at the page layer
- Direct use of low-level `fetch` is allowed only for third-party external services, binary downloads, SSE/streaming responses, WebSocket handshakes, or other cases not yet supported by the unified wrapper; such usage must include a code comment explaining the reason
### 10.3 shadcn Usage Process
- First confirm that `web/components.json` exists; if it exists, do not run `init` again
- Commands must be run from the `web` directory
- Dependencies must be installed with `pnpm`
- Prefer adding new base components with:
- `cd web && pnpm dlx shadcn@latest add button`
- `cd web && pnpm dlx shadcn@latest add button dialog form table`
### 10.4 Next.js Conventions
- Prefer App Router
- Add `"use client"` explicitly when client state or side effects are required
- Pages and layouts should follow the `layout.tsx` and `page.tsx` conventions
- Checks should preferably reuse existing scripts: `dev`, `build`, `start`, `lint`, `format`, `typecheck`
### 10.5 Enum Management
- All frontend enums should be defined uniformly in `web/lib/enums.ts`
- Enums are defined by the backend; frontend enums are generated with `task enums`
### 10.6 Dashboard List and Form Baseline
- Dashboard CRUD pages should preferably follow: `docs/design/specs/frontend-list-form-best-practice.md`
- Baseline example: `web/app/dashboard/quick-replies`
- Default to a two-layer structure: `page.tsx` manages the list and state, `_components/edit.tsx` manages the dialog form
- Forms should default to: `react-hook-form` + `zod` + `web/components/ui/field.tsx`
- API calls should stay in the page layer or service layer; form components should not call APIs directly
- After adding or modifying dashboard list/form pages, the AI Agent must first self-check compliance with that document, then run `cd web && pnpm typecheck`
### 10.7 Other Frontend Standards
- All frontend display times must be formatted as `yyyy-MM-dd HH:mm:ss`; preferably use `formatDateTime` from `web/lib/utils.ts`
- Dropdown components should not use the shadcn `select` component; use the shadcn `combobox` component instead. The project has a general dropdown wrapper at `web/components/option-combobox.tsx`; use it where possible.
- If data is used inside a component, the component should load it itself as much as possible instead of receiving it from outside. Preserve component independence.
## 11. Pre-Commit Checklist
After each change, at minimum confirm:
1. There are no cross-layer calls or reverse dependencies
2. Write operations have clear transaction boundaries
3. Responses still follow the unified `JsonResult` structure
4. Compatibility with both SQLite and MySQL is preserved
5. Necessary tests were added, at least covering core service paths
6. `gofmt` was run for Go changes
7. Frontend changes passed at least `pnpm lint` or `pnpm typecheck` from the `web` directory
+2 -27
View File
@@ -1,28 +1,5 @@
# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS flowgram-editor-builder
WORKDIR /src/flowgram-editor
RUN corepack enable && corepack prepare pnpm@10.30.2 --activate
COPY flowgram-editor/package.json flowgram-editor/pnpm-lock.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY flowgram-editor/ ./
RUN pnpm build
FROM node:24-alpine AS web-builder
WORKDIR /src/web
RUN corepack enable && corepack prepare pnpm@10.30.2 --activate
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ ./
COPY --from=flowgram-editor-builder /src/web/public/flowgram-editor ./public/flowgram-editor
RUN pnpm build:sdk && pnpm build
FROM golang:1.26-alpine AS server-builder
WORKDIR /src
@@ -32,7 +9,6 @@ RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . ./
COPY --from=web-builder /src/web/out ./web/out
ARG TARGETOS=linux
ARG TARGETARCH
@@ -57,7 +33,6 @@ RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . ./
COPY --from=web-builder /src/web/out ./web/out
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
@@ -106,7 +81,7 @@ EXPOSE 8083
VOLUME ["/app/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:8083/ >/dev/null || exit 1
CMD wget -qO- http://127.0.0.1:8083/api/health >/dev/null || exit 1
CMD ["/app/agent-desk", "-config", "/app/config/config.yaml"]
@@ -126,6 +101,6 @@ EXPOSE 8083
VOLUME ["/app/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:8083/ >/dev/null || exit 1
CMD wget -qO- http://127.0.0.1:8083/api/health >/dev/null || exit 1
CMD ["/app/agent-desk", "-config", "/app/config/config.yaml"]
+20 -199
View File
@@ -1,219 +1,40 @@
APP := agent-desk
APP := agent-desk-server
MAIN := ./cmd/server
WEB_DIR := web
DIST_DIR := dist
FRONTEND_DIR ?= ../agent-desk-web
GO ?= go
PNPM ?= pnpm
GOOS ?= $(shell $(GO) env GOOS)
GOARCH ?= $(shell $(GO) env GOARCH)
DEV_PORT ?= 8083
DEV_API_BASE_URL ?= http://127.0.0.1:$(DEV_PORT)
DEV_SERVER_URL ?= $(DEV_API_BASE_URL)/api/health
LANCEDB_VERSION ?= v0.1.2
LANCEDB_DOWNLOAD_SCRIPT ?= https://raw.githubusercontent.com/lancedb/lancedb-go/main/scripts/download-artifacts.sh
LANCEDB ?= 0
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
ifeq ($(GOOS),windows)
APP_EXT := .exe
else
APP_EXT :=
endif
ifeq ($(LANCEDB),1)
BUILD_NAME_SUFFIX := -lancedb
BUILD_TAGS := -tags lancedb
BUILD_CGO_ENABLED := 1
else
BUILD_NAME_SUFFIX :=
BUILD_TAGS :=
BUILD_CGO_ENABLED :=
endif
BUILD_OUTPUT := $(DIST_DIR)/$(APP)$(BUILD_NAME_SUFFIX)$(APP_EXT)
ifeq ($(UNAME_M),x86_64)
LANCEDB_ARCH := amd64
else ifeq ($(UNAME_M),amd64)
LANCEDB_ARCH := amd64
else ifeq ($(UNAME_M),arm64)
LANCEDB_ARCH := arm64
else ifeq ($(UNAME_M),aarch64)
LANCEDB_ARCH := arm64
else
LANCEDB_ARCH := unsupported
endif
ifeq ($(UNAME_S),Darwin)
LANCEDB_PLATFORM := darwin
LANCEDB_SYSTEM_LDFLAGS := -framework Security -framework CoreFoundation
else ifeq ($(UNAME_S),Linux)
LANCEDB_PLATFORM := linux
LANCEDB_SYSTEM_LDFLAGS := -lm -ldl -lpthread
else ifneq (,$(findstring MINGW,$(UNAME_S)))
LANCEDB_PLATFORM := windows
LANCEDB_ARCH := amd64
LANCEDB_SYSTEM_LDFLAGS :=
else ifneq (,$(findstring MSYS,$(UNAME_S)))
LANCEDB_PLATFORM := windows
LANCEDB_ARCH := amd64
LANCEDB_SYSTEM_LDFLAGS :=
else ifneq (,$(findstring CYGWIN,$(UNAME_S)))
LANCEDB_PLATFORM := windows
LANCEDB_ARCH := amd64
LANCEDB_SYSTEM_LDFLAGS :=
else
LANCEDB_PLATFORM := unsupported
LANCEDB_SYSTEM_LDFLAGS :=
endif
LANCEDB_PLATFORM_ARCH := $(LANCEDB_PLATFORM)_$(LANCEDB_ARCH)
LANCEDB_NATIVE_LIB := $(CURDIR)/lib/$(LANCEDB_PLATFORM_ARCH)/liblancedb_go.a
LANCEDB_CGO_CFLAGS := -I$(CURDIR)/include
LANCEDB_CGO_LDFLAGS := $(LANCEDB_NATIVE_LIB) $(LANCEDB_SYSTEM_LDFLAGS)
.DEFAULT_GOAL := build
.PHONY: help dev build release generator enums \
_web-build-spa _web-dev _prepare-dist _lancedb-artifacts _lancedb-check _lancedb-release-check
.PHONY: help dev build test generator enums clean
help:
@echo "Available targets:"
@echo " make dev Start backend and frontend development servers"
@echo " make build Build the current system into dist/"
@echo " make build LANCEDB=1"
@echo " Build the current-platform LanceDB binary into dist/"
@echo " make release Build linux/darwin/windows release binaries into dist/"
@echo " make release LANCEDB=1"
@echo " Build LanceDB release binaries into dist/"
@echo " make generator Run code generation"
@echo " make enums Generate frontend enums"
@echo " make help Show this help"
@echo " make dev Start the API server on port $(DEV_PORT)"
@echo " make build Build the API binary into $(DIST_DIR)/"
@echo " make test Run all Go tests"
@echo " make generator Run backend code generation"
@echo " make enums Generate frontend enums into $(FRONTEND_DIR)"
@echo " make clean Remove backend build output"
dev: _lancedb-check
@AGENT_DESK_SERVER_PORT="$(DEV_PORT)" CGO_ENABLED=1 CGO_CFLAGS="$(LANCEDB_CGO_CFLAGS)" CGO_LDFLAGS="$(LANCEDB_CGO_LDFLAGS)" \
$(GO) run -tags "dev lancedb" $(MAIN) & \
server_pid=$$!; \
trap 'kill $$server_pid 2>/dev/null || true' EXIT INT TERM; \
echo "Waiting for server at $(DEV_SERVER_URL)..."; \
until curl -fsS "$(DEV_SERVER_URL)" >/dev/null 2>&1; do \
if ! kill -0 $$server_pid 2>/dev/null; then \
wait $$server_pid; \
exit $$?; \
fi; \
sleep 1; \
done; \
echo "Server is ready; starting web dev server..."; \
$(MAKE) _web-dev
dev:
@AGENT_DESK_SERVER_PORT="$(DEV_PORT)" $(GO) run -tags dev $(MAIN)
ifeq ($(LANCEDB),1)
build: _lancedb-check _prepare-dist _web-build-spa
else
build: _prepare-dist _web-build-spa
endif
@echo "Building $(BUILD_OUTPUT)..."
ifeq ($(LANCEDB),1)
@CGO_ENABLED=$(BUILD_CGO_ENABLED) CGO_CFLAGS="$(LANCEDB_CGO_CFLAGS)" CGO_LDFLAGS="$(LANCEDB_CGO_LDFLAGS)" \
$(GO) build $(BUILD_TAGS) -v -o $(BUILD_OUTPUT) $(MAIN)
else
@$(GO) build -v -o $(BUILD_OUTPUT) $(MAIN)
endif
build:
@mkdir -p $(DIST_DIR)
@$(GO) build -v -o $(DIST_DIR)/$(APP) $(MAIN)
ifeq ($(LANCEDB),1)
release: _lancedb-release-check _prepare-dist _web-build-spa
else
release: _prepare-dist _web-build-spa
endif
@echo "Building release binaries in $(DIST_DIR)..."
@if [ "$(LANCEDB)" = "1" ]; then \
set -e; \
if [ ! -f "$(CURDIR)/include/lancedb.h" ]; then \
echo "Missing LanceDB header: $(CURDIR)/include/lancedb.h"; \
exit 1; \
fi; \
build_lancedb() { \
platform="$$1"; \
arch="$$2"; \
ext="$$3"; \
system_ldflags="$$4"; \
native_lib="$(CURDIR)/lib/$${platform}_$${arch}/liblancedb_go.a"; \
output="$(DIST_DIR)/$(APP)-lancedb-$${platform}-$${arch}$${ext}"; \
if [ ! -f "$$native_lib" ]; then \
echo "Missing LanceDB native library for $${platform}/$${arch}: $$native_lib"; \
echo "LanceDB release builds require matching native artifacts and a CGO-capable toolchain for each target platform."; \
exit 1; \
fi; \
echo "Building $$output..."; \
CGO_ENABLED=1 CGO_CFLAGS="-I$(CURDIR)/include" CGO_LDFLAGS="$$native_lib $$system_ldflags" \
GOOS="$$platform" GOARCH="$$arch" $(GO) build -tags lancedb -v -o "$$output" $(MAIN); \
}; \
build_lancedb linux amd64 "" "-lm -ldl -lpthread"; \
build_lancedb linux arm64 "" "-lm -ldl -lpthread"; \
build_lancedb darwin amd64 "" "-framework Security -framework CoreFoundation"; \
build_lancedb darwin arm64 "" "-framework Security -framework CoreFoundation"; \
build_lancedb windows amd64 ".exe" ""; \
else \
GOOS=linux GOARCH=amd64 $(GO) build -v -o $(DIST_DIR)/$(APP)-linux-amd64 $(MAIN); \
GOOS=linux GOARCH=arm64 $(GO) build -v -o $(DIST_DIR)/$(APP)-linux-arm64 $(MAIN); \
GOOS=darwin GOARCH=amd64 $(GO) build -v -o $(DIST_DIR)/$(APP)-darwin-amd64 $(MAIN); \
GOOS=darwin GOARCH=arm64 $(GO) build -v -o $(DIST_DIR)/$(APP)-darwin-arm64 $(MAIN); \
GOOS=windows GOARCH=amd64 $(GO) build -v -o $(DIST_DIR)/$(APP)-windows-amd64.exe $(MAIN); \
fi
test:
@$(GO) test ./...
generator:
@$(GO) run ./cmd/generator/generator.go
enums:
@$(GO) run ./cmd/enums/generator.go
@$(GO) run ./cmd/enums/generator.go -output "$(FRONTEND_DIR)/lib/generated/enums.ts"
_web-build-spa:
@cd $(WEB_DIR) && $(PNPM) build:sdk && $(PNPM) build
_web-dev:
@cd $(WEB_DIR) && NEXT_PUBLIC_API_BASE_URL="" NEXT_API_BASE_URL="$(DEV_API_BASE_URL)" $(PNPM) dev
_prepare-dist:
@mkdir -p $(DIST_DIR)
_lancedb-artifacts:
@if [ "$(LANCEDB_PLATFORM)" = "unsupported" ] || [ "$(LANCEDB_ARCH)" = "unsupported" ]; then \
echo "Unsupported LanceDB platform: $(UNAME_S)/$(UNAME_M)"; \
exit 1; \
fi
@if [ -f "$(LANCEDB_NATIVE_LIB)" ] && [ -f "$(CURDIR)/include/lancedb.h" ]; then \
echo "LanceDB native artifacts already exist for $(LANCEDB_PLATFORM_ARCH)."; \
else \
echo "Downloading LanceDB native artifacts $(LANCEDB_VERSION) for $(LANCEDB_PLATFORM_ARCH)..."; \
curl -sSL "$(LANCEDB_DOWNLOAD_SCRIPT)" | bash -s "$(LANCEDB_VERSION)"; \
fi
_lancedb-check: _lancedb-artifacts
@if [ ! -f "$(LANCEDB_NATIVE_LIB)" ]; then \
echo "Missing LanceDB native library: $(LANCEDB_NATIVE_LIB)"; \
exit 1; \
fi
@if [ ! -f "$(CURDIR)/include/lancedb.h" ]; then \
echo "Missing LanceDB header: $(CURDIR)/include/lancedb.h"; \
exit 1; \
fi
_lancedb-release-check:
@if [ ! -f "$(CURDIR)/include/lancedb.h" ]; then \
echo "Missing LanceDB header: $(CURDIR)/include/lancedb.h"; \
exit 1; \
fi
@missing=0; \
for target in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64 windows_amd64; do \
native_lib="$(CURDIR)/lib/$${target}/liblancedb_go.a"; \
if [ ! -f "$$native_lib" ]; then \
echo "Missing LanceDB native library: $$native_lib"; \
missing=1; \
fi; \
done; \
if [ "$$missing" = "1" ]; then \
echo "LanceDB release builds require matching native artifacts and a CGO-capable toolchain for each target platform."; \
exit 1; \
fi
clean:
@$(GO) clean
@find $(DIST_DIR) -mindepth 1 -maxdepth 1 -type f -delete 2>/dev/null || true
+21 -257
View File
@@ -1,278 +1,42 @@
# AgentDesk
# Agent Desk Server
English | [简体中文](README_ZH.md)
Customer-service API server built with Go, Gin, GORM, and `github.com/mlogclub/simple`.
An open-source AI Agent customer support system with knowledge-based answers, human handoff, ticket workflows, and self-hosted deployment.
The frontend is an independent sibling project, normally located at `../agent-desk-web`.
> Built for teams that need online support, knowledge-base Q&A, human collaboration, and service tracking in one system. It is not just an LLM inside a chat box; it is an AI Helpdesk foundation designed around real support operations.
## Product Preview
Customer chat, agent workspace, knowledge base, model configuration, and AI Agent orchestration are managed in one system.
### Customer Chat
![Customer Chat](screenshots/1.png)
Customers can start a conversation from the web chat page. The AI Agent responds first with knowledge-grounded answers. When the user explicitly asks for a human, the system can start a handoff confirmation flow.
### Agent Workspace
![Agent Workspace](screenshots/2.png)
The support workspace includes conversation lists, message handling, AI-to-human handoff, agent replies, conversation tags, linked customers, and ticket context for daily support work.
### Knowledge Base and AI Agent Configuration
| Knowledge Base FAQ | AI Agent Configuration |
| --- | --- |
| ![Knowledge Base FAQ](screenshots/4.png) | ![AI Agent Configuration](screenshots/5.png) |
The knowledge base stores FAQs, documents, and retrievable content. AI Agents can be bound to model configurations, knowledge bases, Skills, and tools to create support agents for specific scenarios.
### Model Configuration
![Model Configuration](screenshots/3.png)
Model configuration supports OpenAI-compatible providers. You can configure LLMs, embedding models, rerank models, context limits, output settings, timeout, retry behavior, and enablement state.
## Why Use It
- **AI-first support**: Let AI Agents handle common questions, standard procedures, and knowledge-base answers first.
- **Knowledge-constrained replies**: Use RAG and the Answerability Gate to decide whether retrieved knowledge is strong enough to answer, reducing unsupported responses.
- **Natural human handoff**: Move to human agents when knowledge is insufficient, the user asks for help, or a workflow requires human confirmation.
- **Conversation-to-ticket loop**: Online chat, support handling, ticket creation, status flow, and progress records stay in one system.
- **Built for extension**: The backend uses Go, the frontend uses Next.js, and the runtime supports Skills, MCP, and OpenAI-compatible model access.
- **Self-host friendly**: Supports SQLite / MySQL and Qdrant for local trials, intranet deployment, and enterprise self-hosting.
## Core Capabilities
- **AI Agent support**: AI replies first, with fallback, confirmation, tool calling, and human collaboration.
- **Online conversation system**: Visitor sessions, message send/receive, unread status, assignment, transfer, and close flows.
- **Agent workspace**: Agents can take over conversations, reply to users, transfer teammates, link customers, and create tickets.
- **Knowledge-base RAG**: Knowledge bases, documents, FAQs, chunking, vector retrieval, retrieval logs, and quality analysis.
- **Answerability Gate**: Checks whether retrieved content can support an answer; otherwise returns a fallback and recommends human support.
- **Ticket system**: Create tickets from conversations, categorize, assign, move through status flows, record progress, and close the loop.
- **Support organization management**: Agent profiles, teams, schedules, and automatic assignment.
- **AI extensibility**: Skills, MCP debugging, and external tool integration.
- **Multiple entry points**: Admin dashboard, agent workspace, customer-facing web pages, and embeddable SDK.
## Use Cases
- Website live support
- SaaS product support
- AI + human hybrid support
- Internal enterprise service desk
- After-sales service, incident reporting, complaints, and operations support
- Support teams that need knowledge-base Q&A with human collaboration
## Quick Start
The fastest way to try the full stack is Docker Compose:
```bash
docker compose up -d --build
```
For the full English setup guide, see [Docker Compose Quick Start](https://agent-desk.huabei.pro/docs/getting-started/docker-compose.html).
To embed customer support on your website, see [Web Widget Integration](https://agent-desk.huabei.pro/docs/integration/web-widget.html).
To connect OpenAI-compatible model providers, see [Model Provider Configuration](https://agent-desk.huabei.pro/docs/config/model-provider.html).
Compose starts:
- `agent-desk`: application service on port `8083`
- `mysql`: MySQL 8.4 with the `mysql-data` volume
- `qdrant`: vector database with the `qdrant-data` volume, ports `6333` / `6334`
After startup, open:
- Admin dashboard: `http://localhost:8083/dashboard`
- Agent workspace: `http://localhost:8083/dashboard/conversations`
- Customer web integration demo: `http://localhost:8083/support/demo`
- Customer chat page: `http://localhost:8083/support/chat`
Default administrator account:
- Username: `admin`
- Password: `ChangeMe123!`
> Before exposing the system to the public internet or a team environment, change the default administrator password and configure independent authentication, session, and model secrets.
## Local Development
### Requirements
- Go `1.26+`
- Node.js `20+`
- `pnpm`
- Qdrant
### Prepare Configuration
## Development
```bash
cp config/config.example.yaml config/config.yaml
go mod download
make dev
```
The default configuration uses:
- SQLite: `data/app.db`
- Backend: `http://127.0.0.1:8083`
- Qdrant gRPC: `127.0.0.1:6334`
If Qdrant is not running locally, start it with Docker:
```bash
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
```
Install frontend dependencies:
```bash
cd web
pnpm install
cd ..
```
Start backend and frontend development servers together:
```bash
task dev
```
Default development URLs:
- Admin dashboard: `http://localhost:3000/dashboard`
- Agent workspace: `http://localhost:3000/dashboard/conversations`
- Customer web integration demo: `http://localhost:3000/support/demo`
- Customer chat page: `http://localhost:3000/support/chat`
## Tech Stack
- Backend: Golang + Gin + GORM + `github.com/mlogclub/simple`
- Frontend: Next.js 16 + React 19 + shadcn/ui + Tailwind CSS
- Database: SQLite / MySQL
- Vector DB: Qdrant
- AI: OpenAI-compatible LLM / Embedding + RAG + Skills + MCP
## Project Structure
The API listens on `http://127.0.0.1:8083` by default. Health check:
```text
.
├── cmd/ # server / migration / generator / testdata
├── internal/
│ ├── bootstrap/ # startup, routes, database, and migration initialization
│ ├── builders/ # model / aggregate result to response DTO mapping
│ ├── handlers/ # dashboard / api / third HTTP handlers
│ ├── middleware/ # Gin middleware
│ ├── migration/ # idempotent data migrations
│ ├── models/ # GORM models
│ ├── repositories/ # data access layer
│ ├── services/ # business orchestration and transaction boundaries
│ ├── ai/ # LLM / RAG / Runtime / Skills / MCP
│ └── pkg/ # config / dto / enums / httpx / utils and shared packages
├── web/ # Next.js frontend project
│ ├── app/dashboard/ # admin dashboard and agent workspace
│ ├── app/support/ # customer integration and chat pages
│ ├── components/ # React components
│ ├── lib/ # API client, SDK source, and utilities
│ └── public/sdk/ # built embeddable SDK
├── config/ # configuration files
├── docker/ # Docker configuration
└── docs/ # documentation site
GET http://127.0.0.1:8083/api/health
```
## Common Commands
Set `server.frontendUrl` and `server.cors.allowedOrigins` in `config/config.yaml` to the frontend origin. Local defaults use `http://127.0.0.1:3000`.
## Commands
```bash
task dev # start backend and frontend development servers
task build # build the frontend SPA and current-platform Go binary into dist/
task build:lancedb # build the current-platform LanceDB binary into dist/
task release # build linux/darwin/windows release binaries into dist/
task release:lancedb # build LanceDB release binaries into dist/
task generator # run code generation
task enums # generate frontend enums
task --list # show available tasks
make dev
make build
make test
make generator
make enums FRONTEND_DIR=../agent-desk-web
```
## AI Agent Workflow
`make enums` writes the backend-owned shared enums into the frontend project.
```mermaid
flowchart TD
A[User starts a support request<br/>Web support entry / Open API] --> B[Create or match a conversation]
B --> C[Customer sends a message]
C --> D[Trigger AI Reply Runtime]
D --> E[Load conversation history / AI configuration]
E --> F[Retrieve from bound knowledge bases]
F --> G{Are retrieved chunks enough to answer?}
G -- No --> Z[Return knowledge fallback<br/>and recommend human support]
G -- Yes --> H[Prepare Skills / MCP Tools]
H --> I[Pass trusted knowledge context to the Agent]
I --> J{Direct reply?}
J -- Yes --> K[LLM generates a knowledge-grounded reply]
J -- No --> N{Call Graph / MCP Tool?}
N -- Yes --> O[Run Skill / Graph / MCP Tool]
O --> P{Need user confirmation?}
P -- No --> I
P -- Yes --> Q[Ask the user to confirm]
Q --> R{Confirmation result}
R -- Confirm handoff --> S[Move conversation to human handoff pool]
S --> T[Automatic or manual assignment]
T --> U[Agent workspace takeover]
U --> V{Need ticket tracking?}
V -- Yes --> W[Create or link a ticket]
V -- No --> X[Human agent continues handling]
W --> X
X --> Y[Resolve and close]
R -- Confirm ticket --> AA[Create a ticket from the current conversation]
AA --> I
R -- Cancel --> K
N -- No --> K
```
## Support Loop
```mermaid
flowchart LR
A[Customer request] --> B[AI Agent handles first]
B --> C{Can the knowledge base answer?}
C -- Yes --> D[AI replies with trusted knowledge]
C -- No --> E[Fallback / recommend human support]
D --> F{Need a human?}
E --> G[Human takeover]
F -- No --> H[Conversation ends or data is retained]
F -- Yes --> G
G --> I[Agent workspace handles the case]
I --> J{Need follow-up tracking?}
J -- Yes --> K[Create / link a ticket]
J -- No --> L[Resolve directly]
K --> M[Ticket status flow and progress records]
M --> N[Complete]
L --> N
```
## Docker Image
If you only need to build the application image, prepare MySQL and Qdrant yourself and mount a configuration file:
## Docker
```bash
docker build -t mlogclub/agent-desk .
docker run --rm -p 8083:8083 \
-v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \
-v agent-desk-data:/app/data \
mlogclub/agent-desk
docker build --target app -t agent-desk-server .
docker run --rm -p 8083:8083 agent-desk-server
```
Compose uses [docker/agent-desk.yaml](docker/agent-desk.yaml) as the in-container configuration. The application reaches `mysql` and `qdrant` through Docker service names.
## Open-source Positioning
`AgentDesk` is useful as an open-source foundation for:
- AI customer support systems
- AI Helpdesk / AI Support Platform projects
- RAG answerability + human handoff implementation references
- Enterprise AI Agent application frameworks
If you are looking for a customer support system centered on AI Agents rather than a simple LLM chat box, this project is designed for that purpose.
The Docker image contains only the API service. Deploy the frontend separately.
+15 -260
View File
@@ -1,278 +1,33 @@
# AgentDesk
# Agent Desk 后端
[English](README.md) | 简体中文
客服系统后端 API 项目,使用 Go、Gin、GORM 和 `github.com/mlogclub/simple`
开源的 AI Agent 客服系统,支持知识库问答、人工接管、工单闭环和私有化部署
> 面向需要同时处理在线咨询、知识库问答、人工协同和服务跟踪的团队。它不是把 LLM 接进聊天框,而是一套围绕客服场景设计的 AI Helpdesk 基础系统。
## 产品预览
客户侧在线咨询、客服工作台、知识库、模型配置和 AI Agent 编排都在同一套系统中完成。
### 客户侧在线咨询
![客户侧在线咨询](screenshots/1.png)
客户可以在 Web 聊天页中直接发起咨询。AI Agent 会先接待,基于知识库回答问题;当用户明确要求人工介入时,会触发转人工确认流程。
### 客服工作台
![客服工作台](screenshots/2.png)
客服工作台支持会话列表、消息处理、AI 转人工、客服回复、会话标签、关联客户和工单信息查看,适合客服日常接待使用。
### 知识库与 AI 配置
| 知识库 FAQ | AI Agent 配置 |
| --- | --- |
| ![知识库 FAQ](screenshots/4.png) | ![AI Agent 配置](screenshots/5.png) |
知识库用于沉淀 FAQ、文档和可检索内容;AI Agent 可以绑定模型配置、知识库、Skills 和工具能力,形成面向具体客服场景的智能客服实例。
### 模型配置
![模型配置](screenshots/3.png)
模型配置支持 OpenAI-compatible 接入方式,可分别配置大语言模型、向量模型和重排模型,并管理上下文、输出、超时、重试和启用状态。
## 为什么选择它
- **AI 先接待**:让 AI Agent 优先处理常见问题、标准流程和知识库问答。
- **知识约束回答**:通过 RAG 和 Answerability Gate 判断知识片段是否足以回答,减少超出知识库范围的乱答。
- **自然转人工**:当知识库不足、用户明确要求或流程需要人工确认时,进入人工接管。
- **会话到工单闭环**:在线会话、客服接待、工单创建、状态流转和处理记录在同一套系统里完成。
- **适合二次开发**:后端使用 Go,前端使用 Next.js,支持 Skills、MCP 和 OpenAI-compatible 模型接入。
- **可私有化部署**:支持 SQLite / MySQL 和 Qdrant,适合本地体验、内网部署和企业自托管。
## 核心能力
- **AI Agent 客服**:AI 优先回复,支持兜底、确认、工具调用和人工协同。
- **在线会话系统**:支持访客会话、消息收发、未读状态、会话分配、转接和关闭。
- **客服工作台**:客服可接管会话、回复用户、转接同事、关联客户和创建工单。
- **知识库 RAG**:支持知识库、文档、FAQ、切片、向量检索、检索日志和质量分析。
- **Answerability Gate**:判断检索内容是否足以支撑回答,不足时返回兜底提示并建议联系人工。
- **工单系统**:支持从会话创建工单、分类、指派、状态流转、进展记录和闭环处理。
- **客服组织管理**:支持客服档案、客服组、排班和自动分配能力。
- **AI 扩展能力**:支持 Skills、MCP 调试和外部工具接入。
- **多入口接入**:提供管理后台、客服工作台、客户侧 Web 页面和嵌入式 SDK。
## 适用场景
- 官网在线客服
- SaaS 产品支持
- AI + 人工混合接待
- 企业内部服务台
- 售后、报障、投诉和运营支持
- 需要知识库问答与人工协同的客服团队
## 快速开始
推荐先用 Docker Compose 体验完整服务:
```bash
docker compose up -d --build
```
完整英文配置与排查说明见 [Docker Compose Quick Start](https://agent-desk.huabei.pro/zh/docs/getting-started/docker-compose.html)。
如需在官网或产品中嵌入客服入口,见 [Web Widget Integration](https://agent-desk.huabei.pro/zh/docs/integration/web-widget.html)。
如需接入 OpenAI-compatible 模型供应商,见 [Model Provider Configuration](https://agent-desk.huabei.pro/zh/docs/config/model-provider.html)。
Compose 默认会启动:
- `agent-desk`:应用服务,端口 `8083`
- `mysql`MySQL 8.4,数据卷 `mysql-data`
- `qdrant`:向量数据库,数据卷 `qdrant-data`,端口 `6333` / `6334`
启动后访问:
- 管理后台:`http://localhost:8083/dashboard`
- 客服工作台:`http://localhost:8083/dashboard/conversations`
- 客户侧 Web 接入示例:`http://localhost:8083/support/demo`
- 客户侧聊天页:`http://localhost:8083/support/chat`
默认管理员账号:
- 用户名:`admin`
- 密码:`ChangeMe123!`
> 首次用于公网或团队环境前,请务必修改默认管理员密码,并配置独立的鉴权、会话和模型密钥。
前端已拆分为独立项目,默认位于同级目录 `../agent-desk-web`
## 本地开发
### 环境要求
- Go `1.26+`
- Node.js `20+`
- `pnpm`
- Qdrant
### 准备配置
```bash
cp config/config.example.yaml config/config.yaml
go mod download
make dev
```
默认配置使用
- SQLite`data/app.db`
- Backend`http://127.0.0.1:8083`
- Qdrant gRPC`127.0.0.1:6334`
如果本地还没有 Qdrant,可以用 Docker 启动:
```bash
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
```
安装前端依赖:
```bash
cd web
pnpm install
cd ..
```
同时启动后端和前端开发服务:
```bash
task dev
```
开发环境默认入口:
- 管理后台:`http://localhost:3000/dashboard`
- 客服工作台:`http://localhost:3000/dashboard/conversations`
- 客户侧 Web 接入示例:`http://localhost:3000/support/demo`
- 客户侧聊天页:`http://localhost:3000/support/chat`
## 技术栈
- BackendGolang + Gin + GORM + `github.com/mlogclub/simple`
- FrontendNext.js 16 + React 19 + shadcn/ui + Tailwind CSS
- DatabaseSQLite / MySQL
- Vector DBQdrant
- AIOpenAI-compatible LLM / Embedding + RAG + Skills + MCP
## 项目结构
默认 API 地址:`http://127.0.0.1:8083`,健康检查
```text
.
├── cmd/ # server / migration / generator / testdata
├── internal/
│ ├── bootstrap/ # 启动、路由、数据库和迁移初始化
│ ├── builders/ # model / 聚合结果到 response DTO 的映射
│ ├── handlers/ # dashboard / api / third HTTP handlers
│ ├── middleware/ # Gin middleware
│ ├── migration/ # 幂等数据迁移
│ ├── models/ # GORM models
│ ├── repositories/ # 数据访问层
│ ├── services/ # 业务编排和事务边界
│ ├── ai/ # LLM / RAG / Runtime / Skills / MCP
│ └── pkg/ # config / dto / enums / httpx / utils 等基础包
├── web/ # Next.js 前端工程
│ ├── app/dashboard/ # 管理后台与客服工作台
│ ├── app/support/ # 客户侧接入和聊天页面
│ ├── components/ # React 组件
│ ├── lib/ # API client、SDK 源码和工具函数
│ └── public/sdk/ # 构建后的嵌入式 SDK
├── config/ # 配置文件
├── docker/ # Docker 配置
└── docs/ # 项目文档
GET http://127.0.0.1:8083/api/health
```
`config/config.yaml` 中将 `server.frontendUrl``server.cors.allowedOrigins` 配置为前端地址,本地默认为 `http://127.0.0.1:3000`
## 常用命令
```bash
task dev # 同时启动后端和前端开发服务
task build # 构建前端 SPA 和当前平台 Go 二进制
task build:lancedb # 构建当前平台 LanceDB 二进制
task release # 构建常用平台二进制
task release:lancedb # 构建 LanceDB 发布二进制
task generator # 执行代码生成
task enums # 生成前端枚举
task --list # 查看可用任务
make dev
make build
make test
make generator
make enums FRONTEND_DIR=../agent-desk-web
```
## AI Agent 工作流
```mermaid
flowchart TD
A[用户发起咨询<br/>Web 客服入口 / Open API] --> B[创建或匹配会话]
B --> C[客户发送消息]
C --> D[触发 AI Reply Runtime]
D --> E[加载会话历史 / AI 配置]
E --> F[按绑定知识库执行检索]
F --> G{知识片段是否足以回答?}
G -- 否 --> Z[返回知识库兜底提示<br/>并建议联系人工客服]
G -- 是 --> H[准备 Skills / MCP Tools]
H --> I[将可信知识上下文交给 Agent]
I --> J{直接回复?}
J -- 是 --> K[LLM 基于知识生成回复并返回用户]
J -- 否 --> N{是否调用 Graph / MCP Tool?}
N -- 是 --> O[执行 Skill / Graph / MCP Tool]
O --> P{需要用户确认?}
P -- 否 --> I
P -- 是 --> Q[向用户发起确认]
Q --> R{用户确认结果}
R -- 确认转人工 --> S[会话转人工并进入待接入池]
S --> T[自动分配或人工分配]
T --> U[客服工作台接管]
U --> V{是否需要工单跟踪?}
V -- 是 --> W[创建或关联工单]
V -- 否 --> X[人工继续处理]
W --> X
X --> Y[问题解决并关闭]
R -- 确认建单 --> AA[从当前会话创建工单]
AA --> I
R -- 取消 --> K
N -- 否 --> K
```
## 业务闭环
```mermaid
flowchart LR
A[客户咨询] --> B[AI Agent 接待]
B --> C{知识库可回答?}
C -- 是 --> D[AI 基于可信知识回复]
C -- 否 --> E[兜底提示 / 建议人工]
D --> F{是否需要人工?}
E --> G[人工接管]
F -- 否 --> H[会话结束或沉淀数据]
F -- 是 --> G
G --> I[客服工作台处理]
I --> J{是否需要跟踪?}
J -- 是 --> K[创建 / 关联工单]
J -- 否 --> L[直接解决]
K --> M[工单流转与进展记录]
M --> N[处理完成]
L --> N
```
## Docker 镜像
如果只需要构建应用镜像,可以自行准备 MySQL 和 Qdrant,并挂载配置文件:
```bash
docker build -t mlogclub/agent-desk .
docker run --rm -p 8083:8083 \
-v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \
-v agent-desk-data:/app/data \
mlogclub/agent-desk
```
Compose 使用 [docker/agent-desk.yaml](docker/agent-desk.yaml) 作为容器内配置,应用会通过 Docker 内部服务名访问 `mysql``qdrant`
## 开源定位
`AgentDesk` 适合作为以下方向的开源基础项目:
- AI 客服系统
- AI Helpdesk / AI Support Platform
- RAG 可回答性判定 + Human Handoff 的落地样板
- 面向企业场景的 AI Agent 应用框架
如果你在寻找一个以 AI Agent 为中心,而不是仅仅把 LLM 嵌进聊天框的客服系统,这个项目就是为此设计的。
`make enums` 会把后端定义的共享枚举生成到前端项目。
+15 -236
View File
@@ -3,264 +3,43 @@ version: '3'
silent: true
vars:
APP: agent-desk
APP: agent-desk-server
MAIN: ./cmd/server
WEB_DIR: web
DIST_DIR: dist
GO:
sh: 'printf "%s" "${GO:-go}"'
PNPM:
sh: 'printf "%s" "${PNPM:-pnpm}"'
DEV_PORT:
sh: 'printf "%s" "${DEV_PORT:-8083}"'
DEV_API_BASE_URL:
sh: 'printf "%s" "${DEV_API_BASE_URL:-http://127.0.0.1:${DEV_PORT:-8083}}"'
LANCEDB_VERSION:
sh: 'printf "%s" "${LANCEDB_VERSION:-v0.1.2}"'
LANCEDB_DOWNLOAD_SCRIPT:
sh: 'printf "%s" "${LANCEDB_DOWNLOAD_SCRIPT:-https://raw.githubusercontent.com/lancedb/lancedb-go/main/scripts/download-artifacts.sh}"'
HOST_OS:
sh: uname -s
HOST_ARCH:
sh: uname -m
HOST_GOOS:
sh: '${GO:-go} env GOOS'
HOST_LANCEDB_PLATFORM:
sh: |
case "$(uname -s)" in
Darwin) printf darwin ;;
Linux) printf linux ;;
MINGW*|MSYS*|CYGWIN*) printf windows ;;
*) printf unsupported ;;
esac
HOST_LANCEDB_ARCH:
sh: |
case "$(uname -m)" in
x86_64|amd64) printf amd64 ;;
arm64|aarch64) printf arm64 ;;
*) printf unsupported ;;
esac
HOST_LANCEDB_TARGET:
sh: |
case "$(uname -s)" in
Darwin) platform=darwin ;;
Linux) platform=linux ;;
MINGW*|MSYS*|CYGWIN*) platform=windows ;;
*) platform=unsupported ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
arm64|aarch64) arch=arm64 ;;
*) arch=unsupported ;;
esac
printf "%s_%s" "$platform" "$arch"
HOST_LANCEDB_LIB:
sh: |
case "$(uname -s)" in
Darwin) platform=darwin ;;
Linux) platform=linux ;;
MINGW*|MSYS*|CYGWIN*) platform=windows ;;
*) platform=unsupported ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
arm64|aarch64) arch=arm64 ;;
*) arch=unsupported ;;
esac
printf "%s/lib/%s_%s/liblancedb_go.a" "{{.ROOT_DIR}}" "$platform" "$arch"
LANCEDB_CGO_CFLAGS: '-I{{.ROOT_DIR}}/include'
FRONTEND_DIR:
sh: 'printf "%s" "${FRONTEND_DIR:-../agent-desk-web}"'
tasks:
default:
desc: Show available tasks
cmds:
- task --list
dev:
desc: Start backend and frontend development servers
deps:
- task: dev:backend
- task: dev:frontend
desc: Start the API server
cmds:
- 'AGENT_DESK_SERVER_PORT="{{.DEV_PORT}}" {{.GO}} run -tags dev {{.MAIN}}'
build:
desc: Build the frontend SPA and current-platform Go binary into dist/
deps:
- task: build:web
- task: build:dist
desc: Build the API binary
cmds:
- |
ext=""
if [ "{{.HOST_GOOS}}" = "windows" ]; then
ext=".exe"
fi
output="{{.DIST_DIR}}/{{.APP}}${ext}"
echo "Building $output..."
{{.GO}} build -v -o "$output" {{.MAIN}}
- mkdir -p {{.DIST_DIR}}
- '{{.GO}} build -v -o {{.DIST_DIR}}/{{.APP}} {{.MAIN}}'
build:lancedb:
desc: Build the current-platform LanceDB binary into dist/
deps:
- task: lancedb:ensure-current
- task: build:web
- task: build:dist
test:
desc: Run all Go tests
cmds:
- |
case "$(uname -s)" in
Darwin) system_ldflags="-framework Security -framework CoreFoundation" ;;
Linux) system_ldflags="-lm -ldl -lpthread" ;;
*) system_ldflags="" ;;
esac
ext=""
if [ "{{.HOST_GOOS}}" = "windows" ]; then
ext=".exe"
fi
output="{{.DIST_DIR}}/{{.APP}}-lancedb${ext}"
echo "Building $output..."
CGO_ENABLED=1 CGO_CFLAGS="{{.LANCEDB_CGO_CFLAGS}}" CGO_LDFLAGS="{{.HOST_LANCEDB_LIB}} $system_ldflags" \
{{.GO}} build -tags lancedb -v -o "$output" {{.MAIN}}
release:
desc: Build linux/darwin/windows release binaries into dist/
deps:
- task: build:web
- task: build:dist
cmds:
- |
echo "Building release binaries in {{.DIST_DIR}}..."
GOOS=linux GOARCH=amd64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-linux-amd64" {{.MAIN}}
GOOS=linux GOARCH=arm64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-linux-arm64" {{.MAIN}}
GOOS=darwin GOARCH=amd64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-darwin-amd64" {{.MAIN}}
GOOS=darwin GOARCH=arm64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-darwin-arm64" {{.MAIN}}
GOOS=windows GOARCH=amd64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-windows-amd64.exe" {{.MAIN}}
release:lancedb:
desc: Build LanceDB release binaries into dist/
deps:
- task: lancedb:check-release
- task: build:web
- task: build:dist
cmds:
- |
echo "Building LanceDB release binaries in {{.DIST_DIR}}..."
build_lancedb() {
platform="$1"
arch="$2"
ext="$3"
system_ldflags="$4"
native_lib="{{.ROOT_DIR}}/lib/${platform}_${arch}/liblancedb_go.a"
output="{{.DIST_DIR}}/{{.APP}}-lancedb-${platform}-${arch}${ext}"
echo "Building $output..."
CGO_ENABLED=1 CGO_CFLAGS="-I{{.ROOT_DIR}}/include" CGO_LDFLAGS="$native_lib $system_ldflags" \
GOOS="$platform" GOARCH="$arch" {{.GO}} build -tags lancedb -v -o "$output" {{.MAIN}}
}
build_lancedb linux amd64 "" "-lm -ldl -lpthread"
build_lancedb linux arm64 "" "-lm -ldl -lpthread"
build_lancedb darwin amd64 "" "-framework Security -framework CoreFoundation"
build_lancedb darwin arm64 "" "-framework Security -framework CoreFoundation"
build_lancedb windows amd64 ".exe" ""
- '{{.GO}} test ./...'
generator:
desc: Run code generation
desc: Run backend code generation
cmds:
- '{{.GO}} run ./cmd/generator/generator.go'
enums:
desc: Generate frontend enums
desc: Generate TypeScript enums into the sibling frontend project
cmds:
- '{{.GO}} run ./cmd/enums/generator.go'
dev:backend:
internal: true
cmds:
- task: lancedb:ensure-current
- |
echo "Starting backend on http://127.0.0.1:{{.DEV_PORT}} ..."
case "$(uname -s)" in
Darwin) system_ldflags="-framework Security -framework CoreFoundation" ;;
Linux) system_ldflags="-lm -ldl -lpthread" ;;
*) system_ldflags="" ;;
esac
AGENT_DESK_SERVER_PORT="{{.DEV_PORT}}" CGO_ENABLED=1 CGO_CFLAGS="{{.LANCEDB_CGO_CFLAGS}}" CGO_LDFLAGS="{{.HOST_LANCEDB_LIB}} $system_ldflags" \
{{.GO}} run -tags "dev lancedb" {{.MAIN}}
dev:frontend:
internal: true
deps:
- task: build:flowgram-editor
cmds:
- |
echo "Starting frontend on http://localhost:3000 ..."
cd {{.WEB_DIR}} && NEXT_PUBLIC_API_BASE_URL="" NEXT_API_BASE_URL="{{.DEV_API_BASE_URL}}" {{.PNPM}} dev
build:web:
internal: true
deps:
- task: build:flowgram-editor
cmds:
- 'cd {{.WEB_DIR}} && {{.PNPM}} build:sdk && {{.PNPM}} build'
build:flowgram-editor:
internal: true
sources:
- flowgram-editor/src/**/*
- flowgram-editor/package.json
- flowgram-editor/pnpm-lock.yaml
- flowgram-editor/rsbuild.config.ts
generates:
- web/public/flowgram-editor/index.html
cmds:
- 'cd flowgram-editor && {{.PNPM}} install --frozen-lockfile'
- 'cd flowgram-editor && {{.PNPM}} build'
build:dist:
internal: true
cmds:
- 'mkdir -p {{.DIST_DIR}}'
lancedb:ensure-current:
internal: true
cmds:
- |
if [ "{{.HOST_LANCEDB_PLATFORM}}" = "unsupported" ] || [ "{{.HOST_LANCEDB_ARCH}}" = "unsupported" ]; then
echo "Unsupported LanceDB platform: {{.HOST_OS}}/{{.HOST_ARCH}}"
exit 1
fi
- |
if [ -f "{{.HOST_LANCEDB_LIB}}" ] && [ -f "{{.ROOT_DIR}}/include/lancedb.h" ]; then
echo "LanceDB native artifacts already exist for {{.HOST_LANCEDB_TARGET}}."
else
echo "Downloading LanceDB native artifacts {{.LANCEDB_VERSION}} for {{.HOST_LANCEDB_TARGET}}..."
curl -sSL "{{.LANCEDB_DOWNLOAD_SCRIPT}}" | bash -s "{{.LANCEDB_VERSION}}"
fi
- |
if [ ! -f "{{.HOST_LANCEDB_LIB}}" ]; then
echo "Missing LanceDB native library: {{.HOST_LANCEDB_LIB}}"
exit 1
fi
- |
if [ ! -f "{{.ROOT_DIR}}/include/lancedb.h" ]; then
echo "Missing LanceDB header: {{.ROOT_DIR}}/include/lancedb.h"
exit 1
fi
lancedb:check-release:
internal: true
cmds:
- |
if [ ! -f "{{.ROOT_DIR}}/include/lancedb.h" ]; then
echo "Missing LanceDB header: {{.ROOT_DIR}}/include/lancedb.h"
exit 1
fi
- |
missing=0
for target in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64 windows_amd64; do
native_lib="{{.ROOT_DIR}}/lib/${target}/liblancedb_go.a"
if [ ! -f "$native_lib" ]; then
echo "Missing LanceDB native library: $native_lib"
missing=1
fi
done
if [ "$missing" = "1" ]; then
echo "LanceDB release builds require matching native artifacts and a CGO-capable toolchain for each target platform."
exit 1
fi
- '{{.GO}} run ./cmd/enums/generator.go -output "{{.FRONTEND_DIR}}/lib/generated/enums.ts"'
+9 -5
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/token"
@@ -15,9 +16,9 @@ import (
)
const (
enumsPkgName = "enums"
enumsDir = "internal/pkg/enums"
outputPath = "web/lib/generated/enums.ts"
enumsPkgName = "enums"
enumsDir = "internal/pkg/enums"
defaultOutputPath = "dist/enums.ts"
)
type enumValueType string
@@ -47,15 +48,18 @@ type enumDef struct {
}
func main() {
outputPath := flag.String("output", defaultOutputPath, "TypeScript enum output path")
flag.Parse()
defs, err := parseEnums(enumsDir)
if err != nil {
panic(err)
}
content := buildTSFile(defs)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(*outputPath), 0o755); err != nil {
panic(err)
}
if err := os.WriteFile(outputPath, []byte(content), 0o644); err != nil {
if err := os.WriteFile(*outputPath, []byte(content), 0o644); err != nil {
panic(err)
}
}
+7 -4
View File
@@ -2,20 +2,23 @@ language: zh-CN
server:
port: 8083
# Public address of the independently deployed frontend. Login callbacks redirect here.
frontendUrl: http://127.0.0.1:3000
cors:
# Browser CORS allowlist. In production, replace this with the actual frontend or embedded-site domains, such as https://support.example.com.
# Leave it empty to reject cross-origin browser requests. Same-origin and non-browser calls are still supported.
allowedOrigins:
- http://127.0.0.1:8083
- http://localhost:8083
- http://127.0.0.1:3000
- http://localhost:3000
db:
# Database driver. Supported values: sqlite, mysql.
# Database driver. Supported values: sqlite, mysql, postgres (postgresql is also accepted).
type: sqlite
# Database connection string.
# SQLite example: file:./data/app.db?_busy_timeout=5000
# MySQL example: user:password@tcp(127.0.0.1:3306)/cs_ai_agent_db?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local
# For MySQL, keep parseTime=True so datetime fields are scanned into Go time values correctly.
# PostgreSQL example: host=127.0.0.1 user=cs_ai_agent password=change-me dbname=cs_ai_agent port=5432 sslmode=disable TimeZone=Asia/Shanghai
dsn: file:./data/app.db?_busy_timeout=5000
# Maximum number of idle connections kept in the pool. Values <= 0 use the database/sql default.
maxIdleConns: 5
@@ -61,7 +64,7 @@ storage:
root: data/storage
# Public URL prefix used when returning local file URLs. The server must expose this path as static files.
# Example: storage key "images/a.png" becomes "/storage/images/a.png".
baseUrl: /storage
baseUrl: http://127.0.0.1:8083/storage
oss:
# Aliyun OSS endpoint. Both "oss-cn-hangzhou.aliyuncs.com" and "https://oss-cn-hangzhou.aliyuncs.com" are accepted.
endpoint: ""
+2 -24
View File
@@ -1,24 +1,4 @@
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: cs_ai_agent
MYSQL_USER: cs_ai_agent
MYSQL_PASSWORD: cs_ai_agent_password
MYSQL_ROOT_PASSWORD: cs_ai_agent_root_password
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$${MYSQL_USER}\" -p\"$${MYSQL_PASSWORD}\" --silent"]
interval: 10s
timeout: 5s
retries: 10
agent-desk:
build:
context: .
@@ -28,9 +8,6 @@ services:
LANCEDB_VERSION: v0.1.2
image: mlogclub/agent-desk:latest-lancedb
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
ports:
- "8083:8083"
volumes:
@@ -38,7 +15,8 @@ services:
- ./docker/agent-desk-lancedb.yaml:/app/config/config.yaml:ro
environment:
TZ: Asia/Shanghai
AGENT_DESK_DB_TYPE: postgres
AGENT_DESK_DB_DSN: ${AGENT_DESK_DB_DSN:-host=host.docker.internal user=cs_ai_agent password=cs_ai_agent_password dbname=cs_ai_agent port=5432 sslmode=disable TimeZone=Asia/Shanghai}
volumes:
mysql-data:
agent-desk-data:
+2 -23
View File
@@ -1,24 +1,4 @@
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: cs_ai_agent
MYSQL_USER: cs_ai_agent
MYSQL_PASSWORD: cs_ai_agent_password
MYSQL_ROOT_PASSWORD: cs_ai_agent_root_password
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$${MYSQL_USER}\" -p\"$${MYSQL_PASSWORD}\" --silent"]
interval: 10s
timeout: 5s
retries: 10
qdrant:
image: qdrant/qdrant:latest
restart: unless-stopped
@@ -35,8 +15,6 @@ services:
image: mlogclub/agent-desk:latest
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
qdrant:
condition: service_started
ports:
@@ -46,8 +24,9 @@ services:
- ./docker/agent-desk.yaml:/app/config/config.yaml:ro
environment:
TZ: Asia/Shanghai
AGENT_DESK_DB_TYPE: postgres
AGENT_DESK_DB_DSN: ${AGENT_DESK_DB_DSN:-host=host.docker.internal user=cs_ai_agent password=cs_ai_agent_password dbname=cs_ai_agent port=5432 sslmode=disable TimeZone=Asia/Shanghai}
volumes:
mysql-data:
qdrant-data:
agent-desk-data:
+6 -5
View File
@@ -2,14 +2,15 @@ language: zh-CN
server:
port: 8083
frontendUrl: http://127.0.0.1:3000
cors:
allowedOrigins:
- http://localhost:8083
- http://127.0.0.1:8083
- http://localhost:3000
- http://127.0.0.1:3000
db:
type: mysql
dsn: cs_ai_agent:cs_ai_agent_password@tcp(mysql:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&loc=Local
type: postgres
dsn: host=host.docker.internal user=cs_ai_agent password=cs_ai_agent_password dbname=cs_ai_agent port=5432 sslmode=disable TimeZone=Asia/Shanghai
logger:
level: info
@@ -26,7 +27,7 @@ storage:
maxUploadSizeMB: 5
local:
root: /app/data/storage
baseUrl: /storage
baseUrl: http://127.0.0.1:8083/storage
oss:
endpoint: ""
bucket: ""
+4 -3
View File
@@ -2,10 +2,11 @@ language: zh-CN
server:
port: 8083
frontendUrl: http://127.0.0.1:3000
cors:
allowedOrigins:
- http://localhost:8083
- http://127.0.0.1:8083
- http://localhost:3000
- http://127.0.0.1:3000
db:
type: sqlite
@@ -35,7 +36,7 @@ storage:
maxUploadSizeMB: 20
local:
root: /app/data/storage
baseUrl: /storage
baseUrl: http://127.0.0.1:8083/storage
oss:
endpoint: ""
bucket: ""
+6 -5
View File
@@ -2,15 +2,16 @@ language: zh-CN
server:
port: 8083
frontendUrl: http://127.0.0.1:3000
cors:
# 浏览器跨域白名单。生产部署时改为实际前端/嵌入站点域名。
allowedOrigins:
- http://127.0.0.1:8083
- http://localhost:8083
- http://127.0.0.1:3000
- http://localhost:3000
db:
type: mysql
dsn: cs_ai_agent:cs_ai_agent_password@tcp(mysql:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local
type: postgres
dsn: host=host.docker.internal user=cs_ai_agent password=cs_ai_agent_password dbname=cs_ai_agent port=5432 sslmode=disable TimeZone=Asia/Shanghai
maxIdleConns: 5
maxOpenConns: 20
connMaxIdleTimeSeconds: 300
@@ -36,7 +37,7 @@ storage:
maxUploadSizeMB: 20
local:
root: /app/data/storage
baseUrl: /storage
baseUrl: http://127.0.0.1:8083/storage
oss:
endpoint: ""
bucket: ""
Submodule docs deleted from 23fdf317bf
-386
View File
@@ -1,386 +0,0 @@
# FlowGram.AI - Demo Free Layout
Best-practice demo for free layout
## Installation
```shell
npx @flowgram.ai/create-app@latest free-layout
```
## Project Overview
### Core Tech Stack
- **Frontend framework**: React 18 + TypeScript
- **Build tool**: Rsbuild (a modern build tool based on Rspack)
- **Styling**: Less + Styled Components + CSS Variables
- **UI library**: Semi Design (@douyinfe/semi-ui)
- **State management**: Flowgrams in-house editor framework
- **Dependency injection**: Inversify
### Core Dependencies
- **@flowgram.ai/free-layout-editor**: Core dependency for the free layout editor
- **@flowgram.ai/free-snap-plugin**: Auto-alignment and guide-lines plugin
- **@flowgram.ai/free-lines-plugin**: Connection line rendering plugin
- **@flowgram.ai/free-node-panel-plugin**: Node add-panel rendering plugin
- **@flowgram.ai/minimap-plugin**: Minimap plugin
- **@flowgram.ai/export-plugin**: Download/export plugin
- **@flowgram.ai/free-container-plugin**: Sub-canvas plugin
- **@flowgram.ai/free-group-plugin**: Grouping plugin
- **@flowgram.ai/form-materials**: Form materials
- **@flowgram.ai/runtime-interface**: Runtime interfaces
- **@flowgram.ai/runtime-js**: JS runtime module
- **@flowgram.ai/panel-manager-plugin**: Sidebar panel management
## Code Guide
### Directory Structure
```
src/
├── app.tsx # Application entry file
├── editor.tsx # Main editor component
├── initial-data.ts # Initial data configuration
├── assets/ # Static assets
├── components/ # Component library
│ ├── index.ts
│ ├── add-node/ # Add-node component
│ ├── base-node/ # Base node components
│ ├── comment/ # Comment components
│ ├── group/ # Group components
│ ├── line-add-button/ # Connection add button
│ ├── node-menu/ # Node menu
│ ├── node-panel/ # Node add panel
│ ├── selector-box-popover/ # Selection box popover
│ ├── sidebar/ # Sidebar
│ ├── testrun/ # Test-run module
│ │ ├── hooks/ # Test-run hooks
│ │ ├── node-status-bar/ # Node status bar
│ │ ├── testrun-button/ # Test-run button
│ │ ├── testrun-form/ # Test-run form
│ │ ├── testrun-json-input/ # JSON input component
│ │ └── testrun-panel/ # Test-run panel
│ └── tools/ # Utility components
├── context/ # React Context
│ ├── node-render-context.ts # Current rendering node context
│ ├── sidebar-context # Sidebar context
├── form-components/ # Form component library
│ ├── form-content/ # Form content
│ ├── form-header/ # Form header
│ ├── form-inputs/ # Form inputs
│ └── form-item/ # Form item
│ └── feedback.tsx # Validation error rendering
├── hooks/
│ ├── index.ts
│ ├── use-editor-props.tsx # Editor props hook
│ ├── use-is-sidebar.ts # Sidebar state hook
│ ├── use-node-render-context.ts # Node render context hook
│ └── use-port-click.ts # Port click hook
├── nodes/ # Node definitions
│ ├── index.ts
│ ├── constants.ts # Node constants
│ ├── default-form-meta.ts # Default form metadata
│ ├── block-end/ # Block end node
│ ├── block-start/ # Block start node
│ ├── break/ # Break node
│ ├── code/ # Code node
│ ├── comment/ # Comment node
│ ├── condition/ # Condition node
│ ├── continue/ # Continue node
│ ├── end/ # End node
│ ├── group/ # Group node
│ ├── http/ # HTTP node
│ ├── llm/ # LLM node
│ ├── loop/ # Loop node
│ ├── start/ # Start node
│ └── variable/ # Variable node
├── plugins/ # Plugin system
│ ├── index.ts
│ ├── context-menu-plugin/ # Right-click context menu plugin
│ ├── runtime-plugin/ # Runtime plugin
│ │ ├── client/ # Client
│ │ │ ├── browser-client/ # Browser client
│ │ │ └── server-client/ # Server client
│ │ └── runtime-service/ # Runtime service
│ └── variable-panel-plugin/ # Variable panel plugin
│ └── components/ # Variable panel components
├── services/ # Service layer
│ ├── index.ts
│ └── custom-service.ts # Custom service
├── shortcuts/ # Shortcuts system
│ ├── index.ts
│ ├── constants.ts # Shortcut constants
│ ├── shortcuts.ts # Shortcut definitions
│ ├── type.ts # Type definitions
│ ├── collapse/ # Collapse shortcut
│ ├── copy/ # Copy shortcut
│ ├── delete/ # Delete shortcut
│ ├── expand/ # Expand shortcut
│ ├── paste/ # Paste shortcut
│ ├── select-all/ # Select-all shortcut
│ ├── zoom-in/ # Zoom-in shortcut
│ └── zoom-out/ # Zoom-out shortcut
├── styles/ # Styles
├── typings/ # Type definitions
│ ├── index.ts
│ ├── json-schema.ts # JSON Schema types
│ └── node.ts # Node type definitions
└── utils/ # Utility functions
├── index.ts
└── on-drag-line-end.ts # Handle end of drag line
```
### Key Directory Functions
#### 1. `/components` - Component Library
- **base-node**: Base rendering components for all nodes
- **testrun**: Complete test-run module, including status bar, form, and panel
- **sidebar**: Sidebar components providing tools and property panels
- **node-panel**: Node add panel with drag-to-add capability
#### 2. `/nodes` - Node System
Each node type has its own directory, including:
- Node registration (`index.ts`)
- Form metadata (`form-meta.ts`)
- Node-specific components and logic
#### 3. `/plugins` - Plugin System
- **runtime-plugin**: Supports both browser and server modes
- **context-menu-plugin**: Right-click context menu
- **variable-panel-plugin**: Variable management panel
#### 4. `/shortcuts` - Shortcuts System
Complete keyboard shortcut support, including:
- Basic actions: copy, paste, delete, select-all
- View actions: zoom-in, zoom-out, collapse, expand
- Each shortcut has its own implementation module
## Application Architecture
### Core Design Patterns
#### 1. Plugin Architecture
Highly modular plugin system; each feature is an independent plugin:
```typescript
plugins: () => [
createFreeLinesPlugin({ renderInsideLine: LineAddButton }),
createMinimapPlugin({ /* config */ }),
createFreeSnapPlugin({ /* alignment config */ }),
createFreeNodePanelPlugin({ renderer: NodePanel }),
createContainerNodePlugin({}),
createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),
createContextMenuPlugin({}),
createRuntimePlugin({ mode: 'browser' }),
createVariablePanelPlugin({})
]
```
#### 2. Node Registry Pattern
Manage different workflow node types via a registry:
```typescript
export const nodeRegistries: FlowNodeRegistry[] = [
ConditionNodeRegistry, // Condition node
StartNodeRegistry, // Start node
EndNodeRegistry, // End node
LLMNodeRegistry, // LLM node
LoopNodeRegistry, // Loop node
CommentNodeRegistry, // Comment node
HTTPNodeRegistry, // HTTP node
CodeNodeRegistry, // Code node
// ... more node types
];
```
#### 3. Dependency Injection
Use Inversify for service DI:
```typescript
onBind: ({ bind }) => {
bind(CustomService).toSelf().inSingletonScope();
}
```
## Core Features
### 1. Editor Configuration System
`useEditorProps` is the configuration center of the editor:
```typescript
export function useEditorProps(
initialData: FlowDocumentJSON,
nodeRegistries: FlowNodeRegistry[]
): FreeLayoutProps {
return useMemo<FreeLayoutProps>(() => ({
background: true, // Background grid
readonly: false, // Readonly mode
initialData, // Initial data
nodeRegistries, // Node registries
// Core feature configs
playground: { preventGlobalGesture: true /* Prevent Mac browser swipe gestures */ },
nodeEngine: { enable: true },
variableEngine: { enable: true },
history: { enable: true, enableChangeNode: true },
// Business rules
canAddLine: (ctx, fromPort, toPort) => { /* Connection rules */ },
canDeleteLine: (ctx, line) => { /* Line deletion rules */ },
canDeleteNode: (ctx, node) => { /* Node deletion rules */ },
canDropToNode: (ctx, params) => { /* Drag-and-drop rules */ },
// Plugins
plugins: () => [/* Plugin list */],
// Events
onContentChange: debounce((ctx, event) => { /* Auto save */ }, 1000),
onInit: (ctx) => { /* Initialization */ },
onAllLayersRendered: (ctx) => { /* After render */ }
}), []);
}
```
### 2. Node Type System
The app supports multiple workflow node types:
```typescript
export enum WorkflowNodeType {
Start = 'start', // Start node
End = 'end', // End node
LLM = 'llm', // Large language model node
HTTP = 'http', // HTTP request node
Code = 'code', // Code execution node
Variable = 'variable', // Variable node
Condition = 'condition', // Conditional node
Loop = 'loop', // Loop node
BlockStart = 'block-start', // Sub-canvas start node
BlockEnd = 'block-end', // Sub-canvas end node
Comment = 'comment', // Comment node
Continue = 'continue', // Continue node
Break = 'break', // Break node
}
```
Each node follows a unified registration pattern:
```typescript
export const StartNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Start,
meta: {
isStart: true,
deleteDisable: true, // Not deletable
copyDisable: true, // Not copyable
nodePanelVisible: false, // Hidden in node panel
defaultPorts: [{ type: 'output' }],
size: { width: 360, height: 211 }
},
info: {
icon: iconStart,
description: 'The starting node of the workflow, used to set up information needed to launch the workflow.'
},
formMeta, // Form configuration
canAdd() { return false; } // Disallow multiple start nodes
};
```
### 3. Plugin Architecture
App features are modularized via the plugin system:
#### Core Plugin List
1. **FreeLinesPlugin** - Connection rendering and interaction
2. **MinimapPlugin** - Minimap navigation
3. **FreeSnapPlugin** - Auto-alignment and guide-lines
4. **FreeNodePanelPlugin** - Node add panel
5. **ContainerNodePlugin** - Container nodes (e.g., loop nodes)
6. **FreeGroupPlugin** - Node grouping
7. **ContextMenuPlugin** - Right-click context menu
8. **RuntimePlugin** - Workflow runtime
9. **VariablePanelPlugin** - Variable management panel
### 4. Runtime System
Two run modes are supported:
```typescript
createRuntimePlugin({
mode: 'browser', // Browser mode
// mode: 'server', // Server mode
// serverConfig: {
// domain: 'localhost',
// port: 4000,
// protocol: 'http',
// },
})
```
## Design Philosophy and Advantages
### 1. Highly Modular
- **Plugin architecture**: Each feature is an independent plugin, easy to extend and maintain
- **Node registry system**: Add new node types without changing core code
- **Componentized UI**: Highly reusable components with clear responsibilities
### 2. Type Safety
- **Full TypeScript support**: End-to-end type safety from configuration to runtime
- **JSON Schema integration**: Node data validated by schemas
- **Strongly typed plugin interfaces**: Clear type constraints for plugin development
### 3. User Experience
- **Real-time preview**: Run and debug workflows live
- **Rich interactions**: Dragging, zooming, snapping, shortcuts for a complete editing experience
- **Visual feedback**: Minimap, status indicators, line animations
### 4. Extensibility
- **Open plugin system**: Third parties can easily develop custom plugins
- **Flexible node system**: Custom node types and form configurations supported
- **Multiple runtimes**: Both browser and server modes
### 5. Performance
- **On-demand loading**: Components and plugins support lazy loading
- **Debounce**: Performance optimizations for high-frequency operations like auto-save
## Technical Highlights
### 1. In-house Editor Framework
Based on `@flowgram.ai/free-layout-editor`, providing:
- Free-layout canvas system
- Full undo/redo functionality
- Lifecycle management for nodes and connections
- Variable engine and expression system
### 2. Advanced Build Configuration
Using Rsbuild as the build tool:
```typescript
export default defineConfig({
plugins: [pluginReact(), pluginLess()],
source: {
entry: { index: './src/app.tsx' },
decorators: { version: 'legacy' } // Enable decorators
},
tools: {
rspack: {
ignoreWarnings: [/Critical dependency/] // Ignore specific warnings
}
}
});
```
### 3. Internationalization
Built-in multilingual support:
```typescript
i18n: {
locale: navigator.language,
languages: {
'zh-CN': {
'Never Remind': '不再提示',
'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',
},
'en-US': {},
}
}
```
-386
View File
@@ -1,386 +0,0 @@
# FlowGram.AI - Demo Free Layout
自由布局最佳实践 demo
## 安装
```shell
npx @flowgram.ai/create-app@latest free-layout
```
## 项目概览
### 核心技术栈
- **前端框架**: React 18 + TypeScript
- **构建工具**: Rsbuild (基于 Rspack 的现代构建工具)
- **样式方案**: Less + Styled Components + CSS Variables
- **UI 组件库**: Semi Design (@douyinfe/semi-ui)
- **状态管理**: 基于 Flowgram 自研的编辑器框架
- **依赖注入**: Inversify
### 核心依赖包
- **@flowgram.ai/free-layout-editor**: 自由布局编辑器核心依赖
- **@flowgram.ai/free-snap-plugin**: 自动对齐及辅助线插件
- **@flowgram.ai/free-lines-plugin**: 连线渲染插件
- **@flowgram.ai/free-node-panel-plugin**: 节点添加面板渲染插件
- **@flowgram.ai/minimap-plugin**: 缩略图插件
- **@flowgram.ai/export-plugin**: 下载导出插件
- **@flowgram.ai/free-container-plugin**: 子画布插件
- **@flowgram.ai/free-group-plugin**: 分组插件
- **@flowgram.ai/form-materials**: 表单物料
- **@flowgram.ai/runtime-interface**: 运行时接口
- **@flowgram.ai/runtime-js**: js 运行时模块
- **@flowgram.ai/panel-manager-plugin**: 侧边栏面板管理
## 代码说明
### 目录结构
```
src/
├── app.tsx # 应用入口文件
├── editor.tsx # 编辑器主组件
├── initial-data.ts # 初始化数据配置
├── assets/ # 静态资源
├── components/ # 组件库
│ ├── index.ts
│ ├── add-node/ # 添加节点组件
│ ├── base-node/ # 基础节点组件
│ ├── comment/ # 注释组件
│ ├── group/ # 分组组件
│ ├── line-add-button/ # 连线添加按钮
│ ├── node-menu/ # 节点菜单
│ ├── node-panel/ # 节点添加面板
│ ├── selector-box-popover/ # 选择框弹窗
│ ├── sidebar/ # 侧边栏
│ ├── testrun/ # 测试运行组件
│ │ ├── hooks/ # 测试运行钩子
│ │ ├── node-status-bar/ # 节点状态栏
│ │ ├── testrun-button/ # 测试运行按钮
│ │ ├── testrun-form/ # 测试运行表单
│ │ ├── testrun-json-input/ # JSON输入组件
│ │ └── testrun-panel/ # 测试运行面板
│ └── tools/ # 工具组件
├── context/ # React Context
│ ├── node-render-context.ts # 当前渲染节点 Context
│ ├── sidebar-context # 侧边栏 Context
├── form-components/ # 表单组件库
│ ├── form-content/ # 表单内容
│ ├── form-header/ # 表单头部
│ ├── form-inputs/ # 表单输入
│ └── form-item/ # 表单项
│ └── feedback.tsx # 表单校验错误渲染
├── hooks/
│ ├── index.ts
│ ├── use-editor-props.tsx # 编辑器属性钩子
│ ├── use-is-sidebar.ts # 侧边栏状态钩子
│ ├── use-node-render-context.ts # 节点渲染上下文钩子
│ └── use-port-click.ts # 端口点击钩子
├── nodes/ # 节点定义
│ ├── index.ts
│ ├── constants.ts # 节点常量定义
│ ├── default-form-meta.ts # 默认表单元数据
│ ├── block-end/ # 块结束节点
│ ├── block-start/ # 块开始节点
│ ├── break/ # 中断节点
│ ├── code/ # 代码节点
│ ├── comment/ # 注释节点
│ ├── condition/ # 条件节点
│ ├── continue/ # 继续节点
│ ├── end/ # 结束节点
│ ├── group/ # 分组节点
│ ├── http/ # HTTP节点
│ ├── llm/ # LLM节点
│ ├── loop/ # 循环节点
│ ├── start/ # 开始节点
│ └── variable/ # 变量节点
├── plugins/ # 插件系统
│ ├── index.ts
│ ├── context-menu-plugin/ # 右键菜单插件
│ ├── runtime-plugin/ # 运行时插件
│ │ ├── client/ # 客户端
│ │ │ ├── browser-client/ # 浏览器客户端
│ │ │ └── server-client/ # 服务器客户端
│ │ └── runtime-service/ # 运行时服务
│ └── variable-panel-plugin/ # 变量面板插件
│ └── components/ # 变量面板组件
├── services/ # 服务层
│ ├── index.ts
│ └── custom-service.ts # 自定义服务
├── shortcuts/ # 快捷键系统
│ ├── index.ts
│ ├── constants.ts # 快捷键常量
│ ├── shortcuts.ts # 快捷键定义
│ ├── type.ts # 类型定义
│ ├── collapse/ # 折叠快捷键
│ ├── copy/ # 复制快捷键
│ ├── delete/ # 删除快捷键
│ ├── expand/ # 展开快捷键
│ ├── paste/ # 粘贴快捷键
│ ├── select-all/ # 全选快捷键
│ ├── zoom-in/ # 放大快捷键
│ └── zoom-out/ # 缩小快捷键
├── styles/ # 样式文件
├── typings/ # 类型定义
│ ├── index.ts
│ ├── json-schema.ts # JSON Schema类型
│ └── node.ts # 节点类型定义
└── utils/ # 工具函数
├── index.ts
└── on-drag-line-end.ts # 拖拽连线结束处理
```
### 关键目录功能说明
#### 1. `/components` - 组件库
- **base-node**: 所有节点的基础渲染组件
- **testrun**: 完整的测试运行功能模块,包含状态栏、表单、面板等
- **sidebar**: 侧边栏组件,提供工具和属性面板
- **node-panel**: 节点添加面板,支持拖拽添加新节点
#### 2. `/nodes` - 节点系统
每个节点类型都有独立的目录,包含:
- 节点注册信息 (`index.ts`)
- 表单元数据定义 (`form-meta.ts`)
- 节点特定的组件和逻辑
#### 3. `/plugins` - 插件系统
- **runtime-plugin**: 支持浏览器和服务器两种运行模式
- **context-menu-plugin**: 右键菜单功能
- **variable-panel-plugin**: 变量管理面板
#### 4. `/shortcuts` - 快捷键系统
完整的快捷键支持,包括:
- 基础操作:复制、粘贴、删除、全选
- 视图操作:放大、缩小、折叠、展开
- 每个快捷键都有独立的实现模块
## 应用架构设计
### 核心设计模式
#### 1. 插件化架构 (Plugin Architecture)
应用采用高度模块化的插件系统,每个功能都作为独立插件存在:
```typescript
plugins: () => [
createFreeLinesPlugin({ renderInsideLine: LineAddButton }),
createMinimapPlugin({ /* 配置 */ }),
createFreeSnapPlugin({ /* 对齐配置 */ }),
createFreeNodePanelPlugin({ renderer: NodePanel }),
createContainerNodePlugin({}),
createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),
createContextMenuPlugin({}),
createRuntimePlugin({ mode: 'browser' }),
createVariablePanelPlugin({})
]
```
#### 2. 节点注册系统 (Node Registry Pattern)
通过注册表模式管理不同类型的工作流节点:
```typescript
export const nodeRegistries: FlowNodeRegistry[] = [
ConditionNodeRegistry, // 条件节点
StartNodeRegistry, // 开始节点
EndNodeRegistry, // 结束节点
LLMNodeRegistry, // LLM节点
LoopNodeRegistry, // 循环节点
CommentNodeRegistry, // 注释节点
HTTPNodeRegistry, // HTTP节点
CodeNodeRegistry, // 代码节点
// ... 更多节点类型
];
```
#### 3. 依赖注入模式 (Dependency Injection)
使用 Inversify 框架实现服务的依赖注入:
```typescript
onBind: ({ bind }) => {
bind(CustomService).toSelf().inSingletonScope();
}
```
## 核心功能分析
### 1. 编辑器配置系统
`useEditorProps` 是整个编辑器的配置中心,包含:
```typescript
export function useEditorProps(
initialData: FlowDocumentJSON,
nodeRegistries: FlowNodeRegistry[]
): FreeLayoutProps {
return useMemo<FreeLayoutProps>(() => ({
background: true, // 背景网格
readonly: false, // 是否只读
initialData, // 初始数据
nodeRegistries, // 节点注册表
// 核心功能配置
playground: { preventGlobalGesture: true /* 阻止 mac 浏览器手势翻页 */ },
nodeEngine: { enable: true },
variableEngine: { enable: true },
history: { enable: true, enableChangeNode: true },
// 业务逻辑配置
canAddLine: (ctx, fromPort, toPort) => { /* 连线规则 */ },
canDeleteLine: (ctx, line) => { /* 删除连线规则 */ },
canDeleteNode: (ctx, node) => { /* 删除节点规则 */ },
canDropToNode: (ctx, params) => { /* 拖拽规则 */ },
// 插件配置
plugins: () => [/* 插件列表 */],
// 事件处理
onContentChange: debounce((ctx, event) => { /* 自动保存 */ }, 1000),
onInit: (ctx) => { /* 初始化 */ },
onAllLayersRendered: (ctx) => { /* 渲染完成 */ }
}), []);
}
```
### 2. 节点类型系统
应用支持多种工作流节点类型:
```typescript
export enum WorkflowNodeType {
Start = 'start', // 开始节点
End = 'end', // 结束节点
LLM = 'llm', // 大语言模型节点
HTTP = 'http', // HTTP请求节点
Code = 'code', // 代码执行节点
Variable = 'variable', // 变量节点
Condition = 'condition', // 条件判断节点
Loop = 'loop', // 循环节点
BlockStart = 'block-start', // 子画布开始节点
BlockEnd = 'block-end', // 子画布结束节点
Comment = 'comment', // 注释节点
Continue = 'continue', // 继续节点
Break = 'break', // 中断节点
}
```
每个节点都遵循统一的注册模式:
```typescript
export const StartNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Start,
meta: {
isStart: true,
deleteDisable: true, // 不可删除
copyDisable: true, // 不可复制
nodePanelVisible: false, // 不在节点面板显示
defaultPorts: [{ type: 'output' }],
size: { width: 360, height: 211 }
},
info: {
icon: iconStart,
description: '工作流的起始节点,用于设置启动工作流所需的信息。'
},
formMeta, // 表单配置
canAdd() { return false; } // 不允许添加多个开始节点
};
```
### 3. 插件化架构
应用的功能通过插件系统实现模块化:
#### 核心插件列表
1. **FreeLinesPlugin** - 连线渲染和交互
2. **MinimapPlugin** - 缩略图导航
3. **FreeSnapPlugin** - 自动对齐和辅助线
4. **FreeNodePanelPlugin** - 节点添加面板
5. **ContainerNodePlugin** - 容器节点(如循环节点)
6. **FreeGroupPlugin** - 节点分组功能
7. **ContextMenuPlugin** - 右键菜单
8. **RuntimePlugin** - 工作流运行时
9. **VariablePanelPlugin** - 变量管理面板
### 4. 运行时系统
应用支持两种运行模式:
```typescript
createRuntimePlugin({
mode: 'browser', // 浏览器模式
// mode: 'server', // 服务器模式
// serverConfig: {
// domain: 'localhost',
// port: 4000,
// protocol: 'http',
// },
})
```
## 设计理念与架构优势
### 1. 高度模块化
- **插件化架构**: 每个功能都是独立插件,易于扩展和维护
- **节点注册系统**: 新节点类型可以轻松添加,无需修改核心代码
- **组件化设计**: UI组件高度复用,职责清晰
### 2. 类型安全
- **完整的TypeScript支持**: 从配置到运行时的全链路类型保护
- **JSON Schema集成**: 节点数据结构通过Schema验证
- **强类型的插件接口**: 插件开发有明确的类型约束
### 3. 用户体验优化
- **实时预览**: 支持工作流的实时运行和调试
- **丰富的交互**: 拖拽、缩放、对齐、快捷键等完整的编辑体验
- **可视化反馈**: 缩略图、状态指示、连线动画等视觉反馈
### 4. 扩展性设计
- **开放的插件系统**: 第三方可以轻松开发自定义插件
- **灵活的节点系统**: 支持自定义节点类型和表单配置
- **多运行时支持**: 浏览器和服务器双模式运行
### 5. 性能优化
- **按需加载**: 组件和插件支持按需加载
- **防抖处理**: 自动保存等高频操作的性能优化
## 技术亮点
### 1. 自研编辑器框架
基于 `@flowgram.ai/free-layout-editor` 自研框架,提供:
- 自由布局的画布系统
- 完整的撤销/重做功能
- 节点和连线的生命周期管理
- 变量引擎和表达式系统
### 2. 先进的构建配置
使用 Rsbuild 作为构建工具:
```typescript
export default defineConfig({
plugins: [pluginReact(), pluginLess()],
source: {
entry: { index: './src/app.tsx' },
decorators: { version: 'legacy' } // 支持装饰器
},
tools: {
rspack: {
ignoreWarnings: [/Critical dependency/] // 忽略特定警告
}
}
});
```
### 3. 国际化支持
内置多语言支持:
```typescript
i18n: {
locale: navigator.language,
languages: {
'zh-CN': {
'Never Remind': '不再提示',
'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',
},
'en-US': {},
}
}
```
-20
View File
@@ -1,20 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
rules: {
'no-console': 'off',
'react/prop-types': 'off',
},
settings: {
react: {
version: 'detect',
},
},
});
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bundler="rspack">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flow FreeLayoutEditor Demo</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
-78
View File
@@ -1,78 +0,0 @@
{
"name": "@agent-desk/flowgram-editor",
"version": "1.0.4",
"description": "",
"keywords": [],
"license": "MIT",
"private": true,
"main": "./src/index.ts",
"files": [
"src/",
"eslint.config.js",
".gitignore",
"index.html",
"package.json",
"rsbuild.config.ts",
"tsconfig.json",
"README.md",
"README.zh_CN.md"
],
"dependencies": {
"@douyinfe/semi-icons": "^2.80.0",
"@douyinfe/semi-ui": "^2.80.0",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9",
"react": "^18",
"react-dom": "^18",
"styled-components": "^5",
"classnames": "^2.5.1",
"@flowgram.ai/runtime-interface": "1.0.12",
"@flowgram.ai/free-snap-plugin": "1.0.12",
"@flowgram.ai/free-node-panel-plugin": "1.0.12",
"@flowgram.ai/minimap-plugin": "1.0.12",
"@flowgram.ai/free-lines-plugin": "1.0.12",
"@flowgram.ai/free-layout-editor": "1.0.12",
"@flowgram.ai/export-plugin": "1.0.12",
"@flowgram.ai/free-container-plugin": "1.0.12",
"@flowgram.ai/free-group-plugin": "1.0.12",
"@flowgram.ai/panel-manager-plugin": "1.0.12",
"@flowgram.ai/form-materials": "1.0.12",
"@flowgram.ai/free-stack-plugin": "1.0.12",
"@flowgram.ai/runtime-js": "1.0.12"
},
"devDependencies": {
"@rsbuild/core": "^1.2.16",
"@rsbuild/plugin-react": "^1.1.1",
"@rsbuild/plugin-less": "^1.1.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/styled-components": "^5",
"typescript": "^5.8.3",
"eslint": "^9.0.0",
"cross-env": "~7.0.3",
"@flowgram.ai/eslint-config": "1.0.12",
"@flowgram.ai/ts-config": "1.0.12"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "cross-env MODE=app NODE_ENV=production rsbuild build",
"build:fast": "exit 0",
"build:watch": "exit 0",
"build:prod": "cross-env MODE=app NODE_ENV=production rsbuild build",
"build:analyze": "BUNDLE_ANALYZE=true rsbuild build",
"clean": "rimraf dist",
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix",
"ts-check": "tsc --noEmit",
"start": "cross-env NODE_ENV=development rsbuild dev --open",
"test": "exit",
"test:cov": "exit",
"watch": "exit 0"
}
}
-10224
View File
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginLess } from '@rsbuild/plugin-less';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [pluginReact(), pluginLess()],
source: {
entry: {
index: './src/app.tsx',
},
/**
* support inversify @injectable() and @inject decorators
*/
decorators: {
version: 'legacy',
},
},
html: {
title: 'FlowGram Workflow Editor',
},
output: {
assetPrefix: '/flowgram-editor/',
distPath: {
root: '../web/public/flowgram-editor',
},
cleanDistPath: true,
},
tools: {
rspack: {
/**
* ignore warnings from @coze-editor/editor/language-typescript
*/
ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/],
},
},
});
-18
View File
@@ -1,18 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { createRoot } from 'react-dom/client';
import { unstableSetCreateRoot } from '@flowgram.ai/form-materials';
import { Editor } from './editor';
/**
* React 18/19 polyfill for form-materials
*/
unstableSetCreateRoot(createRoot);
const app = createRoot(document.getElementById('root')!);
app.render(<Editor />);
@@ -1,13 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconAutoLayout = (
<svg width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M3 2C2.44772 2 2 2.44771 2 3V12C2 12.5523 2.44772 13 3 13H10C10.5523 13 11 12.5523 11 12V3C11 2.44772 10.5523 2 10 2H3zM4 11V4H9V11H4zM21 22C21.5523 22 22 21.5523 22 21V12C22 11.4477 21.5523 11 21 11H14C13.4477 11 13 11.4477 13 12V21C13 21.5523 13.4477 22 14 22H21zM20 13V20H15V13H20zM2 16C2 15.4477 2.44772 15 3 15H10C10.5523 15 11 15.4477 11 16V21C11 21.5523 10.5523 22 10 22H3C2.44772 22 2 21.5523 2 21V16zM4 20V17H9V20H4zM21 9C21.5523 9 22 8.55228 22 8V3C22 2.44772 21.5523 2 21 2H14C13.4477 2 13 2.44772 13 3V8C13 8.55228 13.4477 9 14 9H21zM20 4V7H15V4H20z"
></path>
</svg>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

@@ -1,24 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconCancel = ({ className, style }: Props) => (
<svg
className={className}
style={style}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.5 8C8.67157 8 8 8.67157 8 9.5V14.5C8 15.3284 8.67157 16 9.5 16H14.5C15.3284 16 16 15.3284 16 14.5V9.5C16 8.67157 15.3284 8 14.5 8H9.5Z"></path>
<path d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"></path>
</svg>
);
@@ -1,24 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC } from 'react';
interface IconCommentProps {
style?: CSSProperties;
}
export const IconComment: FC<IconCommentProps> = ({ style }) => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style={style}
>
<path d="M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z"></path>
<path d="M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z"></path>
</svg>
);
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44" height="45" viewBox="0 0 44 45" fill="none" class="injected-svg" data-src="https://lf3-static.bytednsdoc.com/obj/eden-cn/uvpahtvabh_lm_zhhwh/ljhwZthlaukjlkulzlp/activity_icons/exclusive-split-0518.svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4705 14.0152C15.299 12.8436 15.299 10.944 16.4705 9.77244L20.7131 5.5297C21.8846 4.3581 23.784 4.3581 24.9556 5.5297L29.1981 9.77244C30.3697 10.944 30.3697 12.8436 29.1981 14.0152L25.1206 18.0929H32.6674C36.5334 18.0929 39.6674 21.2269 39.6674 25.0929V33.154V33.3271V37.154C39.6674 38.2585 38.7719 39.154 37.6674 39.154H33.6674C32.5628 39.154 31.6674 38.2585 31.6674 37.154V33.3271V33.154V26.0929H23.5948H15.6674V33.1327L17.2685 33.1244C18.8397 33.1163 19.6322 35.0156 18.5212 36.1266L12.7374 41.9103C12.0506 42.5971 10.9371 42.5971 10.2503 41.9103L4.52588 36.1859C3.42107 35.0811 4.19797 33.1917 5.76038 33.1837L7.66737 33.1739V25.0929C7.66737 21.227 10.8014 18.0929 14.6674 18.0929H20.5481L16.4705 14.0152Z" fill="url(#paint0_linear_2752_183702-7)"/>
<defs>
<linearGradient id="paint0_linear_2752_183702-7" x1="38.52" y1="43.3915" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20.006 4C22.145 4 23.9853 7.39855 24.7651 12.241H15.2469C16.0267 7.39855 17.867 4 20.006 4ZM15.021 20.9119C14.908 19.8023 14.8424 18.6486 14.8424 17.4436C14.8424 16.2421 14.908 15.0848 15.021 13.9752H24.9837C25.0966 15.0848 25.1623 16.2421 25.1623 17.4436C25.1623 18.645 25.0966 19.7987 24.9837 20.9119H15.021ZM23.8044 4.56199C27.6525 5.71199 30.7644 8.55942 32.3022 12.2409H26.4936C26.0199 9.15463 25.1162 6.39537 23.8044 4.56199ZM16.1971 4.56199C12.3563 5.71199 9.23701 8.55942 7.70652 12.2409H13.5151C13.9815 9.15463 14.8852 6.39537 16.1971 4.56199ZM26.7119 13.9752H32.8776C33.1691 15.0848 33.3368 16.2421 33.3368 17.4436C33.3368 18.645 33.1691 19.7987 32.874 20.9119H26.7119C26.8249 19.7766 26.8906 18.6083 26.8906 17.4436C26.8906 16.2789 26.8249 15.1142 26.7119 13.9752ZM13.122 17.4436C13.122 16.2789 13.1876 15.1105 13.3006 13.9752H7.13127C6.83975 15.0885 6.66848 16.2421 6.66848 17.4436C6.66848 18.645 6.83975 19.8023 7.13127 20.912H13.2933C13.1876 19.7767 13.122 18.6119 13.122 17.4436ZM4 25.3373C4 23.8005 5.24582 22.5547 6.78261 22.5547H33.2174C34.7542 22.5547 36 23.8005 36 25.3373V33.2174C36 34.7542 34.7542 36 33.2174 36H6.78261C5.24582 36 4 34.7542 4 33.2174V25.3373ZM10.9109 28.1569H8.48666V25.9161H6.66848V32.6388H8.48666V29.8376H10.9109V32.6388H12.7291V25.9161H10.9109V28.1569ZM13.9412 27.5968H15.7594V32.6388H17.5776V27.5968H19.3958V25.9161H13.9412V27.5968ZM20.6079 27.5968H22.426V32.6388H24.2442V27.5968H26.0625V25.9161H20.6079V27.5968ZM31.5169 25.9161H27.2746V32.6388H29.0927V30.3979H31.5169C32.5472 30.3979 33.3351 29.6696 33.3351 28.7172V27.5968C33.3351 26.6445 32.5472 25.9161 31.5169 25.9161ZM31.5169 28.7172H29.0927V27.5968H31.5169V28.7172Z"
fill="#3370FF" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

@@ -1,24 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconMinimap = () => (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="g1">
<path
id="path1"
fill="#000000"
stroke="none"
d="M 18.09091 6.883101 L 5.409091 6.883101 L 5.409091 16.746737 L 10.664648 16.746737 C 10.927091 17.116341 11.30353 17.422749 11.792977 17.611004 L 12.664289 17.946156 L 12.744959 18.155828 L 5.409091 18.155828 C 4.630871 18.155828 4 17.524979 4 16.746737 L 4 6.883101 C 4 6.104881 4.630871 5.47401 5.409091 5.47401 L 18.09091 5.47401 C 18.86915 5.47401 19.5 6.104881 19.5 6.883101 L 19.5 12.52348 C 19.247208 11.883823 18.730145 11.365912 18.09091 11.111994 L 18.09091 6.883101 Z M 18.09091 18.155828 L 17.881165 18.155828 L 19.469212 14.368896 C 19.479921 14.343321 19.490206 14.317817 19.5 14.292241 L 19.5 16.746737 C 19.5 17.524979 18.86915 18.155828 18.09091 18.155828 Z"
/>
<path
id="path2"
fill="#000000"
fillRule="evenodd"
stroke="none"
d="M 18.494614 13.960189 C 18.982441 12.796985 17.813459 11.628003 16.650255 12.11576 L 12.133272 14.01 C 10.962248 14.501069 10.987188 16.168798 12.172375 16.62464 L 13.482055 17.128389 L 13.985805 18.438068 C 14.441646 19.623184 16.109375 19.648125 16.600443 18.477171 L 18.494614 13.960189 Z M 17.19515 13.415224 L 15.30098 17.932205 L 14.79723 16.622526 C 14.654066 16.250385 14.359989 15.956307 13.987918 15.813213 L 12.678168 15.309464 L 17.19515 13.415224 Z"
/>
</g>
</svg>
);
-41
View File
@@ -1,41 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export function IconMouse(props: { width?: number; height?: number }) {
const { width, height } = props;
return (
<svg
width={width || 34}
height={height || 52}
viewBox="0 0 34 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M30.9998 16.6666V35.3333C30.9998 37.5748 30.9948 38.4695 30.9 39.1895C30.2108 44.4247 26.0912 48.5443 20.856 49.2335C20.1361 49.3283 19.2413 49.3333 16.9998 49.3333C14.7584 49.3333 13.8636 49.3283 13.1437 49.2335C7.90847 48.5443 3.78888 44.4247 3.09965 39.1895C3.00487 38.4695 2.99984 37.5748 2.99984 35.3333V16.6666C2.99984 14.4252 3.00487 13.5304 3.09965 12.8105C3.78888 7.57528 7.90847 3.45569 13.1437 2.76646C13.7232 2.69017 14.4159 2.67202 15.8332 2.66785V9.86573C14.4738 10.3462 13.4998 11.6426 13.4998 13.1666V17.8332C13.4998 19.3571 14.4738 20.6536 15.8332 21.1341V23.6666C15.8332 24.3109 16.3555 24.8333 16.9998 24.8333C17.6442 24.8333 18.1665 24.3109 18.1665 23.6666V21.1341C19.5259 20.6536 20.4998 19.3572 20.4998 17.8332V13.1666C20.4998 11.6426 19.5259 10.3462 18.1665 9.86571V2.66785C19.5837 2.67202 20.2765 2.69017 20.856 2.76646C26.0912 3.45569 30.2108 7.57528 30.9 12.8105C30.9948 13.5304 30.9998 14.4252 30.9998 16.6666ZM0.666504 16.6666C0.666504 14.4993 0.666504 13.4157 0.786276 12.5059C1.61335 6.22368 6.55687 1.28016 12.8391 0.453085C13.7489 0.333313 14.8325 0.333313 16.9998 0.333313C19.1671 0.333313 20.2508 0.333313 21.1605 0.453085C27.4428 1.28016 32.3863 6.22368 33.2134 12.5059C33.3332 13.4157 33.3332 14.4994 33.3332 16.6666V35.3333C33.3332 37.5006 33.3332 38.5843 33.2134 39.494C32.3863 45.7763 27.4428 50.7198 21.1605 51.5469C20.2508 51.6666 19.1671 51.6666 16.9998 51.6666C14.8325 51.6666 13.7489 51.6666 12.8391 51.5469C6.55687 50.7198 1.61335 45.7763 0.786276 39.494C0.666504 38.5843 0.666504 37.5006 0.666504 35.3333V16.6666ZM15.8332 13.1666C15.8332 13.0011 15.8676 12.8437 15.9297 12.7011C15.9886 12.566 16.0722 12.4443 16.1749 12.3416C16.386 12.1305 16.6777 11.9999 16.9998 11.9999C17.6435 11.9999 18.1654 12.5212 18.1665 13.1646L18.1665 13.1666V17.8332L18.1665 17.8353C18.1665 17.8364 18.1665 17.8376 18.1665 17.8387C18.1661 17.9132 18.1588 17.986 18.1452 18.0565C18.0853 18.3656 17.9033 18.6312 17.6515 18.8011C17.4655 18.9266 17.2412 18.9999 16.9998 18.9999C16.3555 18.9999 15.8332 18.4776 15.8332 17.8332V13.1666Z"
fill="currentColor"
fillOpacity="0.8"
/>
</svg>
);
}
export const IconMouseTool = () => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.5 8C4.5 4.13401 7.63401 1 11.5 1H12.5C16.366 1 19.5 4.13401 19.5 8V17C19.5 20.3137 16.8137 23 13.5 23H10.5C7.18629 23 4.5 20.3137 4.5 17V8ZM11.2517 3.00606C8.60561 3.13547 6.5 5.32184 6.5 8V17C6.5 19.2091 8.29086 21 10.5 21H13.5C15.7091 21 17.5 19.2091 17.5 17V8C17.5 5.32297 15.3962 3.13732 12.7517 3.00622V5.28013C13.2606 5.54331 13.6074 6.06549 13.6074 6.66669V8.75759C13.6074 9.35879 13.2606 9.88097 12.7517 10.1441V11.4091C12.7517 11.8233 12.4159 12.1591 12.0017 12.1591C11.5875 12.1591 11.2517 11.8233 11.2517 11.4091V10.1457C10.7411 9.88298 10.3931 9.35994 10.3931 8.75759V6.66669C10.3931 6.06433 10.7411 5.5413 11.2517 5.27862V3.00606ZM12.0017 6.14397C11.7059 6.14397 11.466 6.38381 11.466 6.67968V8.74462C11.466 9.03907 11.7036 9.27804 11.9975 9.28031L12.0002 9.28032C12.0456 9.28032 12.0896 9.27482 12.1316 9.26447C12.3401 9.21256 12.5002 9.0386 12.5318 8.82287C12.5345 8.80149 12.5359 8.7797 12.5359 8.75759V6.66669C12.5359 6.64463 12.5345 6.62288 12.5318 6.60154C12.4999 6.38354 12.3368 6.20817 12.1252 6.15826C12.0856 6.14891 12.0442 6.14397 12.0017 6.14397Z"
></path>
</svg>
);
-56
View File
@@ -1,56 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export function IconPad(props: { width?: number; height?: number }) {
const { width, height } = props;
return (
<svg
width={width || 48}
height={height || 38}
viewBox="0 0 48 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="1.83317"
y="1.49998"
width="44.3333"
height="35"
rx="3.5"
stroke="currentColor"
strokeOpacity="0.8"
strokeWidth="2.33333"
/>
<path
d="M14.6665 30.6667H33.3332"
stroke="currentColor"
strokeOpacity="0.8"
strokeWidth="2.33333"
strokeLinecap="round"
/>
</svg>
);
}
export const IconPadTool = () => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z"
></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z"
></path>
</svg>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

@@ -1,37 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconSuccessFill = ({ className, style }: Props) => (
<svg
className={className}
style={style}
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<g clipPath="url(#icon-workflow-run-success_svg__a)">
<path
fill="#3EC254"
d="M.833 10A9.166 9.166 0 0 0 10 19.168a9.166 9.166 0 0 0 9.167-9.166A9.166 9.166 0 0 0 10 .834a9.166 9.166 0 0 0-9.167 9.167"
></path>
<path
fill="#fff"
d="M6.077 9.755a.833.833 0 0 0 0 1.179l2.357 2.357a.833.833 0 0 0 1.179 0l4.714-4.714a.833.833 0 1 0-1.178-1.179l-4.125 4.125-1.768-1.768a.833.833 0 0 0-1.179 0"
></path>
</g>
<defs>
<clipPath id="icon-workflow-run-success_svg__a">
<path fill="#fff" d="M0 0h20v20H0z"></path>
</clipPath>
</defs>
</svg>
);
@@ -1,15 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconSwitchLine = (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
id="switch-line"
fill="currentColor"
stroke="none"
d="M 12.728118 10.060962 C 13.064282 8.716098 14.272528 7.772551 15.65877 7.772343 L 17.689898 7.772343 C 18.0798 7.772343 18.39588 7.456264 18.39588 7.066362 C 18.39588 6.676458 18.0798 6.36038 17.689898 6.36038 L 15.659616 6.36038 C 13.62515 6.360315 11.851767 7.745007 11.358504 9.718771 C 11.02234 11.063635 9.814095 12.007183 8.427853 12.007389 L 7.101437 12.007389 C 6.711768 12.007389 6.395878 12.323277 6.395878 12.712947 C 6.395878 13.102616 6.711768 13.418506 7.101437 13.418506 L 8.426159 13.418506 C 9.812716 13.418323 11.021417 14.361954 11.357657 15.707124 C 11.850921 17.680887 13.624304 19.065578 15.65877 19.065516 L 17.689049 19.065516 C 18.078953 19.065516 18.395033 18.749435 18.395033 18.359533 C 18.395033 17.969631 18.078953 17.653551 17.689049 17.653551 L 15.65877 17.653551 C 14.272528 17.653345 13.064282 16.709797 12.728118 15.364932 C 12.454905 14.27114 11.774856 13.322707 10.826583 12.712947 C 11.774536 12.10303 12.454268 11.154617 12.727271 10.060962 Z"
/>
</svg>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,27 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconWarningFill = ({ className, style }: Props) => (
<svg
className={className}
style={style}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z"
></path>
</svg>
);
@@ -1,28 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Button } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
import { useAddNode } from './use-add-node';
export const AddNode = (props: { disabled: boolean }) => {
const addNode = useAddNode();
return (
<Button
data-testid="demo.free-layout.add-node"
icon={<IconPlus />}
color="highlight"
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
disabled={props.disabled}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
addNode(rect);
}}
>
Add Node
</Button>
);
};
@@ -1,115 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
import {
useService,
WorkflowDocument,
usePlayground,
PositionSchema,
WorkflowNodeEntity,
WorkflowSelectService,
WorkflowNodeJSON,
getAntiOverlapPosition,
WorkflowNodeMeta,
FlowNodeBaseType,
} from '@flowgram.ai/free-layout-editor';
// hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook
const useGetPanelPosition = () => {
const playground = usePlayground();
return useCallback(
(targetBoundingRect: DOMRect): PositionSchema =>
// convert mouse position to canvas position - 将鼠标位置转换为画布位置
playground.config.getPosFromMouseEvent({
clientX: targetBoundingRect.left + 64,
clientY: targetBoundingRect.top - 7,
}),
[playground]
);
};
// hook to handle node selection - 处理节点选择的 hook
const useSelectNode = () => {
const selectService = useService(WorkflowSelectService);
return useCallback(
(node?: WorkflowNodeEntity) => {
if (!node) {
return;
}
// select the target node - 选择目标节点
selectService.selectNode(node);
},
[selectService]
);
};
const getContainerNode = (selectService: WorkflowSelectService) => {
const { activatedNode } = selectService;
if (!activatedNode) {
return;
}
const { isContainer } = activatedNode.getNodeMeta<WorkflowNodeMeta>();
if (isContainer) {
return activatedNode;
}
const parentNode = activatedNode.parent;
if (!parentNode || parentNode.flowNodeType === FlowNodeBaseType.ROOT) {
return;
}
return parentNode;
};
// main hook for adding new nodes - 添加新节点的主 hook
export const useAddNode = () => {
const workflowDocument = useService(WorkflowDocument);
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
const selectService = useService(WorkflowSelectService);
const playground = usePlayground();
const getPanelPosition = useGetPanelPosition();
const select = useSelectNode();
return useCallback(
async (targetBoundingRect: DOMRect): Promise<void> => {
// calculate panel position based on target element - 根据目标元素计算面板位置
const panelPosition = getPanelPosition(targetBoundingRect);
const containerNode = getContainerNode(selectService);
await new Promise<void>((resolve) => {
// call the node panel service to show the panel - 调用节点面板服务来显示面板
nodePanelService.callNodePanel({
position: panelPosition,
enableMultiAdd: true,
containerNode,
panelProps: {},
// handle node selection from panel - 处理从面板中选择节点
onSelect: async (panelParams?: NodePanelResult) => {
if (!panelParams) {
return;
}
const { nodeType, nodeJSON } = panelParams;
const position = Boolean(containerNode)
? getAntiOverlapPosition(workflowDocument, {
x: 0,
y: 200,
})
: undefined;
// create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
nodeType,
position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
select(node);
},
// handle panel close - 处理面板关闭
onClose: () => {
resolve();
},
});
});
},
[getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select]
);
};
@@ -1,49 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { FlowNodeEntity, useClientContext, useNodeRender } from '@flowgram.ai/free-layout-editor';
import { ConfigProvider } from '@douyinfe/semi-ui';
import { NodeStatusBar } from '../testrun/node-status-bar';
import { NodeRenderContext } from '../../context';
import { ErrorIcon } from './styles';
import { NodeWrapper } from './node-wrapper';
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
/**
* Provides methods related to node rendering
* 提供节点渲染相关的方法
*/
const nodeRender = useNodeRender();
const ctx = useClientContext();
/**
* It can only be used when nodeEngine is enabled
* 只有在节点引擎开启时候才能使用表单
*/
const form = nodeRender.form;
/**
* Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library
* 用于让 Tooltip 跟随节点缩放, 这个可以根据不同的 ui 库自己实现
*/
const getPopupContainer = useCallback(
() => ctx.playground.node.querySelector('.gedit-flow-render-layer') as HTMLDivElement,
[]
);
return (
<ConfigProvider getPopupContainer={getPopupContainer}>
<NodeRenderContext.Provider value={nodeRender}>
<NodeWrapper>
{form?.state.invalid && <ErrorIcon />}
{form?.render()}
</NodeWrapper>
<NodeStatusBar />
</NodeRenderContext.Provider>
</ConfigProvider>
);
};
@@ -1,83 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState } from 'react';
import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { FlowNodeMeta } from '../../typings';
import { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
import { useNodeRenderContext, usePortClick } from '../../hooks';
import { scrollToView } from './utils';
import { NodeWrapperStyle } from './styles';
export interface NodeWrapperProps {
isScrollToView?: boolean;
children: React.ReactNode;
}
/**
* Used for drag-and-drop/click events and ports rendering of nodes
* 用于节点的拖拽/点击事件和点位渲染
*/
export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
const { children, isScrollToView = false } = props;
const nodeRender = useNodeRenderContext();
const { node, selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur, readonly } =
nodeRender;
const [isDragging, setIsDragging] = useState(false);
const form = nodeRender.form;
const ctx = useClientContext();
const onPortClick = usePortClick();
const meta = node.getNodeMeta<FlowNodeMeta>();
const { open } = useNodeFormPanel();
const portsRender = ports.map((p) => (
<WorkflowPortRender key={p.id} entity={p} onClick={!readonly ? onPortClick : undefined} />
));
return (
<>
<NodeWrapperStyle
className={selected ? 'selected' : ''}
ref={nodeRef}
draggable
onDragStart={(e) => {
startDrag(e);
setIsDragging(true);
}}
onTouchStart={(e) => {
startDrag(e as unknown as React.MouseEvent);
setIsDragging(true);
}}
onClick={(e) => {
selectNode(e);
if (!isDragging) {
open({
nodeId: nodeRender.node.id,
});
// 可选:将 isScrollToView 设为 true,可以让节点选中后滚动到画布中间
// Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected.
if (isScrollToView) {
scrollToView(ctx, nodeRender.node);
}
}
}}
onMouseUp={() => setIsDragging(false)}
onFocus={onFocus}
onBlur={onBlur}
data-node-selected={String(selected)}
style={{
...meta.wrapperStyle,
outline: form?.state.invalid ? '1px solid red' : 'none',
}}
>
{children}
</NodeWrapperStyle>
{portsRender}
</>
);
};
@@ -1,39 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
import { IconInfoCircle } from '@douyinfe/semi-icons';
export const NodeWrapperStyle = styled.div`
align-items: flex-start;
background-color: #fff;
border: 1px solid rgba(6, 7, 9, 0.15);
border-radius: 8px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
width: 360px;
height: auto;
&.selected {
border: 1px solid #4e40e5;
}
`;
export const ErrorIcon = () => (
<IconInfoCircle
style={{
position: 'absolute',
color: 'red',
left: -6,
top: -6,
zIndex: 1,
background: 'white',
borderRadius: 8,
}}
/>
);
@@ -1,23 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FreeLayoutPluginContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
export function scrollToView(
ctx: FreeLayoutPluginContext,
node: FlowNodeEntity,
sidebarWidth = 448
) {
const bounds = node.transform.bounds;
ctx.playground.scrollToView({
bounds,
scrollDelta: {
x: sidebarWidth / 2,
y: 0,
},
zoom: 1,
scrollToCenter: true,
});
}
@@ -1,48 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IBlankArea {
model: CommentEditorModel;
}
export const BlankArea: FC<IBlankArea> = (props) => {
const { model } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
return (
<div
className="workflow-comment-blank-area h-full w-full"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
model.setFocus(false);
selectNode(e);
playground.node.focus(); // 防止节点无法被删除
}}
onClick={(e) => {
model.setFocus(true);
model.selectEnd();
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};
@@ -1,120 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC } from 'react';
import type { CommentEditorModel } from '../model';
import { ResizeArea } from './resize-area';
import { DragArea } from './drag-area';
interface IBorderArea {
model: CommentEditorModel;
overflow: boolean;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
}
export const BorderArea: FC<IBorderArea> = (props) => {
const { model, overflow, onResize } = props;
return (
<div style={{ zIndex: 999 }}>
{/* 左边 */}
<DragArea
style={{
position: 'absolute',
left: -10,
top: 10,
width: 20,
height: 'calc(100% - 20px)',
}}
model={model}
/>
{/* 右边 */}
<DragArea
style={{
position: 'absolute',
right: -10,
top: 10,
height: 'calc(100% - 20px)',
width: overflow ? 10 : 20, // 防止遮挡滚动条
}}
model={model}
/>
{/* 上边 */}
<DragArea
style={{
position: 'absolute',
top: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/* 下边 */}
<DragArea
style={{
position: 'absolute',
bottom: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/** 左上角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
top: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}
onResize={onResize}
/>
{/** 右上角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
top: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}
onResize={onResize}
/>
{/** 右下角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
bottom: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}
onResize={onResize}
/>
{/** 左下角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
bottom: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}
onResize={onResize}
/>
</div>
);
};
@@ -1,50 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { ReactNode, FC, CSSProperties } from 'react';
interface ICommentContainer {
focused: boolean;
children?: ReactNode;
style?: React.CSSProperties;
}
export const CommentContainer: FC<ICommentContainer> = (props) => {
const { focused, children, style } = props;
const scrollbarStyle = {
// 滚动条样式
scrollbarWidth: 'thin',
scrollbarColor: 'rgb(159 159 158 / 65%) transparent',
// 针对 WebKit 浏览器(如 Chrome、Safari)的样式
'&:WebkitScrollbar': {
width: '4px',
},
'&::WebkitScrollbarTrack': {
background: 'transparent',
},
'&::WebkitScrollbarThumb': {
backgroundColor: 'rgb(159 159 158 / 65%)',
borderRadius: '20px',
border: '2px solid transparent',
},
} as unknown as CSSProperties;
return (
<div
className="workflow-comment-container"
data-flow-editor-selectable="false"
style={{
// tailwind 不支持 outline 的样式,所以这里需要使用 style 来设置
outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',
backgroundColor: focused ? '#FFF3EA' : '#FFFBED',
...scrollbarStyle,
...style,
}}
>
{children}
</div>
);
};
@@ -1,94 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC, useState, useEffect, type WheelEventHandler } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IContentDragArea {
model: CommentEditorModel;
focused: boolean;
overflow: boolean;
}
export const ContentDragArea: FC<IContentDragArea> = (props) => {
const { model, focused, overflow } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const [active, setActive] = useState(false);
useEffect(() => {
// 当编辑器失去焦点时,取消激活状态
if (!focused) {
setActive(false);
}
}, [focused]);
const handleWheel: WheelEventHandler<HTMLDivElement> = (e) => {
const editorElement = model.element;
if (active || !overflow || !editorElement) {
return;
}
e.stopPropagation();
const maxScroll = editorElement.scrollHeight - editorElement.clientHeight;
const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll);
editorElement.scroll(0, newScrollTop);
};
const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
if (active) {
return;
}
mouseDownEvent.preventDefault();
mouseDownEvent.stopPropagation();
model.setFocus(false);
selectNode(mouseDownEvent);
playground.node.focus(); // 防止节点无法被删除
const startX = mouseDownEvent.clientX;
const startY = mouseDownEvent.clientY;
const handleMouseUp = (mouseMoveEvent: MouseEvent) => {
const deltaX = mouseMoveEvent.clientX - startX;
const deltaY = mouseMoveEvent.clientY - startY;
// 判断是拖拽还是点击
const delta = 5;
if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {
// 点击后隐藏
setActive(true);
}
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('click', handleMouseUp);
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('click', handleMouseUp);
};
return (
<div
className="workflow-comment-content-drag-area"
onMouseDown={handleMouseDown}
onWheel={handleWheel}
style={{
display: active ? 'none' : undefined,
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};
@@ -1,48 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, MouseEvent, TouchEvent, type FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import { type CommentEditorModel } from '../model';
interface IDragArea {
model: CommentEditorModel;
stopEvent?: boolean;
style?: CSSProperties;
}
export const DragArea: FC<IDragArea> = (props) => {
const { model, stopEvent = true, style } = props;
const playground = usePlayground();
const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();
const handleDrag = (e: MouseEvent | TouchEvent) => {
if (stopEvent) {
e.preventDefault();
e.stopPropagation();
}
model.setFocus(false);
onStartDrag(e as MouseEvent);
selectNode(e as MouseEvent);
playground.node.focus(); // 防止节点无法被删除
};
return (
<div
className="workflow-comment-drag-area"
data-flow-editor-selectable="false"
draggable={true}
style={style}
onMouseDown={handleDrag}
onTouchStart={handleDrag}
onFocus={onFocus}
onBlur={onBlur}
/>
);
};
@@ -1,66 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC, type CSSProperties, useEffect, useRef } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { usePlaceholder } from '../hooks';
import { CommentEditorEvent } from '../constant';
interface ICommentEditor {
model: CommentEditorModel;
style?: CSSProperties;
value?: string;
onChange?: (value: string) => void;
}
export const CommentEditor: FC<ICommentEditor> = (props) => {
const { model, style, onChange } = props;
const playground = usePlayground();
const placeholder = usePlaceholder({ model });
const editorRef = useRef<HTMLTextAreaElement | null>(null);
// 同步编辑器内部值变化
useEffect(() => {
const disposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change) {
return;
}
onChange?.(model.value);
});
return () => disposer.dispose();
}, [model, onChange]);
useEffect(() => {
if (!editorRef.current) {
return;
}
model.element = editorRef.current;
}, [editorRef]);
return (
<div className="workflow-comment-editor">
<p className="workflow-comment-editor-placeholder">{placeholder}</p>
<textarea
className="workflow-comment-editor-textarea"
ref={editorRef}
style={style}
readOnly={playground.config.readonly}
onChange={(e) => {
const { value } = e.target;
model.setValue(value);
}}
onFocus={() => {
model.setFocus(true);
}}
onBlur={() => {
model.setFocus(false);
}}
/>
</div>
);
};
@@ -1,108 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.workflow-comment {
width: auto;
height: auto;
min-width: 120px;
min-height: 80px;
}
.workflow-comment-container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
height: 100%;
border-radius: 8px;
outline: 1px solid;
padding: 6px 2px 6px 10px;
overflow: hidden;
}
.workflow-comment-drag-area {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
}
.workflow-comment-content-drag-area {
position: absolute;
height: 100%;
width: calc(100% - 22px);
}
.workflow-comment-resize-area {
position: absolute;
width: 10px;
height: 10px;
}
.workflow-comment-editor {
width: 100%;
height: 100%;
}
.workflow-comment-editor-placeholder {
margin: 0;
position: absolute;
pointer-events: none;
color: rgba(55, 67, 106, 0.38);
font-weight: 500;
}
.workflow-comment-editor-textarea {
width: 100%;
height: 100%;
box-sizing: border-box;
appearance: none;
border: none;
margin: 0;
padding: 0;
width: 100%;
background: none;
color: inherit;
font-family: inherit;
font-size: 16px;
resize: none;
outline: none;
}
.workflow-comment-more-button {
position: absolute;
right: 6px;
}
.workflow-comment-more-button > .semi-button {
color: rgba(255, 255, 255, 0);
background: none;
}
.workflow-comment-more-button > .semi-button:hover {
color: #ffa100;
background: #fbf2d2cc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button-focused > .semi-button:hover {
color: #ff811a;
background: #ffe3cecc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button > .semi-button:active {
color: #f2b600;
background: #ede5c7cc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button-focused > .semi-button:active {
color: #ff811a;
background: #eed5c1cc;
backdrop-filter: blur(1px);
}
@@ -1,8 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import './index.css';
export { CommentRender } from './render';
@@ -1,26 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { NodeMenu } from '../../node-menu';
interface IMoreButton {
node: WorkflowNodeEntity;
focused: boolean;
deleteNode: () => void;
}
export const MoreButton: FC<IMoreButton> = ({ node, focused, deleteNode }) => (
<div
className={`workflow-comment-more-button ${
focused ? 'workflow-comment-more-button-focused' : ''
}`}
>
<NodeMenu node={node} deleteNode={deleteNode} />
</div>
);
@@ -1,83 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import {
Field,
FieldRenderProps,
FlowNodeFormData,
Form,
FormModelV2,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { useOverflow } from '../hooks/use-overflow';
import { useModel } from '../hooks/use-model';
import { useSize } from '../hooks';
import { CommentEditorFormField } from '../constant';
import { MoreButton } from './more-button';
import { CommentEditor } from './editor';
import { ContentDragArea } from './content-drag-area';
import { CommentContainer } from './container';
import { BorderArea } from './border-area';
export const CommentRender: FC<{
node: WorkflowNodeEntity;
}> = (props) => {
const { node } = props;
const model = useModel();
const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formControl = formModel?.formControl;
const { width, height, onResize } = useSize();
const { overflow, updateOverflow } = useOverflow({ model, height });
return (
<div
className="workflow-comment"
style={{
width,
height,
}}
ref={nodeRef}
data-node-selected={String(focused)}
onMouseEnter={updateOverflow}
onMouseDown={(e) => {
setTimeout(() => {
// 防止 selectNode 拦截事件,导致 slate 编辑器无法聚焦
selectNode(e);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay
}, 20);
}}
>
<Form control={formControl}>
<>
{/* 背景 */}
<CommentContainer focused={focused} style={{ height }}>
<Field name={CommentEditorFormField.Note}>
{({ field }: FieldRenderProps<string>) => (
<>
{/** 编辑器 */}
<CommentEditor model={model} value={field.value} onChange={field.onChange} />
{/* 内容拖拽区域(点击后隐藏) */}
<ContentDragArea model={model} focused={focused} overflow={overflow} />
{/* 更多按钮 */}
<MoreButton node={node} focused={focused} deleteNode={deleteNode} />
</>
)}
</Field>
</CommentContainer>
{/* 边框 */}
<BorderArea model={model} overflow={overflow} onResize={onResize} />
</>
</Form>
</div>
);
};
@@ -1,89 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, type FC } from 'react';
import { MouseTouchEvent, useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
interface IResizeArea {
model: CommentEditorModel;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
getDelta?: (delta: { x: number; y: number }) => {
top: number;
right: number;
bottom: number;
left: number;
};
style?: CSSProperties;
}
export const ResizeArea: FC<IResizeArea> = (props) => {
const { model, onResize, getDelta, style } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const handleResizeStart = (
startResizeEvent: React.MouseEvent | React.TouchEvent | MouseEvent
) => {
MouseTouchEvent.preventDefault(startResizeEvent);
startResizeEvent.stopPropagation();
if (!onResize) {
return;
}
const { resizing, resizeEnd } = onResize();
model.setFocus(false);
selectNode(startResizeEvent as React.MouseEvent);
playground.node.focus(); // 防止节点无法被删除
const { clientX: startX, clientY: startY } = MouseTouchEvent.getEventCoord(
startResizeEvent as MouseEvent
);
const handleResizing = (mouseMoveEvent: MouseEvent | TouchEvent) => {
const { clientX: moveX, clientY: moveY } = MouseTouchEvent.getEventCoord(mouseMoveEvent);
const deltaX = moveX - startX;
const deltaY = moveY - startY;
const delta = getDelta?.({ x: deltaX, y: deltaY });
if (!delta || !resizing) {
return;
}
resizing(delta);
};
const handleResizeEnd = () => {
resizeEnd();
document.removeEventListener('mousemove', handleResizing);
document.removeEventListener('mouseup', handleResizeEnd);
document.removeEventListener('click', handleResizeEnd);
document.removeEventListener('touchmove', handleResizing);
document.removeEventListener('touchend', handleResizeEnd);
document.removeEventListener('touchcancel', handleResizeEnd);
};
document.addEventListener('mousemove', handleResizing);
document.addEventListener('mouseup', handleResizeEnd);
document.addEventListener('click', handleResizeEnd);
document.addEventListener('touchmove', handleResizing, { passive: false });
document.addEventListener('touchend', handleResizeEnd);
document.addEventListener('touchcancel', handleResizeEnd);
};
return (
<div
className="workflow-comment-resize-area"
style={style}
data-flow-editor-selectable="false"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
);
};
@@ -1,27 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable @typescript-eslint/naming-convention -- enum */
export enum CommentEditorFormField {
Size = 'size',
Note = 'note',
}
/** 编辑器事件 */
export enum CommentEditorEvent {
/** 初始化事件 */
Init = 'init',
/** 内容变更事件 */
Change = 'change',
/** 多选事件 */
MultiSelect = 'multiSelect',
/** 单选事件 */
Select = 'select',
/** 失焦事件 */
Blur = 'blur',
}
export const CommentEditorDefaultValue = '';
@@ -1,7 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { useSize } from './use-size';
export { usePlaceholder } from './use-placeholder';
@@ -1,55 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useMemo } from 'react';
import {
FlowNodeFormData,
FormModelV2,
useEntityFromContext,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorFormField } from '../constant';
export const useModel = () => {
const node = useEntityFromContext<WorkflowNodeEntity>();
const { selected: focused } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const model = useMemo(() => new CommentEditorModel(), []);
// 同步失焦状态
useEffect(() => {
if (focused) {
return;
}
model.setFocus(focused);
}, [focused, model]);
// 同步表单值初始化
useEffect(() => {
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setInitValue(value); // 设置初始值
model.selectEnd(); // 设置初始化光标位置
}, [formModel, model]);
// 同步表单外部值变化:undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Note && name !== '') {
return;
}
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setValue(value);
});
return () => disposer.dispose();
}, [formModel, model]);
return model;
};
@@ -1,50 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useState, useEffect } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
export const useOverflow = (params: { model: CommentEditorModel; height: number }) => {
const { model, height } = params;
const playground = usePlayground();
const [overflow, setOverflow] = useState(false);
const isOverflow = useCallback((): boolean => {
if (!model.element) {
return false;
}
return model.element.scrollHeight > model.element.clientHeight;
}, [model, height, playground]);
// 更新 overflow
const updateOverflow = useCallback(() => {
setOverflow(isOverflow());
}, [isOverflow]);
// 监听高度变化
useEffect(() => {
updateOverflow();
}, [height, updateOverflow]);
// 监听 change 事件
useEffect(() => {
const changeDisposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change && params.type !== CommentEditorEvent.Init) {
return;
}
updateOverflow();
});
return () => {
changeDisposer.dispose();
};
}, [model, updateOverflow]);
return { overflow, updateOverflow };
};
@@ -1,34 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState, useEffect } from 'react';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
export const usePlaceholder = (params: { model: CommentEditorModel }): string | undefined => {
const { model } = params;
const [placeholder, setPlaceholder] = useState<string | undefined>('Enter a comment...');
// 监听 change 事件
useEffect(() => {
const changeDisposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change && params.type !== CommentEditorEvent.Init) {
return;
}
if (params.value) {
setPlaceholder(undefined);
} else {
setPlaceholder('Enter a comment...');
}
});
return () => {
changeDisposer.dispose();
};
}, [model]);
return placeholder;
};
@@ -1,168 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useEffect, useState } from 'react';
import {
FlowNodeFormData,
FormModelV2,
FreeOperationType,
HistoryService,
TransformData,
useCurrentEntity,
usePlayground,
useService,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorFormField } from '../constant';
export const useSize = () => {
const node = useCurrentEntity();
const nodeMeta = node.getNodeMeta();
const playground = usePlayground();
const historyService = useService(HistoryService);
const { size = { width: 240, height: 150 } } = nodeMeta;
const transform = node.getData(TransformData);
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
const [width, setWidth] = useState(formSize?.width ?? size.width);
const [height, setHeight] = useState(formSize?.height ?? size.height);
// 初始化表单值
useEffect(() => {
const initSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!initSize) {
formModel.setValueIn(CommentEditorFormField.Size, {
width,
height,
});
}
}, [formModel, width, height]);
// 同步表单外部值变化:初始化/undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Size && name !== '') {
return;
}
const newSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!newSize) {
return;
}
setWidth(newSize.width);
setHeight(newSize.height);
});
return () => disposer.dispose();
}, [formModel]);
const onResize = useCallback(() => {
const resizeState = {
width,
height,
originalWidth: width,
originalHeight: height,
positionX: transform.position.x,
positionY: transform.position.y,
offsetX: 0,
offsetY: 0,
};
const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {
if (!resizeState) {
return;
}
const { zoom } = playground.config;
const top = delta.top / zoom;
const right = delta.right / zoom;
const bottom = delta.bottom / zoom;
const left = delta.left / zoom;
const minWidth = 120;
const minHeight = 80;
const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);
const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);
// 如果宽度或高度小于最小值,则不更新偏移量
const newOffsetX =
(left > 0 || right < 0) && newWidth <= minWidth
? resizeState.offsetX
: left / 2 + right / 2;
const newOffsetY =
(top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;
const newPositionX = resizeState.positionX + newOffsetX;
const newPositionY = resizeState.positionY + newOffsetY;
resizeState.width = newWidth;
resizeState.height = newHeight;
resizeState.offsetX = newOffsetX;
resizeState.offsetY = newOffsetY;
// 更新状态
setWidth(newWidth);
setHeight(newHeight);
// 更新偏移量
transform.update({
position: {
x: newPositionX,
y: newPositionY,
},
});
};
const resizeEnd = () => {
historyService.transact(() => {
historyService.pushOperation(
{
type: FreeOperationType.dragNodes,
value: {
ids: [node.id],
value: [
{
x: resizeState.positionX + resizeState.offsetX,
y: resizeState.positionY + resizeState.offsetY,
},
],
oldValue: [
{
x: resizeState.positionX,
y: resizeState.positionY,
},
],
},
},
{
noApply: true,
}
);
formModel.setValueIn(CommentEditorFormField.Size, {
width: resizeState.width,
height: resizeState.height,
});
});
};
return {
resizing,
resizeEnd,
};
}, [node, width, height, transform, playground, formModel, historyService]);
return {
width,
height,
onResize,
};
};
@@ -1,6 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { CommentRender } from './components';
@@ -1,127 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Emitter } from '@flowgram.ai/free-layout-editor';
import { CommentEditorEventParams } from './type';
import { CommentEditorDefaultValue, CommentEditorEvent } from './constant';
export class CommentEditorModel {
private innerValue: string = CommentEditorDefaultValue;
private emitter: Emitter<CommentEditorEventParams> = new Emitter();
private editor: HTMLTextAreaElement;
/** 注册事件 */
public on = this.emitter.event;
/** 获取当前值 */
public get value(): string {
return this.innerValue;
}
/** 外部设置模型值 */
public setValue(value: string = CommentEditorDefaultValue): void {
if (!this.initialized) {
return;
}
if (value === this.innerValue) {
return;
}
this.innerValue = value;
this.syncEditorValue();
this.emitter.fire({
type: CommentEditorEvent.Change,
value: this.innerValue,
});
}
/** 外部设置模型值 */
public setInitValue(value: string = CommentEditorDefaultValue): void {
if (!this.initialized) {
return;
}
if (value === this.innerValue) {
return;
}
this.innerValue = value;
this.syncEditorValue();
this.emitter.fire({
type: CommentEditorEvent.Init,
value: this.innerValue,
});
}
public set element(el: HTMLTextAreaElement) {
if (this.initialized) {
return;
}
this.editor = el;
}
/** 获取编辑器 DOM 节点 */
public get element(): HTMLTextAreaElement {
return this.editor;
}
/** 编辑器聚焦/失焦 */
public setFocus(focused: boolean): void {
if (!this.initialized) {
return;
}
if (focused && !this.focused) {
this.editor.focus();
} else if (!focused && this.focused) {
this.editor.blur();
this.deselect();
this.emitter.fire({
type: CommentEditorEvent.Blur,
});
}
}
/** 选择末尾 */
public selectEnd(): void {
if (!this.initialized) {
return;
}
// 获取文本长度
const length = this.editor.value.length;
// 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)
this.editor.setSelectionRange(length, length);
}
/** 获取聚焦状态 */
public get focused(): boolean {
return document.activeElement === this.editor;
}
/** 取消选择文本 */
private deselect(): void {
const selection: Selection | null = window.getSelection();
// 清除所有选择区域
if (selection) {
selection.removeAllRanges();
}
}
/** 是否初始化 */
private get initialized(): boolean {
return Boolean(this.editor);
}
/**
* 同步编辑器实例内容
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
*/
private syncEditorValue(): void {
if (!this.initialized) {
return;
}
this.editor.value = this.innerValue;
}
}
@@ -1,35 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { CommentEditorEvent } from './constant';
interface CommentEditorChangeEvent {
type: CommentEditorEvent.Change;
value: string;
}
interface CommentEditorMultiSelectEvent {
type: CommentEditorEvent.MultiSelect;
}
interface CommentEditorSelectEvent {
type: CommentEditorEvent.Select;
}
interface CommentEditorBlurEvent {
type: CommentEditorEvent.Blur;
}
interface CommentEditorInitEvent {
type: CommentEditorEvent.Init;
value: string;
}
export type CommentEditorEventParams =
| CommentEditorChangeEvent
| CommentEditorMultiSelectEvent
| CommentEditorSelectEvent
| CommentEditorBlurEvent
| CommentEditorInitEvent;
@@ -1,105 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
type GroupColor = {
'50': string;
'300': string;
'400': string;
};
export const defaultColor = 'Blue';
export const groupColors: Record<string, GroupColor> = {
Red: {
'50': '#fef2f2',
'300': '#fca5a5',
'400': '#f87171',
},
Orange: {
'50': '#fff7ed',
'300': '#fdba74',
'400': '#fb923c',
},
Amber: {
'50': '#fffbeb',
'300': '#fcd34d',
'400': '#fbbf24',
},
Yellow: {
'50': '#fef9c3',
'300': '#fde047',
'400': '#facc15',
},
Lime: {
'50': '#f7fee7',
'300': '#bef264',
'400': '#a3e635',
},
Green: {
'50': '#f0fdf4',
'300': '#86efac',
'400': '#4ade80',
},
Emerald: {
'50': '#ecfdf5',
'300': '#6ee7b7',
'400': '#34d399',
},
Teal: {
'50': '#f0fdfa',
'300': '#5eead4',
'400': '#2dd4bf',
},
Cyan: {
'50': '#ecfeff',
'300': '#67e8f9',
'400': '#22d3ee',
},
Sky: {
'50': '#ecfeff',
'300': '#7dd3fc',
'400': '#38bdf8',
},
Blue: {
'50': '#eff6ff',
'300': '#93c5fd',
'400': '#60a5fa',
},
Indigo: {
'50': '#eef2ff',
'300': '#a5b4fc',
'400': '#818cf8',
},
Violet: {
'50': '#f5f3ff',
'300': '#c4b5fd',
'400': '#a78bfa',
},
Purple: {
'50': '#faf5ff',
'300': '#d8b4fe',
'400': '#c084fc',
},
Fuchsia: {
'50': '#fdf4ff',
'300': '#f0abfc',
'400': '#e879f9',
},
Pink: {
'50': '#fdf2f8',
'300': '#f9a8d4',
'400': '#f472b6',
},
Rose: {
'50': '#fff1f2',
'300': '#fda4af',
'400': '#fb7185',
},
Gray: {
'50': '#f9fafb',
'300': '#d1d5db',
'400': '#9ca3af',
},
};
@@ -1,55 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC, useEffect } from 'react';
import { useWatch, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
interface GroupBackgroundProps {
node: WorkflowNodeEntity;
style?: CSSProperties;
selected: boolean;
}
export const GroupBackground: FC<GroupBackgroundProps> = ({ node, style, selected }) => {
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
const color = groupColors[colorName];
useEffect(() => {
const styleElement = document.createElement('style');
// 使用独特的选择器
const styleContent = `
.workflow-group-render[data-group-id="${node.id}"] .workflow-group-background {
border: 1px solid ${color['300']};
}
.workflow-group-render.selected[data-group-id="${node.id}"] .workflow-group-background {
border: 1px solid #4e40e5;
}
`;
styleElement.textContent = styleContent;
document.head.appendChild(styleElement);
return () => {
styleElement.remove();
};
}, [color]);
return (
<div
className="workflow-group-background"
data-flow-editor-selectable="true"
style={{
...style,
backgroundColor: `${color['300']}${selected ? '40' : '29'}`,
}}
/>
);
};
@@ -1,50 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { Popover, Tooltip } from '@douyinfe/semi-ui';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
export const GroupColor: FC = () => (
<Field<string> name={GroupField.Color}>
{({ field }) => {
const colorName = field.value ?? defaultColor;
return (
<Popover
position="top"
mouseLeaveDelay={300}
content={
<div className="workflow-group-color-palette">
{Object.entries(groupColors).map(([name, color]) => (
<Tooltip content={name} key={name} mouseEnterDelay={300}>
<span
className="workflow-group-color-item"
key={name}
style={{
backgroundColor: color['300'],
borderColor: name === colorName ? color['400'] : '#fff',
}}
onClick={() => field.onChange(name)}
/>
</Tooltip>
))}
</div>
}
>
<span
className="workflow-group-color"
style={{
backgroundColor: groupColors[colorName]['300'],
}}
/>
</Popover>
);
}}
</Field>
);
@@ -1,41 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { FC, ReactNode, MouseEvent, CSSProperties, TouchEvent } from 'react';
import { useWatch } from '@flowgram.ai/free-layout-editor';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
interface GroupHeaderProps {
onDrag: (e: MouseEvent | TouchEvent) => void;
onFocus: () => void;
onBlur: () => void;
children: ReactNode;
style?: CSSProperties;
}
export const GroupHeader: FC<GroupHeaderProps> = ({ onDrag, onFocus, onBlur, children, style }) => {
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
const color = groupColors[colorName];
return (
<div
className="workflow-group-header"
data-flow-editor-selectable="false"
onMouseDown={onDrag}
onTouchStart={onDrag}
onFocus={onFocus}
onBlur={onBlur}
style={{
...style,
backgroundColor: color['50'],
borderColor: color['300'],
}}
>
{children}
</div>
);
};
@@ -1,52 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
interface IconGroupProps {
size?: number;
}
export const IconGroup: FC<IconGroupProps> = ({ size }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
style={{
width: size,
height: size,
}}
>
<path
id="group"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 0.009766 10 L 0.009766 9.990234 L 0 9.990234 L 0 7.5 L 1 7.5 L 1 9 L 2.5 9 L 2.5 10 L 0.009766 10 Z M 3.710938 10 L 3.710938 9 L 6.199219 9 L 6.199219 10 L 3.710938 10 Z M 7.5 10 L 7.5 9 L 9 9 L 9 7.5 L 10 7.5 L 10 9.990234 L 9.990234 9.990234 L 9.990234 10 L 7.5 10 Z M 0 6.289063 L 0 3.800781 L 1 3.800781 L 1 6.289063 L 0 6.289063 Z M 9 6.289063 L 9 3.800781 L 10 3.800781 L 10 6.289063 L 9 6.289063 Z M 0 2.5 L 0 0.009766 L 0.009766 0.009766 L 0.009766 0 L 2.5 0 L 2.5 1 L 1 1 L 1 2.5 L 0 2.5 Z M 9 2.5 L 9 1 L 7.5 1 L 7.5 0 L 9.990234 0 L 9.990234 0.009766 L 10 0.009766 L 10 2.5 L 9 2.5 Z M 3.710938 1 L 3.710938 0 L 6.199219 0 L 6.199219 1 L 3.710938 1 Z"
/>
</svg>
);
export const IconUngroup: FC<IconGroupProps> = ({ size }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
style={{
width: size,
height: size,
}}
>
<path
id="ungroup"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 9.654297 10.345703 L 8.808594 9.5 L 7.175781 9.5 L 7.175781 8.609375 L 7.917969 8.609375 L 1.390625 2.082031 L 1.390625 2.824219 L 0.5 2.824219 L 0.5 1.191406 L -0.345703 0.345703 L 0.283203 -0.283203 L 1.166016 0.599609 L 2.724609 0.599609 L 2.724609 1.490234 L 2.056641 1.490234 L 8.509766 7.943359 L 8.509766 7.275391 L 9.400391 7.275391 L 9.400391 8.833984 L 10.283203 9.716797 L 9.654297 10.345703 Z M 0.509766 9.5 L 0.509766 9.490234 L 0.5 9.490234 L 0.5 7.275391 L 1.390625 7.275391 L 1.390625 8.609375 L 2.724609 8.609375 L 2.724609 9.5 L 0.509766 9.5 Z M 3.802734 9.5 L 3.802734 8.609375 L 6.017578 8.609375 L 6.017578 9.5 L 3.802734 9.5 Z M 0.5 6.197266 L 0.5 3.982422 L 1.390625 3.982422 L 1.390625 6.197266 L 0.5 6.197266 Z M 8.509766 6.197266 L 8.509766 3.982422 L 9.400391 3.982422 L 9.400391 6.197266 L 8.509766 6.197266 Z M 8.509766 2.824219 L 8.509766 1.490234 L 7.175781 1.490234 L 7.175781 0.599609 L 9.390625 0.599609 L 9.390625 0.609375 L 9.400391 0.609375 L 9.400391 2.824219 L 8.509766 2.824219 Z M 3.802734 1.490234 L 3.802734 0.599609 L 6.017578 0.599609 L 6.017578 1.490234 L 3.802734 1.490234 Z"
/>
</svg>
);
@@ -1,7 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { GroupNodeRender } from './node-render';
export { IconGroup } from './icon-group';
@@ -1,82 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MouseEvent, useEffect } from 'react';
import {
FlowNodeFormData,
Form,
FormModelV2,
useNodeRender,
} from '@flowgram.ai/free-layout-editor';
import { useNodeSize } from '@flowgram.ai/free-container-plugin';
import { HEADER_HEIGHT, HEADER_PADDING } from '../constant';
import { UngroupButton } from './ungroup';
import { GroupTools } from './tools';
import { GroupTips } from './tips';
import { GroupHeader } from './header';
import { GroupBackground } from './background';
export const GroupNodeRender = () => {
const { node, selected, selectNode, nodeRef, startDrag, onFocus, onBlur } = useNodeRender();
const nodeSize = useNodeSize();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formControl = formModel?.formControl;
const { height, width } = nodeSize ?? {};
const nodeHeight = height ?? 0;
useEffect(() => {
// prevent lines in outside cannot be selected - 防止外层线条不可选中
const element = node.renderData.node;
element.style.pointerEvents = 'none';
}, [node]);
return (
<div
className={`workflow-group-render ${selected ? 'selected' : ''}`}
ref={nodeRef}
data-group-id={node.id}
data-node-selected={String(selected)}
onMouseDown={selectNode}
onClick={(e) => {
selectNode(e);
}}
style={{
width,
height,
}}
>
<Form control={formControl}>
<>
<GroupHeader
onDrag={(e) => {
startDrag(e as MouseEvent);
e.stopPropagation();
}}
onFocus={onFocus}
onBlur={onBlur}
style={{
height: HEADER_HEIGHT,
}}
>
<GroupTools />
</GroupHeader>
<GroupTips />
<UngroupButton node={node} />
<GroupBackground
node={node}
selected={selected}
style={{
top: HEADER_HEIGHT + HEADER_PADDING,
height: nodeHeight - HEADER_HEIGHT - HEADER_PADDING,
}}
/>
</>
</Form>
</div>
);
};
@@ -1,38 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable @typescript-eslint/naming-convention -- no need */
const STORAGE_KEY = 'workflow-move-into-group-tip-visible';
const STORAGE_VALUE = 'false';
export class TipsGlobalStore {
private static _instance?: TipsGlobalStore;
public static get instance(): TipsGlobalStore {
if (!this._instance) {
this._instance = new TipsGlobalStore();
}
return this._instance;
}
private closed = false;
public isClosed(): boolean {
return this.isCloseForever() || this.closed;
}
public close(): void {
this.closed = true;
}
public isCloseForever(): boolean {
return localStorage.getItem(STORAGE_KEY) === STORAGE_VALUE;
}
public closeForever(): void {
localStorage.setItem(STORAGE_KEY, STORAGE_VALUE);
}
}
@@ -1,14 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconClose = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#060709"
fillOpacity="0.5"
d="M12.13 12.128a.5.5 0 0 0 .001-.706L8.71 8l3.422-3.423a.5.5 0 0 0-.001-.705.5.5 0 0 0-.706-.002L8.002 7.293 4.579 3.87a.5.5 0 0 0-.705.002.5.5 0 0 0-.002.705L7.295 8l-3.423 3.422a.5.5 0 0 0 .002.706c.195.195.51.197.705.001l3.423-3.422 3.422 3.422c.196.196.51.194.706-.001"
></path>
</svg>
);
@@ -1,41 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useControlTips } from './use-control';
import { GroupTipsStyle } from './style';
import { isMacOS } from './is-mac-os';
import { IconClose } from './icon-close';
export const GroupTips = () => {
const { visible, close, closeForever } = useControlTips();
if (!visible) {
return null;
}
return (
<GroupTipsStyle className={'workflow-group-tips'}>
<div className="container">
<div className="content">
<p className="text">{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}</p>
<div
className="space"
style={{
width: 0,
}}
/>
</div>
<div className="actions">
<p className="close-forever" onClick={closeForever}>
Never Remind
</p>
<div className="close" onClick={close}>
<IconClose />
</div>
</div>
</div>
</GroupTipsStyle>
);
};
@@ -1,6 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);
@@ -1,79 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
export const GroupTipsStyle = styled.div`
position: absolute;
top: 35px;
width: 100%;
height: 28px;
white-space: nowrap;
pointer-events: auto;
.container {
display: inline-flex;
justify-content: center;
height: 100%;
width: 100%;
background-color: rgb(255 255 255);
border-radius: 8px 8px 0 0;
.content {
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: flex-start;
width: fit-content;
height: 100%;
padding: 0 12px;
.text {
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: rgba(15, 21, 40, 82%);
text-overflow: ellipsis;
margin: 0;
}
.space {
width: 128px;
}
}
.actions {
display: flex;
gap: 8px;
align-items: center;
height: 28px;
padding: 0 12px;
.close-forever {
cursor: pointer;
padding: 0 3px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 12px;
color: rgba(32, 41, 69, 62%);
margin: 0;
}
.close {
display: flex;
cursor: pointer;
height: 100%;
align-items: center;
}
}
}
`;
@@ -1,71 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useEffect, useState } from 'react';
import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
import {
NodeIntoContainerService,
NodeIntoContainerType,
} from '@flowgram.ai/free-container-plugin';
import { TipsGlobalStore } from './global-store';
export const useControlTips = () => {
const node = useCurrentEntity();
const [visible, setVisible] = useState(false);
const globalStore = TipsGlobalStore.instance;
const nodeIntoContainerService = useService<NodeIntoContainerService>(NodeIntoContainerService);
const show = useCallback(() => {
if (globalStore.isClosed()) {
return;
}
setVisible(true);
}, [globalStore]);
const close = useCallback(() => {
globalStore.close();
setVisible(false);
}, [globalStore]);
const closeForever = useCallback(() => {
globalStore.closeForever();
close();
}, [close, globalStore]);
useEffect(() => {
// 监听移入
const inDisposer = nodeIntoContainerService.on((e) => {
if (e.type !== NodeIntoContainerType.In) {
return;
}
if (e.targetContainer === node) {
show();
}
});
// 监听移出事件
const outDisposer = nodeIntoContainerService.on((e) => {
if (e.type !== NodeIntoContainerType.Out) {
return;
}
if (e.sourceContainer === node && !node.blocks.length) {
setVisible(false);
}
});
return () => {
inDisposer.dispose();
outDisposer.dispose();
};
}, [nodeIntoContainerService, node, show, close, visible]);
return {
visible,
close,
closeForever,
};
};
@@ -1,38 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useState } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { Input } from '@douyinfe/semi-ui';
import { GroupField } from '../constant';
export const GroupTitle: FC = () => {
const [inputting, setInputting] = useState(false);
return (
<Field<string> name={GroupField.Title}>
{({ field }) =>
inputting ? (
<Input
autoFocus
className="workflow-group-title-input"
size="small"
value={field.value}
onChange={field.onChange}
onMouseDown={(e) => e.stopPropagation()}
onBlur={() => setInputting(false)}
draggable={false}
onEnterPress={() => setInputting(false)}
/>
) : (
<p className="workflow-group-title" onDoubleClick={() => setInputting(true)}>
{field.value ?? 'Group'}
</p>
)
}
</Field>
);
};
@@ -1,19 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { IconHandle } from '@douyinfe/semi-icons';
import { GroupTitle } from './title';
import { GroupColor } from './color';
export const GroupTools: FC = () => (
<div className="workflow-group-tools">
<IconHandle className="workflow-group-tools-drag" />
<GroupTitle />
<GroupColor />
</div>
);
@@ -1,36 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC } from 'react';
import { CommandRegistry, useService, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconUngroup } from './icon-group';
interface UngroupButtonProps {
node: WorkflowNodeEntity;
style?: CSSProperties;
}
export const UngroupButton: FC<UngroupButtonProps> = ({ node, style }) => {
const commandRegistry = useService(CommandRegistry);
return (
<Tooltip content="Ungroup">
<div className="workflow-group-ungroup" style={style}>
<Button
icon={<IconUngroup size={14} />}
style={{ height: 30, width: 30 }}
theme="borderless"
type="tertiary"
onClick={() => {
commandRegistry.executeCommand(WorkflowGroupCommand.Ungroup, node);
}}
/>
</div>
</Tooltip>
);
};
@@ -1,12 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const HEADER_HEIGHT = 30;
export const HEADER_PADDING = 5;
export enum GroupField {
Title = 'title',
Color = 'color',
}
@@ -1,117 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.workflow-group-render {
border-radius: 8px;
pointer-events: none;
}
.workflow-group-background {
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
}
.workflow-group-header {
height: 30px;
width: fit-content;
background-color: #fefce8;
border: 1px solid #facc15;
border-radius: 8px;
padding-right: 8px;
pointer-events: auto;
}
.workflow-group-ungroup {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
width: 30px;
position: absolute;
top: 35px;
right: 0;
border-radius: 8px;
cursor: pointer;
pointer-events: auto;
}
.workflow-group-ungroup .semi-button {
color: #9ca3af;
}
.workflow-group-ungroup:hover .semi-button {
color: #374151;
}
.workflow-group-background {
position: absolute;
pointer-events: none;
top: 0;
background-color: #fddf4729;
border: 1px solid #fde047;
border-radius: 8px;
width: 100%;
}
.workflow-group-render.selected .workflow-group-background {
border: 1px solid #facc15;
}
.workflow-group-tools {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
height: 100%;
cursor: move;
color: oklch(44.6% 0.043 257.281);
font-size: 14px;
}
.workflow-group-title {
margin: 0;
max-width: 242px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.workflow-group-tools-drag {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding-left: 4px;
}
.workflow-group-color {
width: 16px;
height: 16px;
border-radius: 8px;
background-color: #fde047;
margin-left: 4px;
cursor: pointer;
}
.workflow-group-title-input {
width: 242px;
border: none;
color: #374151;
}
.workflow-group-color-palette {
display: grid;
grid-template-columns: repeat(6, 24px);
gap: 12px;
margin: 8px;
padding: 8px;
}
.workflow-group-color-item {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #fde047;
cursor: pointer;
border: 3px solid;
}
@@ -1,9 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import './index.css';
export { GroupNodeRender } from './components';
export { IconGroup } from './components';
-10
View File
@@ -1,10 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './base-node';
export * from './line-add-button';
export * from './node-panel';
export * from './comment';
export * from './group';
@@ -1,31 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconPlusCircle = () => (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="add">
<path
id="background"
fill="#ffffff"
fillRule="evenodd"
stroke="none"
d="M 24 12 C 24 5.372583 18.627417 0 12 0 C 5.372583 0 -0 5.372583 -0 12 C -0 18.627417 5.372583 24 12 24 C 18.627417 24 24 18.627417 24 12 Z"
/>
<path
id="content"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 22 12.005 C 22 6.482153 17.522848 2.004999 12 2.004999 C 6.477152 2.004999 2 6.482153 2 12.005 C 2 17.527847 6.477152 22.004999 12 22.004999 C 17.522848 22.004999 22 17.527847 22 12.005 Z"
/>
<path
id="cross"
fill="#ffffff"
stroke="none"
d="M 11.411996 16.411797 C 11.411996 16.736704 11.675362 17 12.00023 17 C 12.325109 17 12.588474 16.736704 12.588474 16.411797 L 12.588474 12.58826 L 16.41201 12.58826 C 16.736919 12.58826 17.000216 12.324894 17.000216 12.000015 C 17.000216 11.675147 16.736919 11.411781 16.41201 11.411781 L 12.588474 11.411781 L 12.588474 7.588234 C 12.588474 7.263367 12.325109 7 12.00023 7 C 11.675362 7 11.411996 7.263367 11.411996 7.588234 L 11.411996 11.411781 L 7.588449 11.411781 C 7.263581 11.411781 7.000215 11.675147 7.000215 12.000015 C 7.000215 12.324894 7.263581 12.58826 7.588449 12.58826 L 11.411996 12.58826 L 11.411996 16.411797 Z"
/>
</g>
</svg>
);
@@ -1,13 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.line-add-button {
position: absolute;
width: 24px;
height: 24px;
cursor: pointer;
color: inherit;
pointer-events: all;
}
@@ -1,129 +0,0 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import {
WorkflowNodePanelService,
WorkflowNodePanelUtils,
} from '@flowgram.ai/free-node-panel-plugin';
import { LineRenderProps } from '@flowgram.ai/free-lines-plugin';
import {
delay,
HistoryService,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowNodeJSON,
} from '@flowgram.ai/free-layout-editor';
import './index.less';
import { useVisible } from './use-visible';
import { IconPlusCircle } from './button';
export const LineAddButton = (props: LineRenderProps) => {
const { line, selected, hovered, color } = props;
const visible = useVisible({ line, selected, hovered });
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
const document = useService(WorkflowDocument);
const dragService = useService(WorkflowDragService);
const linesManager = useService(WorkflowLinesManager);
const historyService = useService(HistoryService);
const { fromPort, toPort } = line;
const onClick = useCallback(async () => {
// calculate the middle point of the line - 计算线条的中点位置
const position = {
x: (line.position.from.x + line.position.to.x) / 2,
y: (line.position.from.y + line.position.to.y) / 2,
};
// get container node for the new node - 获取新节点的容器节点
const containerNode = fromPort!.node.parent;
// show node selection panel - 显示节点选择面板
const result = await nodePanelService.singleSelectNodePanel({
position,
containerNode,
panelProps: {
enableScrollClose: true,
fromPort,
},
});
if (!result) {
return;
}
const { nodeType, nodeJSON } = result;
// adjust position for the new node - 调整新节点的位置
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
nodeType,
position,
fromPort,
toPort,
containerNode,
document,
dragService,
});
// create new workflow node - 创建新的工作流节点
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
nodeType,
nodePosition,
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
// auto offset subsequent nodes - 自动偏移后续节点
if (fromPort && toPort) {
WorkflowNodePanelUtils.subNodesAutoOffset({
node,
fromPort,
toPort,
containerNode,
historyService,
dragService,
linesManager,
});
}
// wait for node render - 等待节点渲染
await delay(20);
// build connection lines - 构建连接线
WorkflowNodePanelUtils.buildLine({
fromPort,
node,
toPort,
linesManager,
});
// remove original line - 移除原始线条
line.dispose();
}, []);
if (!visible) {
return <></>;
}
return (
<div
className="line-add-button"
style={{
transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,
color,
}}
data-testid="sdk.workflow.canvas.line.add"
data-line-id={line.id}
onClick={onClick}
>
<IconPlusCircle />
</div>
);
};

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