refactor: align package folder names with npm package names
CI / check (pull_request) Failing after 8m30s

Rename packages/ subdirectories to match their @united-workforce/* scope:
  cli-workflow → cli
  workflow-agent-builtin → agent-builtin
  workflow-agent-claude-code → agent-claude-code
  workflow-agent-hermes → agent-hermes
  workflow-dashboard → dashboard
  workflow-protocol → protocol
  workflow-util-agent → util-agent
  workflow-util → util

Updated all tsconfig references, scripts, and active docs.
Historical docs (docs/plans/, docs/superpowers/) left as-is.

Closes #21
This commit is contained in:
2026-06-02 23:45:45 +08:00
parent e4e4288d00
commit 5970456a54
266 changed files with 207 additions and 207 deletions
+10
View File
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import { Outlet } from "react-router";
export function Layout(): ReactNode {
return (
<div className="h-screen w-screen bg-background text-foreground">
<Outlet />
</div>
);
}
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,92 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className,
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className,
)}
{...props}
/>
);
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -0,0 +1,135 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,20 @@
import { Input as InputPrimitive } from "@base-ui/react/input";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,19 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: generic Label component; control association handled by consumer
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
@@ -0,0 +1,21 @@
"use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -0,0 +1,18 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+319
View File
@@ -0,0 +1,319 @@
import { type ReactFlowInstance, useReactFlow } from "@xyflow/react";
import type { FC, PropsWithChildren } from "react";
import { createContext, useContext, useLayoutEffect, useMemo, useSyncExternalStore } from "react";
import type { AnyWorkNode } from "./type";
type Reduce<T> = (data: T) => T;
type Setter<T> = (ch: Reduce<T> | T) => void;
interface State<T, A> {
readonly get: () => T;
readonly set: Setter<T>;
readonly use: () => T;
readonly listen: (cb: VoidFunction) => VoidFunction;
readonly actions: A;
readonly onlyView: boolean;
}
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
// biome-ignore lint/suspicious/noExplicitAny: UseV intentionally erases the action type
type UseV = <T>(sub: SubModel<T, any>) => T;
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
export const uuid = () => Math.round((Math.random() + 1) * Date.now()).toString(36);
export function generate<T>(val: T) {
const listener = new Set<VoidFunction>();
const get = () => val;
function set(ch: T | ((prev: T) => T)) {
const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
if (Object.is(val, next)) return;
val = next;
for (const call of listener) {
call();
}
}
const listen = (call: VoidFunction) => {
listener.add(call);
return () => listener.delete(call);
};
const use = () => useSyncExternalStore(listen, get, get);
return { get, set, use, listen };
}
class SubModel<T, A> {
public readonly name: string;
private readonly make: () => T;
private readonly create: Create<T, A>;
private readonly onlyView: boolean;
constructor(name: string, _make: () => T, _create: Create<T, A>, _onlyView = false) {
this.name = name;
this.make = _make;
this.create = _create;
this.onlyView = _onlyView;
}
public gen(model: Model): State<T, A> {
const { get, set, use, listen } = generate(this.make());
const actions = this.create(set, get, model);
return { get, set, use, listen, actions, onlyView: this.onlyView };
}
use(): [T, A] {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { query } = useContext(Context);
const { use, actions } = query(this);
return [use(), actions];
}
useData(): T {
const { query } = useContext(Context);
return query(this).use();
}
useCreation(): A {
const { query } = useContext(Context);
return query(this).actions;
}
}
// biome-ignore lint/suspicious/noExplicitAny: snapshot data is heterogeneous
type Snapshot = [name: string, data: any];
class Model {
private ustack: Snapshot[][] = [];
private rstack: Snapshot[][] = [];
private transaction = 0;
// biome-ignore lint/suspicious/noExplicitAny: backup stores heterogeneous state values
private backup = new Map<string, any>();
public flow = {} as ReactFlowInstance<AnyWorkNode>;
private stackListeners = new Set<() => void>();
public readonly stackState: readonly [boolean, boolean] = [false, false];
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
private readonly store: Map<string, State<any, any>>;
public readonly use: Use;
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
constructor(store: Map<string, State<any, any>>, use: Use) {
this.store = store;
this.use = use;
}
public reset() {
this.ustack = [];
this.rstack = [];
this.transaction = 0;
this.backup.clear();
this.triggerStackState();
}
public readonly listenStackState = (cb: () => void) => {
this.stackListeners.add(cb);
return () => this.stackListeners.delete(cb);
};
private triggerStackState() {
// @ts-expect-error
this.stackState = [this.canUndo(), this.canRedo()];
for (const call of this.stackListeners) {
call();
}
}
private getStackState = () => this.stackState;
public useStackState() {
const get = this.getStackState;
return useSyncExternalStore(this.listenStackState, get, get);
}
public log() {
// biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
const snapshots: Record<string, any> = {};
for (const [name, state] of this.store) {
snapshots[name] = state.get();
}
}
public undo() {
const { ustack, rstack, store } = this;
const item = ustack.pop();
if (!item) return;
const step: Snapshot[] = [];
for (const [name, data] of item) {
const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
step.push([name, get()]);
set(data);
}
rstack.push(step);
this.triggerStackState();
}
public redo() {
const { ustack, rstack, store } = this;
const item = rstack.pop();
if (!item) return;
const step: Snapshot[] = [];
for (const [name, data] of item) {
const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
step.push([name, get()]);
set(data);
}
ustack.push(step);
this.triggerStackState();
}
public canUndo() {
return this.ustack.length > 0;
}
public canRedo() {
return this.rstack.length > 0;
}
public startTransaction() {
if (this.transaction === 0) {
this.backup.clear();
for (const [name, state] of this.store) {
if (state.onlyView) continue;
this.backup.set(name, state.get());
}
}
this.transaction += 1;
return this.endTransaction;
}
public endTransaction = () => {
if (this.transaction === 0) return;
this.transaction -= 1;
if (this.transaction === 0) {
const changes: Snapshot[] = [];
for (const [name, state] of this.store) {
if (state.onlyView) continue;
const before = this.backup.get(name);
if (Object.is(before, state.get())) continue;
changes.push([name, before]);
}
this.backup.clear();
if (changes.length === 0) return;
this.ustack.push(changes);
this.rstack.length = 0;
this.triggerStackState();
}
};
}
function build() {
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
const store = new Map<string, State<any, any>>();
// biome-ignore lint/suspicious/noExplicitAny: memo cache stores heterogeneous values
const mem: Record<string, any> = {};
function use<T, A>(m: SubModel<T, A>): [T, A] {
const state = query(m);
return [state.get(), state.actions];
}
const model = new Model(store, use);
if (process.env.NODE_ENV === "development") {
// @ts-expect-error
window.__md__ = model;
}
function query<T, A>(m: SubModel<T, A>): State<T, A> {
const exist = store.get(m.name);
if (exist) return exist as State<T, A>;
const created = m.gen(model);
store.set(m.name, created);
return created;
}
return { query, model, mem, use };
}
const Context = createContext(build());
export function useModel() {
return useContext(Context).model;
}
export function RegisterFlowToContext() {
const { model } = useContext(Context);
const instance = useReactFlow<AnyWorkNode>();
useLayoutEffect(() => {
model.flow = instance;
}, [instance, model]);
return null;
}
export const ModelProvider: FC<PropsWithChildren> = (p) => (
<Context.Provider value={useMemo(build, [])}>{p.children}</Context.Provider>
);
function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
return new SubModel<T, A>(name, make, create);
}
// biome-ignore lint/suspicious/noExplicitAny: default create returns setter directly
const defaultCreate: Create<any, Setter<any>> = (set) => set;
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>;
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>;
function defineView<T>(
name: string,
make: () => T,
create?: Create<T, unknown>,
): SubModel<T, unknown> {
// biome-ignore lint/suspicious/noExplicitAny: wraps into SubModel with erased action type
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
}
function memoize<T>(init: (use: Use, model: Model) => T) {
const id = uuid();
return {
use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, model, use } = useContext(Context);
if (!mem[id]) {
mem[id] = init(use, model);
}
return mem[id] as T;
},
};
}
function compute<T>(calc: (use: UseV) => T) {
const id = uuid();
return {
use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, query } = useContext(Context);
let state: ReturnType<typeof generate<T>> = mem[id];
if (state) return state.use();
// biome-ignore lint/suspicious/noExplicitAny: deps collect heterogeneous SubModels
const deps = new Set<SubModel<any, any>>();
// biome-ignore lint/suspicious/noExplicitAny: useV erases action type
let usev = (m: SubModel<any, any>) => {
deps.add(m);
return query(m).get();
};
mem[id] = state = generate<T>(calc(usev));
if (deps.size) {
usev = (m) => query(m).get();
const update = () => state.set(calc(usev));
for (const m of deps) {
query(m).listen(update);
}
}
return state.use();
},
};
}
export const define = {
model: defineModel,
view: defineView,
memoize,
compute,
};
@@ -0,0 +1,6 @@
import { GradientEdge, StatusEdge } from "./status";
export const edgeTypes = {
status: StatusEdge,
default: GradientEdge,
};
@@ -0,0 +1,248 @@
import {
type Edge,
EdgeLabelRenderer,
type EdgeProps,
getSmoothStepPath,
useReactFlow,
} from "@xyflow/react";
import { Check } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx";
import type { StatusEdge as StatusEdgeType } from "../type.ts";
const SOURCE_COLOR = "#10b981";
const TARGET_COLOR = "#3b82f6";
const LACK_COLOR = "#ff5252";
const RADIUS = 12;
function GradientPath({
id,
path,
sourceX,
sourceY,
targetX,
targetY,
hasStatus,
selected,
}: {
id: string;
path: string;
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
hasStatus: boolean;
selected: boolean;
}) {
const gradientId = `gradient-${id}`;
const showLack = !hasStatus;
const strokeStyle = selected
? { stroke: "#f59e0b", strokeWidth: 2 }
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
return (
<>
<defs>
<linearGradient
id={gradientId}
gradientUnits="userSpaceOnUse"
x1={sourceX}
y1={sourceY}
x2={targetX}
y2={targetY}
>
<stop offset="0%" stopColor={showLack ? LACK_COLOR : SOURCE_COLOR} />
<stop offset="100%" stopColor={showLack ? LACK_COLOR : TARGET_COLOR} />
</linearGradient>
</defs>
<path
d={path}
fill="none"
stroke="transparent"
strokeWidth={20}
className="react-flow__edge-interaction"
/>
<path id={id} d={path} fill="none" className="react-flow__edge-path" style={strokeStyle} />
</>
);
}
type StatusLabelProps = {
status: string | undefined;
labelX: number;
labelY: number;
onSave: (value: string) => void;
};
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
function handleBadgeClick() {
setInputValue(status || "");
setIsOpen(true);
}
function handleSave() {
if (inputValue.trim()) {
onSave(inputValue.trim());
}
setIsOpen(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
handleSave();
}
if (e.key === "Escape") {
setIsOpen(false);
}
}
useEffect(() => {
if (!isOpen) return;
function handleClickOutside(e: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("pointerdown", handleClickOutside, true);
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
}, [isOpen]);
const displayStatus = status?.trim() || null;
return (
<div
ref={containerRef}
className="absolute pointer-events-auto"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
zIndex: isOpen ? 1000 : undefined,
}}
onPointerDown={(e) => e.stopPropagation()}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: click handler on badge label */}
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
<span
className={cn(
"inline-block px-1 bg-white rounded text-[10px]",
displayStatus
? "border border-gray-300 text-black"
: "border border-dashed text-red-500",
)}
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
>
{displayStatus ?? "status"}
</span>
</div>
{isOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 z-50 bg-white rounded shadow-lg border border-gray-200 p-1">
<div className="flex items-center gap-0.5">
<input
type="text"
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
placeholder="输入状态"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button
type="button"
onClick={handleSave}
className="p-0.5 text-blue-600 hover:bg-blue-50 rounded"
>
<Check size={10} />
</button>
</div>
</div>
)}
</div>
);
}
export function StatusEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
selected,
data,
}: EdgeProps<StatusEdgeType>): ReactNode {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: RADIUS,
});
const flow = useReactFlow();
const model = useModel();
const status = data?.status;
function handleSave(value: string) {
model.startTransaction();
flow.updateEdgeData(id, { status: value });
requestAnimationFrame(model.endTransaction);
}
return (
<>
<GradientPath
id={id}
path={edgePath}
sourceX={sourceX}
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
hasStatus={!!status?.trim()}
selected={!!selected}
/>
<EdgeLabelRenderer>
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
</EdgeLabelRenderer>
</>
);
}
export function GradientEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
selected,
}: EdgeProps<Edge>): ReactNode {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: RADIUS,
});
return (
<GradientPath
id={id}
path={edgePath}
sourceX={sourceX}
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
hasStatus={true}
selected={!!selected}
/>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Background, Controls, type Edge, ReactFlow, ReactFlowProvider } from "@xyflow/react";
import { createContext, createElement, memo, useContext, useEffect, useLayoutEffect } from "react";
// @ts-expect-error
import "@xyflow/react/dist/style.css";
import { ModelProvider, RegisterFlowToContext } from "./context";
import { edgeTypes } from "./edges";
import { FlowModel, InternalField } from "./injection";
import { edgesModel, handlers, injection, nodesModel } from "./model";
import { nodeTypes } from "./nodes";
import { Dialogs, TopCenterPanel } from "./panel";
import type { AnyWorkNode } from "./type";
export * from "./trans/type";
const proOptions = { hideAttribution: true };
const ReadonlyContext = createContext(false);
export const useReadonly = () => useContext(ReadonlyContext);
function Flow() {
const [nodes, { onNodesChange }] = nodesModel.use();
const [edges, { onEdgesChange, onConnect }] = edgesModel.use();
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } =
handlers.use();
const readonly = useReadonly();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
<ReactFlowProvider>
<ReactFlow<AnyWorkNode, Edge>
nodes={nodes}
edges={edges}
onNodesChange={readonly ? undefined : onNodesChange}
onEdgesChange={readonly ? undefined : onEdgesChange}
onConnect={readonly ? undefined : onConnect}
fitView
proOptions={proOptions}
onNodeDragStart={readonly ? undefined : onNodeDragStart}
onNodeDragStop={readonly ? undefined : onNodeDragStop}
onConnectEnd={readonly ? undefined : onConnectEnd}
onBeforeDelete={readonly ? undefined : onBeforeDelete}
onDelete={readonly ? undefined : onDelete}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={!readonly}
nodesConnectable={!readonly}
elementsSelectable={!readonly}
>
<RegisterFlowToContext />
<Background />
<Controls />
{!readonly && <TopCenterPanel />}
{!readonly && <Dialogs />}
</ReactFlow>
</ReactFlowProvider>
</div>
);
}
const MemoFlow = memo(Flow);
interface Props {
model: FlowModel;
readonly?: boolean;
}
function Connect({ model }: { model: FlowModel }) {
const { loadSteps } = handlers.use();
const inject = injection.useCreation();
const instance = model[InternalField];
useLayoutEffect(() => {
return inject(instance);
}, [instance, inject]);
useEffect(() => {
return instance.on("load", loadSteps);
}, [instance, loadSteps]);
return <MemoFlow />;
}
export { FlowModel };
// biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component
export default ({ model, readonly = false }: Props) => (
<ReadonlyContext.Provider value={readonly}>
<ModelProvider>{createElement(Connect, { model })}</ModelProvider>
</ReadonlyContext.Provider>
);
@@ -0,0 +1,48 @@
import type { WorkFlowSteps } from "./trans";
import { Eventer } from "./utils/eventer";
interface PublicEvents {
save: WorkFlowSteps;
}
interface PrivateEvents {
load: WorkFlowSteps;
}
export const InternalField = Symbol("InternalField");
export class Injection extends Eventer<PrivateEvents> {
public readonly emitPublic: Eventer<PublicEvents>["emit"];
private inital_steps: WorkFlowSteps | undefined;
constructor(emitPublic: Eventer<PublicEvents>["emit"], inital_steps?: WorkFlowSteps) {
super();
this.emitPublic = emitPublic;
this.inital_steps = inital_steps;
}
public on: Eventer<PrivateEvents>["on"] = (type, lisenter) => {
const off = super.on(type, lisenter);
if (type === "load" && this.inital_steps) {
lisenter(this.inital_steps);
this.inital_steps = undefined;
}
return off;
};
}
export class FlowModel {
private readonly eventer = new Eventer<PublicEvents>();
public on = this.eventer.on.bind(this.eventer);
public off = this.eventer.off.bind(this.eventer);
public readonly [InternalField]: Injection;
constructor(inital_steps?: WorkFlowSteps) {
this[InternalField] = new Injection(this.eventer.emit.bind(this.eventer), inital_steps);
}
public load(steps: WorkFlowSteps) {
this[InternalField].emit("load", steps);
}
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from "bun:test";
import type { Edge, Node } from "@xyflow/react";
import { LayoutLR } from "../index.js";
function makeNode(id: string): Node {
return { id, type: "role", data: {}, position: { x: 0, y: 0 } } as Node;
}
function makeEdge(source: string, target: string): Edge {
return { id: `${source}-${target}`, source, target } as Edge;
}
describe("LayoutLR / assignLayers", () => {
it("1.1 Empty graph: start gets layer 0, end gets higher layer", () => {
const nodes = [makeNode("start"), makeNode("end")];
const result = LayoutLR(nodes, []);
const start = result.find((n) => n.id === "start");
const end = result.find((n) => n.id === "end");
// start has no position change necessarily, but positions should be assigned
expect(start).toBeDefined();
expect(end).toBeDefined();
// end should be to the right of start
expect((end?.position.x ?? 0) > (start?.position.x ?? 0)).toBe(true);
});
it("1.2 Linear chain: start → A → B → end — layers assigned in order", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
const result = LayoutLR(nodes, edges);
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
expect(xOf("start") < xOf("A")).toBe(true);
expect(xOf("A") < xOf("B")).toBe(true);
expect(xOf("B") < xOf("end")).toBe(true);
});
it("1.3 Diamond: A and B share same layer", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("C"), makeNode("end")];
const edges = [
makeEdge("start", "A"),
makeEdge("start", "B"),
makeEdge("A", "C"),
makeEdge("B", "C"),
makeEdge("C", "end"),
];
const result = LayoutLR(nodes, edges);
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
expect(xOf("A")).toBe(xOf("B")); // same layer
expect(xOf("A") < xOf("C")).toBe(true);
expect(xOf("C") < xOf("end")).toBe(true);
});
it("1.4 Isolated node placed in middle layer (not layer 0, not end layer)", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("isolated"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
const result = LayoutLR(nodes, edges);
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
const xIsolated = xOf("isolated");
expect(xIsolated > xOf("start")).toBe(true);
expect(xIsolated < xOf("end")).toBe(true);
});
it("1.5 end node is always last (highest x)", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
const result = LayoutLR(nodes, edges);
const endX = result.find((n) => n.id === "end")?.position.x ?? 0;
for (const node of result) {
if (node.id !== "end") {
expect(node.position.x < endX).toBe(true);
}
}
});
it("1.6 start node is always first (x = 0 or smallest x)", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
const result = LayoutLR(nodes, edges);
const startX = result.find((n) => n.id === "start")?.position.x ?? 0;
for (const node of result) {
expect(node.position.x >= startX).toBe(true);
}
});
});
@@ -0,0 +1,251 @@
import type { Edge, Node } from "@xyflow/react";
const DEFAULT_NODE_WIDTH = 120;
const DEFAULT_NODE_HEIGHT = 50;
const HORIZONTAL_GAP = 80; // 层与层之间的水平间距
const VERTICAL_GAP = 40; // 同层节点之间的垂直间距
/**
* 获取节点的尺寸
*/
function getNodeSize(node: Node): { width: number; height: number } {
return {
width: node.measured?.width ?? DEFAULT_NODE_WIDTH,
height: node.measured?.height ?? DEFAULT_NODE_HEIGHT,
};
}
/**
* 构建邻接表(出边)和入度表
*/
function buildGraph(nodes: Node[], edges: Edge[]) {
const nodeIds = new Set(nodes.map((n) => n.id));
const outgoing = new Map<string, string[]>(); // nodeId -> [targetIds]
const incoming = new Map<string, string[]>(); // nodeId -> [sourceIds]
const inDegree = new Map<string, number>();
// 初始化
for (const node of nodes) {
outgoing.set(node.id, []);
incoming.set(node.id, []);
inDegree.set(node.id, 0);
}
// 构建图
for (const edge of edges) {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
outgoing.get(edge.source)?.push(edge.target);
incoming.get(edge.target)?.push(edge.source);
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
}
}
return { outgoing, incoming, inDegree };
}
function processTarget(
target: string,
newLayer: number,
layers: Map<string, number>,
inDegree: Map<string, number>,
queue: string[],
): void {
const existingLayer = layers.get(target);
if (existingLayer === undefined) {
layers.set(target, newLayer);
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
if (inDegree.get(target) === 0) queue.push(target);
} else {
layers.set(target, Math.max(existingLayer, newLayer));
}
}
/**
* BFS 分层(排除 end 节点,稍后单独处理)
*/
function bfsLayers(
outgoing: Map<string, string[]>,
inDegree: Map<string, number>,
layers: Map<string, number>,
): void {
const queue: string[] = ["start"];
while (queue.length > 0) {
const current = queue.shift() ?? "";
const currentLayer = layers.get(current) ?? 0;
for (const target of outgoing.get(current) ?? []) {
if (target === "end") continue;
processTarget(target, currentLayer + 1, layers, inDegree, queue);
}
}
}
/**
* 处理孤立节点(没有被分配层级的非 start/end 节点),放在中间层
*/
function placeIsolatedNodes(nodes: Node[], layers: Map<string, number>, maxLayer: number): void {
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
for (const node of nodes) {
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
layers.set(node.id, middleLayer);
}
}
}
/**
* 计算最大层级(排除 end 节点)
*/
function maxLayerExcludingEnd(layers: Map<string, number>): number {
let max = 0;
for (const [id, layer] of layers) {
if (id !== "end") max = Math.max(max, layer);
}
return max;
}
/**
* 使用拓扑排序将节点分层
* - 'start' 节点固定在第 0 层
* - 'end' 节点固定在最后一层
* - 孤立节点放在中间层
*/
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
const { outgoing, inDegree } = buildGraph(nodes, edges);
const layers = new Map<string, number>();
layers.set("start", 0);
bfsLayers(outgoing, inDegree, layers);
const afterBfsMax = maxLayerExcludingEnd(layers);
placeIsolatedNodes(nodes, layers, afterBfsMax);
const finalMax = maxLayerExcludingEnd(layers);
layers.set("end", finalMax + 1);
return layers;
}
/**
* 按层级分组节点
*/
function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>): Map<number, N[]> {
const groups = new Map<number, N[]>();
for (const node of nodes) {
const layer = layers.get(node.id) ?? 0;
if (!groups.has(layer)) {
groups.set(layer, []);
}
groups.get(layer)?.push(node);
}
return groups;
}
/**
* 计算每层的最大宽度
*/
function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, number> {
const widths = new Map<number, number>();
for (const [layer, nodesInLayer] of layerGroups) {
let maxWidth = 0;
for (const node of nodesInLayer) {
const { width } = getNodeSize(node);
maxWidth = Math.max(maxWidth, width);
}
widths.set(layer, maxWidth);
}
return widths;
}
/**
* 计算每层的 X 起始位置
*/
function calculateLayerXPositions(
layerWidths: Map<number, number>,
maxLayer: number,
): Map<number, number> {
const xPositions = new Map<number, number>();
let currentX = 0;
for (let layer = 0; layer <= maxLayer; layer++) {
xPositions.set(layer, currentX);
const layerWidth = layerWidths.get(layer) ?? DEFAULT_NODE_WIDTH;
currentX += layerWidth + HORIZONTAL_GAP;
}
return xPositions;
}
/**
* Todo: 1-N 情况下的布局优化
* Todo: 如果计算完了之后,所有节点的位置都没变,则不更新节点,避免不必要的重渲染
* node 中有 measured 属性,可以获得其尺寸,如果没有,则使用一个默认尺寸 120*50
* edge 的 source 和 target 分别对应两端的 node 的 id
*
* 算法步骤:
* 1. 使用拓扑排序将节点分层(从左到右)
* 2. 计算每层的 X 位置
* 3. 在每层内垂直居中排列节点
*/
export function LayoutLR<N extends Node>(nodes: N[], edges: Edge[]): N[] {
if (nodes.length === 0) {
return [];
}
// 1. 分配层级
const layers = assignLayers(nodes, edges);
// 2. 按层级分组
const layerGroups = groupByLayer(nodes, layers);
// 3. 计算每层宽度和 X 位置
const maxLayer = Math.max(...layers.values());
const layerWidths = calculateLayerWidths(layerGroups);
const layerXPositions = calculateLayerXPositions(layerWidths, maxLayer);
// 4. 计算每层的总高度,用于垂直居中
const layerHeights = new Map<number, number>();
for (const [layer, nodesInLayer] of layerGroups) {
let totalHeight = 0;
for (const node of nodesInLayer) {
const { height } = getNodeSize(node);
totalHeight += height;
}
totalHeight += (nodesInLayer.length - 1) * VERTICAL_GAP;
layerHeights.set(layer, totalHeight);
}
// 找到最大高度,用于垂直居中对齐
const maxHeight = Math.max(...layerHeights.values());
// 5. 为每个节点分配位置,并检查是否有变化
const layoutedNodes: N[] = [];
let hasChanged = false;
for (const [layer, nodesInLayer] of layerGroups) {
const layerHeight = layerHeights.get(layer) ?? 0;
const startY = (maxHeight - layerHeight) / 2; // 垂直居中
const x = layerXPositions.get(layer) ?? 0;
let currentY = startY;
for (const node of nodesInLayer) {
const { height } = getNodeSize(node);
const newPosition = { x, y: currentY };
if (node.position.x !== newPosition.x || node.position.y !== newPosition.y) {
hasChanged = true;
layoutedNodes.push({
...node,
position: newPosition,
});
} else {
layoutedNodes.push(node);
}
currentY += height + VERTICAL_GAP;
}
}
return hasChanged ? layoutedNodes : nodes;
}
@@ -0,0 +1,66 @@
import type { Edge } from "@xyflow/react";
import { define } from "../context";
import type { AnyWorkNode, RoleNodeData } from "../type";
import { edgesModel } from "./edges";
import { nodesModel } from "./nodes";
type ConnectHandle = {
id?: string | null;
nodeId: string;
type: "source" | "target";
};
export type AddNodeState = {
fromNode: AnyWorkNode;
fromHandle: ConnectHandle;
position: { x: number; y: number };
};
type CommitParams = {
data: RoleNodeData;
};
function addNodeView() {
return null as AddNodeState | null;
}
export const addNodeViewModel = define.view("addNodeView", addNodeView, (set, get, model) => {
function start(state: AddNodeState) {
set(state);
}
function cancel() {
set(null);
}
function commit(params: CommitParams) {
const state = get();
if (!state) return;
set(null);
const { fromNode, fromHandle, position } = state;
const { data } = params;
const id = `n${Date.now()}`;
const node = {
id,
data,
position,
type: "role" as const,
origin: [0.0, 0.5] as [number, number],
};
const [fnid, fhid] = [fromNode.id, fromHandle.id];
const newEdge: Edge =
fromHandle.type === "source"
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
model.startTransaction();
model.use(nodesModel)[1].set((nds) => nds.concat(node));
model.use(edgesModel)[1].set((eds) => eds.concat(newEdge));
requestAnimationFrame(model.endTransaction);
}
return { start, commit, cancel };
});
@@ -0,0 +1,85 @@
import { applyEdgeChanges, type Connection, type Edge, type EdgeChange } from "@xyflow/react";
import { define } from "../context";
function makeEdges(): Edge[] {
return [];
}
function isInputHandle(handle: string | null | undefined): boolean {
return handle === "input" || handle === "input-top" || handle === "input-bottom";
}
function isOutputHandle(handle: string | null | undefined): boolean {
return handle === "output" || handle === "output-top" || handle === "output-bottom";
}
function normalizeConnection(params: Edge | Connection): Edge | Connection {
if (isInputHandle(params.sourceHandle) && isOutputHandle(params.targetHandle)) {
return {
...params,
source: params.target,
sourceHandle: params.targetHandle ?? null,
target: params.source,
targetHandle: params.sourceHandle ?? null,
} as Edge | Connection;
}
return params;
}
let edgeCounter = 0;
export const edgesModel = define.model("edges", makeEdges, (set, get, model) => {
function onEdgesChange(changes: EdgeChange[]) {
const whites = new Set(["add", "replace"]);
if (changes.some((c) => whites.has(c.type))) {
model.startTransaction();
set((eds) => applyEdgeChanges(changes, eds));
requestAnimationFrame(model.endTransaction);
return;
}
set((eds) => applyEdgeChanges(changes, eds));
}
function onConnect(params: Edge | Connection) {
const normalized = normalizeConnection(params);
if (normalized.source === normalized.target) return;
if (!isOutputHandle(normalized.sourceHandle) || !isInputHandle(normalized.targetHandle)) return;
const currentEdges = get();
const duplicate = currentEdges.some(
(e) => e.source === normalized.source && e.target === normalized.target,
);
if (duplicate) return;
model.startTransaction();
const id = `e-${normalized.source}-${normalized.target}-${++edgeCounter}`;
const edge: Edge = {
...normalized,
id,
animated: true,
} as Edge;
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
if (existingFromSource.length > 0) {
edge.type = "status";
edge.data = { status: "" };
const promoted = currentEdges.map((e) => {
if (e.source === normalized.source && e.type !== "status") {
return { ...e, type: "status" as const, data: { status: "_" } };
}
return e;
});
set([...promoted, edge]);
} else {
set((eds) => [...eds, edge]);
}
requestAnimationFrame(model.endTransaction);
}
return { onEdgesChange, onConnect, set };
});
@@ -0,0 +1,41 @@
import { define } from "../context";
import type { RoleNodeData, WorkNode } from "../type";
import { nodesModel } from "./nodes";
export type EditNodeState = {
node: WorkNode<"role">;
};
function editNodeView() {
return null as EditNodeState | null;
}
export const editNodeViewModel = define.view("editNodeView", editNodeView, (set, get, model) => {
function start(nodeId: string) {
const [nodes] = model.use(nodesModel);
const node = nodes.find((n) => n.id === nodeId);
if (node?.type !== "role") return;
set({ node: node as WorkNode<"role"> });
}
function cancel() {
set(null);
}
function commit(data: RoleNodeData) {
const state = get();
if (!state) return;
set(null);
const { editNode } = model.use(nodesModel)[1];
model.startTransaction();
editNode(state.node.id, (node) => {
// biome-ignore lint/suspicious/noExplicitAny: node data type varies by node kind
node.data = data as any;
});
requestAnimationFrame(model.endTransaction);
}
return { start, commit, cancel };
});
@@ -0,0 +1,141 @@
import type { OnBeforeDelete, OnConnectEnd, OnDelete, OnNodeDrag } from "@xyflow/react";
import { define } from "../context";
import { LayoutLR } from "../layout";
import type { WorkFlowSteps } from "../trans";
import { transIn, transOut, validate } from "../trans";
import type { AnyWorkNode } from "../type";
import { addNodeViewModel } from "./add-node-view";
import { edgesModel } from "./edges";
import { editNodeViewModel } from "./edit-node-view";
import { injection } from "./inject";
import { nodesModel } from "./nodes";
export const handlers = define.memoize((use, model) => {
const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => {
model.startTransaction();
};
const onNodeDragStop: OnNodeDrag<AnyWorkNode> = () => {
model.endTransaction();
};
const onConnectEnd: OnConnectEnd = (event, state) => {
const { isValid, to, fromHandle, fromNode } = state;
if (isValid) return;
if (!to || !fromHandle || !fromNode) return;
const { clientX, clientY } = event as MouseEvent;
use(addNodeViewModel)[1].start({
// biome-ignore lint/suspicious/noExplicitAny: ReactFlow node type mismatch
fromNode: fromNode as any as AnyWorkNode,
fromHandle: fromHandle,
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
});
};
function isProtectedNode(node: AnyWorkNode): boolean {
return node.type === "start" || node.type === "end";
}
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
if (nodes.some(isProtectedNode)) return false;
model.startTransaction();
return true;
};
const onDelete: OnDelete = ({ edges: deletedEdges }) => {
if (deletedEdges.length > 0) {
const currentEdges = use(edgesModel)[0];
const sourcesToCheck = new Set(
deletedEdges.filter((e) => e.type === "status").map((e) => e.source),
);
if (sourcesToCheck.size > 0) {
let needsDowngrade = false;
const updatedEdges = currentEdges.map((e) => {
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
if (siblings.length === 1) {
needsDowngrade = true;
const { data: _, ...rest } = e;
return { ...rest, type: "default" as const };
}
return e;
});
if (needsDowngrade) {
use(edgesModel)[1].set(updatedEdges);
}
}
}
model.endTransaction();
};
function autoLayoutLR() {
const [nodes, { set }] = use(nodesModel);
const edges = use(edgesModel)[0];
const layoutedNodes = LayoutLR(nodes, edges);
model.startTransaction();
set(layoutedNodes);
model.endTransaction();
}
function resetView() {
use(addNodeViewModel)[1].cancel();
use(editNodeViewModel)[1].cancel();
}
function handleEscape() {
const [addView, addViewActions] = use(addNodeViewModel);
const [editView, editViewActions] = use(editNodeViewModel);
if (addView) addViewActions.cancel();
if (editView) editViewActions.cancel();
}
function handleUndoRedo(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) {
if (event.shiftKey) model.redo();
else model.undo();
} else if (event.code === "KeyY" && (event.ctrlKey || event.metaKey)) {
model.redo();
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === "Escape") {
handleEscape();
return;
}
handleUndoRedo(event);
}
function loadSteps(steps: WorkFlowSteps) {
resetView();
const { nodes, edges } = transIn(steps);
use(nodesModel)[1].set(nodes);
use(edgesModel)[1].set(edges);
autoLayoutLR();
model.reset();
}
function saveData() {
const nodes = use(nodesModel)[0];
const edges = use(edgesModel)[0];
const result = validate(nodes, edges);
if (result.valid) {
const steps = transOut(nodes, edges);
const instance = use(injection)[0];
instance.emitPublic("save", steps);
}
return result;
}
return {
onNodeDragStart,
onNodeDragStop,
onConnectEnd,
onBeforeDelete,
onDelete,
autoLayoutLR,
handleKeyDown,
loadSteps,
saveData,
};
});
@@ -0,0 +1,6 @@
export { type AddNodeState, addNodeViewModel } from "./add-node-view";
export { edgesModel } from "./edges";
export { type EditNodeState, editNodeViewModel } from "./edit-node-view";
export { handlers } from "./handlers";
export { injection } from "./inject";
export { nodesModel } from "./nodes";
@@ -0,0 +1,26 @@
/**
* 外部注入的回调函数,存到这里以方便内部调用,避免透传
*/
import { define } from "../context.tsx";
import { Injection } from "../injection.ts";
const NOOP = () => {};
const placeholder = new Injection(NOOP);
function make(): Injection {
return placeholder;
}
export const injection = define.view("injection", make, (set) => {
function reset() {
set(make());
}
function inject(instance: Injection) {
set(instance);
return reset;
}
return inject;
});
@@ -0,0 +1,51 @@
import { applyNodeChanges, type NodeChange } from "@xyflow/react";
import { type Draft, produce } from "immer";
import { define } from "../context";
import type { AnyWorkNode } from "../type";
function makeNodes(): AnyWorkNode[] {
return [
{
id: "start",
type: "start",
data: { label: "Start" },
position: { x: 0, y: 0 },
},
{
id: "end",
data: { label: "End" },
position: { x: 1000, y: 0 },
type: "end",
},
];
}
export const nodesModel = define.model("nodes", makeNodes, (set, _get, model) => {
const whites = new Set<NodeChange["type"]>(["add", "replace"]);
function onNodesChange(changes: NodeChange<AnyWorkNode>[]) {
if (changes.some((c) => whites.has(c.type))) {
model.startTransaction();
set((nds) => applyNodeChanges(changes, nds));
requestAnimationFrame(model.endTransaction);
return;
}
set((nds) => applyNodeChanges(changes, nds));
}
function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) {
set(
produce((draft) => {
const node = draft.find((n) => n.id === id);
if (node) updater(node);
}),
);
}
function deleteNode(id: string) {
model.startTransaction();
set((nds) => nds.filter((n) => n.id !== id));
requestAnimationFrame(model.endTransaction);
}
return { onNodesChange, set, editNode, deleteNode };
});
@@ -0,0 +1,19 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { EndNode } from "./nodes.style";
interface NodeData {
label: string;
[key: string]: unknown;
}
type NodeType = Node<NodeData, "end">;
type Props = NodeProps<NodeType>;
export function NodeEnd({ data }: Props) {
return (
<EndNode>
<Handle type="target" position={Position.Left} id="input" />
{data?.label || "End"}
</EndNode>
);
}
@@ -0,0 +1,9 @@
import { NodeEnd } from "./end";
import { NodeRole } from "./role";
import { NodeStart } from "./start";
export const nodeTypes = {
start: NodeStart,
end: NodeEnd,
role: NodeRole,
};
@@ -0,0 +1,27 @@
import { Pencil, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "../../components/ui/button.tsx";
type Props = {
onEdit: (() => void) | undefined;
onDelete: (() => void) | undefined;
};
export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
return (
<div className="flex gap-1 px-2 py-1 bg-white rounded-lg shadow-md border border-gray-200">
<Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑">
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 />
</Button>
</div>
);
}
@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils.ts";
type Props = {
className: string | null;
children: ReactNode;
};
function BaseNode({ className, children }: Props): ReactNode {
return (
<div
className={cn(
"rounded-lg border-2 border-border bg-white px-4 py-3 text-center text-sm font-medium min-w-[120px]",
className,
)}
>
{children}
</div>
);
}
export function StartNode({ children }: { children: ReactNode }): ReactNode {
return (
<BaseNode className="bg-gradient-to-br from-green-50 to-green-200 border-green-500 text-green-500">
{children}
</BaseNode>
);
}
export function EndNode({ children }: { children: ReactNode }): ReactNode {
return (
<BaseNode className="bg-gradient-to-br from-indigo-50 to-blue-100 border-blue-600 text-blue-600">
{children}
</BaseNode>
);
}
export function NodeContent({ children }: { children: ReactNode }): ReactNode {
return (
<div className="flex items-start gap-2.5 px-3.5 py-3 min-w-[160px] max-w-[240px]">
{children}
</div>
);
}
export function NodeIcon({ className, children }: Props): ReactNode {
return (
<div className={cn("flex items-center justify-center w-8 h-8 rounded-lg shrink-0", className)}>
{children}
</div>
);
}
export function NodeBody({ children }: { children: ReactNode }): ReactNode {
return <div className="flex-1 min-w-0">{children}</div>;
}
export function NodeKindLabel({ className, children }: Props): ReactNode {
return (
<div className={cn("text-[10px] font-semibold uppercase tracking-wide mb-1", className)}>
{children}
</div>
);
}
export function NodeHint({ children }: { children: ReactNode }): ReactNode {
return <div className="text-[13px] text-gray-800 leading-snug break-words">{children}</div>;
}
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
return <div className="text-[11px] text-gray-400 mt-0.5">{children}</div>;
}
export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
return (
<NodeIcon className="bg-gradient-to-br from-teal-50 to-teal-200 text-teal-700">
{children}
</NodeIcon>
);
}
export function RoleKindLabel({ children }: { children: ReactNode }): ReactNode {
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
}
@@ -0,0 +1,122 @@
import { Handle, type NodeProps, NodeToolbar, Position, useNodeConnections } from "@xyflow/react";
import { Users } from "lucide-react";
import { useMemo } from "react";
import { useReadonly } from "../flow";
import { nodesModel } from "../model";
import { editNodeViewModel } from "../model/edit-node-view";
import type { WorkNode } from "../type";
import { NodeToolbarActions } from "./node-toolbar";
import { NodeBody, NodeContent, NodeHint, RoleIcon, RoleKindLabel } from "./nodes.style";
type Props = NodeProps<WorkNode<"role">>;
const containerClass =
"bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600";
const sourceClass =
"!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
export function NodeRole({ data, id, selected }: Props) {
const startEdit = editNodeViewModel.useCreation().start;
const { deleteNode } = nodesModel.useCreation();
const connections = useNodeConnections();
const readonly = useReadonly();
const connectedHandles = useMemo(() => {
const set = new Set<string>();
for (const c of connections) {
if (c.target === id && c.targetHandle) set.add(c.targetHandle);
if (c.source === id && c.sourceHandle) set.add(c.sourceHandle);
}
return set;
}, [connections, id]);
const hasInputConnection =
connectedHandles.has("input") ||
connectedHandles.has("input-top") ||
connectedHandles.has("input-bottom");
const hasOutputConnection =
connectedHandles.has("output") ||
connectedHandles.has("output-top") ||
connectedHandles.has("output-bottom");
const showHandle = (handleId: string, alwaysShow: boolean) => {
if (readonly) return connectedHandles.has(handleId);
return alwaysShow;
};
return (
<div className={containerClass}>
{showHandle("input", true) && (
<Handle
type="target"
position={Position.Left}
id="input"
className={targetClass}
isConnectableStart
/>
)}
{showHandle("input-top", hasInputConnection) && (
<Handle
type="target"
position={Position.Top}
id="input-top"
style={{ left: "30%" }}
className={targetClass}
isConnectableStart
/>
)}
{showHandle("input-bottom", hasInputConnection) && (
<Handle
type="target"
position={Position.Bottom}
id="input-bottom"
style={{ left: "30%" }}
className={targetClass}
isConnectableStart
/>
)}
<NodeContent>
<RoleIcon>
<Users size={16} />
</RoleIcon>
<NodeBody>
<RoleKindLabel>Role</RoleKindLabel>
<NodeHint>{data.name}</NodeHint>
</NodeBody>
</NodeContent>
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
<NodeToolbarActions onEdit={() => startEdit(id)} onDelete={() => deleteNode(id)} />
</NodeToolbar>
{showHandle("output", true) && (
<Handle
type="source"
position={Position.Right}
id="output"
className={sourceClass}
isConnectableEnd
/>
)}
{showHandle("output-top", hasOutputConnection) && (
<Handle
type="source"
position={Position.Top}
id="output-top"
style={{ left: "70%" }}
className={sourceClass}
isConnectableEnd
/>
)}
{showHandle("output-bottom", hasOutputConnection) && (
<Handle
type="source"
position={Position.Bottom}
id="output-bottom"
style={{ left: "70%" }}
className={sourceClass}
isConnectableEnd
/>
)}
</div>
);
}
@@ -0,0 +1,31 @@
import { Handle, type Node, type NodeProps, Position, useNodeConnections } from "@xyflow/react";
import { useMemo } from "react";
import { StartNode } from "./nodes.style";
interface NodeData {
label: string;
[key: string]: unknown;
}
type NodeType = Node<NodeData, "start">;
type Props = NodeProps<NodeType>;
export function NodeStart({ data, id }: Props) {
const connections = useNodeConnections();
const outputConnected = useMemo(() => {
return connections.some((conn) => conn.source === id);
}, [connections, id]);
return (
<StartNode>
{data?.label || "Start"}
<Handle
type="source"
position={Position.Right}
id="output"
isConnectable={!outputConnected}
/>
</StartNode>
);
}
@@ -0,0 +1,143 @@
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "../../components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx";
import { Label } from "../../components/ui/label.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { addNodeViewModel } from "../model/index.ts";
import type { RoleNodeData } from "../type.ts";
type FormProps = {
onSubmit: (params: { data: RoleNodeData }) => void;
onCancel: () => void;
};
function Form({ onSubmit, onCancel }: FormProps): ReactNode {
const [name, setName] = useState("新角色");
const [description, setDescription] = useState("");
const [identity, setIdentity] = useState("");
const [prepare, setPrepare] = useState("");
const [execute, setExecute] = useState("");
const [report, setReport] = useState("");
useEffect(() => {
setName("新角色");
setDescription("");
setIdentity("");
setPrepare("");
setExecute("");
setReport("");
}, []);
function handleConfirm() {
if (!name.trim()) return;
onSubmit({
data: {
name: name.trim(),
description,
identity,
prepare,
execute,
report,
},
});
}
return (
<>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Textarea
rows={2}
className="resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="角色描述"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Identity)</Label>
<Textarea
rows={2}
className="resize-none"
value={identity}
onChange={(e) => setIdentity(e.target.value)}
placeholder="角色身份定义"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Prepare)</Label>
<Textarea
rows={2}
className="resize-none"
value={prepare}
onChange={(e) => setPrepare(e.target.value)}
placeholder="执行前准备指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Execute)</Label>
<Textarea
rows={2}
className="resize-none"
value={execute}
onChange={(e) => setExecute(e.target.value)}
placeholder="核心执行指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Report)</Label>
<Textarea
rows={2}
className="resize-none"
value={report}
onChange={(e) => setReport(e.target.value)}
placeholder="输出格式指令"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
</Button>
<Button size="sm" onClick={handleConfirm}>
</Button>
</DialogFooter>
</>
);
}
export function AddNodeDialog(): ReactNode {
const state = addNodeViewModel.useData();
const { commit, cancel } = addNodeViewModel.useCreation();
return (
<Dialog
open={state !== null}
onOpenChange={(open) => {
if (!open) cancel();
}}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form onSubmit={commit} onCancel={cancel} />}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,143 @@
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "../../components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx";
import { Label } from "../../components/ui/label.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { type EditNodeState, editNodeViewModel } from "../model/edit-node-view.ts";
import type { RoleNodeData } from "../type.ts";
type FormProps = {
state: EditNodeState;
onSubmit: (data: RoleNodeData) => void;
onCancel: () => void;
};
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
const data = state.node.data;
const [name, setName] = useState(data.name);
const [description, setDescription] = useState(data.description);
const [identity, setIdentity] = useState(data.identity);
const [prepare, setPrepare] = useState(data.prepare);
const [execute, setExecute] = useState(data.execute);
const [report, setReport] = useState(data.report);
useEffect(() => {
setName(data.name);
setDescription(data.description);
setIdentity(data.identity);
setPrepare(data.prepare);
setExecute(data.execute);
setReport(data.report);
}, [data]);
function handleConfirm() {
if (!name.trim()) return;
onSubmit({
name: name.trim(),
description,
identity,
prepare,
execute,
report,
});
}
return (
<>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Textarea
rows={2}
className="resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="角色描述"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Identity)</Label>
<Textarea
rows={2}
className="resize-none"
value={identity}
onChange={(e) => setIdentity(e.target.value)}
placeholder="角色身份定义"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Prepare)</Label>
<Textarea
rows={2}
className="resize-none"
value={prepare}
onChange={(e) => setPrepare(e.target.value)}
placeholder="执行前准备指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Execute)</Label>
<Textarea
rows={2}
className="resize-none"
value={execute}
onChange={(e) => setExecute(e.target.value)}
placeholder="核心执行指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Report)</Label>
<Textarea
rows={2}
className="resize-none"
value={report}
onChange={(e) => setReport(e.target.value)}
placeholder="输出格式指令"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
</Button>
<Button size="sm" onClick={handleConfirm}>
</Button>
</DialogFooter>
</>
);
}
export function EditNodeDialog(): ReactNode {
const state = editNodeViewModel.useData();
const { commit, cancel } = editNodeViewModel.useCreation();
return (
<Dialog
open={state !== null}
onOpenChange={(open) => {
if (!open) cancel();
}}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,21 @@
import { Panel } from "@xyflow/react";
import { AddNodeDialog } from "./add-node";
import { EditNodeDialog } from "./edit-node";
import { Toolbar } from "./toolbar";
export function Dialogs() {
return (
<>
<AddNodeDialog />
<EditNodeDialog />
</>
);
}
export function TopCenterPanel() {
return (
<Panel position="top-center">
<Toolbar />
</Panel>
);
}
@@ -0,0 +1,141 @@
import { useReactFlow, useStoreApi } from "@xyflow/react";
import { LayoutList, Redo2, Save, Undo2, Users } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Button } from "../../components/ui/button.tsx";
import { Separator } from "../../components/ui/separator.tsx";
import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx";
import { handlers, nodesModel } from "../model/index.ts";
import type { RoleNodeData, WorkNode } from "../type.ts";
import { uuid } from "../utils/index.ts";
const DEFAULT_ROLE_DATA: RoleNodeData = {
name: "新角色",
description: "",
identity: "",
prepare: "",
execute: "",
report: "",
};
export function Toolbar(): ReactNode {
const model = useModel();
const flow = useReactFlow();
const store = useStoreApi();
const nodesActions = nodesModel.useCreation();
const { autoLayoutLR } = handlers.use();
const [canUndo, canRedo] = model.useStackState();
function handleUndo() {
model.undo();
}
function handleRedo() {
model.redo();
}
function handleAddNode() {
const { x, y, zoom } = flow.getViewport();
const { width, height } = store.getState();
const centerX = (width / 2 - x) / zoom;
const centerY = (height / 2 - y) / zoom;
const id = `n${uuid()}`;
const node: WorkNode<"role"> = {
id,
type: "role",
position: { x: centerX - 80, y: centerY - 40 },
data: { ...DEFAULT_ROLE_DATA },
};
model.startTransaction();
nodesActions.set((nds) => nds.concat(node));
requestAnimationFrame(model.endTransaction);
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md">
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
title="撤销 (Undo)"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="重做 (Redo)"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 />
</Button>
</div>
<Separator orientation="vertical" className="h-6" />
<Button variant="ghost" size="icon-sm" title="添加角色" onClick={handleAddNode}>
<Users />
</Button>
<Separator orientation="vertical" className="h-6" />
<Button variant="ghost" size="icon-sm" title="自动布局" onClick={autoLayoutLR}>
<LayoutList />
</Button>
<SaveButton />
</div>
);
}
function SaveButton(): ReactNode {
const { saveData } = handlers.use();
const [toast, setToast] = useState<{
open: boolean;
severity: "success" | "error";
message: ReactNode;
}>({ open: false, severity: "success", message: "" });
function handleSave() {
const { valid, errors } = saveData();
if (valid) {
setToast({ open: true, severity: "success", message: "流程保存成功" });
} else {
const errorMessages = errors.map(({ message, nodeId }) => (
<div key={nodeId ?? message}>
{nodeId ? `节点 ${nodeId}` : ""}
{message}
</div>
));
setToast({
open: true,
severity: "error",
message: errorMessages || "流程校验失败",
});
}
setTimeout(() => setToast((prev) => ({ ...prev, open: false })), 4000);
}
return (
<>
<Button variant="ghost" size="icon-sm" title="保存流程" onClick={handleSave}>
<Save />
</Button>
{toast.open && (
<div
className={cn(
"fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg text-sm text-white shadow-lg",
toast.severity === "success" ? "bg-green-600" : "bg-red-600",
)}
>
{toast.message}
</div>
)}
</>
);
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from "bun:test";
import { transIn } from "../trans-in.js";
import type { WorkFlowStep } from "../type.js";
function makeStep(name: string, transitions: WorkFlowStep["transitions"]): WorkFlowStep {
return {
role: {
name,
description: "",
identity: "",
prepare: "",
execute: "",
report: "",
},
transitions,
};
}
describe("transIn", () => {
it("4.1 Empty steps → start + end nodes, no edges", () => {
const { nodes, edges } = transIn([]);
expect(nodes).toHaveLength(2);
expect(nodes.find((n) => n.id === "start")).toBeDefined();
expect(nodes.find((n) => n.id === "end")).toBeDefined();
expect(edges).toHaveLength(0);
});
it("4.2 Single step with no END transition → start→role edge exists", () => {
const steps = [makeStep("A", [])];
const { nodes, edges } = transIn(steps);
expect(nodes).toHaveLength(3); // start, end, role-A
const startEdge = edges.find((e) => e.source === "start");
expect(startEdge).toBeDefined();
const roleNode = nodes.find((n) => n.type === "role");
expect(startEdge?.target).toBe(roleNode?.id);
});
it("4.3 Single step with END transition → edge to end node exists", () => {
const steps = [makeStep("A", [{ status: "_", target: "END" }])];
const { edges } = transIn(steps);
const endEdge = edges.find((e) => e.target === "end");
expect(endEdge).toBeDefined();
});
it("4.4 Two steps with default transitions chain", () => {
const steps = [
makeStep("A", [{ status: "_", target: "B" }]),
makeStep("B", [{ status: "_", target: "END" }]),
];
const { edges } = transIn(steps);
// Should have start→A, A→B, B→end
expect(edges.find((e) => e.source === "start")).toBeDefined();
const nodeAId = edges.find((e) => e.source === "start")?.target;
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
expect(edges.find((e) => e.target === "end")).toBeDefined();
// No status edges for single default transitions
expect(edges.every((e) => e.type !== "status")).toBe(true);
});
it("4.5 Step with multiple transitions → status edges", () => {
const steps = [
makeStep("A", [
{ status: "_", target: "B" },
{ status: "approved", target: "C" },
]),
makeStep("B", []),
makeStep("C", []),
];
const { edges } = transIn(steps);
const nodeAId = edges.find((e) => e.source === "start")?.target;
const outEdges = edges.filter((e) => e.source === nodeAId);
expect(outEdges.every((e) => e.type === "status")).toBe(true);
});
it("4.5b Multiple transitions include expected status values", () => {
const steps = [
makeStep("A", [
{ status: "_", target: "B" },
{ status: "approved", target: "C" },
]),
makeStep("B", []),
makeStep("C", []),
];
const { edges } = transIn(steps);
const nodeAId = edges.find((e) => e.source === "start")?.target;
const outEdges = edges.filter((e) => e.source === nodeAId);
const defaultEdge = outEdges.find(
(e) => (e as { data?: { status?: string } }).data?.status === "_",
);
expect(defaultEdge).toBeDefined();
const approvedEdge = outEdges.find(
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
);
expect(approvedEdge).toBeDefined();
});
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
const steps = [
makeStep("A", [{ status: "_", target: "END" }]),
makeStep("B", [{ status: "_", target: "END" }]),
];
const { edges } = transIn(steps);
// start→A and start→B; end has 2 incoming edges
const incomingToEnd = edges.filter((e) => e.target === "end");
expect(incomingToEnd[0].targetHandle).toBe("input");
});
it("4.7 Same role name maps to same node id across steps", () => {
const steps = [
makeStep("A", [{ status: "_", target: "B" }]),
makeStep("B", [{ status: "_", target: "A" }]),
];
const { edges } = transIn(steps);
const aId = edges.find((e) => e.source === "start")?.target;
// B→A edge target should be same node as start→A edge target
const bToAEdge = edges.find(
(e) => e.source !== "start" && e.target === aId && e.target !== "end",
);
expect(bToAEdge).toBeDefined();
});
});
@@ -0,0 +1,137 @@
import { describe, expect, it } from "bun:test";
import type { AnyWorkEdge, AnyWorkNode } from "../../type.js";
import { validate } from "../validate.js";
function roleNode(id: string): AnyWorkNode {
return {
id,
type: "role",
data: { name: id, description: "", identity: "", prepare: "", execute: "", report: "" },
position: { x: 0, y: 0 },
} as AnyWorkNode;
}
function startNode(): AnyWorkNode {
return {
id: "start",
type: "start",
data: { label: "Start" },
position: { x: 0, y: 0 },
} as AnyWorkNode;
}
function endNode(): AnyWorkNode {
return {
id: "end",
type: "end",
data: { label: "End" },
position: { x: 0, y: 0 },
} as AnyWorkNode;
}
function defaultEdge(source: string, target: string): AnyWorkEdge {
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
}
function statusEdge(source: string, target: string, status: string): AnyWorkEdge {
return {
id: `${source}-${target}-status`,
source,
target,
type: "status" as const,
data: { status },
animated: true,
} as AnyWorkEdge;
}
// Helper: build a minimal valid graph with 2 role nodes for validateRoleNodes tests
function baseNodes(...roles: AnyWorkNode[]): AnyWorkNode[] {
return [startNode(), ...roles, endNode()];
}
describe("validateRoleNodes (via validate)", () => {
it("5.1 Role node with no incoming edge → error about missing input", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const nodes = baseNodes(n1, n2);
// n1 has no incoming, n2 has incoming+outgoing
const edges = [defaultEdge("start", "n2"), defaultEdge("n1", "end"), defaultEdge("n2", "end")];
const result = validate(nodes, edges);
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
expect(nodeErrors.some((e) => e.message.includes("缺少输入连接"))).toBe(true);
});
it("5.2 Role node with no outgoing edge → error about missing output", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const nodes = baseNodes(n1, n2);
const edges = [
defaultEdge("start", "n1"),
defaultEdge("start", "n2"),
defaultEdge("n2", "end"),
// n1 has no outgoing
];
const result = validate(nodes, edges);
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
});
it("5.3 Empty status on status edge → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
statusEdge("n1", "n2", "_"),
statusEdge("n1", "n3", ""), // empty status → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("状态值不能为空"))).toBe(true);
});
it("5.4 Mix of status and non-status outgoing → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
statusEdge("n1", "n2", "approved"),
defaultEdge("n1", "n3"), // mix → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("所有出边必须附带状态"))).toBe(true);
});
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const nodes = baseNodes(n1, n2);
const edges = [defaultEdge("start", "n1"), defaultEdge("n1", "n2"), defaultEdge("n2", "end")];
const result = validate(nodes, edges);
const roleErrors = result.errors.filter((e) => e.nodeId === "n1" || e.nodeId === "n2");
expect(roleErrors).toHaveLength(0);
});
it("5.6 Valid role node (1 in, 2 status out with statuses) → no errors", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
statusEdge("n1", "n2", "_"),
statusEdge("n1", "n3", "approved"),
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
const n1Errors = result.errors.filter((e) => e.nodeId === "n1");
expect(n1Errors).toHaveLength(0);
});
});
@@ -0,0 +1,4 @@
export * from "./trans-in";
export * from "./trans-out";
export * from "./type";
export * from "./validate";
@@ -0,0 +1,175 @@
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
import { uuid } from "../utils";
import type { WorkFlowStep } from "./type";
type Result = {
nodes: AnyWorkNode[];
edges: AnyWorkEdge[];
};
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
const DEFAULT_STATUS = "_";
function assignHandles(
indices: number[],
edges: AnyWorkEdge[],
handles: readonly string[],
key: "sourceHandle" | "targetHandle",
): void {
if (indices.length === 1) {
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
} else if (indices.length === 2) {
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
edges[indices[1]] = { ...edges[indices[1]], [key]: handles[0] };
} else {
for (let i = 0; i < indices.length; i++) {
edges[indices[i]] = { ...edges[indices[i]], [key]: handles[i % handles.length] };
}
}
}
function buildNodeMap(
steps: WorkFlowStep[],
nodes: AnyWorkNode[],
): { nameToId: Map<string, string>; idToOrder: Map<string, number> } {
const nameToId = new Map<string, string>();
const idToOrder = new Map<string, number>();
nameToId.set("END", "end");
idToOrder.set("start", -1);
idToOrder.set("end", steps.length);
for (let si = 0; si < steps.length; si++) {
const step = steps[si];
const nodeId = `n${uuid()}`;
nameToId.set(step.role.name, nodeId);
idToOrder.set(nodeId, si);
nodes.push({ id: nodeId, type: "role", data: { ...step.role }, position: { x: 0, y: 0 } });
}
return { nameToId, idToOrder };
}
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
if (step.transitions.length <= 1) return step.transitions;
return [...step.transitions].sort((a, b) => {
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
return 0;
});
}
function buildStepEdges(
sourceId: string,
step: WorkFlowStep,
nameToId: Map<string, string>,
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
const hasMultiple = step.transitions.length > 1;
const sorted = sortTransitions(step);
const primaryEdges: AnyWorkEdge[] = [];
const statusEdges: AnyWorkEdge[] = [];
for (let i = 0; i < sorted.length; i++) {
const t = sorted[i];
const targetId = nameToId.get(t.target);
if (!targetId) continue;
const edgeId = `e-${sourceId}-${targetId}-${i}`;
if (hasMultiple || t.status !== DEFAULT_STATUS) {
const edge: StatusEdge = {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
type: "status",
data: { status: t.status },
animated: true,
};
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
else statusEdges.push(edge);
} else {
primaryEdges.push({
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
animated: true,
});
}
}
return { primaryEdges, statusEdges };
}
function pushStepEdges(
edges: AnyWorkEdge[],
primaryEdges: AnyWorkEdge[],
statusEdges: AnyWorkEdge[],
idToOrder: Map<string, number>,
): void {
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
if (statusEdges.length > 0) {
const statusHandles = ["output-top", "output-bottom"] as const;
const sorted = [...statusEdges].sort(
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
);
for (let i = 0; i < sorted.length; i++) {
edges.push({ ...sorted[i], sourceHandle: statusHandles[i % statusHandles.length] });
}
}
}
function assignTargetHandles(edges: AnyWorkEdge[], idToOrder: Map<string, number>): void {
const incomingByTarget = new Map<string, number[]>();
for (let i = 0; i < edges.length; i++) {
const target = edges[i].target;
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
incomingByTarget.get(target)?.push(i);
}
for (const indices of incomingByTarget.values()) {
indices.sort(
(a, b) => (idToOrder.get(edges[a].source) ?? 0) - (idToOrder.get(edges[b].source) ?? 0),
);
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
}
}
export function transIn(steps: WorkFlowStep[]): Result {
const startNode: AnyWorkNode = {
id: "start",
type: "start",
data: { label: "Start" },
position: { x: 0, y: 0 },
};
const endNode: AnyWorkNode = {
id: "end",
type: "end",
data: { label: "End" },
position: { x: 250, y: 0 },
};
if (steps.length === 0) return { nodes: [startNode, endNode], edges: [] };
const nodes: AnyWorkNode[] = [startNode, endNode];
const edges: AnyWorkEdge[] = [];
const { nameToId, idToOrder } = buildNodeMap(steps, nodes);
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
edges.push({
id: `e-start-${firstStepId}`,
source: "start",
sourceHandle: "output",
target: firstStepId,
targetHandle: "input",
animated: true,
});
for (const step of steps) {
const sourceId = nameToId.get(step.role.name) ?? "";
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
}
assignTargetHandles(edges, idToOrder);
return { nodes, edges };
}
@@ -0,0 +1,74 @@
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
import type { WorkFlowStep, WorkFlowTransition } from "./type";
const DEFAULT_STATUS = "_";
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
const nodeMap = new Map<string, AnyWorkNode>();
for (const node of nodes) {
nodeMap.set(node.id, node);
}
const outgoingEdges = new Map<string, AnyWorkEdge[]>();
for (const edge of edges) {
if (!outgoingEdges.has(edge.source)) {
outgoingEdges.set(edge.source, []);
}
outgoingEdges.get(edge.source)?.push(edge);
}
const startOutEdges = outgoingEdges.get("start") ?? [];
if (startOutEdges.length === 0) return [];
const firstNodeId = startOutEdges[0].target;
const visited = new Set<string>();
const steps: WorkFlowStep[] = [];
traverse(firstNodeId, nodeMap, outgoingEdges, visited, steps);
return steps;
}
function traverse(
nodeId: string,
nodeMap: Map<string, AnyWorkNode>,
outgoingEdges: Map<string, AnyWorkEdge[]>,
visited: Set<string>,
steps: WorkFlowStep[],
): void {
if (visited.has(nodeId) || nodeId === "start" || nodeId === "end") return;
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (node?.type !== "role") return;
const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? [];
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
const targetNode = nodeMap.get(edge.target);
const target =
edge.target === "end"
? "END"
: targetNode?.type === "role"
? (targetNode as WorkNode<"role">).data.name
: edge.target;
const status =
edge.type === "status"
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
: DEFAULT_STATUS;
return { target, status };
});
const { name, description, identity, prepare, execute, report } = roleNode.data;
steps.push({
role: { name, description, identity, prepare, execute, report },
transitions,
});
for (const edge of outEdges) {
traverse(edge.target, nodeMap, outgoingEdges, visited, steps);
}
}
@@ -0,0 +1,6 @@
export type {
WorkFlowRole,
WorkFlowStep,
WorkFlowSteps,
WorkFlowTransition,
} from "../../../shared/types.ts";
@@ -0,0 +1,187 @@
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
export type ValidationError = {
nodeId: string | null;
message: string;
};
export type ValidationResult = {
valid: boolean;
errors: ValidationError[];
};
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
const errors: ValidationError[] = [];
const outgoing = buildEdgeMap(edges, "source");
const incoming = buildEdgeMap(edges, "target");
const startNodes = nodes.filter((n) => n.type === "start");
const endNodes = nodes.filter((n) => n.type === "end");
const roleNodes = nodes.filter((n) => n.type === "role");
validateStartNode(startNodes, outgoing, errors);
validateEndNode(endNodes, incoming, outgoing, errors);
validateRoleNodes(roleNodes, outgoing, incoming, errors);
validateRoleCount(roleNodes, errors);
validateReachability(nodes, edges, startNodes, endNodes, errors);
return { valid: errors.length === 0, errors };
}
function buildEdgeMap(edges: AnyWorkEdge[], key: "source" | "target"): Map<string, AnyWorkEdge[]> {
const map = new Map<string, AnyWorkEdge[]>();
for (const edge of edges) {
const id = edge[key];
if (!map.has(id)) {
map.set(id, []);
}
map.get(id)?.push(edge);
}
return map;
}
function validateStartNode(
startNodes: AnyWorkNode[],
outgoing: Map<string, AnyWorkEdge[]>,
errors: ValidationError[],
): void {
if (startNodes.length === 0) {
errors.push({ nodeId: null, message: "缺少 Start 节点" });
return;
}
if (startNodes.length > 1) {
errors.push({ nodeId: null, message: "Start 节点只能有一个" });
return;
}
const startId = startNodes[0].id;
const outEdges = outgoing.get(startId) ?? [];
if (outEdges.length === 0) {
errors.push({ nodeId: startId, message: "Start 节点必须有一个输出连接" });
} else if (outEdges.length > 1) {
errors.push({ nodeId: startId, message: "Start 节点只能有一个输出连接" });
}
}
function validateEndNode(
endNodes: AnyWorkNode[],
incoming: Map<string, AnyWorkEdge[]>,
outgoing: Map<string, AnyWorkEdge[]>,
errors: ValidationError[],
): void {
if (endNodes.length === 0) {
errors.push({ nodeId: null, message: "缺少 End 节点" });
return;
}
if (endNodes.length > 1) {
errors.push({ nodeId: null, message: "End 节点只能有一个" });
return;
}
const endId = endNodes[0].id;
const inEdges = incoming.get(endId) ?? [];
if (inEdges.length === 0) {
errors.push({ nodeId: endId, message: "End 节点必须有至少一个输入连接" });
}
const outEdges = outgoing.get(endId) ?? [];
if (outEdges.length > 0) {
errors.push({ nodeId: endId, message: "End 节点不能有输出连接" });
}
}
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
return statusEdges.some((edge) => {
const status = (edge as StatusEdge).data?.status?.trim();
return !status;
});
}
function validateRoleNodeEdges(
node: AnyWorkNode,
outEdges: AnyWorkEdge[],
inEdges: AnyWorkEdge[],
errors: ValidationError[],
): void {
if (inEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
}
if (outEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
return;
}
if (outEdges.length <= 1) return;
const statusEdges = outEdges.filter((e) => e.type === "status");
if (statusEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
} else if (hasEmptyStatusOnEdge(statusEdges)) {
errors.push({ nodeId: node.id, message: "状态边的状态值不能为空" });
}
}
function validateRoleNodes(
roleNodes: AnyWorkNode[],
outgoing: Map<string, AnyWorkEdge[]>,
incoming: Map<string, AnyWorkEdge[]>,
errors: ValidationError[],
): void {
for (const node of roleNodes) {
validateRoleNodeEdges(node, outgoing.get(node.id) ?? [], incoming.get(node.id) ?? [], errors);
}
}
function validateRoleCount(roleNodes: AnyWorkNode[], errors: ValidationError[]): void {
if (roleNodes.length < 2) {
errors.push({ nodeId: null, message: "工作流至少需要 2 个角色节点" });
}
}
function validateReachability(
nodes: AnyWorkNode[],
edges: AnyWorkEdge[],
startNodes: AnyWorkNode[],
endNodes: AnyWorkNode[],
errors: ValidationError[],
): void {
if (startNodes.length !== 1 || endNodes.length !== 1) return;
const forwardAdj = new Map<string, string[]>();
const backwardAdj = new Map<string, string[]>();
for (const edge of edges) {
if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []);
forwardAdj.get(edge.source)?.push(edge.target);
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
backwardAdj.get(edge.target)?.push(edge.source);
}
const reachableFromStart = bfs(startNodes[0].id, forwardAdj);
const reachableFromEnd = bfs(endNodes[0].id, backwardAdj);
for (const node of nodes) {
if (node.type === "start" || node.type === "end") continue;
if (!reachableFromStart.has(node.id)) {
errors.push({ nodeId: node.id, message: "节点不可从 Start 到达(孤立节点)" });
}
if (!reachableFromEnd.has(node.id)) {
errors.push({ nodeId: node.id, message: "节点无法到达 End(死端节点)" });
}
}
}
function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
const visited = new Set<string>();
const queue = [startId];
visited.add(startId);
while (queue.length > 0) {
const current = queue.shift() ?? "";
for (const next of adj.get(current) ?? []) {
if (!visited.has(next)) {
visited.add(next);
queue.push(next);
}
}
}
return visited;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Edge, Node } from "@xyflow/react";
type AnyKeyBase = { [key: string]: unknown | undefined };
export type RoleNodeData = AnyKeyBase & {
name: string;
description: string;
identity: string;
prepare: string;
execute: string;
report: string;
};
export type NodeMap = {
start: { label: string };
end: { label: string };
role: RoleNodeData;
};
export type WorkNodeType = keyof NodeMap;
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
export type StatusEdgeData = AnyKeyBase & {
status: string;
};
export type StatusEdge = Edge<StatusEdgeData, "status">;
export type AnyWorkEdge = StatusEdge | Edge;
@@ -0,0 +1,37 @@
type Maper<T> = {
[key: string]: T;
};
type Listen<T> = (data: T) => void;
// biome-ignore lint/suspicious/noExplicitAny: generic event map requires any
export class Eventer<M extends Maper<any>> {
// biome-ignore lint/complexity/noBannedTypes: Set<Function> needed for heterogeneous listener types
private lisenters = {} as { [K in keyof M]: Set<Function> };
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
let set = this.lisenters[key];
if (set === undefined) {
set = new Set();
this.lisenters[key] = set;
}
set.add(lisenter);
return () => this.off(key, lisenter);
}
public off<K extends keyof M>(key: K, lisenter?: Listen<M[K]>) {
const set = this.lisenters[key];
if (set === undefined) return;
if (lisenter === undefined) set.clear();
else set.delete(lisenter);
}
public emit<K extends keyof M>(key: K, data: M[K]) {
const set = this.lisenters[key];
if (set === undefined) return;
// Todo: maybe implement stoping bubble
for (const call of set) {
call(data);
}
}
}
@@ -0,0 +1,5 @@
export function uuid() {
const now = Date.now();
const randon = 1 + Math.random();
return Math.round(now * randon).toString(36);
}
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
function judge(container: HTMLElement, target: HTMLElement): boolean {
if (container === target) {
return true;
}
if (target === document.body) {
return false;
}
const parent = target.parentElement;
return parent ? judge(container, parent) : false;
}
export function useClickOutRef<T extends HTMLElement>(callback: () => void, delay = 0) {
const ref = useRef<T>(null);
const flag = useRef<boolean>(delay === 0);
useEffect(() => {
if (!delay) return;
const timer = setTimeout(() => {
flag.current = true;
}, delay);
return () => clearTimeout(timer);
}, [delay]);
useEffect(() => {
function handle(ev: MouseEvent) {
if (!flag.current) return;
const container = ref.current;
const target = ev.target as HTMLElement;
if (container && target) {
if (judge(container, target)) return;
callback();
}
}
document.addEventListener("click", handle);
return () => document.removeEventListener("click", handle);
}, [callback]);
return ref;
}
+150
View File
@@ -0,0 +1,150 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius: 0.625rem;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--font-heading: var(--font-sans);
--font-sans: "Geist Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--success: oklch(0.55 0.15 160);
--success-foreground: oklch(0.985 0 0);
--warning: oklch(0.75 0.18 75);
--warning-foreground: oklch(0.145 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--success: oklch(0.6 0.15 160);
--success-foreground: oklch(0.985 0 0);
--warning: oklch(0.75 0.18 75);
--warning-foreground: oklch(0.145 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
body {
margin: 0;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+8
View File
@@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import { router } from "./router.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(<RouterProvider router={router} />);
}
+84
View File
@@ -0,0 +1,84 @@
import { ArrowLeft, Eye, Pencil } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
export function DetailPage(): ReactNode {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const location = useLocation();
const editing = location.pathname.endsWith("/edit");
const [model, setModel] = useState<FlowModel | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const nameRef = useRef(name);
nameRef.current = name;
useEffect(() => {
if (!name) return;
let cancelled = false;
fetch(`/api/workflows/${encodeURIComponent(name)}`)
.then((res) => {
if (!res.ok) throw new Error("not found");
return res.json() as Promise<WorkFlowSteps>;
})
.then((steps) => {
if (cancelled) return;
const m = new FlowModel(steps.length > 0 ? steps : undefined);
m.on("save", (savedSteps) => {
const n = nameRef.current;
if (!n) return;
setSaving(true);
fetch(`/api/workflows/${encodeURIComponent(n)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(savedSteps),
}).then(() => setSaving(false));
});
setModel(m);
setLoading(false);
})
.catch(() => {
if (!cancelled) navigate("/");
});
return () => {
cancelled = true;
};
}, [name, navigate]);
if (loading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">...</div>
);
}
const basePath = `/workflow/${encodeURIComponent(name ?? "")}`;
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b px-4 py-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate("/")}>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-base font-medium">{name}</h1>
<div className="flex-1" />
{editing ? (
<Button variant="outline" size="sm" onClick={() => navigate(basePath)}>
<Eye className="size-3.5" data-icon="inline-start" />
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => navigate(`${basePath}/edit`)}>
<Pencil className="size-3.5" data-icon="inline-start" />
</Button>
)}
{saving && <span className="text-xs text-muted-foreground">...</span>}
</div>
<div className="flex-1">{model && <FlowEditor model={model} readonly={!editing} />}</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { type ReactNode, useState } from "react";
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
const DEFAULT_STEPS: WorkFlowSteps = [
{
role: {
name: "planner",
description: "分析需求并制定实施计划",
identity: "你是一位资深的技术架构师",
prepare: "阅读用户需求,理解项目背景",
execute: "制定详细的实施计划和步骤分解",
report: "输出结构化的计划文档,包含步骤列表和预期产出",
},
transitions: [{ target: "developer", status: "_" }],
},
{
role: {
name: "developer",
description: "根据计划编写代码实现",
identity: "你是一位经验丰富的全栈开发者",
prepare: "阅读计划文档,理解技术要求",
execute: "编写高质量的代码实现",
report: "输出变更文件列表和实现摘要",
},
transitions: [{ target: "reviewer", status: "_" }],
},
{
role: {
name: "reviewer",
description: "审查代码质量并决定是否通过",
identity: "你是一位严谨的代码审查员",
prepare: "阅读代码变更和实现摘要",
execute: "检查代码质量、安全性和最佳实践",
report: "输出审查结果,包含 approved 状态和评审意见",
},
transitions: [
{ target: "END", status: "approved" },
{ target: "developer", status: "rejected" },
],
},
];
export function EditorPage(): ReactNode {
const [model] = useState(() => new FlowModel(DEFAULT_STEPS));
return (
<div className="h-full w-full">
<FlowEditor model={model} />
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { Plus, Trash2, Workflow } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { WorkflowSummary } from "../../shared/types.ts";
export function HomePage(): ReactNode {
const navigate = useNavigate();
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const fetchWorkflows = useCallback(async () => {
const res = await fetch("/api/workflows");
const data = await res.json();
setWorkflows(data);
setLoading(false);
}, []);
useEffect(() => {
fetchWorkflows();
}, [fetchWorkflows]);
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
await fetch("/api/workflows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
});
setNewName("");
setNewDesc("");
setCreateOpen(false);
fetchWorkflows();
};
const handleDelete = async (name: string) => {
await fetch(`/api/workflows/${encodeURIComponent(name)}`, { method: "DELETE" });
fetchWorkflows();
};
return (
<div className="mx-auto max-w-4xl p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Workflows</h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger render={<Button />}>
<Plus className="size-4" data-icon="inline-start" />
Workflow
</DialogTrigger>
<DialogContent>
<form onSubmit={handleCreate}>
<DialogHeader>
<DialogTitle> Workflow</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="mt-4 flex flex-col gap-3">
<Input
placeholder="名称 (kebab-case,如 solve-issue)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
<Textarea
placeholder="描述"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
rows={3}
/>
</div>
<DialogFooter className="mt-4">
<Button type="submit" disabled={!newName.trim()}>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{loading ? (
<div className="text-muted-foreground py-12 text-center">...</div>
) : workflows.length === 0 ? (
<div className="py-12 text-center">
<Workflow className="mx-auto size-12 text-muted-foreground/50" />
<p className="text-muted-foreground mt-4"> Workflow</p>
<p className="text-muted-foreground/70 text-sm mt-1"></p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{workflows.map((wf) => (
<Card
key={wf.name}
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={() => navigate(`/workflow/${encodeURIComponent(wf.name)}`)}
>
<CardHeader>
<CardTitle>{wf.name}</CardTitle>
<CardDescription>{wf.description || "无描述"}</CardDescription>
<CardAction>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
handleDelete(wf.name);
}}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</CardAction>
</CardHeader>
</Card>
))}
</div>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { createBrowserRouter } from "react-router";
import { Layout } from "./app.tsx";
import { DetailPage } from "./pages/detail.tsx";
import { HomePage } from "./pages/home.tsx";
export const router = createBrowserRouter([
{
path: "/",
Component: Layout,
children: [
{
index: true,
Component: HomePage,
},
{
path: "workflow/:name",
Component: DetailPage,
},
{
path: "workflow/:name/edit",
Component: DetailPage,
},
],
},
]);