refactor: align package folder names with npm package names
CI / check (pull_request) Failing after 8m30s
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:
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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} />);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
Reference in New Issue
Block a user