chore: fix all biome lint errors across monorepo
- Fix import ordering (organizeImports) across multiple packages - Replace forEach with for...of loops (noForEach) - Replace non-null assertions with fallback values (noNonNullAssertion) - Add biome-ignore comments for justified noExplicitAny usages - Remove parameter properties, use explicit class properties (noParameterProperties) - Fix string concatenation to template literals (useTemplate) - Fix format issues (CSS, TypeScript) - Add tailwindDirectives CSS parser config in biome.json - Replace var with const (noVar) Result: 0 errors, 12 warnings (all cognitive complexity, acceptable)
This commit is contained in:
@@ -62,9 +62,9 @@ const olderEntry = JSON.stringify({
|
||||
|
||||
async function writeLogFiles(): Promise<void> {
|
||||
const logsDir = join(storageRoot, "logs");
|
||||
await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-20.jsonl"), `${[entry1, entry2, entry3].join("\n")}\n`);
|
||||
await writeFile(join(logsDir, "2026-05-19.jsonl"), `${oldEntry}\n`);
|
||||
await writeFile(join(logsDir, "2026-05-18.jsonl"), `${olderEntry}\n`);
|
||||
}
|
||||
|
||||
describe("cmdLogList", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolvePath } from "../src/tools/path.js";
|
||||
import { resolve } from "node:path";
|
||||
import { resolvePath } from "../src/tools/path.js";
|
||||
|
||||
describe("resolvePath", () => {
|
||||
test("resolves relative paths against cwd", () => {
|
||||
|
||||
@@ -56,8 +56,7 @@ function runShell(
|
||||
|
||||
export const runCommandTool: BuiltinTool = {
|
||||
name: "run_command",
|
||||
description:
|
||||
"Run a shell command. Output is truncated to 32KB.",
|
||||
description: "Run a shell command. Output is truncated to 32KB.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["command"],
|
||||
|
||||
@@ -73,9 +73,7 @@ describe("parseClaudeCodeStreamOutput", () => {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" },
|
||||
],
|
||||
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" }],
|
||||
},
|
||||
session_id: "sess-123",
|
||||
}),
|
||||
@@ -167,7 +165,12 @@ describe("storeClaudeCodeDetail", () => {
|
||||
durationMs: 15000,
|
||||
model: "claude-sonnet-4.5",
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 100, outputTokens: 50, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 },
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
turns: [
|
||||
{ index: 0, role: "assistant", content: "hello", toolCalls: null },
|
||||
{ index: 1, role: "tool_result", content: "world", toolCalls: null },
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
@@ -11,6 +8,7 @@ import {
|
||||
getCachedSessionId,
|
||||
setCachedSessionId,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
|
||||
|
||||
@@ -146,7 +144,12 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
log("5VKR8N3Q", "resume failed for session %s, falling back to fresh run: %s", cachedSessionId, err);
|
||||
log(
|
||||
"5VKR8N3Q",
|
||||
"resume failed for session %s, falling back to fresh run: %s",
|
||||
cachedSessionId,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,8 +267,7 @@ export class HermesAcpClient {
|
||||
case "tool_call": {
|
||||
const title = (update.title as string) ?? "";
|
||||
const rawInput = update.rawInput;
|
||||
const args =
|
||||
rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
||||
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
||||
const toolCallId = update.toolCallId as string;
|
||||
this.pendingTools.set(toolCallId, { name: title, args });
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ export {
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export type {
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>Workflow UI</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
(() => {
|
||||
const t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,3 @@ const server = await createServer({
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
|
||||
// biome-ignore lint/nursery/noConsole: CLI user-facing output
|
||||
console.log(`Workflow UI running at http://localhost:${PORT}`);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { WorkFlowSteps } from "../shared/types.ts";
|
||||
import {
|
||||
listWorkflows,
|
||||
getWorkflow,
|
||||
createWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
getWorkflow,
|
||||
listWorkflows,
|
||||
saveWorkflow,
|
||||
} from "./workflow.ts";
|
||||
|
||||
export function createApi() {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises";
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import YAML from "yaml";
|
||||
import type {
|
||||
WorkflowPayload,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||
|
||||
const WORKFLOW_DIR = join(import.meta.dirname, "..", "tmp", "workflow");
|
||||
@@ -67,7 +63,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition)!;
|
||||
condName = expressionToName.get(t.condition) ?? null;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
@@ -90,7 +86,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
|
||||
if (steps.length > 0) {
|
||||
const firstRole = steps[0].role.name;
|
||||
graph["$START"] = [
|
||||
graph.$START = [
|
||||
{
|
||||
role: firstRole,
|
||||
condition: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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",
|
||||
@@ -37,8 +37,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -52,7 +52,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
@@ -13,11 +13,11 @@ function 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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -53,20 +53,17 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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
|
||||
)}
|
||||
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">) {
|
||||
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
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} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -43,7 +39,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -52,7 +48,7 @@ function DialogContent({
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -60,32 +56,21 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -94,54 +79,46 @@ function DialogFooter({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
<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
|
||||
)}
|
||||
className={cn("font-heading text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -155,4 +132,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createContext, useMemo, useSyncExternalStore, useContext, useLayoutEffect } from 'react';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useReactFlow, ReactFlowInstance } from '@xyflow/react';
|
||||
import type { AnyWorkNode } from './type';
|
||||
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> {
|
||||
interface State<T, A> {
|
||||
readonly get: () => T;
|
||||
readonly set: Setter<T>;
|
||||
readonly use: () => T;
|
||||
@@ -15,6 +15,7 @@ interface State<T, 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;
|
||||
|
||||
@@ -24,10 +25,12 @@ 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;
|
||||
const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
|
||||
if (Object.is(val, next)) return;
|
||||
val = next;
|
||||
listener.forEach(call => call());
|
||||
for (const call of listener) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
const listen = (call: VoidFunction) => {
|
||||
listener.add(call);
|
||||
@@ -38,21 +41,26 @@ export function generate<T>(val: T) {
|
||||
}
|
||||
|
||||
class SubModel<T, A> {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private make: () => T,
|
||||
private create: Create<T, A>,
|
||||
private onlyView = false,
|
||||
) {}
|
||||
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 { make, create, onlyView } = this;
|
||||
const { get, set, use, listen } = generate(make());
|
||||
const actions = create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView };
|
||||
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];
|
||||
@@ -67,20 +75,27 @@ class SubModel<T, A> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
constructor(
|
||||
private readonly store: Map<string, State<any, any>>,
|
||||
public readonly use: Use,
|
||||
) {}
|
||||
// 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 = [];
|
||||
@@ -93,12 +108,14 @@ class Model {
|
||||
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()];
|
||||
this.stackListeners.forEach(call => call());
|
||||
for (const call of this.stackListeners) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
|
||||
private getStackState = () => this.stackState;
|
||||
@@ -108,13 +125,11 @@ class Model {
|
||||
}
|
||||
|
||||
public log() {
|
||||
console.log('undo stack:', this.ustack);
|
||||
console.log('redo stack:', this.rstack);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
|
||||
const snapshots: Record<string, any> = {};
|
||||
this.store.forEach((state, name) => {
|
||||
for (const [name, state] of this.store) {
|
||||
snapshots[name] = state.get();
|
||||
});
|
||||
console.log('current state:', snapshots);
|
||||
}
|
||||
}
|
||||
|
||||
public undo() {
|
||||
@@ -122,11 +137,13 @@ class Model {
|
||||
const item = ustack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
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();
|
||||
}
|
||||
@@ -136,11 +153,13 @@ class Model {
|
||||
const item = rstack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
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();
|
||||
}
|
||||
@@ -156,10 +175,10 @@ class Model {
|
||||
public startTransaction() {
|
||||
if (this.transaction === 0) {
|
||||
this.backup.clear();
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
this.backup.set(name, state.get());
|
||||
});
|
||||
}
|
||||
}
|
||||
this.transaction += 1;
|
||||
return this.endTransaction;
|
||||
@@ -170,24 +189,26 @@ class Model {
|
||||
this.transaction -= 1;
|
||||
if (this.transaction === 0) {
|
||||
const changes: Snapshot[] = [];
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
const before = this.backup.get(name);
|
||||
if (Object.is(before, state.get())) return;
|
||||
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);
|
||||
@@ -195,8 +216,8 @@ function build() {
|
||||
}
|
||||
|
||||
const model = new Model(store, use);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// @ts-ignore
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// @ts-expect-error
|
||||
window.__md__ = model;
|
||||
}
|
||||
|
||||
@@ -206,9 +227,9 @@ function build() {
|
||||
const created = m.gen(model);
|
||||
store.set(m.name, created);
|
||||
return created;
|
||||
};
|
||||
}
|
||||
|
||||
return { query, model, mem, use }
|
||||
return { query, model, mem, use };
|
||||
}
|
||||
|
||||
const Context = createContext(build());
|
||||
@@ -222,24 +243,28 @@ export function RegisterFlowToContext() {
|
||||
const instance = useReactFlow<AnyWorkNode>();
|
||||
useLayoutEffect(() => {
|
||||
model.flow = instance;
|
||||
}, [instance]);
|
||||
}, [instance, model]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModelProvider: FC<PropsWithChildren> = (p) => (
|
||||
<Context.Provider value={useMemo(build, [])}>
|
||||
{p.children}
|
||||
</Context.Provider>
|
||||
<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?: any): any {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -247,9 +272,12 @@ 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);
|
||||
const fn = mem[id] || (mem[id] = init(use, model));
|
||||
return fn as T;
|
||||
if (!mem[id]) {
|
||||
mem[id] = init(use, model);
|
||||
}
|
||||
return mem[id] as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -258,21 +286,29 @@ 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>>();
|
||||
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
|
||||
// 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();
|
||||
usev = (m) => query(m).get();
|
||||
const update = () => state.set(calc(usev));
|
||||
deps.forEach(m => query(m).listen(update));
|
||||
for (const m of deps) {
|
||||
query(m).listen(update);
|
||||
}
|
||||
}
|
||||
return state.use();
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const define = {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
@@ -38,7 +38,7 @@ function GradientPath({
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const strokeStyle = selected
|
||||
? { stroke: '#f59e0b', strokeWidth: 2 }
|
||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
|
||||
return (
|
||||
@@ -63,13 +63,7 @@ function GradientPath({
|
||||
strokeWidth={20}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
<path
|
||||
id={id}
|
||||
d={path}
|
||||
fill="none"
|
||||
className="react-flow__edge-path"
|
||||
style={strokeStyle}
|
||||
/>
|
||||
<path id={id} d={path} fill="none" className="react-flow__edge-path" style={strokeStyle} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -143,13 +137,12 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
}}
|
||||
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]",
|
||||
condition
|
||||
? "border border-gray-300 text-black"
|
||||
: "border border-dashed text-red-500",
|
||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
@@ -166,7 +159,6 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -183,7 +175,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional');
|
||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
@@ -200,7 +192,13 @@ export function ConditionalEdge({
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: RADIUS,
|
||||
});
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
@@ -224,14 +222,20 @@ export function ConditionalEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : (condition ? true : false)}
|
||||
hasCondition={isElse ? null : !!condition}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse
|
||||
? <ElseBadge labelX={labelX} labelY={labelY} />
|
||||
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
}
|
||||
{isElse ? (
|
||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
||||
) : (
|
||||
<ConditionLabel
|
||||
condition={condition}
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
@@ -248,7 +252,13 @@ export function GradientEdge({
|
||||
selected,
|
||||
}: EdgeProps<Edge>): ReactNode {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: RADIUS,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConditionalEdge, GradientEdge } from './conditional';
|
||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { memo, createElement, useLayoutEffect, useEffect, createContext, useContext } from 'react';
|
||||
import { ReactFlow, ReactFlowProvider, Controls, Background, type Edge } from '@xyflow/react';
|
||||
// @ts-ignore
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { nodesModel, edgesModel, handlers, injection } from './model';
|
||||
import { ModelProvider, RegisterFlowToContext } from './context';
|
||||
import { nodeTypes } from './nodes';
|
||||
import { edgeTypes } from './edges';
|
||||
import { Dialogs, TopCenterPanel } from './panel';
|
||||
import type { AnyWorkNode } from './type';
|
||||
import { FlowModel, InternalField } from './injection';
|
||||
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';
|
||||
export * from "./trans/type";
|
||||
|
||||
const proOptions = { hideAttribution: true };
|
||||
|
||||
@@ -20,11 +20,13 @@ 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 { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } =
|
||||
handlers.use();
|
||||
const readonly = useReadonly();
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
|
||||
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow<AnyWorkNode, Edge>
|
||||
nodes={nodes}
|
||||
@@ -70,11 +72,11 @@ function Connect({ model }: { model: FlowModel }) {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return inject(instance);
|
||||
}, [instance]);
|
||||
}, [instance, inject]);
|
||||
|
||||
useEffect(() => {
|
||||
return instance.on('load', loadSteps);
|
||||
}, [instance]);
|
||||
return instance.on("load", loadSteps);
|
||||
}, [instance, loadSteps]);
|
||||
|
||||
return <MemoFlow />;
|
||||
}
|
||||
@@ -83,8 +85,6 @@ 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>
|
||||
<ModelProvider>{createElement(Connect, { model })}</ModelProvider>
|
||||
</ReadonlyContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from './utils/eventer';
|
||||
import type { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from "./utils/eventer";
|
||||
|
||||
interface PublicEvents {
|
||||
save: WorkFlowSteps;
|
||||
@@ -9,19 +9,21 @@ interface PrivateEvents {
|
||||
load: WorkFlowSteps;
|
||||
}
|
||||
|
||||
export const InternalField = Symbol('InternalField');
|
||||
export const InternalField = Symbol("InternalField");
|
||||
|
||||
export class Injection extends Eventer<PrivateEvents> {
|
||||
constructor(
|
||||
public readonly emitPublic: Eventer<PublicEvents>['emit'],
|
||||
private inital_steps?: WorkFlowSteps,
|
||||
) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
@@ -37,13 +39,10 @@ export class FlowModel {
|
||||
public readonly [InternalField]: Injection;
|
||||
|
||||
constructor(inital_steps?: WorkFlowSteps) {
|
||||
this[InternalField] = new Injection(
|
||||
this.eventer.emit.bind(this.eventer),
|
||||
inital_steps,
|
||||
);
|
||||
this[InternalField] = new Injection(this.eventer.emit.bind(this.eventer), inital_steps);
|
||||
}
|
||||
|
||||
public load(steps: WorkFlowSteps) {
|
||||
this[InternalField].emit('load', steps);
|
||||
this[InternalField].emit("load", steps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 120;
|
||||
const DEFAULT_NODE_HEIGHT = 50;
|
||||
@@ -34,8 +34,8 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
// 构建图
|
||||
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);
|
||||
outgoing.get(edge.source)?.push(edge.target);
|
||||
incoming.get(edge.target)?.push(edge.source);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
@@ -55,17 +55,17 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
const queue: string[] = [];
|
||||
|
||||
// 1. start 节点固定在第 0 层
|
||||
layers.set('start', 0);
|
||||
queue.push('start');
|
||||
layers.set("start", 0);
|
||||
queue.push("start");
|
||||
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = layers.get(current)!;
|
||||
const current = queue.shift() ?? "";
|
||||
const currentLayer = layers.get(current) ?? 0;
|
||||
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
// 跳过 end 节点,稍后处理
|
||||
if (target === 'end') continue;
|
||||
if (target === "end") continue;
|
||||
|
||||
const newLayer = currentLayer + 1;
|
||||
const existingLayer = layers.get(target);
|
||||
@@ -93,7 +93,7 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
// 把它们放在中间层
|
||||
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)) {
|
||||
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
|
||||
layers.set(node.id, middleLayer);
|
||||
}
|
||||
}
|
||||
@@ -101,13 +101,13 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
// 5. 重新计算最大层级(可能因为孤立节点而变化)
|
||||
maxLayer = 0;
|
||||
for (const [id, layer] of layers) {
|
||||
if (id !== 'end') {
|
||||
if (id !== "end") {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. end 节点固定在最后一层
|
||||
layers.set('end', maxLayer + 1);
|
||||
layers.set("end", maxLayer + 1);
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>):
|
||||
if (!groups.has(layer)) {
|
||||
groups.set(layer, []);
|
||||
}
|
||||
groups.get(layer)!.push(node);
|
||||
groups.get(layer)?.push(node);
|
||||
}
|
||||
|
||||
return groups;
|
||||
@@ -152,7 +152,7 @@ function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, num
|
||||
*/
|
||||
function calculateLayerXPositions(
|
||||
layerWidths: Map<number, number>,
|
||||
maxLayer: number
|
||||
maxLayer: number,
|
||||
): Map<number, number> {
|
||||
const xPositions = new Map<number, number>();
|
||||
let currentX = 0;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Edge } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import type { RoleNodeData, AnyWorkNode } from '../type';
|
||||
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';
|
||||
type: "source" | "target";
|
||||
};
|
||||
|
||||
export type AddNodeState = {
|
||||
@@ -21,10 +21,10 @@ type CommitParams = {
|
||||
};
|
||||
|
||||
function addNodeView() {
|
||||
return null as (AddNodeState | null);
|
||||
return null as AddNodeState | null;
|
||||
}
|
||||
|
||||
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => {
|
||||
export const addNodeViewModel = define.view("addNodeView", addNodeView, (set, get, model) => {
|
||||
function start(state: AddNodeState) {
|
||||
set(state);
|
||||
}
|
||||
@@ -42,12 +42,19 @@ export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, ge
|
||||
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 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 };
|
||||
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));
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
} from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
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';
|
||||
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';
|
||||
return handle === "output" || handle === "output-top" || handle === "output-bottom";
|
||||
}
|
||||
|
||||
function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
@@ -33,10 +28,10 @@ function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
|
||||
let edgeCounter = 0;
|
||||
|
||||
export const edgesModel = define.model('edges', makeEdges, (set, get, model) => {
|
||||
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))) {
|
||||
const whites = new Set(["add", "replace"]);
|
||||
if (changes.some((c) => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
@@ -54,7 +49,7 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
|
||||
|
||||
const currentEdges = get();
|
||||
const duplicate = currentEdges.some(
|
||||
e => e.source === normalized.source && e.target === normalized.target,
|
||||
(e) => e.source === normalized.source && e.target === normalized.target,
|
||||
);
|
||||
if (duplicate) return;
|
||||
|
||||
@@ -67,15 +62,15 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
|
||||
animated: true,
|
||||
} as Edge;
|
||||
|
||||
const existingFromSource = currentEdges.filter(e => e.source === normalized.source);
|
||||
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
edge.type = 'conditional';
|
||||
edge.data = { condition: '' };
|
||||
edge.type = "conditional";
|
||||
edge.data = { condition: "" };
|
||||
|
||||
const promoted = currentEdges.map(e => {
|
||||
if (e.source === normalized.source && e.type !== 'conditional') {
|
||||
return { ...e, type: 'conditional' as const, data: { condition: '' } };
|
||||
const promoted = currentEdges.map((e) => {
|
||||
if (e.source === normalized.source && e.type !== "conditional") {
|
||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import type { RoleNodeData, WorkNode } from '../type';
|
||||
import { define } from "../context";
|
||||
import type { RoleNodeData, WorkNode } from "../type";
|
||||
import { nodesModel } from "./nodes";
|
||||
|
||||
export type EditNodeState = {
|
||||
node: WorkNode<'role'>;
|
||||
node: WorkNode<"role">;
|
||||
};
|
||||
|
||||
function editNodeView() {
|
||||
return null as (EditNodeState | null);
|
||||
return null as EditNodeState | null;
|
||||
}
|
||||
|
||||
export const editNodeViewModel = define.view('editNodeView', editNodeView, (set, get, model) => {
|
||||
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 || node.type !== 'role') return;
|
||||
set({ node: node as WorkNode<'role'> });
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.type !== "role") return;
|
||||
set({ node: node as WorkNode<"role"> });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -31,6 +31,7 @@ export const editNodeViewModel = define.view('editNodeView', editNodeView, (set,
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { OnNodeDrag, OnConnectEnd, OnBeforeDelete, OnDelete } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { addNodeViewModel } from './add-node-view';
|
||||
import type { AnyWorkNode } from '../type';
|
||||
import { LayoutLR } from '../layout';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import { injection } from './inject';
|
||||
import { transIn, transOut, validate } from '../trans';
|
||||
import type { WorkFlowSteps } from '../trans';
|
||||
import { editNodeViewModel } from './edit-node-view';
|
||||
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> = () => {
|
||||
@@ -23,6 +23,7 @@ export const handlers = define.memoize((use, model) => {
|
||||
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 }),
|
||||
@@ -31,15 +32,17 @@ export const handlers = define.memoize((use, model) => {
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'start' || node.type === 'end') {
|
||||
if (node.type === "start" || node.type === "end") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (edges.length > 0) {
|
||||
const allEdges = use(edgesModel)[0];
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== 'conditional') continue;
|
||||
const siblings = allEdges.filter(e => e.source === edge.source && e.type === 'conditional');
|
||||
if (edge.type !== "conditional") continue;
|
||||
const siblings = allEdges.filter(
|
||||
(e) => e.source === edge.source && e.type === "conditional",
|
||||
);
|
||||
if (siblings.length >= 2 && siblings[0].id === edge.id) {
|
||||
return false;
|
||||
}
|
||||
@@ -52,20 +55,20 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (deletedEdges.length > 0) {
|
||||
const currentEdges = use(edgesModel)[0];
|
||||
const sourcesToCheck = new Set(
|
||||
deletedEdges
|
||||
.filter(e => e.type === 'conditional')
|
||||
.map(e => e.source),
|
||||
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
|
||||
);
|
||||
|
||||
if (sourcesToCheck.size > 0) {
|
||||
let needsDowngrade = false;
|
||||
const updatedEdges = currentEdges.map(e => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== 'conditional') return e;
|
||||
const siblings = currentEdges.filter(s => s.source === e.source && s.type === 'conditional');
|
||||
const updatedEdges = currentEdges.map((e) => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
||||
const siblings = currentEdges.filter(
|
||||
(s) => s.source === e.source && s.type === "conditional",
|
||||
);
|
||||
if (siblings.length === 1) {
|
||||
needsDowngrade = true;
|
||||
const { data: _, ...rest } = e;
|
||||
return { ...rest, type: 'default' as const };
|
||||
return { ...rest, type: "default" as const };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
@@ -94,7 +97,7 @@ export const handlers = define.memoize((use, model) => {
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.code === 'Escape') {
|
||||
if (event.code === "Escape") {
|
||||
const [addView, addViewActions] = use(addNodeViewModel);
|
||||
const [editView, editViewActions] = use(editNodeViewModel);
|
||||
if (addView) addViewActions.cancel();
|
||||
@@ -102,12 +105,12 @@ export const handlers = define.memoize((use, model) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 'KeyZ') {
|
||||
if (event.code === "KeyZ") {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.shiftKey) model.redo();
|
||||
else model.undo();
|
||||
}
|
||||
} else if (event.code === 'KeyY') {
|
||||
} else if (event.code === "KeyY") {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
model.redo();
|
||||
}
|
||||
@@ -130,7 +133,7 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (result.valid) {
|
||||
const steps = transOut(nodes, edges);
|
||||
const instance = use(injection)[0];
|
||||
instance.emitPublic('save', steps);
|
||||
instance.emitPublic("save", steps);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { nodesModel } from './nodes';
|
||||
export { edgesModel } from './edges';
|
||||
export { addNodeViewModel, type AddNodeState } from './add-node-view';
|
||||
export { editNodeViewModel, type EditNodeState } from './edit-node-view';
|
||||
export { handlers } from './handlers';
|
||||
export { injection } from './inject';
|
||||
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";
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import { define } from "../context.tsx";
|
||||
import { Injection } from '../injection.ts';
|
||||
|
||||
import { Injection } from "../injection.ts";
|
||||
|
||||
const NOOP = () => {};
|
||||
const placeholder = new Injection(NOOP);
|
||||
@@ -13,7 +12,7 @@ function make(): Injection {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
export const injection = define.view('injection', make, (set) => {
|
||||
export const injection = define.view("injection", make, (set) => {
|
||||
function reset() {
|
||||
set(make());
|
||||
}
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
import { produce, type Draft } from 'immer';
|
||||
import { applyNodeChanges, NodeChange } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import type { AnyWorkNode } from '../type';
|
||||
|
||||
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' },
|
||||
id: "start",
|
||||
type: "start",
|
||||
data: { label: "Start" },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
data: { label: 'End' },
|
||||
id: "end",
|
||||
data: { label: "End" },
|
||||
position: { x: 1000, y: 0 },
|
||||
type: 'end',
|
||||
type: "end",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const nodesModel = define.model('nodes', makeNodes, (set, _get, model) => {
|
||||
const whites = new Set<NodeChange['type']>(['add', 'replace']);
|
||||
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))) {
|
||||
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);
|
||||
}));
|
||||
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));
|
||||
set((nds) => nds.filter((n) => n.id !== id));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Handle, Position, Node, NodeProps } from '@xyflow/react';
|
||||
import { EndNode } from './nodes.style';
|
||||
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 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'}
|
||||
<Handle type="target" position={Position.Left} id="input" />
|
||||
{data?.label || "End"}
|
||||
</EndNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeStart } from './start';
|
||||
import { NodeEnd } from './end';
|
||||
import { NodeRole } from './role';
|
||||
import { NodeEnd } from "./end";
|
||||
import { NodeRole } from "./role";
|
||||
import { NodeStart } from "./start";
|
||||
|
||||
export const nodeTypes = {
|
||||
start: NodeStart,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
|
||||
type Props = {
|
||||
@@ -13,7 +13,13 @@ export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
|
||||
<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="删除">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,7 @@ export function NodeContent({ children }: { children: ReactNode }): ReactNode {
|
||||
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-center w-8 h-8 rounded-lg shrink-0", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -62,23 +57,14 @@ export function NodeBody({ children }: { children: ReactNode }): ReactNode {
|
||||
|
||||
export function NodeKindLabel({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[10px] font-semibold uppercase tracking-wide mb-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
return <div className="text-[13px] text-gray-800 leading-snug break-words">{children}</div>;
|
||||
}
|
||||
|
||||
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
|
||||
@@ -93,8 +79,6 @@ export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleKindLabel({
|
||||
children,
|
||||
}: { children: ReactNode }): ReactNode {
|
||||
export function RoleKindLabel({ children }: { children: ReactNode }): ReactNode {
|
||||
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { Handle, Position, NodeToolbar, useNodeConnections, type NodeProps } from '@xyflow/react';
|
||||
import { Users } from 'lucide-react';
|
||||
import {
|
||||
NodeContent,
|
||||
NodeBody,
|
||||
RoleIcon,
|
||||
RoleKindLabel,
|
||||
NodeHint,
|
||||
} from './nodes.style';
|
||||
import { NodeToolbarActions } from './node-toolbar';
|
||||
import { editNodeViewModel } from '../model/edit-node-view';
|
||||
import { nodesModel } from '../model';
|
||||
import type { WorkNode } from '../type';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useReadonly } from '../flow';
|
||||
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'>>;
|
||||
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 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";
|
||||
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;
|
||||
@@ -35,8 +31,14 @@ export function NodeRole({ data, id, selected }: Props) {
|
||||
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 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);
|
||||
@@ -45,9 +47,35 @@ export function NodeRole({ data, id, selected }: Props) {
|
||||
|
||||
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 />}
|
||||
{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} />
|
||||
@@ -58,14 +86,37 @@ export function NodeRole({ data, id, selected }: Props) {
|
||||
</NodeBody>
|
||||
</NodeContent>
|
||||
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
|
||||
<NodeToolbarActions
|
||||
onEdit={() => startEdit(id)}
|
||||
onDelete={() => deleteNode(id)}
|
||||
/>
|
||||
<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 />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Handle, Position, Node, NodeProps, useNodeConnections } from '@xyflow/react';
|
||||
import { StartNode } from './nodes.style';
|
||||
import { useMemo } from 'react';
|
||||
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 NodeType = Node<NodeData, "start">;
|
||||
type Props = NodeProps<NodeType>;
|
||||
|
||||
export function NodeStart({ data, id }: Props) {
|
||||
@@ -19,7 +19,7 @@ export function NodeStart({ data, id }: Props) {
|
||||
|
||||
return (
|
||||
<StartNode>
|
||||
{data?.label || 'Start'}
|
||||
{data?.label || "Start"}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { addNodeViewModel, type AddNodeState } from "../model/index.ts";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog.tsx";
|
||||
import { Input } from "../../components/ui/input.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import { Label } from "../../components/ui/label.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { type AddNodeState, addNodeViewModel } from "../model/index.ts";
|
||||
import type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
@@ -34,7 +34,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
setPrepare("");
|
||||
setExecute("");
|
||||
setReport("");
|
||||
}, [state]);
|
||||
}, []);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!name.trim()) return;
|
||||
@@ -59,11 +59,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
<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="角色名称"
|
||||
/>
|
||||
<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>
|
||||
@@ -136,7 +132,9 @@ export function AddNodeDialog(): ReactNode {
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) cancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
editNodeViewModel,
|
||||
type EditNodeState,
|
||||
} from "../model/edit-node-view.ts";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog.tsx";
|
||||
import { Input } from "../../components/ui/input.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { Button } from "../../components/ui/button.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 = {
|
||||
@@ -61,11 +58,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
<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="角色名称"
|
||||
/>
|
||||
<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>
|
||||
@@ -138,7 +131,9 @@ export function EditNodeDialog(): ReactNode {
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) cancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { AddNodeDialog } from './add-node';
|
||||
import { EditNodeDialog } from './edit-node';
|
||||
import { Toolbar } from './toolbar';
|
||||
|
||||
import { Panel } from "@xyflow/react";
|
||||
import { AddNodeDialog } from "./add-node";
|
||||
import { EditNodeDialog } from "./edit-node";
|
||||
import { Toolbar } from "./toolbar";
|
||||
|
||||
export function Dialogs() {
|
||||
return (
|
||||
@@ -13,7 +12,6 @@ export function Dialogs() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function TopCenterPanel() {
|
||||
return (
|
||||
<Panel position="top-center">
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Users,
|
||||
LayoutList,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
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 { Separator } from "../../components/ui/separator.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import type { RoleNodeData, WorkNode } from "../type.ts";
|
||||
import { uuid } from "../utils/index.ts";
|
||||
import { useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const DEFAULT_ROLE_DATA: RoleNodeData = {
|
||||
name: '新角色',
|
||||
description: '',
|
||||
identity: '',
|
||||
prepare: '',
|
||||
execute: '',
|
||||
report: '',
|
||||
name: "新角色",
|
||||
description: "",
|
||||
identity: "",
|
||||
prepare: "",
|
||||
execute: "",
|
||||
report: "",
|
||||
};
|
||||
|
||||
export function Toolbar(): ReactNode {
|
||||
@@ -48,9 +41,9 @@ export function Toolbar(): ReactNode {
|
||||
const centerY = (height / 2 - y) / zoom;
|
||||
|
||||
const id = `n${uuid()}`;
|
||||
const node: WorkNode<'role'> = {
|
||||
const node: WorkNode<"role"> = {
|
||||
id,
|
||||
type: 'role',
|
||||
type: "role",
|
||||
position: { x: centerX - 80, y: centerY - 40 },
|
||||
data: { ...DEFAULT_ROLE_DATA },
|
||||
};
|
||||
@@ -63,10 +56,22 @@ export function Toolbar(): ReactNode {
|
||||
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}>
|
||||
<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}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="重做 (Redo)"
|
||||
onClick={handleRedo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<Redo2 />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -101,14 +106,12 @@ function SaveButton(): ReactNode {
|
||||
if (valid) {
|
||||
setToast({ open: true, severity: "success", message: "流程保存成功" });
|
||||
} else {
|
||||
const errorMessages = errors.map(
|
||||
({ message, nodeId }) => (
|
||||
<div key={nodeId ?? message}>
|
||||
{nodeId ? `节点 ${nodeId}:` : ""}
|
||||
{message}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
const errorMessages = errors.map(({ message, nodeId }) => (
|
||||
<div key={nodeId ?? message}>
|
||||
{nodeId ? `节点 ${nodeId}:` : ""}
|
||||
{message}
|
||||
</div>
|
||||
));
|
||||
setToast({
|
||||
open: true,
|
||||
severity: "error",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './type';
|
||||
export * from './trans-in';
|
||||
export * from './trans-out';
|
||||
export * from './validate';
|
||||
export * from "./trans-in";
|
||||
export * from "./trans-out";
|
||||
export * from "./type";
|
||||
export * from "./validate";
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep } from './type';
|
||||
import { uuid } from '../utils';
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } 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 _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
||||
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
||||
|
||||
function assignHandles(
|
||||
indices: number[],
|
||||
edges: AnyWorkEdge[],
|
||||
handles: readonly string[],
|
||||
key: 'sourceHandle' | 'targetHandle',
|
||||
key: "sourceHandle" | "targetHandle",
|
||||
): void {
|
||||
if (indices.length === 1) {
|
||||
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
|
||||
@@ -29,8 +29,18 @@ function assignHandles(
|
||||
}
|
||||
|
||||
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 } };
|
||||
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: [] };
|
||||
@@ -40,9 +50,9 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const edges: AnyWorkEdge[] = [];
|
||||
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);
|
||||
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];
|
||||
@@ -51,25 +61,25 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
idToOrder.set(nodeId, si);
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'role',
|
||||
type: "role",
|
||||
data: { ...step.role },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const firstStepId = nameToId.get(steps[0].role.name)!;
|
||||
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
||||
edges.push({
|
||||
id: `e-start-${firstStepId}`,
|
||||
source: 'start',
|
||||
sourceHandle: 'output',
|
||||
source: "start",
|
||||
sourceHandle: "output",
|
||||
target: firstStepId,
|
||||
targetHandle: 'input',
|
||||
targetHandle: "input",
|
||||
animated: true,
|
||||
});
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name)!;
|
||||
const sourceOrder = idToOrder.get(sourceId)!;
|
||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
|
||||
const hasMultipleTransitions = step.transitions.length > 1;
|
||||
|
||||
const sorted = hasMultipleTransitions
|
||||
@@ -95,10 +105,10 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
type: 'conditional',
|
||||
data: { condition: t.condition ?? '' },
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
type: "conditional",
|
||||
data: { condition: t.condition ?? "" },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultipleTransitions && i === 0) {
|
||||
@@ -111,8 +121,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
@@ -120,7 +130,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
|
||||
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
|
||||
for (const e of elseEdges) {
|
||||
edges.push({ ...e, sourceHandle: 'output' });
|
||||
edges.push({ ...e, sourceHandle: "output" });
|
||||
}
|
||||
if (ifEdges.length > 0) {
|
||||
const sortedIf = [...ifEdges].sort((a, b) => {
|
||||
@@ -128,7 +138,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const ob = idToOrder.get(b.target) ?? 0;
|
||||
return ob - oa;
|
||||
});
|
||||
const ifHandles = ['output-top', 'output-bottom'] as const;
|
||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
||||
for (let i = 0; i < sortedIf.length; i++) {
|
||||
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
}
|
||||
@@ -140,7 +150,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
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);
|
||||
incomingByTarget.get(target)?.push(i);
|
||||
}
|
||||
|
||||
for (const indices of incomingByTarget.values()) {
|
||||
@@ -149,7 +159,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const ob = idToOrder.get(edges[b].source) ?? 0;
|
||||
return oa - ob;
|
||||
});
|
||||
assignHandles(indices, edges, IN_HANDLES, 'targetHandle');
|
||||
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, WorkNode, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep, WorkFlowTransition } from './type';
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
|
||||
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
||||
|
||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||
const nodeMap = new Map<string, AnyWorkNode>();
|
||||
@@ -12,10 +12,10 @@ export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowSt
|
||||
if (!outgoingEdges.has(edge.source)) {
|
||||
outgoingEdges.set(edge.source, []);
|
||||
}
|
||||
outgoingEdges.get(edge.source)!.push(edge);
|
||||
outgoingEdges.get(edge.source)?.push(edge);
|
||||
}
|
||||
|
||||
const startOutEdges = outgoingEdges.get('start') ?? [];
|
||||
const startOutEdges = outgoingEdges.get("start") ?? [];
|
||||
if (startOutEdges.length === 0) return [];
|
||||
|
||||
const firstNodeId = startOutEdges[0].target;
|
||||
@@ -34,23 +34,26 @@ function traverse(
|
||||
visited: Set<string>,
|
||||
steps: WorkFlowStep[],
|
||||
): void {
|
||||
if (visited.has(nodeId) || nodeId === 'start' || nodeId === 'end') return;
|
||||
if (visited.has(nodeId) || nodeId === "start" || nodeId === "end") return;
|
||||
visited.add(nodeId);
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
if (!node || node.type !== "role") return;
|
||||
|
||||
const roleNode = node as WorkNode<'role'>;
|
||||
const roleNode = node as WorkNode<"role">;
|
||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
const target = edge.target === 'end'
|
||||
? 'END'
|
||||
: (targetNode?.type === 'role' ? (targetNode as WorkNode<'role'>).data.name : edge.target);
|
||||
const target =
|
||||
edge.target === "end"
|
||||
? "END"
|
||||
: targetNode?.type === "role"
|
||||
? (targetNode as WorkNode<"role">).data.name
|
||||
: edge.target;
|
||||
|
||||
let condition: string | null = null;
|
||||
if (edge.type === 'conditional') {
|
||||
if (edge.type === "conditional") {
|
||||
const isElse = outEdges.length >= 2 && index === 0;
|
||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type {
|
||||
WorkFlowRole,
|
||||
WorkFlowTransition,
|
||||
WorkFlowStep,
|
||||
WorkFlowSteps,
|
||||
WorkFlowTransition,
|
||||
} from "../../../shared/types.ts";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
|
||||
export type ValidationError = {
|
||||
nodeId: string | null;
|
||||
@@ -13,12 +13,12 @@ export type ValidationResult = {
|
||||
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
const outgoing = buildEdgeMap(edges, 'source');
|
||||
const incoming = buildEdgeMap(edges, 'target');
|
||||
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');
|
||||
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);
|
||||
@@ -29,17 +29,14 @@ export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): Validation
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function buildEdgeMap(
|
||||
edges: AnyWorkEdge[],
|
||||
key: 'source' | 'target',
|
||||
): Map<string, AnyWorkEdge[]> {
|
||||
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);
|
||||
map.get(id)?.push(edge);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -50,20 +47,20 @@ function validateStartNode(
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (startNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 Start 节点' });
|
||||
errors.push({ nodeId: null, message: "缺少 Start 节点" });
|
||||
return;
|
||||
}
|
||||
if (startNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'Start 节点只能有一个' });
|
||||
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 节点必须有一个输出连接' });
|
||||
errors.push({ nodeId: startId, message: "Start 节点必须有一个输出连接" });
|
||||
} else if (outEdges.length > 1) {
|
||||
errors.push({ nodeId: startId, message: 'Start 节点只能有一个输出连接' });
|
||||
errors.push({ nodeId: startId, message: "Start 节点只能有一个输出连接" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,23 +71,23 @@ function validateEndNode(
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (endNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 End 节点' });
|
||||
errors.push({ nodeId: null, message: "缺少 End 节点" });
|
||||
return;
|
||||
}
|
||||
if (endNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'End 节点只能有一个' });
|
||||
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 节点必须有至少一个输入连接' });
|
||||
errors.push({ nodeId: endId, message: "End 节点必须有至少一个输入连接" });
|
||||
}
|
||||
|
||||
const outEdges = outgoing.get(endId) ?? [];
|
||||
if (outEdges.length > 0) {
|
||||
errors.push({ nodeId: endId, message: 'End 节点不能有输出连接' });
|
||||
errors.push({ nodeId: endId, message: "End 节点不能有输出连接" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,22 +102,22 @@ function validateRoleNodes(
|
||||
const outEdges = outgoing.get(node.id) ?? [];
|
||||
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输入连接' });
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
|
||||
}
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输出连接' });
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
|
||||
}
|
||||
|
||||
if (outEdges.length > 1) {
|
||||
const conditionalEdges = outEdges.filter(e => e.type === 'conditional');
|
||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: '多输出节点的所有出边必须附带条件' });
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
||||
} else {
|
||||
const ifEdges = conditionalEdges.slice(1);
|
||||
for (const edge of ifEdges) {
|
||||
const condEdge = edge as ConditionalEdge;
|
||||
if (!condEdge.data?.condition?.trim()) {
|
||||
errors.push({ nodeId: node.id, message: '条件边的条件表达式不能为空' });
|
||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -129,12 +126,9 @@ function validateRoleNodes(
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoleCount(
|
||||
roleNodes: AnyWorkNode[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
function validateRoleCount(roleNodes: AnyWorkNode[], errors: ValidationError[]): void {
|
||||
if (roleNodes.length < 2) {
|
||||
errors.push({ nodeId: null, message: '工作流至少需要 2 个角色节点' });
|
||||
errors.push({ nodeId: null, message: "工作流至少需要 2 个角色节点" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,21 +145,21 @@ function validateReachability(
|
||||
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);
|
||||
forwardAdj.get(edge.source)?.push(edge.target);
|
||||
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
|
||||
backwardAdj.get(edge.target)!.push(edge.source);
|
||||
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 (node.type === "start" || node.type === "end") continue;
|
||||
if (!reachableFromStart.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点不可从 Start 到达(孤立节点)' });
|
||||
errors.push({ nodeId: node.id, message: "节点不可从 Start 到达(孤立节点)" });
|
||||
}
|
||||
if (!reachableFromEnd.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点无法到达 End(死端节点)' });
|
||||
errors.push({ nodeId: node.id, message: "节点无法到达 End(死端节点)" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +169,7 @@ function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
|
||||
const queue = [startId];
|
||||
visited.add(startId);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const current = queue.shift() ?? "";
|
||||
for (const next of adj.get(current) ?? []) {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Node, Edge } from '@xyflow/react';
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
type AnyKeyBase = { [key: string]: unknown | undefined };
|
||||
|
||||
@@ -19,11 +19,11 @@ export type NodeMap = {
|
||||
|
||||
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 AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
||||
|
||||
export type ConditionalEdgeData = AnyKeyBase & {
|
||||
condition: string;
|
||||
};
|
||||
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, 'conditional'>;
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
interface Maper<T> { [key: string]: T }
|
||||
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) {
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.lisenters[key] = set;
|
||||
}
|
||||
@@ -26,6 +30,8 @@ export class Eventer<M extends Maper<any>> {
|
||||
const set = this.lisenters[key];
|
||||
if (set === undefined) return;
|
||||
// Todo: maybe implement stoping bubble
|
||||
set.forEach(call => call(data));
|
||||
for (const call of set) {
|
||||
call(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
export function uuid() {
|
||||
const now = Date.now();
|
||||
const randon = 1 + Math.random();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
function judge(container: HTMLElement, target: HTMLElement): boolean {
|
||||
if (container === target) {
|
||||
@@ -8,14 +7,11 @@ function judge(container: HTMLElement, target: HTMLElement): boolean {
|
||||
if (target === document.body) {
|
||||
return false;
|
||||
}
|
||||
let parent = target.parentElement;
|
||||
const parent = target.parentElement;
|
||||
return parent ? judge(container, parent) : false;
|
||||
}
|
||||
|
||||
export function useClickOutRef<T extends HTMLElement>(
|
||||
callback: () => void,
|
||||
delay = 0,
|
||||
) {
|
||||
export function useClickOutRef<T extends HTMLElement>(callback: () => void, delay = 0) {
|
||||
const ref = useRef<T>(null);
|
||||
const flag = useRef<boolean>(delay === 0);
|
||||
|
||||
@@ -37,8 +33,8 @@ export function useClickOutRef<T extends HTMLElement>(
|
||||
callback();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handle);
|
||||
return () => document.removeEventListener('click', handle);
|
||||
document.addEventListener("click", handle);
|
||||
return () => document.removeEventListener("click", handle);
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--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);
|
||||
@@ -147,4 +147,4 @@ body {
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from "react";
|
||||
import { useParams, useNavigate, useLocation } from "react-router";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
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 { ArrowLeft, Pencil, Eye } from "lucide-react";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
|
||||
export function DetailPage(): ReactNode {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
@@ -44,18 +44,18 @@ export function DetailPage(): ReactNode {
|
||||
if (!cancelled) navigate("/");
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [name, navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">加载中...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const basePath = `/workflow/${encodeURIComponent(name!)}`;
|
||||
const basePath = `/workflow/${encodeURIComponent(name ?? "")}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -66,29 +66,19 @@ export function DetailPage(): ReactNode {
|
||||
<h1 className="text-base font-medium">{name}</h1>
|
||||
<div className="flex-1" />
|
||||
{editing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(basePath)}
|
||||
>
|
||||
<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`)}
|
||||
>
|
||||
<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 className="flex-1">{model && <FlowEditor model={model} readonly={!editing} />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
|
||||
const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode, type FormEvent } from "react";
|
||||
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, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
|
||||
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, Workflow } from "lucide-react";
|
||||
import type { WorkflowSummary } from "../../shared/types.ts";
|
||||
|
||||
export function HomePage(): ReactNode {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createBrowserRouter } from "react-router";
|
||||
import { Layout } from "./app.tsx";
|
||||
import { HomePage } from "./pages/home.tsx";
|
||||
import { DetailPage } from "./pages/detail.tsx";
|
||||
import { HomePage } from "./pages/home.tsx";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Plugin } from "vite";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import { createApi } from "./server/api.ts";
|
||||
|
||||
function buildRequest(req: IncomingMessage, body: string | null): Request {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { createProcessLogger } from "../src/process-logger/index.js";
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ export {
|
||||
validateFrontmatter,
|
||||
} from "./frontmatter-markdown/index.js";
|
||||
export { createLogger } from "./logger.js";
|
||||
export { createProcessLogger } from "./process-logger/index.js";
|
||||
export type {
|
||||
CreateProcessLoggerOptions,
|
||||
ProcessLogFn,
|
||||
ProcessLogger,
|
||||
ProcessLoggerContext,
|
||||
} from "./process-logger/index.js";
|
||||
export { createProcessLogger } from "./process-logger/index.js";
|
||||
export { normalizeRefsField } from "./refs-field.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
|
||||
Reference in New Issue
Block a user