Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9fcb15384 | |||
| 5e868a2977 | |||
| 76fab22827 | |||
| 669875fb46 | |||
| 6d94be34a9 | |||
| d95fe45a3d | |||
| b9252b5ce2 | |||
| 4d47effd39 | |||
| 7b93ce8f3e | |||
| 67870392ab | |||
| 9316b843f6 | |||
| 6b9ff9781d | |||
| 487c48effa | |||
| 4eca2d533c | |||
| 45dacf540b |
@@ -11,3 +11,5 @@ solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
.claude
|
||||
tmp
|
||||
@@ -0,0 +1,83 @@
|
||||
# Test Spec: uwf setup model connectivity validation (#335)
|
||||
|
||||
## Context
|
||||
|
||||
File: `packages/cli-workflow/src/commands/setup.ts`
|
||||
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
|
||||
|
||||
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
|
||||
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
|
||||
- Use `AbortSignal.timeout(15_000)` for the request
|
||||
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
|
||||
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
|
||||
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
|
||||
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
|
||||
|
||||
## Test Cases (vitest)
|
||||
|
||||
### 1. `validateModel` — success path
|
||||
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: true, value: undefined }`
|
||||
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
|
||||
|
||||
### 2. `validateModel` — HTTP error (401 unauthorized)
|
||||
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: false, error: <string containing "401"> }`
|
||||
|
||||
### 3. `validateModel` — HTTP error (404 model not found)
|
||||
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
|
||||
- Assert returns `{ ok: false, error: <string containing "404"> }`
|
||||
|
||||
### 4. `validateModel` — network timeout
|
||||
- Mock `fetch` to throw `DOMException` with name `AbortError`
|
||||
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
|
||||
|
||||
### 5. `validateModel` — network error (DNS failure, connection refused)
|
||||
- Mock `fetch` to throw `TypeError("fetch failed")`
|
||||
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
|
||||
|
||||
### 6. `cmdSetup` — includes validation result on success
|
||||
- Mock global `fetch` for `/chat/completions` to succeed
|
||||
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
|
||||
- Assert returned object has `validation: { ok: true, value: undefined }`
|
||||
- Assert config files are still written (existing behavior preserved)
|
||||
|
||||
### 7. `cmdSetup` — includes validation result on failure (config still saved)
|
||||
- Mock global `fetch` for `/chat/completions` to return 401
|
||||
- Call `cmdSetup({ ... })`
|
||||
- Assert returned object has `validation: { ok: false, error: ... }`
|
||||
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
|
||||
|
||||
### 8. `cmdSetupInteractive` — prints success message on validation pass
|
||||
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
|
||||
- Mock stdin to provide valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a success message like "Model verified" or "✓"
|
||||
|
||||
### 9. `cmdSetupInteractive` — prints warning on validation failure
|
||||
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
|
||||
- Mock stdin for valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a warning about model not being reachable and suggests trying a different model
|
||||
|
||||
### 10. `validateModel` — request body correctness
|
||||
- Mock `fetch` to capture the request body
|
||||
- Call `validateModel(baseUrl, apiKey, "test-model")`
|
||||
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
|
||||
|
||||
## Export Requirements
|
||||
|
||||
- `validateModel` must be exported (for direct unit testing)
|
||||
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
|
||||
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
|
||||
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
|
||||
@@ -0,0 +1,150 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { cmdSetup, validateModel } from "../commands/setup.js";
|
||||
|
||||
describe("validateModel", () => {
|
||||
const BASE_URL = "https://api.example.com/v1";
|
||||
const API_KEY = "sk-test-key";
|
||||
const MODEL = "test-model";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("success path — returns ok on 200", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result).toEqual({ ok: true, value: undefined });
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0]!;
|
||||
expect(url).toBe(`${BASE_URL}/chat/completions`);
|
||||
expect((opts as RequestInit).headers).toEqual(
|
||||
expect.objectContaining({ Authorization: `Bearer ${API_KEY}` }),
|
||||
);
|
||||
const body = JSON.parse((opts as RequestInit).body as string);
|
||||
expect(body).toEqual({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("HTTP 401 — returns error containing 401", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("401");
|
||||
}
|
||||
});
|
||||
|
||||
test("HTTP 404 — returns error containing 404", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Not Found", { status: 404, statusText: "Not Found" }),
|
||||
);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("404");
|
||||
}
|
||||
});
|
||||
|
||||
test("network timeout — returns error mentioning timeout", async () => {
|
||||
const err = new DOMException("signal timed out", "AbortError");
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(err);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.toLowerCase()).toMatch(/timeout|timed out/);
|
||||
}
|
||||
});
|
||||
|
||||
test("network error (DNS/connection) — returns error mentioning connectivity", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.toLowerCase()).toMatch(/connect|reach|network/);
|
||||
}
|
||||
});
|
||||
|
||||
test("request body correctness", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
await validateModel(BASE_URL, API_KEY, "my-special-model");
|
||||
|
||||
const body = JSON.parse((mockFetch.mock.calls[0]![1] as RequestInit).body as string);
|
||||
expect(body).toEqual({
|
||||
model: "my-special-model",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSetup with validation", () => {
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-validate-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const setupArgs = () => ({
|
||||
provider: "testprovider",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "test-model",
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
test("includes validation result on success", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await cmdSetup(setupArgs());
|
||||
|
||||
expect(result.validation).toEqual({ ok: true, value: undefined });
|
||||
// Config files should still be written
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
|
||||
test("includes validation failure — config still saved", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
const result = await cmdSetup(setupArgs());
|
||||
|
||||
expect(result.validation).toBeDefined();
|
||||
expect((result.validation as { ok: boolean }).ok).toBe(false);
|
||||
// Config files should still be written despite validation failure
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const CLI_PATH = join(import.meta.dirname, "..", "cli.js");
|
||||
|
||||
function runCli(args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
||||
try {
|
||||
const stdout = execFileSync("bun", ["run", CLI_PATH, ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, WORKFLOW_STORAGE_ROOT: "/tmp/uwf-test-nonexistent" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? "",
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("thread step --count CLI parsing", () => {
|
||||
test("--help shows -c/--count option", () => {
|
||||
const result = runCli(["thread", "step", "--help"]);
|
||||
expect(result.stdout).toContain("--count");
|
||||
expect(result.stdout).toContain("-c");
|
||||
});
|
||||
|
||||
test("description says 'one or more steps'", () => {
|
||||
const result = runCli(["thread", "step", "--help"]);
|
||||
expect(result.stdout).toContain("one or more steps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdThreadStep count logic", () => {
|
||||
test("count=0 fails with validation error", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "0"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
test("negative count fails with validation error", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "-1"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
test("non-integer count fails with validation error", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "1.5"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
test("count=1 is the default (no -c flag)", () => {
|
||||
// Without -c, it should attempt to run 1 step (failing on missing thread, not on count validation)
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
// Should NOT contain "positive integer" error — should fail on thread lookup instead
|
||||
expect(result.stderr).not.toContain("positive integer");
|
||||
});
|
||||
|
||||
test("count=3 passes validation (fails on thread lookup)", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "3"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
// Should NOT contain "positive integer" error — should fail on thread/storage lookup
|
||||
expect(result.stderr).not.toContain("positive integer");
|
||||
});
|
||||
});
|
||||
@@ -109,15 +109,21 @@ thread
|
||||
|
||||
thread
|
||||
.command("step")
|
||||
.description("Execute one step")
|
||||
.description("Execute one or more steps")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||
.option("-c, --count <number>", "Number of steps to run (default: 1)")
|
||||
.action((threadId: string, opts: { agent: string | undefined; count: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||
writeOutput(result);
|
||||
const count = opts.count !== undefined ? Number(opts.count) : 1;
|
||||
const results = await cmdThreadStep(storageRoot, threadId, agentOverride, count);
|
||||
if (results.length === 1) {
|
||||
writeOutput(results[0]);
|
||||
} else {
|
||||
writeOutput(results);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,45 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Send a minimal chat completion request to verify the model is reachable.
|
||||
* Returns ok on 2xx, error with reason string otherwise.
|
||||
*/
|
||||
export async function validateModel(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
): Promise<Result<void, string>> {
|
||||
try {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return { ok: false, error: "Request timed out — model endpoint unreachable" };
|
||||
}
|
||||
return { ok: false, error: `Network error — could not reach endpoint (${String(err)})` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
||||
* Keep in sync with providers.yaml in cli-workflow.
|
||||
@@ -163,12 +199,16 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
||||
envData[envName] = args.apiKey;
|
||||
saveEnvFile(envPath, envData);
|
||||
|
||||
// Validate model connectivity
|
||||
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
defaultAgent: merged.defaultAgent,
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -328,7 +368,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
await cmdSetup({
|
||||
const setupResult = await cmdSetup({
|
||||
provider: providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -336,6 +376,19 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
// Show validation result
|
||||
if (setupResult.validation && typeof setupResult.validation === "object") {
|
||||
const v = setupResult.validation as { ok: boolean; error?: string };
|
||||
if (v.ok) {
|
||||
console.log("✓ Model verified — connection successful.\n");
|
||||
} else {
|
||||
console.log(`\n⚠ Warning: Could not reach model — ${v.error}`);
|
||||
console.log(
|
||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Setup complete! Get started:\n");
|
||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||
|
||||
@@ -200,7 +200,6 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
done: false,
|
||||
sessionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +210,6 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
done: true,
|
||||
sessionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -626,12 +624,7 @@ function resolveAgentConfig(
|
||||
return agentConfig;
|
||||
}
|
||||
|
||||
type SpawnAgentResult = {
|
||||
stepHash: CasRef;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): SpawnAgentResult {
|
||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||
const argv = [...agent.args, threadId, role];
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -653,24 +646,10 @@ function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): Spawn
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
|
||||
// Try JSON output first (new protocol)
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
const stepHash = parsed.stepHash;
|
||||
const sessionId = parsed.sessionId;
|
||||
if (typeof stepHash === "string" && isCasRef(stepHash) && typeof sessionId === "string") {
|
||||
return { stepHash, sessionId };
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to legacy CAS hash parsing
|
||||
}
|
||||
|
||||
// Legacy: plain CAS hash on stdout
|
||||
if (!isCasRef(line)) {
|
||||
fail(`agent stdout is not a valid CAS hash or JSON: ${line || "(empty)"}`);
|
||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
}
|
||||
return { stepHash: line, sessionId: null };
|
||||
return line;
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
@@ -694,6 +673,27 @@ export async function cmdThreadStep(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
count: number,
|
||||
): Promise<StepOutput[]> {
|
||||
if (count < 1 || !Number.isInteger(count)) {
|
||||
fail(`--count must be a positive integer, got: ${count}`);
|
||||
}
|
||||
|
||||
const results: StepOutput[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride);
|
||||
results.push(result);
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function cmdThreadStepOnce(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
@@ -719,7 +719,6 @@ export async function cmdThreadStep(
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
done: true,
|
||||
sessionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -728,7 +727,7 @@ export async function cmdThreadStep(
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const { stepHash: newHead, sessionId } = spawnAgent(agent, threadId, role);
|
||||
const newHead = spawnAgent(agent, threadId, role);
|
||||
|
||||
// Re-create store to pick up nodes written by the agent subprocess
|
||||
const uwfAfter = await createUwfStore(storageRoot);
|
||||
@@ -759,7 +758,6 @@ export async function cmdThreadStep(
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
done,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,32 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("buildOutputFormatInstruction", () => {
|
||||
test("always includes the frontmatter example block", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("---");
|
||||
expect(result).toContain("status: done");
|
||||
expect(result).toContain("confidence:");
|
||||
expect(result).toContain("scope: role");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("scope: role");
|
||||
});
|
||||
|
||||
test("always marks frontmatter as the primary deliverable", () => {
|
||||
@@ -16,17 +35,36 @@ describe("buildOutputFormatInstruction", () => {
|
||||
expect(result).toContain("primary deliverable");
|
||||
});
|
||||
|
||||
test("lists fields from a flat object schema", () => {
|
||||
test("generates planner-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||
expect(result).toContain("status: ready # required | ready | insufficient_info");
|
||||
expect(result).toContain("plan: <string>");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("artifacts:");
|
||||
});
|
||||
|
||||
test("generates reviewer-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(REVIEWER_SCHEMA);
|
||||
expect(result).toContain("approved: true # required | true | false");
|
||||
expect(result).not.toContain("status:");
|
||||
});
|
||||
|
||||
test("lists fields from a flat object schema with required marker", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string" },
|
||||
confidence: { type: "number" },
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`status`");
|
||||
expect(result).toContain("`status` (required)");
|
||||
expect(result).toContain("`confidence`");
|
||||
expect(result).not.toContain("`confidence` (required)");
|
||||
expect(result).toContain("status: <string> # required");
|
||||
expect(result).toContain("confidence: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from an anyOf schema", () => {
|
||||
@@ -45,6 +83,8 @@ describe("buildOutputFormatInstruction", () => {
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`alpha`");
|
||||
expect(result).toContain("`beta`");
|
||||
expect(result).toContain("alpha: <string>");
|
||||
expect(result).toContain("beta: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from a oneOf schema", () => {
|
||||
@@ -63,6 +103,8 @@ describe("buildOutputFormatInstruction", () => {
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`foo`");
|
||||
expect(result).toContain("`bar`");
|
||||
expect(result).toContain("foo: <string>");
|
||||
expect(result).toContain("bar: true # true | false");
|
||||
});
|
||||
|
||||
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||
@@ -80,6 +122,45 @@ describe("buildOutputFormatInstruction", () => {
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
const matches = [...result.matchAll(/`shared`/g)];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(result).toContain("shared: <string>");
|
||||
});
|
||||
|
||||
test("marks required when any union variant requires the field", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { shared: { type: "string" } },
|
||||
required: ["shared"],
|
||||
},
|
||||
{ type: "object", properties: { shared: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`shared` (required)");
|
||||
expect(result).toContain("shared: <string> # required");
|
||||
});
|
||||
|
||||
test("explicitly forbids extra frontmatter fields", () => {
|
||||
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||
expect(result).toMatch(/\b(only|exclusively)\b.*fields/i);
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("forbids extra fields even for empty schema", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("forbids extra fields for anyOf/oneOf schemas", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "object", properties: { alpha: { type: "string" } } },
|
||||
{ type: "object", properties: { beta: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("includes focus reminder about role scope", () => {
|
||||
|
||||
@@ -29,6 +29,27 @@ const STRICT_SCHEMA = {
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (reviewer) — only approved, no standard agent fields. */
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (planner) — custom status enum + plan hash. */
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
async function makeStoreWithSchema(schema: Record<string, unknown>) {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
@@ -134,3 +155,48 @@ describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Role-specific schema fields ───────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — role-specific fields", () => {
|
||||
test("extracts approved only for reviewer schema (no extra standard fields)", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\napproved: true\n---\n\nReview passed.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload).toEqual({ approved: true });
|
||||
expect(payload.status).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("extracts plan and role-specific status for planner schema", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(PLANNER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: ready\nplan: 01HASHPLANNER0001\n---\n\nSpec summary.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("ready");
|
||||
expect(payload.plan).toBe("01HASHPLANNER0001");
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns null when required role-specific field is missing", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
type SchemaProperty = {
|
||||
name: string;
|
||||
schema: JSONSchema;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract top-level property names from a JSON Schema object.
|
||||
*
|
||||
@@ -9,9 +15,44 @@ import type { JSONSchema } from "@uncaged/json-cas";
|
||||
*
|
||||
* Returns an empty array for schemas with no inspectable property definitions.
|
||||
*/
|
||||
function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
export function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
return extractSchemaProperties(schema).map((p) => p.name);
|
||||
}
|
||||
|
||||
function extractSchemaProperties(schema: JSONSchema): SchemaProperty[] {
|
||||
const objectSchemas = collectObjectSchemas(schema);
|
||||
if (objectSchemas.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byName = new Map<string, SchemaProperty>();
|
||||
|
||||
for (const objectSchema of objectSchemas) {
|
||||
const requiredSet = new Set(
|
||||
Array.isArray(objectSchema.required) ? (objectSchema.required as string[]) : [],
|
||||
);
|
||||
const properties = objectSchema.properties as Record<string, JSONSchema> | null | undefined;
|
||||
if (typeof properties !== "object" || properties === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [name, propSchema] of Object.entries(properties)) {
|
||||
const required = requiredSet.has(name);
|
||||
const existing = byName.get(name);
|
||||
if (existing === undefined) {
|
||||
byName.set(name, { name, schema: propSchema, required });
|
||||
} else if (required) {
|
||||
byName.set(name, { ...existing, required: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function collectObjectSchemas(schema: JSONSchema): JSONSchema[] {
|
||||
if (typeof schema.properties === "object" && schema.properties !== null) {
|
||||
return Object.keys(schema.properties as Record<string, unknown>);
|
||||
return [schema];
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(schema.anyOf)
|
||||
@@ -20,18 +61,109 @@ function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
? "oneOf"
|
||||
: null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const fieldSet = new Set<string>();
|
||||
for (const variant of variants) {
|
||||
for (const field of extractSchemaFields(variant)) {
|
||||
fieldSet.add(field);
|
||||
}
|
||||
}
|
||||
return [...fieldSet];
|
||||
if (unionKey === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const result: JSONSchema[] = [];
|
||||
for (const variant of variants) {
|
||||
result.push(...collectObjectSchemas(variant));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolvePropertySchema(prop: JSONSchema): JSONSchema {
|
||||
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(prop.anyOf) ? "anyOf" : Array.isArray(prop.oneOf) ? "oneOf" : null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = prop[unionKey] as JSONSchema[];
|
||||
const nonNull = variants.filter((v) => v.type !== "null");
|
||||
if (nonNull.length === 1) {
|
||||
return nonNull[0];
|
||||
}
|
||||
}
|
||||
|
||||
return prop;
|
||||
}
|
||||
|
||||
function formatYamlScalar(value: unknown): string {
|
||||
if (typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildPropertyComment(parts: string[]): string {
|
||||
const filtered = parts.filter((p) => p.length > 0);
|
||||
return filtered.length > 0 ? ` # ${filtered.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function buildPropertyExampleLine(prop: SchemaProperty): string {
|
||||
const resolved = resolvePropertySchema(prop.schema);
|
||||
const commentParts: string[] = [];
|
||||
if (prop.required) {
|
||||
commentParts.push("required");
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
|
||||
const enumValues = resolved.enum.map((v) => String(v));
|
||||
commentParts.push(...enumValues);
|
||||
const first = resolved.enum[0];
|
||||
return `${prop.name}: ${formatYamlScalar(first)}${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "boolean") {
|
||||
commentParts.push("true", "false");
|
||||
return `${prop.name}: true${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "string") {
|
||||
return `${prop.name}: <string>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "number" || resolved.type === "integer") {
|
||||
return `${prop.name}: <number>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "array") {
|
||||
return `${prop.name}:\n - <item>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "object") {
|
||||
return `${prop.name}: <object>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
return `${prop.name}: <value>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
function buildYamlExampleBlock(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return "---\n\n... your markdown work here ...";
|
||||
}
|
||||
|
||||
const lines = properties.map((p) => buildPropertyExampleLine(p));
|
||||
return `---\n${lines.join("\n")}\n---\n\n... your markdown work here ...`;
|
||||
}
|
||||
|
||||
function buildFieldList(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return " (schema fields will be extracted automatically)";
|
||||
}
|
||||
|
||||
return properties
|
||||
.map((p) => {
|
||||
const suffix = p.required ? " (required)" : "";
|
||||
return ` - \`${p.name}\`${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,28 +174,16 @@ function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
* system prompt so the deliverable format is the first thing the agent sees.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||
const fields = extractSchemaFields(schema);
|
||||
|
||||
const fieldList =
|
||||
fields.length > 0
|
||||
? fields.map((f) => ` - \`${f}\``).join("\n")
|
||||
: " (schema fields will be extracted automatically)";
|
||||
const properties = extractSchemaProperties(schema);
|
||||
const yamlExample = buildYamlExampleBlock(properties);
|
||||
const fieldList = buildFieldList(properties);
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||
|
||||
\`\`\`
|
||||
---
|
||||
status: done # done | needs_input | in_progress | failed
|
||||
next: <role-name> # suggested next role, or omit
|
||||
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
|
||||
artifacts: # list of file paths or CAS hashes you produced
|
||||
- path/to/file.ts
|
||||
scope: role # role | thread
|
||||
---
|
||||
|
||||
... your markdown work here ...
|
||||
${yamlExample}
|
||||
\`\`\`
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
@@ -71,5 +191,7 @@ Your meta output must satisfy these fields:
|
||||
|
||||
${fieldList}
|
||||
|
||||
Output ONLY the fields listed above. Do not add extra fields that are not specified in the schema.
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,139 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef } from "@uncaged/workflow-protocol";
|
||||
import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util";
|
||||
import {
|
||||
type AgentFrontmatter,
|
||||
createLogger,
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "@uncaged/workflow-util";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { extractSchemaFields } from "./build-output-format-instruction.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
const STANDARD_KEYS = ["status", "next", "confidence", "artifacts", "scope"] as const;
|
||||
|
||||
type StandardKey = (typeof STANDARD_KEYS)[number];
|
||||
|
||||
export type FrontmatterFastPathResult = {
|
||||
body: string;
|
||||
outputHash: CasRef;
|
||||
};
|
||||
|
||||
function extractYamlBlock(raw: string): string | null {
|
||||
const fence = "---";
|
||||
if (!raw.startsWith(fence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = raw.slice(fence.length);
|
||||
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
|
||||
const closeIndex = afterOpen.indexOf(`\n${fence}`);
|
||||
if (closeIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return afterOpen.slice(0, closeIndex);
|
||||
}
|
||||
|
||||
function parseRawFrontmatterFields(raw: string): Record<string, unknown> {
|
||||
const yamlText = extractYamlBlock(raw);
|
||||
if (yamlText === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseYaml(yamlText);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
|
||||
return {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: [...frontmatter.artifacts],
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unknown {
|
||||
switch (key) {
|
||||
case "status":
|
||||
return frontmatter.status;
|
||||
case "next":
|
||||
return frontmatter.next;
|
||||
case "confidence":
|
||||
return frontmatter.confidence;
|
||||
case "artifacts":
|
||||
return [...frontmatter.artifacts];
|
||||
case "scope":
|
||||
return frontmatter.scope;
|
||||
}
|
||||
}
|
||||
|
||||
function isStandardKey(key: string): key is StandardKey {
|
||||
return (STANDARD_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function pickFieldValue(
|
||||
field: string,
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
): unknown | undefined {
|
||||
if (!isStandardKey(field)) {
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : undefined;
|
||||
}
|
||||
|
||||
const coerced = pickStandardField(frontmatter, field);
|
||||
if (field === "artifacts" || field === "scope") {
|
||||
return coerced;
|
||||
}
|
||||
if (coerced !== null) {
|
||||
return coerced;
|
||||
}
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : coerced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CAS candidate object from schema property keys and parsed frontmatter.
|
||||
*
|
||||
* When the schema has no inspectable properties, falls back to the five standard
|
||||
* agent frontmatter fields for backward compatibility.
|
||||
*/
|
||||
function buildCandidate(
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
schemaFields: string[],
|
||||
): Record<string, unknown> {
|
||||
if (schemaFields.length === 0) {
|
||||
return defaultCandidate(frontmatter);
|
||||
}
|
||||
|
||||
const candidate: Record<string, unknown> = {};
|
||||
|
||||
for (const field of schemaFields) {
|
||||
const value = pickFieldValue(field, frontmatter, rawFields);
|
||||
if (value !== undefined) {
|
||||
candidate[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to satisfy `outputSchema` from frontmatter fields alone.
|
||||
*
|
||||
@@ -32,16 +158,22 @@ export async function tryFrontmatterFastPath(
|
||||
|
||||
const validationErrors = validateFrontmatter(frontmatter);
|
||||
if (validationErrors.length > 0) {
|
||||
log(
|
||||
"9GNPS4WY",
|
||||
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate: Record<string, unknown> = {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: [...frontmatter.artifacts],
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
const schema = getSchema(store, outputSchema);
|
||||
if (schema === null) {
|
||||
log("8FHMR2QX", `output schema not found in CAS: ${outputSchema}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaFields = extractSchemaFields(schema);
|
||||
const rawFields = parseRawFrontmatterFields(raw);
|
||||
const candidate = buildCandidate(frontmatter, rawFields, schemaFields);
|
||||
|
||||
let outputHash: CasRef;
|
||||
let node: ReturnType<Store["get"]>;
|
||||
@@ -50,10 +182,12 @@ export async function tryFrontmatterFastPath(
|
||||
outputHash = await store.put(outputSchema, candidate);
|
||||
node = store.get(outputHash);
|
||||
} catch {
|
||||
log("2KMQT7NR", "failed to store frontmatter candidate in CAS");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node === null || !validate(store, node)) {
|
||||
log("2KMQT7NR", "stored frontmatter candidate failed schema validation");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,19 +98,6 @@ async function persistStep(options: {
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentCliOutput = {
|
||||
stepHash: CasRef;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an agent CLI entrypoint.
|
||||
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
|
||||
* writes StepNode to CAS, and prints JSON result to stdout.
|
||||
*
|
||||
* If frontmatter extraction fails, retries up to MAX_FRONTMATTER_RETRIES times
|
||||
* by calling agent.continue() with a correction message.
|
||||
*/
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
@@ -161,7 +148,6 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
agentName: agentLabel(options.name),
|
||||
});
|
||||
|
||||
const result: AgentCliOutput = { stepHash, sessionId: agentResult.sessionId };
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
process.stdout.write(`${stepHash}\n`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
|
||||
# Workflow UI — 开发上下文文档
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
workflow-dashboard 是一个 Web 图形编辑器,用于可视化展示和编辑工作流(Workflow)的结构。
|
||||
|
||||
**核心场景**:
|
||||
- 用户本地执行 `uwf connect` 命令,通过 WebSocket 连接到此 Web 服务
|
||||
- CLI 将本地 YAML 工作流文件发送到 server
|
||||
- Server 解析后,提供图形化界面展示工作流的节点拓扑,允许用户进行逻辑编排和节点编辑
|
||||
- 编辑完成后,数据可回传给 CLI 或持久化
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|---|------|------|
|
||||
| 图编辑器 | @xyflow/react v12 | 节点/边渲染、拖拽、连线(strict 连接模式) |
|
||||
| 前端框架 | React 19 | UI 组件 |
|
||||
| 路由 | react-router v7 | Hash 模式路由 |
|
||||
| 状态管理 | 自研 (context.tsx) | 基于 useSyncExternalStore + Immer |
|
||||
| 样式 | Tailwind CSS v4 | 原子化 CSS |
|
||||
| 图标 | lucide-react | 图标库 |
|
||||
| 构建工具 | Vite 8 | Dev server + 打包 |
|
||||
| 后端框架 | Elysia | 轻量 REST API(当前为 stub) |
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server.ts # Vite dev server 入口 (port 3000)
|
||||
├── vite.config.ts # Vite 配置(react + tailwind + elysia 插件 + @ 别名)
|
||||
├── vite-dev.ts # 自定义 Vite 插件
|
||||
├── components.json # shadcn 配置
|
||||
├── server/
|
||||
│ ├── api.ts # Elysia REST API (health + workflow CRUD)
|
||||
│ └── workflow.ts # Workflow 文件读写 + 格式转换
|
||||
├── tmp/workflow/ # Workflow YAML 存储目录(开发阶段)
|
||||
├── src/
|
||||
│ ├── main.tsx # React DOM 入口
|
||||
│ ├── router.tsx # React Router 配置
|
||||
│ ├── app.tsx # 根布局组件
|
||||
│ ├── lib/utils.ts # Tailwind cn() 工具
|
||||
│ ├── components/ui/ # shadcn 组件(button, card, dialog, input, textarea)
|
||||
│ ├── pages/
|
||||
│ │ ├── home.tsx # Home 列表页(workflow 管理)
|
||||
│ │ └── detail.tsx # Workflow 详情/编辑页
|
||||
│ └── editor/ # ★ 核心编辑器
|
||||
│ ├── flow.tsx # FlowEditor 组件 + 公开 API 导出
|
||||
│ ├── type.ts # 内部类型定义
|
||||
│ ├── context.tsx # 自研状态管理框架
|
||||
│ ├── injection.ts # DI 容器(FlowModel / Injection)
|
||||
│ ├── model/ # 状态模型层
|
||||
│ ├── nodes/ # 节点渲染组件
|
||||
│ ├── edges/ # 边渲染组件
|
||||
│ ├── panel/ # UI 面板(工具栏、添加/编辑面板)
|
||||
│ ├── trans/ # 数据转换层(内外格式互转)
|
||||
│ ├── layout/ # 自动布局算法
|
||||
│ └── utils/ # 工具函数
|
||||
```
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 外部格式 — WorkFlowSteps(与 CLI 交换的数据)
|
||||
|
||||
`WorkFlowSteps` 是 `WorkFlowStep[]`,每个 step 描述一个角色节点及其转移关系:
|
||||
|
||||
```typescript
|
||||
type WorkFlowRole = {
|
||||
name: string; // 角色名称(唯一标识)
|
||||
description: string; // 角色描述
|
||||
identity: string; // 身份定义(system prompt)
|
||||
prepare: string; // 执行前准备指令
|
||||
execute: string; // 核心执行指令
|
||||
report: string; // 输出格式指令
|
||||
};
|
||||
|
||||
type WorkFlowTransition = {
|
||||
target: string; // 目标角色名 或 'END'
|
||||
condition: string | null; // 条件表达式,null 为 else(无条件兜底)
|
||||
};
|
||||
|
||||
type WorkFlowStep = {
|
||||
role: WorkFlowRole;
|
||||
transitions: WorkFlowTransition[];
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 内部格式 — ReactFlow Nodes & Edges
|
||||
|
||||
编辑器内部使用 ReactFlow 的 Node/Edge 模型:
|
||||
|
||||
**节点类型**:
|
||||
- `start` → 起始节点(右侧 1 个 source handle)
|
||||
- `end` → 结束节点(左侧 1 个 target handle)
|
||||
- `role` → 角色节点(6 个 handle,见下方)
|
||||
|
||||
**Role 节点 Handle 布局**:
|
||||
|
||||
| 位置 | 类型 | ID | 颜色 |
|
||||
|------|------|----|------|
|
||||
| 左侧 | target (in) | `input` | 蓝色 |
|
||||
| 上方 30% | target (in) | `input-top` | 蓝色 |
|
||||
| 下方 30% | target (in) | `input-bottom` | 蓝色 |
|
||||
| 右侧 | source (out) | `output` | 绿色 |
|
||||
| 上方 70% | source (out) | `output-top` | 绿色 |
|
||||
| 下方 70% | source (out) | `output-bottom` | 绿色 |
|
||||
|
||||
- target handle 设置了 `isConnectableStart`,可以从 in 拖向 out 发起连线(`onConnect` 自动纠正方向)
|
||||
- source handle 设置了 `isConnectableEnd`
|
||||
|
||||
**RoleNodeData** 对齐上游 `RoleDefinition`:
|
||||
```typescript
|
||||
type RoleNodeData = {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
```
|
||||
|
||||
**边类型**:
|
||||
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
||||
- `conditional`(ConditionalEdge)→ 带条件标签的渐变色边,节点有多条出边时使用
|
||||
|
||||
**边渲染特性**:
|
||||
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
||||
- 选中时:变为琥珀色(#f59e0b)单色,方便识别
|
||||
- 缺少条件时:红色(#ff5252)
|
||||
- 交互区域:20px 宽透明路径用于点击
|
||||
|
||||
### 4.3 Else 分支机制
|
||||
|
||||
当一个节点有多条 conditional 出边时:
|
||||
- **edges 数组中排第一个的 conditional 边自动成为 else**(兜底分支)
|
||||
- else 边显示灰色 `else` badge(不可点击,无需设置条件)
|
||||
- 其余边显示 `if` badge(需要设置条件,可点击编辑)
|
||||
- 只有一条 conditional 出边时不显示 else 标签
|
||||
- else 边在有 if 兄弟存在时不能被删除(`onBeforeDelete` 保护)
|
||||
- 序列化时 else 边输出 `condition: null`
|
||||
- 反序列化时 `condition: null` 的 transition 排序到第一个
|
||||
|
||||
### 4.4 条件边自动升级与降级
|
||||
|
||||
- **升级**:当用户从某节点拖出第二条边时,`edgesModel.onConnect` 自动将该节点所有出边升级为 `conditional` 类型。
|
||||
- **降级**:当删除 conditional 边后,若该 source 仅剩一条 conditional 出边,`handlers.onDelete` 自动将其降级回 `default` 类型。
|
||||
|
||||
### 4.5 连线约束
|
||||
|
||||
`onConnect` 中的校验逻辑:
|
||||
1. 禁止自连(source === target)
|
||||
2. 禁止同一对节点之间的重复边(source+target 去重)
|
||||
3. 方向归一化:从 input handle 拖到 output handle 时自动反转 source/target
|
||||
4. Handle 类型校验:source 端必须是 output handle,target 端必须是 input handle
|
||||
|
||||
### 4.6 数据转换层(trans/)
|
||||
|
||||
```
|
||||
WorkFlowSteps ──transIn()──→ { nodes, edges } ──transOut()──→ WorkFlowSteps
|
||||
(反序列化) (序列化)
|
||||
```
|
||||
|
||||
- `transIn(steps)`: 外部步骤列表 → ReactFlow 节点和边
|
||||
- `transOut(nodes, edges)`: ReactFlow 节点和边 → 外部步骤列表
|
||||
- `validate(nodes, edges)`: 校验图结构合法性
|
||||
|
||||
三个函数都是**纯函数**。
|
||||
|
||||
### 4.7 验证规则
|
||||
|
||||
1. start 恰好 1 个,输出恰好 1 条
|
||||
2. end 恰好 1 个,输入 ≥1 条,输出 0 条
|
||||
3. role 节点:输入 ≥1、输出 ≥1
|
||||
4. 多输出时:第一条 conditional 边为 else(跳过 condition 检查),其余必须有非空 condition
|
||||
5. role 节点总数 ≥2
|
||||
6. 无孤立节点(正向 BFS 从 start 可达 + 反向 BFS 从 end 可达)
|
||||
|
||||
## 5. 架构分层
|
||||
|
||||
### 5.1 状态管理框架(context.tsx)
|
||||
|
||||
自研的轻量响应式系统,核心概念:
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| `generate<T>()` | 创建响应式 store(get/set/use/listen) |
|
||||
| `SubModel<T, A>` | 状态切片模板(name + make() + create()) |
|
||||
| `Model` | 事务管理器 + undo/redo 栈 |
|
||||
| `define.model()` | 定义有状态有 actions 的模型 |
|
||||
| `define.view()` | 定义只读视图模型 |
|
||||
| `define.memoize()` | 定义缓存计算模型 |
|
||||
| `define.compute()` | 定义响应式依赖计算(自动追踪) |
|
||||
|
||||
使用 `useSyncExternalStore` 桥接 React 渲染。
|
||||
|
||||
### 5.2 模型层(model/)
|
||||
|
||||
| 模型 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| `nodesModel` | nodes.ts | 节点数组状态 + CRUD 操作 |
|
||||
| `edgesModel` | edges.ts | 边数组状态 + 连线 + conditional 自动升级 + 连线约束 |
|
||||
| `addNodeViewModel` | add-node-view.ts | 添加节点面板的 UI 状态 |
|
||||
| `editNodeViewModel` | edit-node-view.ts | 编辑节点面板的 UI 状态 |
|
||||
| `injection` | inject.ts | DI 实例视图模型 |
|
||||
| `handlers` | handlers.ts | 事件处理器集合(拖拽、连线、删除保护、快捷键、布局、加载/保存) |
|
||||
|
||||
### 5.3 DI 容器(injection.ts)
|
||||
|
||||
```
|
||||
FlowModel(公开 API) Injection(内部实现)
|
||||
├─ load(steps) ──emit──→ emit('load', steps) → handlers.loadSteps()
|
||||
├─ on('save', cb) emit('save', steps) ← handlers.saveData()
|
||||
└─ 持有 Injection 实例
|
||||
```
|
||||
|
||||
- `FlowModel` 是外部消费者唯一接触的类,提供 `load()` 和 `on('save')` 接口
|
||||
- 构造函数接受可选的 `inital_steps` 参数,用于加载默认工作流
|
||||
- `Injection` 是内部事件总线,解耦 server 通信与 UI 状态
|
||||
|
||||
### 5.4 事务与 Undo/Redo
|
||||
|
||||
Model 提供事务机制:
|
||||
- `startTransaction()` 快照当前状态
|
||||
- `endTransaction()` 将快照推入 undo 栈
|
||||
- Ctrl+Z / Ctrl+Y 触发撤销/重做
|
||||
- 拖拽、添加节点、删除等操作自动包裹在事务中
|
||||
|
||||
## 6. 节点体系
|
||||
|
||||
### 6.1 渲染组件
|
||||
|
||||
```
|
||||
ReactFlow
|
||||
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
||||
└─ edgeTypes: { default: GradientEdge, conditional: ConditionalEdge }
|
||||
```
|
||||
|
||||
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
||||
|
||||
### 6.2 节点编辑
|
||||
|
||||
角色节点的编辑器直接内联在 AddNodePanel 和 EditNodePanel 中,可编辑字段:
|
||||
- name(必填)
|
||||
- description、identity、prepare、execute、report(textarea)
|
||||
|
||||
## 7. UI 面板
|
||||
|
||||
| 面板 | 位置 | 内容 |
|
||||
|------|------|------|
|
||||
| Toolbar | 顶部居中 | Undo/Redo、添加角色、自动布局、保存 |
|
||||
| AddNodePanel | 右下角 | 角色节点创建表单(name + 6 字段 → 确认) |
|
||||
| EditNodePanel | 右下角 | 角色节点编辑表单(预填当前数据 → 确认) |
|
||||
|
||||
AddNodePanel 和 EditNodePanel 互斥显示,点击外部自动关闭。
|
||||
|
||||
## 8. 自动布局(layout/)
|
||||
|
||||
`LayoutLR(nodes, edges)` 算法:
|
||||
1. 拓扑排序分层(BFS,start → layer 0,end → max+1)
|
||||
2. 按层分组
|
||||
3. 计算 X/Y 坐标(水平间距 80px,垂直间距 40px)
|
||||
4. 无变化时返回原数组(避免无效重渲染)
|
||||
|
||||
## 9. 核心数据流
|
||||
|
||||
### 加载工作流
|
||||
|
||||
```
|
||||
FlowModel.load(steps) / FlowModel(initialSteps)
|
||||
→ Injection.emit('load', steps)
|
||||
→ handlers.loadSteps()
|
||||
→ transIn(steps) → { nodes, edges }
|
||||
(condition: null 的 transition 排序到第一个,成为 else)
|
||||
→ nodesModel.set(nodes)
|
||||
→ edgesModel.set(edges)
|
||||
→ autoLayoutLR()
|
||||
→ model.reset()(清空 undo/redo)
|
||||
```
|
||||
|
||||
### 保存工作流
|
||||
|
||||
```
|
||||
用户点击 Save
|
||||
→ handlers.saveData()
|
||||
→ validate(nodes, edges)
|
||||
→ 校验失败 → Toast 提示错误
|
||||
→ 校验通过 → transOut(nodes, edges) → WorkFlowSteps
|
||||
(第一条 conditional 边序列化为 condition: null)
|
||||
→ Injection.emit('save', steps)
|
||||
→ FlowModel.emit('save', steps)
|
||||
→ 外部消费者(server/CLI)接收
|
||||
```
|
||||
|
||||
### 连线与条件边升级
|
||||
|
||||
```
|
||||
用户拖线连接两个节点
|
||||
→ edgesModel.onConnect(params)
|
||||
→ normalizeConnection(方向纠正)
|
||||
→ 校验(自连、重复、handle 类型)
|
||||
→ 检查 source 已有出边数量
|
||||
→ 已有出边 → 新边 + 已有边全部升级为 conditional
|
||||
→ 首条出边 → 创建普通边
|
||||
```
|
||||
|
||||
### 删除保护
|
||||
|
||||
```
|
||||
用户选中节点/边按 Delete
|
||||
→ handlers.onBeforeDelete({ nodes, edges })
|
||||
→ start/end 节点 → 阻止
|
||||
→ else 边(有 if 兄弟时)→ 阻止
|
||||
→ 其他 → 允许
|
||||
```
|
||||
|
||||
## 10. 上游数据模型参考
|
||||
|
||||
workflow-dashboard 消费的 YAML 工作流最终映射自 `WorkflowPayload`(定义在 workflow-protocol):
|
||||
|
||||
```typescript
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
||||
conditions: Record<string, ConditionDefinition>; // JSONata 条件表达式
|
||||
graph: Record<string, Transition[]>; // 角色间的转移图
|
||||
};
|
||||
```
|
||||
|
||||
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Transition`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
||||
|
||||
## 11. 当前状态与待完善项
|
||||
|
||||
- **WebSocket 集成**: 尚未实现,CLI connect 的 WebSocket 通信待开发
|
||||
- **验证**: 图结构校验 + 可达性检测 + else 分支规则已实现
|
||||
- **只读模式**: Detail 页面有"编辑/预览"切换按钮,但编辑器尚未实现真正的只读模式(禁止交互)
|
||||
|
||||
## 12. 业务系统
|
||||
|
||||
### 12.1 路由
|
||||
|
||||
| 路由 | 页面 | 文件 |
|
||||
|------|------|------|
|
||||
| `/` | Home — Workflow 列表 | `src/pages/home.tsx` |
|
||||
| `/workflow/:name` | Detail — 预览/编辑 | `src/pages/detail.tsx` |
|
||||
|
||||
### 12.2 后端 API
|
||||
|
||||
Elysia REST API(`server/api.ts`),通过 Vite 插件(`vite-dev.ts`)集成到 dev server。
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/workflows` | 列出所有 workflow(name + description) |
|
||||
| GET | `/api/workflows/:name` | 获取单个 workflow(返回 WorkFlowSteps JSON) |
|
||||
| POST | `/api/workflows` | 新建 workflow(body: `{name, description}`) |
|
||||
| PUT | `/api/workflows/:name` | 保存 workflow(body: WorkFlowSteps JSON) |
|
||||
| DELETE | `/api/workflows/:name` | 删除 workflow |
|
||||
|
||||
### 12.3 数据存储
|
||||
|
||||
- 存储目录:`tmp/workflow/`,文件名 `{name}.yaml`
|
||||
- 存储格式:WorkflowPayload YAML(与上游 workflow-protocol 一致)
|
||||
- Server 端负责 WorkflowPayload ↔ WorkFlowSteps 转换(`server/workflow.ts`)
|
||||
|
||||
字段映射:
|
||||
| WorkFlowRole | RoleDefinition |
|
||||
|--------------|---------------|
|
||||
| name | roles map key |
|
||||
| description | description |
|
||||
| identity | goal |
|
||||
| prepare | capabilities (join/split by `\n`) |
|
||||
| execute | procedure |
|
||||
| report | output |
|
||||
|
||||
条件映射:WorkFlowTransition.condition 存储表达式字符串,保存时提取为 named conditions map。
|
||||
|
||||
### 12.4 shadcn/ui
|
||||
|
||||
已初始化 shadcn(`components.json`),使用 `@` 路径别名。已安装组件:
|
||||
- button、card、dialog、input、textarea
|
||||
- 组件位于 `src/components/ui/`
|
||||
|
||||
### 12.5 目录结构更新
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server/
|
||||
│ ├── api.ts # Elysia REST API(health + workflow CRUD)
|
||||
│ └── workflow.ts # Workflow 文件读写 + 格式转换
|
||||
├── src/
|
||||
│ ├── components/ui/ # shadcn 组件
|
||||
│ ├── pages/
|
||||
│ │ ├── home.tsx # Home 列表页
|
||||
│ │ └── detail.tsx # Workflow 详情/编辑页
|
||||
│ └── ...
|
||||
├── tmp/workflow/ # Workflow YAML 存储目录(开发阶段)
|
||||
└── components.json # shadcn 配置
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow UI</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-dashboard",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun server.ts",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"elysia": "^1.4.28",
|
||||
"immer": "^11.1.8",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router": "^7.15.1",
|
||||
"shadcn": "^4.8.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/bun": "^1.2.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createServer } from "vite";
|
||||
|
||||
const PORT = 3000;
|
||||
|
||||
const server = await createServer({
|
||||
server: { port: PORT },
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
|
||||
// biome-ignore lint/nursery/noConsole: CLI user-facing output
|
||||
console.log(`Workflow UI running at http://localhost:${PORT}`);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { WorkFlowSteps } from "../shared/types.ts";
|
||||
import {
|
||||
listWorkflows,
|
||||
getWorkflow,
|
||||
createWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
} from "./workflow.ts";
|
||||
|
||||
export function createApi() {
|
||||
return new Elysia({ prefix: "/api" })
|
||||
.get("/health", () => ({ status: "ok" }))
|
||||
.get("/workflows", () => listWorkflows())
|
||||
.get("/workflows/:name", async ({ params }) => {
|
||||
try {
|
||||
const steps = await getWorkflow(params.name);
|
||||
return steps;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
})
|
||||
.post(
|
||||
"/workflows",
|
||||
async ({ body }) => {
|
||||
await createWorkflow(body.name, body.description);
|
||||
return { ok: true };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/workflows/:name",
|
||||
async ({ params, body }) => {
|
||||
const steps: WorkFlowSteps = typeof body === "string" ? JSON.parse(body) : body;
|
||||
await saveWorkflow(params.name, steps);
|
||||
return { ok: true };
|
||||
},
|
||||
{
|
||||
body: t.Array(
|
||||
t.Object({
|
||||
role: t.Object({
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
identity: t.String(),
|
||||
prepare: t.String(),
|
||||
execute: t.String(),
|
||||
report: t.String(),
|
||||
}),
|
||||
transitions: t.Array(
|
||||
t.Object({
|
||||
target: t.String(),
|
||||
condition: t.Union([t.String(), t.Null()]),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
.delete("/workflows/:name", async ({ params }) => {
|
||||
try {
|
||||
await deleteWorkflow(params.name);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
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");
|
||||
|
||||
async function ensureDir() {
|
||||
await mkdir(WORKFLOW_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
const conditionMap = new Map<string, string>();
|
||||
for (const [name, def] of Object.entries(payload.conditions)) {
|
||||
conditionMap.set(name, def.expression);
|
||||
}
|
||||
|
||||
const steps: WorkFlowSteps = [];
|
||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||
const graphTransitions = payload.graph[roleName] ?? [];
|
||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
||||
target: t.role === "$END" ? "END" : t.role,
|
||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
||||
}));
|
||||
|
||||
steps.push({
|
||||
role: {
|
||||
name: roleName,
|
||||
description: roleDef.description,
|
||||
identity: roleDef.goal,
|
||||
prepare: roleDef.capabilities.join("\n"),
|
||||
execute: roleDef.procedure,
|
||||
report: roleDef.output,
|
||||
},
|
||||
transitions,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
const conditions: WorkflowPayload["conditions"] = {};
|
||||
const graph: Record<string, Transition[]> = {};
|
||||
|
||||
const expressionToName = new Map<string, string>();
|
||||
let condIdx = 0;
|
||||
|
||||
for (const step of steps) {
|
||||
const r = step.role;
|
||||
roles[r.name] = {
|
||||
description: r.description,
|
||||
goal: r.identity,
|
||||
capabilities: r.prepare ? r.prepare.split("\n").filter(Boolean) : [],
|
||||
procedure: r.execute,
|
||||
output: r.report,
|
||||
frontmatter: "",
|
||||
};
|
||||
|
||||
const transitions: Transition[] = step.transitions.map((t) => {
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition)!;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
conditions[condName] = {
|
||||
description: "",
|
||||
expression: t.condition,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: t.target === "END" ? "$END" : t.target,
|
||||
condition: condName,
|
||||
};
|
||||
});
|
||||
|
||||
graph[r.name] = transitions;
|
||||
}
|
||||
|
||||
if (steps.length > 0) {
|
||||
graph["$START"] = [{ role: steps[0].role.name, condition: null }];
|
||||
}
|
||||
|
||||
return { name, description, roles, conditions, graph };
|
||||
}
|
||||
|
||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
await ensureDir();
|
||||
const files = await readdir(WORKFLOW_DIR);
|
||||
const results: WorkflowSummary[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".yaml")) continue;
|
||||
const content = await readFile(join(WORKFLOW_DIR, file), "utf-8");
|
||||
const payload = YAML.parse(content) as WorkflowPayload;
|
||||
results.push({ name: payload.name, description: payload.description });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getWorkflow(name: string): Promise<WorkFlowSteps> {
|
||||
const content = await readFile(join(WORKFLOW_DIR, `${name}.yaml`), "utf-8");
|
||||
const payload = YAML.parse(content) as WorkflowPayload;
|
||||
return payloadToSteps(payload);
|
||||
}
|
||||
|
||||
export async function createWorkflow(name: string, description: string): Promise<void> {
|
||||
await ensureDir();
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description,
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
export async function saveWorkflow(name: string, steps: WorkFlowSteps): Promise<void> {
|
||||
const filePath = join(WORKFLOW_DIR, `${name}.yaml`);
|
||||
let description = "";
|
||||
try {
|
||||
const existing = await readFile(filePath, "utf-8");
|
||||
const existingPayload = YAML.parse(existing) as WorkflowPayload;
|
||||
description = existingPayload.description;
|
||||
} catch {
|
||||
// file doesn't exist, use empty description
|
||||
}
|
||||
const payload = stepsToPayload(name, description, steps);
|
||||
await writeFile(filePath, YAML.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(name: string): Promise<void> {
|
||||
await unlink(join(WORKFLOW_DIR, `${name}.yaml`));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type WorkFlowRole = {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
|
||||
export type WorkFlowTransition = {
|
||||
target: string;
|
||||
condition: string | null;
|
||||
};
|
||||
|
||||
export type WorkFlowStep = {
|
||||
role: WorkFlowRole;
|
||||
transitions: WorkFlowTransition[];
|
||||
};
|
||||
|
||||
export type WorkFlowSteps = WorkFlowStep[];
|
||||
|
||||
export type WorkflowSummary = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export function Layout(): ReactNode {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-background text-foreground">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,283 @@
|
||||
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';
|
||||
|
||||
type Reduce<T> = (data: T) => T;
|
||||
type Setter<T> = (ch: Reduce<T> | T) => void;
|
||||
|
||||
interface State<T, A> {
|
||||
readonly get: () => T;
|
||||
readonly set: Setter<T>;
|
||||
readonly use: () => T;
|
||||
readonly listen: (cb: VoidFunction) => VoidFunction;
|
||||
readonly actions: A;
|
||||
readonly onlyView: boolean;
|
||||
}
|
||||
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
|
||||
type UseV = <T>(sub: SubModel<T, any>) => T;
|
||||
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
|
||||
|
||||
export const uuid = () => Math.round((Math.random() + 1) * Date.now()).toString(36);
|
||||
|
||||
export function generate<T>(val: T) {
|
||||
const listener = new Set<VoidFunction>();
|
||||
const get = () => val;
|
||||
function set(ch: T | ((prev: T) => T)) {
|
||||
const next = (typeof ch === 'function') ? (ch as (prev: T) => T)(val) : ch;
|
||||
if (Object.is(val, next)) return;
|
||||
val = next;
|
||||
listener.forEach(call => call());
|
||||
}
|
||||
const listen = (call: VoidFunction) => {
|
||||
listener.add(call);
|
||||
return () => listener.delete(call);
|
||||
};
|
||||
const use = () => useSyncExternalStore(listen, get, get);
|
||||
return { get, set, use, listen };
|
||||
}
|
||||
|
||||
class SubModel<T, A> {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private make: () => T,
|
||||
private create: Create<T, A>,
|
||||
private onlyView = false,
|
||||
) {}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
use(): [T, A] {
|
||||
const { query } = useContext(Context);
|
||||
const { use, actions } = query(this);
|
||||
return [use(), actions];
|
||||
}
|
||||
useData(): T {
|
||||
const { query } = useContext(Context);
|
||||
return query(this).use();
|
||||
}
|
||||
useCreation(): A {
|
||||
const { query } = useContext(Context);
|
||||
return query(this).actions;
|
||||
}
|
||||
}
|
||||
|
||||
type Snapshot = [name: string, data: any];
|
||||
class Model {
|
||||
private ustack: Snapshot[][] = [];
|
||||
private rstack: Snapshot[][] = [];
|
||||
private transaction = 0;
|
||||
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,
|
||||
) {}
|
||||
|
||||
public reset() {
|
||||
this.ustack = [];
|
||||
this.rstack = [];
|
||||
this.transaction = 0;
|
||||
this.backup.clear();
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public readonly listenStackState = (cb: () => void) => {
|
||||
this.stackListeners.add(cb);
|
||||
return () => this.stackListeners.delete(cb);
|
||||
}
|
||||
|
||||
private triggerStackState() {
|
||||
// @ts-expect-error
|
||||
this.stackState = [this.canUndo(), this.canRedo()];
|
||||
this.stackListeners.forEach(call => call());
|
||||
}
|
||||
|
||||
private getStackState = () => this.stackState;
|
||||
public useStackState() {
|
||||
const get = this.getStackState;
|
||||
return useSyncExternalStore(this.listenStackState, get, get);
|
||||
}
|
||||
|
||||
public log() {
|
||||
console.log('undo stack:', this.ustack);
|
||||
console.log('redo stack:', this.rstack);
|
||||
const snapshots: Record<string, any> = {};
|
||||
this.store.forEach((state, name) => {
|
||||
snapshots[name] = state.get();
|
||||
});
|
||||
console.log('current state:', snapshots);
|
||||
}
|
||||
|
||||
public undo() {
|
||||
const { ustack, rstack, store } = this;
|
||||
const item = ustack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
rstack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public redo() {
|
||||
const { ustack, rstack, store } = this;
|
||||
const item = rstack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
ustack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public canUndo() {
|
||||
return this.ustack.length > 0;
|
||||
}
|
||||
|
||||
public canRedo() {
|
||||
return this.rstack.length > 0;
|
||||
}
|
||||
|
||||
public startTransaction() {
|
||||
if (this.transaction === 0) {
|
||||
this.backup.clear();
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
this.backup.set(name, state.get());
|
||||
});
|
||||
}
|
||||
this.transaction += 1;
|
||||
return this.endTransaction;
|
||||
}
|
||||
|
||||
public endTransaction = () => {
|
||||
if (this.transaction === 0) return;
|
||||
this.transaction -= 1;
|
||||
if (this.transaction === 0) {
|
||||
const changes: Snapshot[] = [];
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
const before = this.backup.get(name);
|
||||
if (Object.is(before, state.get())) return;
|
||||
changes.push([name, before]);
|
||||
});
|
||||
this.backup.clear();
|
||||
if (changes.length === 0) return;
|
||||
this.ustack.push(changes);
|
||||
this.rstack.length = 0;
|
||||
this.triggerStackState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function build() {
|
||||
const store = new Map<string, State<any, any>>();
|
||||
|
||||
const mem: Record<string, any> = {};
|
||||
function use<T, A>(m: SubModel<T, A>): [T, A] {
|
||||
const state = query(m);
|
||||
return [state.get(), state.actions];
|
||||
}
|
||||
|
||||
const model = new Model(store, use);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// @ts-ignore
|
||||
window.__md__ = model;
|
||||
}
|
||||
|
||||
function query<T, A>(m: SubModel<T, A>): State<T, A> {
|
||||
const exist = store.get(m.name);
|
||||
if (exist) return exist as State<T, A>;
|
||||
const created = m.gen(model);
|
||||
store.set(m.name, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
return { query, model, mem, use }
|
||||
}
|
||||
|
||||
const Context = createContext(build());
|
||||
|
||||
export function useModel() {
|
||||
return useContext(Context).model;
|
||||
}
|
||||
|
||||
export function RegisterFlowToContext() {
|
||||
const { model } = useContext(Context);
|
||||
const instance = useReactFlow<AnyWorkNode>();
|
||||
useLayoutEffect(() => {
|
||||
model.flow = instance;
|
||||
}, [instance]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModelProvider: FC<PropsWithChildren> = (p) => (
|
||||
<Context.Provider value={useMemo(build, [])}>
|
||||
{p.children}
|
||||
</Context.Provider>
|
||||
);
|
||||
|
||||
function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
|
||||
return new SubModel<T, A>(name, make, create);
|
||||
}
|
||||
|
||||
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 {
|
||||
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
|
||||
}
|
||||
|
||||
function memoize<T>(init: (use: Use, model: Model) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
const { mem, model, use } = useContext(Context);
|
||||
const fn = mem[id] || (mem[id] = init(use, model));
|
||||
return fn as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compute<T>(calc: (use: UseV) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
const { mem, query } = useContext(Context);
|
||||
let state: ReturnType<typeof generate<T>> = mem[id];
|
||||
if (state) return state.use();
|
||||
|
||||
const deps = new Set<SubModel<any, any>>();
|
||||
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
|
||||
mem[id] = state = generate<T>(calc(usev));
|
||||
if (deps.size) {
|
||||
usev = m => query(m).get();
|
||||
const update = () => state.set(calc(usev));
|
||||
deps.forEach(m => query(m).listen(update));
|
||||
}
|
||||
return state.use();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const define = {
|
||||
model: defineModel,
|
||||
view: defineView,
|
||||
memoize,
|
||||
compute,
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} 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 { cn } from "../../lib/utils.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
const LACK_COLOR = "#ff5252";
|
||||
const RADIUS = 12;
|
||||
|
||||
function GradientPath({
|
||||
id,
|
||||
path,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
hasCondition,
|
||||
selected,
|
||||
}: {
|
||||
id: string;
|
||||
path: string;
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
hasCondition: boolean | null;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const strokeStyle = selected
|
||||
? { stroke: '#f59e0b', strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={sourceX}
|
||||
y1={sourceY}
|
||||
x2={targetX}
|
||||
y2={targetY}
|
||||
>
|
||||
<stop offset="0%" stopColor={showLack ? LACK_COLOR : SOURCE_COLOR} />
|
||||
<stop offset="100%" stopColor={showLack ? LACK_COLOR : TARGET_COLOR} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={20}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
<path
|
||||
id={id}
|
||||
d={path}
|
||||
fill="none"
|
||||
className="react-flow__edge-path"
|
||||
style={strokeStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
}}
|
||||
>
|
||||
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
|
||||
else
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionLabelProps = {
|
||||
condition: string | undefined;
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
onSave: (value: string) => void;
|
||||
};
|
||||
|
||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function handleBadgeClick() {
|
||||
setInputValue(condition || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (inputValue.trim()) {
|
||||
onSave(inputValue.trim());
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleClickOutside(e: PointerEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", handleClickOutside, true);
|
||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
zIndex: isOpen ? 1000 : undefined,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
if
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 z-50 bg-white rounded shadow-lg border border-gray-200 p-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
type="text"
|
||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
||||
placeholder="输入条件"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="p-0.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<Check size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional');
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
export function ConditionalEdge({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
});
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
|
||||
const allEdges = flow.getEdges();
|
||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
||||
|
||||
const condition = data?.condition;
|
||||
function handleSave(value: string) {
|
||||
model.startTransaction();
|
||||
flow.updateEdgeData(id, { condition: value });
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GradientPath
|
||||
id={id}
|
||||
path={edgePath}
|
||||
sourceX={sourceX}
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : (condition ? true : false)}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse
|
||||
? <ElseBadge labelX={labelX} labelY={labelY} />
|
||||
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GradientEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
}: EdgeProps<Edge>): ReactNode {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
});
|
||||
|
||||
return (
|
||||
<GradientPath
|
||||
id={id}
|
||||
path={edgePath}
|
||||
sourceX={sourceX}
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={null}
|
||||
selected={!!selected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ConditionalEdge, GradientEdge } from './conditional';
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
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';
|
||||
|
||||
export * from './trans/type';
|
||||
|
||||
const proOptions = { hideAttribution: true };
|
||||
|
||||
const ReadonlyContext = createContext(false);
|
||||
export const useReadonly = () => useContext(ReadonlyContext);
|
||||
|
||||
function Flow() {
|
||||
const [nodes, { onNodesChange }] = nodesModel.use();
|
||||
const [edges, { onEdgesChange, onConnect }] = edgesModel.use();
|
||||
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } = handlers.use();
|
||||
const readonly = useReadonly();
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow<AnyWorkNode, Edge>
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={readonly ? undefined : onNodesChange}
|
||||
onEdgesChange={readonly ? undefined : onEdgesChange}
|
||||
onConnect={readonly ? undefined : onConnect}
|
||||
fitView
|
||||
proOptions={proOptions}
|
||||
onNodeDragStart={readonly ? undefined : onNodeDragStart}
|
||||
onNodeDragStop={readonly ? undefined : onNodeDragStop}
|
||||
onConnectEnd={readonly ? undefined : onConnectEnd}
|
||||
onBeforeDelete={readonly ? undefined : onBeforeDelete}
|
||||
onDelete={readonly ? undefined : onDelete}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodesDraggable={!readonly}
|
||||
nodesConnectable={!readonly}
|
||||
elementsSelectable={!readonly}
|
||||
>
|
||||
<RegisterFlowToContext />
|
||||
<Background />
|
||||
<Controls />
|
||||
{!readonly && <TopCenterPanel />}
|
||||
{!readonly && <Dialogs />}
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoFlow = memo(Flow);
|
||||
|
||||
interface Props {
|
||||
model: FlowModel;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function Connect({ model }: { model: FlowModel }) {
|
||||
const { loadSteps } = handlers.use();
|
||||
const inject = injection.useCreation();
|
||||
const instance = model[InternalField];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return inject(instance);
|
||||
}, [instance]);
|
||||
|
||||
useEffect(() => {
|
||||
return instance.on('load', loadSteps);
|
||||
}, [instance]);
|
||||
|
||||
return <MemoFlow />;
|
||||
}
|
||||
|
||||
export { FlowModel };
|
||||
// biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component
|
||||
export default ({ model, readonly = false }: Props) => (
|
||||
<ReadonlyContext.Provider value={readonly}>
|
||||
<ModelProvider>
|
||||
{createElement(Connect, { model })}
|
||||
</ModelProvider>
|
||||
</ReadonlyContext.Provider>
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from './utils/eventer';
|
||||
|
||||
interface PublicEvents {
|
||||
save: WorkFlowSteps;
|
||||
}
|
||||
|
||||
interface PrivateEvents {
|
||||
load: WorkFlowSteps;
|
||||
}
|
||||
|
||||
export const InternalField = Symbol('InternalField');
|
||||
|
||||
export class Injection extends Eventer<PrivateEvents> {
|
||||
constructor(
|
||||
public readonly emitPublic: Eventer<PublicEvents>['emit'],
|
||||
private inital_steps?: WorkFlowSteps,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public on: Eventer<PrivateEvents>['on'] = (type, lisenter) => {
|
||||
const off = super.on(type, lisenter);
|
||||
if (type === 'load' && this.inital_steps) {
|
||||
lisenter(this.inital_steps);
|
||||
this.inital_steps = undefined;
|
||||
}
|
||||
return off;
|
||||
};
|
||||
}
|
||||
|
||||
export class FlowModel {
|
||||
private readonly eventer = new Eventer<PublicEvents>();
|
||||
public on = this.eventer.on.bind(this.eventer);
|
||||
public off = this.eventer.off.bind(this.eventer);
|
||||
|
||||
public readonly [InternalField]: Injection;
|
||||
|
||||
constructor(inital_steps?: WorkFlowSteps) {
|
||||
this[InternalField] = new Injection(
|
||||
this.eventer.emit.bind(this.eventer),
|
||||
inital_steps,
|
||||
);
|
||||
}
|
||||
|
||||
public load(steps: WorkFlowSteps) {
|
||||
this[InternalField].emit('load', steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 120;
|
||||
const DEFAULT_NODE_HEIGHT = 50;
|
||||
const HORIZONTAL_GAP = 80; // 层与层之间的水平间距
|
||||
const VERTICAL_GAP = 40; // 同层节点之间的垂直间距
|
||||
|
||||
/**
|
||||
* 获取节点的尺寸
|
||||
*/
|
||||
function getNodeSize(node: Node): { width: number; height: number } {
|
||||
return {
|
||||
width: node.measured?.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: node.measured?.height ?? DEFAULT_NODE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建邻接表(出边)和入度表
|
||||
*/
|
||||
function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const outgoing = new Map<string, string[]>(); // nodeId -> [targetIds]
|
||||
const incoming = new Map<string, string[]>(); // nodeId -> [sourceIds]
|
||||
const inDegree = new Map<string, number>();
|
||||
|
||||
// 初始化
|
||||
for (const node of nodes) {
|
||||
outgoing.set(node.id, []);
|
||||
incoming.set(node.id, []);
|
||||
inDegree.set(node.id, 0);
|
||||
}
|
||||
|
||||
// 构建图
|
||||
for (const edge of edges) {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
outgoing.get(edge.source)!.push(edge.target);
|
||||
incoming.get(edge.target)!.push(edge.source);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { outgoing, incoming, inDegree };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用拓扑排序将节点分层
|
||||
* - 'start' 节点固定在第 0 层
|
||||
* - 'end' 节点固定在最后一层
|
||||
* - 孤立节点放在中间层
|
||||
*/
|
||||
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
||||
const layers = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
|
||||
// 1. start 节点固定在第 0 层
|
||||
layers.set('start', 0);
|
||||
queue.push('start');
|
||||
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = layers.get(current)!;
|
||||
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
// 跳过 end 节点,稍后处理
|
||||
if (target === 'end') continue;
|
||||
|
||||
const newLayer = currentLayer + 1;
|
||||
const existingLayer = layers.get(target);
|
||||
|
||||
if (existingLayer === undefined) {
|
||||
layers.set(target, newLayer);
|
||||
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
||||
if (inDegree.get(target) === 0) {
|
||||
queue.push(target);
|
||||
}
|
||||
} else {
|
||||
// 如果已有层级,取更大的值(确保所有前驱都在前面)
|
||||
layers.set(target, Math.max(existingLayer, newLayer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 找到当前最大层级
|
||||
let maxLayer = 0;
|
||||
for (const layer of layers.values()) {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
|
||||
// 4. 处理孤立节点(没有被分配层级的非 start/end 节点)
|
||||
// 把它们放在中间层
|
||||
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
||||
for (const node of nodes) {
|
||||
if (node.id !== 'start' && node.id !== 'end' && !layers.has(node.id)) {
|
||||
layers.set(node.id, middleLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 重新计算最大层级(可能因为孤立节点而变化)
|
||||
maxLayer = 0;
|
||||
for (const [id, layer] of layers) {
|
||||
if (id !== 'end') {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. end 节点固定在最后一层
|
||||
layers.set('end', maxLayer + 1);
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按层级分组节点
|
||||
*/
|
||||
function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>): Map<number, N[]> {
|
||||
const groups = new Map<number, N[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const layer = layers.get(node.id) ?? 0;
|
||||
if (!groups.has(layer)) {
|
||||
groups.set(layer, []);
|
||||
}
|
||||
groups.get(layer)!.push(node);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每层的最大宽度
|
||||
*/
|
||||
function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, number> {
|
||||
const widths = new Map<number, number>();
|
||||
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
let maxWidth = 0;
|
||||
for (const node of nodesInLayer) {
|
||||
const { width } = getNodeSize(node);
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
}
|
||||
widths.set(layer, maxWidth);
|
||||
}
|
||||
|
||||
return widths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每层的 X 起始位置
|
||||
*/
|
||||
function calculateLayerXPositions(
|
||||
layerWidths: Map<number, number>,
|
||||
maxLayer: number
|
||||
): Map<number, number> {
|
||||
const xPositions = new Map<number, number>();
|
||||
let currentX = 0;
|
||||
|
||||
for (let layer = 0; layer <= maxLayer; layer++) {
|
||||
xPositions.set(layer, currentX);
|
||||
const layerWidth = layerWidths.get(layer) ?? DEFAULT_NODE_WIDTH;
|
||||
currentX += layerWidth + HORIZONTAL_GAP;
|
||||
}
|
||||
|
||||
return xPositions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo: 1-N 情况下的布局优化
|
||||
* Todo: 如果计算完了之后,所有节点的位置都没变,则不更新节点,避免不必要的重渲染
|
||||
* node 中有 measured 属性,可以获得其尺寸,如果没有,则使用一个默认尺寸 120*50
|
||||
* edge 的 source 和 target 分别对应两端的 node 的 id
|
||||
*
|
||||
* 算法步骤:
|
||||
* 1. 使用拓扑排序将节点分层(从左到右)
|
||||
* 2. 计算每层的 X 位置
|
||||
* 3. 在每层内垂直居中排列节点
|
||||
*/
|
||||
export function LayoutLR<N extends Node>(nodes: N[], edges: Edge[]): N[] {
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. 分配层级
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
// 2. 按层级分组
|
||||
const layerGroups = groupByLayer(nodes, layers);
|
||||
|
||||
// 3. 计算每层宽度和 X 位置
|
||||
const maxLayer = Math.max(...layers.values());
|
||||
const layerWidths = calculateLayerWidths(layerGroups);
|
||||
const layerXPositions = calculateLayerXPositions(layerWidths, maxLayer);
|
||||
|
||||
// 4. 计算每层的总高度,用于垂直居中
|
||||
const layerHeights = new Map<number, number>();
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
let totalHeight = 0;
|
||||
for (const node of nodesInLayer) {
|
||||
const { height } = getNodeSize(node);
|
||||
totalHeight += height;
|
||||
}
|
||||
totalHeight += (nodesInLayer.length - 1) * VERTICAL_GAP;
|
||||
layerHeights.set(layer, totalHeight);
|
||||
}
|
||||
|
||||
// 找到最大高度,用于垂直居中对齐
|
||||
const maxHeight = Math.max(...layerHeights.values());
|
||||
|
||||
// 5. 为每个节点分配位置,并检查是否有变化
|
||||
const layoutedNodes: N[] = [];
|
||||
let hasChanged = false;
|
||||
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
const layerHeight = layerHeights.get(layer) ?? 0;
|
||||
const startY = (maxHeight - layerHeight) / 2; // 垂直居中
|
||||
const x = layerXPositions.get(layer) ?? 0;
|
||||
|
||||
let currentY = startY;
|
||||
|
||||
for (const node of nodesInLayer) {
|
||||
const { height } = getNodeSize(node);
|
||||
const newPosition = { x, y: currentY };
|
||||
if (node.position.x !== newPosition.x || node.position.y !== newPosition.y) {
|
||||
hasChanged = true;
|
||||
layoutedNodes.push({
|
||||
...node,
|
||||
position: newPosition,
|
||||
});
|
||||
} else {
|
||||
layoutedNodes.push(node);
|
||||
}
|
||||
currentY += height + VERTICAL_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanged ? layoutedNodes : nodes;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Edge } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import type { RoleNodeData, AnyWorkNode } from '../type';
|
||||
|
||||
type ConnectHandle = {
|
||||
id?: string | null;
|
||||
nodeId: string;
|
||||
type: 'source' | 'target';
|
||||
};
|
||||
|
||||
export type AddNodeState = {
|
||||
fromNode: AnyWorkNode;
|
||||
fromHandle: ConnectHandle;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
|
||||
type CommitParams = {
|
||||
data: RoleNodeData;
|
||||
};
|
||||
|
||||
function addNodeView() {
|
||||
return null as (AddNodeState | null);
|
||||
}
|
||||
|
||||
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => {
|
||||
function start(state: AddNodeState) {
|
||||
set(state);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
set(null);
|
||||
}
|
||||
|
||||
function commit(params: CommitParams) {
|
||||
const state = get();
|
||||
if (!state) return;
|
||||
set(null);
|
||||
|
||||
const { fromNode, fromHandle, position } = state;
|
||||
const { data } = params;
|
||||
|
||||
const id = `n${Date.now()}`;
|
||||
const node = { id, data, position, type: 'role' as const, origin: [0.0, 0.5] as [number, number] };
|
||||
|
||||
const [fnid, fhid] = [fromNode.id, fromHandle.id];
|
||||
const newEdge: Edge = fromHandle.type === 'source'
|
||||
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
|
||||
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
|
||||
|
||||
model.startTransaction();
|
||||
model.use(nodesModel)[1].set((nds) => nds.concat(node));
|
||||
model.use(edgesModel)[1].set((eds) => eds.concat(newEdge));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { start, commit, cancel };
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
} from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
|
||||
function makeEdges(): Edge[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isInputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'input' || handle === 'input-top' || handle === 'input-bottom';
|
||||
}
|
||||
|
||||
function isOutputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'output' || handle === 'output-top' || handle === 'output-bottom';
|
||||
}
|
||||
|
||||
function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
if (isInputHandle(params.sourceHandle) && isOutputHandle(params.targetHandle)) {
|
||||
return {
|
||||
...params,
|
||||
source: params.target,
|
||||
sourceHandle: params.targetHandle ?? null,
|
||||
target: params.source,
|
||||
targetHandle: params.sourceHandle ?? null,
|
||||
} as Edge | Connection;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
let edgeCounter = 0;
|
||||
|
||||
export const edgesModel = define.model('edges', makeEdges, (set, get, model) => {
|
||||
function onEdgesChange(changes: EdgeChange[]) {
|
||||
const whites = new Set(['add', 'replace']);
|
||||
if (changes.some(c => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
return;
|
||||
}
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
}
|
||||
|
||||
function onConnect(params: Edge | Connection) {
|
||||
const normalized = normalizeConnection(params);
|
||||
|
||||
if (normalized.source === normalized.target) return;
|
||||
|
||||
if (!isOutputHandle(normalized.sourceHandle) || !isInputHandle(normalized.targetHandle)) return;
|
||||
|
||||
const currentEdges = get();
|
||||
const duplicate = currentEdges.some(
|
||||
e => e.source === normalized.source && e.target === normalized.target,
|
||||
);
|
||||
if (duplicate) return;
|
||||
|
||||
model.startTransaction();
|
||||
|
||||
const id = `e-${normalized.source}-${normalized.target}-${++edgeCounter}`;
|
||||
const edge: Edge = {
|
||||
...normalized,
|
||||
id,
|
||||
animated: true,
|
||||
} as Edge;
|
||||
|
||||
const existingFromSource = currentEdges.filter(e => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
edge.type = '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: '' } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
set([...promoted, edge]);
|
||||
} else {
|
||||
set((eds) => [...eds, edge]);
|
||||
}
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { onEdgesChange, onConnect, set };
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import type { RoleNodeData, WorkNode } from '../type';
|
||||
|
||||
export type EditNodeState = {
|
||||
node: WorkNode<'role'>;
|
||||
};
|
||||
|
||||
function editNodeView() {
|
||||
return null as (EditNodeState | null);
|
||||
}
|
||||
|
||||
export const editNodeViewModel = define.view('editNodeView', editNodeView, (set, get, model) => {
|
||||
function start(nodeId: string) {
|
||||
const [nodes] = model.use(nodesModel);
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
set({ node: node as WorkNode<'role'> });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
set(null);
|
||||
}
|
||||
|
||||
function commit(data: RoleNodeData) {
|
||||
const state = get();
|
||||
if (!state) return;
|
||||
set(null);
|
||||
|
||||
const { editNode } = model.use(nodesModel)[1];
|
||||
|
||||
model.startTransaction();
|
||||
editNode(state.node.id, (node) => {
|
||||
node.data = data as any;
|
||||
});
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { start, commit, cancel };
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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';
|
||||
|
||||
export const handlers = define.memoize((use, model) => {
|
||||
const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => {
|
||||
model.startTransaction();
|
||||
};
|
||||
const onNodeDragStop: OnNodeDrag<AnyWorkNode> = () => {
|
||||
model.endTransaction();
|
||||
};
|
||||
const onConnectEnd: OnConnectEnd = (event, state) => {
|
||||
const { isValid, to, fromHandle, fromNode } = state;
|
||||
if (isValid) return;
|
||||
if (!to || !fromHandle || !fromNode) return;
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
use(addNodeViewModel)[1].start({
|
||||
fromNode: fromNode as any as AnyWorkNode,
|
||||
fromHandle: fromHandle,
|
||||
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
|
||||
});
|
||||
};
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
for (const node of nodes) {
|
||||
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 (siblings.length >= 2 && siblings[0].id === edge.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
model.startTransaction();
|
||||
return true;
|
||||
};
|
||||
const onDelete: OnDelete = ({ edges: deletedEdges }) => {
|
||||
if (deletedEdges.length > 0) {
|
||||
const currentEdges = use(edgesModel)[0];
|
||||
const sourcesToCheck = new Set(
|
||||
deletedEdges
|
||||
.filter(e => e.type === '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');
|
||||
if (siblings.length === 1) {
|
||||
needsDowngrade = true;
|
||||
const { data: _, ...rest } = e;
|
||||
return { ...rest, type: 'default' as const };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
if (needsDowngrade) {
|
||||
use(edgesModel)[1].set(updatedEdges);
|
||||
}
|
||||
}
|
||||
}
|
||||
model.endTransaction();
|
||||
};
|
||||
|
||||
function autoLayoutLR() {
|
||||
const [nodes, { set }] = use(nodesModel);
|
||||
const edges = use(edgesModel)[0];
|
||||
|
||||
const layoutedNodes = LayoutLR(nodes, edges);
|
||||
model.startTransaction();
|
||||
set(layoutedNodes);
|
||||
model.endTransaction();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
use(addNodeViewModel)[1].cancel();
|
||||
use(editNodeViewModel)[1].cancel();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.code === 'Escape') {
|
||||
const [addView, addViewActions] = use(addNodeViewModel);
|
||||
const [editView, editViewActions] = use(editNodeViewModel);
|
||||
if (addView) addViewActions.cancel();
|
||||
if (editView) editViewActions.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 'KeyZ') {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.shiftKey) model.redo();
|
||||
else model.undo();
|
||||
}
|
||||
} else if (event.code === 'KeyY') {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
model.redo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSteps(steps: WorkFlowSteps) {
|
||||
resetView();
|
||||
const { nodes, edges } = transIn(steps);
|
||||
use(nodesModel)[1].set(nodes);
|
||||
use(edgesModel)[1].set(edges);
|
||||
autoLayoutLR();
|
||||
model.reset();
|
||||
}
|
||||
|
||||
function saveData() {
|
||||
const nodes = use(nodesModel)[0];
|
||||
const edges = use(edgesModel)[0];
|
||||
const result = validate(nodes, edges);
|
||||
if (result.valid) {
|
||||
const steps = transOut(nodes, edges);
|
||||
const instance = use(injection)[0];
|
||||
instance.emitPublic('save', steps);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
onNodeDragStart,
|
||||
onNodeDragStop,
|
||||
onConnectEnd,
|
||||
onBeforeDelete,
|
||||
onDelete,
|
||||
autoLayoutLR,
|
||||
handleKeyDown,
|
||||
loadSteps,
|
||||
saveData,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export { 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';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 外部注入的回调函数,存到这里以方便内部调用,避免透传
|
||||
*/
|
||||
|
||||
import { define } from "../context.tsx";
|
||||
import { Injection } from '../injection.ts';
|
||||
|
||||
|
||||
const NOOP = () => {};
|
||||
const placeholder = new Injection(NOOP);
|
||||
|
||||
function make(): Injection {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
export const injection = define.view('injection', make, (set) => {
|
||||
function reset() {
|
||||
set(make());
|
||||
}
|
||||
|
||||
function inject(instance: Injection) {
|
||||
set(instance);
|
||||
return reset;
|
||||
}
|
||||
|
||||
return inject;
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { produce, type Draft } from 'immer';
|
||||
import { applyNodeChanges, NodeChange } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import type { AnyWorkNode } from '../type';
|
||||
|
||||
|
||||
function makeNodes(): AnyWorkNode[] {
|
||||
return [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
data: { label: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
data: { label: 'End' },
|
||||
position: { x: 1000, y: 0 },
|
||||
type: 'end',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const nodesModel = define.model('nodes', makeNodes, (set, _get, model) => {
|
||||
const whites = new Set<NodeChange['type']>(['add', 'replace']);
|
||||
function onNodesChange(changes: NodeChange<AnyWorkNode>[]) {
|
||||
if (changes.some(c => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((nds) => applyNodeChanges(changes, nds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
return;
|
||||
}
|
||||
set((nds) => applyNodeChanges(changes, nds));
|
||||
};
|
||||
|
||||
function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) {
|
||||
set(produce((draft) => {
|
||||
const node = draft.find(n => n.id === id);
|
||||
if (node) updater(node);
|
||||
}));
|
||||
}
|
||||
|
||||
function deleteNode(id: string) {
|
||||
model.startTransaction();
|
||||
set((nds) => nds.filter(n => n.id !== id));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { onNodesChange, set, editNode, deleteNode };
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Handle, Position, Node, NodeProps } from '@xyflow/react';
|
||||
import { EndNode } from './nodes.style';
|
||||
|
||||
interface NodeData {
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type NodeType = Node<NodeData, 'end'>;
|
||||
type Props = NodeProps<NodeType>;
|
||||
|
||||
export function NodeEnd({ data }: Props) {
|
||||
return (
|
||||
<EndNode>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="input"
|
||||
/>
|
||||
{data?.label || 'End'}
|
||||
</EndNode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NodeStart } from './start';
|
||||
import { NodeEnd } from './end';
|
||||
import { NodeRole } from './role';
|
||||
|
||||
export const nodeTypes = {
|
||||
start: NodeStart,
|
||||
end: NodeEnd,
|
||||
role: NodeRole,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
|
||||
type Props = {
|
||||
onEdit: (() => void) | undefined;
|
||||
onDelete: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
|
||||
return (
|
||||
<div className="flex gap-1 px-2 py-1 bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑">
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" className="hover:bg-destructive/10 hover:text-destructive" onClick={onDelete} title="删除">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
type Props = {
|
||||
className: string | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function BaseNode({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 border-border bg-white px-4 py-3 text-center text-sm font-medium min-w-[120px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StartNode({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<BaseNode className="bg-gradient-to-br from-green-50 to-green-200 border-green-500 text-green-500">
|
||||
{children}
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
export function EndNode({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<BaseNode className="bg-gradient-to-br from-indigo-50 to-blue-100 border-blue-600 text-blue-600">
|
||||
{children}
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeContent({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 px-3.5 py-3 min-w-[160px] max-w-[240px]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeIcon({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-lg shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeBody({ children }: { children: ReactNode }): ReactNode {
|
||||
return <div className="flex-1 min-w-0">{children}</div>;
|
||||
}
|
||||
|
||||
export function NodeKindLabel({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[10px] font-semibold uppercase tracking-wide mb-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeHint({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="text-[13px] text-gray-800 leading-snug break-words">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
|
||||
return <div className="text-[11px] text-gray-400 mt-0.5">{children}</div>;
|
||||
}
|
||||
|
||||
export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<NodeIcon className="bg-gradient-to-br from-teal-50 to-teal-200 text-teal-700">
|
||||
{children}
|
||||
</NodeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleKindLabel({
|
||||
children,
|
||||
}: { children: ReactNode }): ReactNode {
|
||||
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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';
|
||||
|
||||
type Props = NodeProps<WorkNode<'role'>>;
|
||||
|
||||
const containerClass = "bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
|
||||
const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600";
|
||||
const sourceClass = "!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
|
||||
|
||||
export function NodeRole({ data, id, selected }: Props) {
|
||||
const startEdit = editNodeViewModel.useCreation().start;
|
||||
const { deleteNode } = nodesModel.useCreation();
|
||||
const connections = useNodeConnections();
|
||||
const readonly = useReadonly();
|
||||
|
||||
const connectedHandles = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const c of connections) {
|
||||
if (c.target === id && c.targetHandle) set.add(c.targetHandle);
|
||||
if (c.source === id && c.sourceHandle) set.add(c.sourceHandle);
|
||||
}
|
||||
return set;
|
||||
}, [connections, id]);
|
||||
|
||||
const hasInputConnection = connectedHandles.has('input') || connectedHandles.has('input-top') || connectedHandles.has('input-bottom');
|
||||
const hasOutputConnection = connectedHandles.has('output') || connectedHandles.has('output-top') || connectedHandles.has('output-bottom');
|
||||
|
||||
const showHandle = (handleId: string, alwaysShow: boolean) => {
|
||||
if (readonly) return connectedHandles.has(handleId);
|
||||
return alwaysShow;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{showHandle('input', true) && <Handle type="target" position={Position.Left} id="input" className={targetClass} isConnectableStart />}
|
||||
{showHandle('input-top', hasInputConnection) && <Handle type="target" position={Position.Top} id="input-top" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
|
||||
{showHandle('input-bottom', hasInputConnection) && <Handle type="target" position={Position.Bottom} id="input-bottom" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
|
||||
<NodeContent>
|
||||
<RoleIcon>
|
||||
<Users size={16} />
|
||||
</RoleIcon>
|
||||
<NodeBody>
|
||||
<RoleKindLabel>Role</RoleKindLabel>
|
||||
<NodeHint>{data.name}</NodeHint>
|
||||
</NodeBody>
|
||||
</NodeContent>
|
||||
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
|
||||
<NodeToolbarActions
|
||||
onEdit={() => startEdit(id)}
|
||||
onDelete={() => deleteNode(id)}
|
||||
/>
|
||||
</NodeToolbar>
|
||||
{showHandle('output', true) && <Handle type="source" position={Position.Right} id="output" className={sourceClass} isConnectableEnd />}
|
||||
{showHandle('output-top', hasOutputConnection) && <Handle type="source" position={Position.Top} id="output-top" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
|
||||
{showHandle('output-bottom', hasOutputConnection) && <Handle type="source" position={Position.Bottom} id="output-bottom" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Handle, Position, Node, NodeProps, useNodeConnections } from '@xyflow/react';
|
||||
import { StartNode } from './nodes.style';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface NodeData {
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type NodeType = Node<NodeData, 'start'>;
|
||||
type Props = NodeProps<NodeType>;
|
||||
|
||||
export function NodeStart({ data, id }: Props) {
|
||||
const connections = useNodeConnections();
|
||||
|
||||
const outputConnected = useMemo(() => {
|
||||
return connections.some((conn) => conn.source === id);
|
||||
}, [connections, id]);
|
||||
|
||||
return (
|
||||
<StartNode>
|
||||
{data?.label || 'Start'}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="output"
|
||||
isConnectable={!outputConnected}
|
||||
/>
|
||||
</StartNode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { addNodeViewModel, type AddNodeState } from "../model/index.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
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 type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
state: AddNodeState;
|
||||
onSubmit: (params: { data: RoleNodeData }) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
const [name, setName] = useState("新角色");
|
||||
const [description, setDescription] = useState("");
|
||||
const [identity, setIdentity] = useState("");
|
||||
const [prepare, setPrepare] = useState("");
|
||||
const [execute, setExecute] = useState("");
|
||||
const [report, setReport] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setName("新角色");
|
||||
setDescription("");
|
||||
setIdentity("");
|
||||
setPrepare("");
|
||||
setExecute("");
|
||||
setReport("");
|
||||
}, [state]);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!name.trim()) return;
|
||||
onSubmit({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description,
|
||||
identity,
|
||||
prepare,
|
||||
execute,
|
||||
report,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加角色节点</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">名称 *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">描述</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="角色描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">身份 (Identity)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={identity}
|
||||
onChange={(e) => setIdentity(e.target.value)}
|
||||
placeholder="角色身份定义"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">准备 (Prepare)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={prepare}
|
||||
onChange={(e) => setPrepare(e.target.value)}
|
||||
placeholder="执行前准备指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">执行 (Execute)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={execute}
|
||||
onChange={(e) => setExecute(e.target.value)}
|
||||
placeholder="核心执行指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">报告 (Report)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={report}
|
||||
onChange={(e) => setReport(e.target.value)}
|
||||
placeholder="输出格式指令"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddNodeDialog(): ReactNode {
|
||||
const state = addNodeViewModel.useData();
|
||||
const { commit, cancel } = addNodeViewModel.useCreation();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
editNodeViewModel,
|
||||
type EditNodeState,
|
||||
} from "../model/edit-node-view.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
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 type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
state: EditNodeState;
|
||||
onSubmit: (data: RoleNodeData) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
const data = state.node.data;
|
||||
const [name, setName] = useState(data.name);
|
||||
const [description, setDescription] = useState(data.description);
|
||||
const [identity, setIdentity] = useState(data.identity);
|
||||
const [prepare, setPrepare] = useState(data.prepare);
|
||||
const [execute, setExecute] = useState(data.execute);
|
||||
const [report, setReport] = useState(data.report);
|
||||
|
||||
useEffect(() => {
|
||||
setName(data.name);
|
||||
setDescription(data.description);
|
||||
setIdentity(data.identity);
|
||||
setPrepare(data.prepare);
|
||||
setExecute(data.execute);
|
||||
setReport(data.report);
|
||||
}, [data]);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!name.trim()) return;
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description,
|
||||
identity,
|
||||
prepare,
|
||||
execute,
|
||||
report,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑角色节点</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">名称 *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">描述</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="角色描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">身份 (Identity)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={identity}
|
||||
onChange={(e) => setIdentity(e.target.value)}
|
||||
placeholder="角色身份定义"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">准备 (Prepare)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={prepare}
|
||||
onChange={(e) => setPrepare(e.target.value)}
|
||||
placeholder="执行前准备指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">执行 (Execute)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={execute}
|
||||
onChange={(e) => setExecute(e.target.value)}
|
||||
placeholder="核心执行指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">报告 (Report)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={report}
|
||||
onChange={(e) => setReport(e.target.value)}
|
||||
placeholder="输出格式指令"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditNodeDialog(): ReactNode {
|
||||
const state = editNodeViewModel.useData();
|
||||
const { commit, cancel } = editNodeViewModel.useCreation();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { AddNodeDialog } from './add-node';
|
||||
import { EditNodeDialog } from './edit-node';
|
||||
import { Toolbar } from './toolbar';
|
||||
|
||||
|
||||
export function Dialogs() {
|
||||
return (
|
||||
<>
|
||||
<AddNodeDialog />
|
||||
<EditNodeDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function TopCenterPanel() {
|
||||
return (
|
||||
<Panel position="top-center">
|
||||
<Toolbar />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Users,
|
||||
LayoutList,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useReactFlow, useStoreApi } from "@xyflow/react";
|
||||
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: '',
|
||||
};
|
||||
|
||||
export function Toolbar(): ReactNode {
|
||||
const model = useModel();
|
||||
const flow = useReactFlow();
|
||||
const store = useStoreApi();
|
||||
const nodesActions = nodesModel.useCreation();
|
||||
const { autoLayoutLR } = handlers.use();
|
||||
const [canUndo, canRedo] = model.useStackState();
|
||||
|
||||
function handleUndo() {
|
||||
model.undo();
|
||||
}
|
||||
|
||||
function handleRedo() {
|
||||
model.redo();
|
||||
}
|
||||
|
||||
function handleAddNode() {
|
||||
const { x, y, zoom } = flow.getViewport();
|
||||
const { width, height } = store.getState();
|
||||
const centerX = (width / 2 - x) / zoom;
|
||||
const centerY = (height / 2 - y) / zoom;
|
||||
|
||||
const id = `n${uuid()}`;
|
||||
const node: WorkNode<'role'> = {
|
||||
id,
|
||||
type: 'role',
|
||||
position: { x: centerX - 80, y: centerY - 40 },
|
||||
data: { ...DEFAULT_ROLE_DATA },
|
||||
};
|
||||
|
||||
model.startTransaction();
|
||||
nodesActions.set((nds) => nds.concat(node));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon-sm" title="撤销 (Undo)" onClick={handleUndo} disabled={!canUndo}>
|
||||
<Undo2 />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" title="重做 (Redo)" onClick={handleRedo} disabled={!canRedo}>
|
||||
<Redo2 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<Button variant="ghost" size="icon-sm" title="添加角色" onClick={handleAddNode}>
|
||||
<Users />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<Button variant="ghost" size="icon-sm" title="自动布局" onClick={autoLayoutLR}>
|
||||
<LayoutList />
|
||||
</Button>
|
||||
|
||||
<SaveButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton(): ReactNode {
|
||||
const { saveData } = handlers.use();
|
||||
const [toast, setToast] = useState<{
|
||||
open: boolean;
|
||||
severity: "success" | "error";
|
||||
message: ReactNode;
|
||||
}>({ open: false, severity: "success", message: "" });
|
||||
|
||||
function handleSave() {
|
||||
const { valid, errors } = saveData();
|
||||
if (valid) {
|
||||
setToast({ open: true, severity: "success", message: "流程保存成功" });
|
||||
} else {
|
||||
const errorMessages = errors.map(
|
||||
({ message, nodeId }) => (
|
||||
<div key={nodeId ?? message}>
|
||||
{nodeId ? `节点 ${nodeId}:` : ""}
|
||||
{message}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
setToast({
|
||||
open: true,
|
||||
severity: "error",
|
||||
message: errorMessages || "流程校验失败",
|
||||
});
|
||||
}
|
||||
setTimeout(() => setToast((prev) => ({ ...prev, open: false })), 4000);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="icon-sm" title="保存流程" onClick={handleSave}>
|
||||
<Save />
|
||||
</Button>
|
||||
{toast.open && (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg text-sm text-white shadow-lg",
|
||||
toast.severity === "success" ? "bg-green-600" : "bg-red-600",
|
||||
)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './type';
|
||||
export * from './trans-in';
|
||||
export * from './trans-out';
|
||||
export * from './validate';
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep } from './type';
|
||||
import { uuid } from '../utils';
|
||||
|
||||
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;
|
||||
|
||||
function assignHandles(
|
||||
indices: number[],
|
||||
edges: AnyWorkEdge[],
|
||||
handles: readonly string[],
|
||||
key: 'sourceHandle' | 'targetHandle',
|
||||
): void {
|
||||
if (indices.length === 1) {
|
||||
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
|
||||
} else if (indices.length === 2) {
|
||||
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
|
||||
edges[indices[1]] = { ...edges[indices[1]], [key]: handles[0] };
|
||||
} else {
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
edges[indices[i]] = { ...edges[indices[i]], [key]: handles[i % handles.length] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const startNode: AnyWorkNode = { id: 'start', type: 'start', data: { label: 'Start' }, position: { x: 0, y: 0 } };
|
||||
const endNode: AnyWorkNode = { id: 'end', type: 'end', data: { label: 'End' }, position: { x: 250, y: 0 } };
|
||||
|
||||
if (steps.length === 0) {
|
||||
return { nodes: [startNode, endNode], edges: [] };
|
||||
}
|
||||
|
||||
const nodes: AnyWorkNode[] = [startNode, endNode];
|
||||
const edges: AnyWorkEdge[] = [];
|
||||
const nameToId = new Map<string, string>();
|
||||
const idToOrder = new Map<string, number>();
|
||||
nameToId.set('END', 'end');
|
||||
idToOrder.set('start', -1);
|
||||
idToOrder.set('end', steps.length);
|
||||
|
||||
for (let si = 0; si < steps.length; si++) {
|
||||
const step = steps[si];
|
||||
const nodeId = `n${uuid()}`;
|
||||
nameToId.set(step.role.name, nodeId);
|
||||
idToOrder.set(nodeId, si);
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'role',
|
||||
data: { ...step.role },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const firstStepId = nameToId.get(steps[0].role.name)!;
|
||||
edges.push({
|
||||
id: `e-start-${firstStepId}`,
|
||||
source: 'start',
|
||||
sourceHandle: 'output',
|
||||
target: firstStepId,
|
||||
targetHandle: 'input',
|
||||
animated: true,
|
||||
});
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name)!;
|
||||
const sourceOrder = idToOrder.get(sourceId)!;
|
||||
const hasMultipleTransitions = step.transitions.length > 1;
|
||||
|
||||
const sorted = hasMultipleTransitions
|
||||
? [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
return 0;
|
||||
})
|
||||
: step.transitions;
|
||||
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
|
||||
if (hasMultipleTransitions || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
type: 'conditional',
|
||||
data: { condition: t.condition ?? '' },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultipleTransitions && i === 0) {
|
||||
elseEdges.push(edge);
|
||||
} else {
|
||||
ifEdges.push(edge);
|
||||
}
|
||||
} else {
|
||||
elseEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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' });
|
||||
}
|
||||
if (ifEdges.length > 0) {
|
||||
const sortedIf = [...ifEdges].sort((a, b) => {
|
||||
const oa = idToOrder.get(a.target) ?? 0;
|
||||
const ob = idToOrder.get(b.target) ?? 0;
|
||||
return ob - oa;
|
||||
});
|
||||
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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
|
||||
const incomingByTarget = new Map<string, number[]>();
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const target = edges[i].target;
|
||||
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
|
||||
incomingByTarget.get(target)!.push(i);
|
||||
}
|
||||
|
||||
for (const indices of incomingByTarget.values()) {
|
||||
indices.sort((a, b) => {
|
||||
const oa = idToOrder.get(edges[a].source) ?? 0;
|
||||
const ob = idToOrder.get(edges[b].source) ?? 0;
|
||||
return oa - ob;
|
||||
});
|
||||
assignHandles(indices, edges, IN_HANDLES, 'targetHandle');
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, WorkNode, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep, WorkFlowTransition } from './type';
|
||||
|
||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||
const nodeMap = new Map<string, AnyWorkNode>();
|
||||
for (const node of nodes) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
|
||||
const outgoingEdges = new Map<string, AnyWorkEdge[]>();
|
||||
for (const edge of edges) {
|
||||
if (!outgoingEdges.has(edge.source)) {
|
||||
outgoingEdges.set(edge.source, []);
|
||||
}
|
||||
outgoingEdges.get(edge.source)!.push(edge);
|
||||
}
|
||||
|
||||
const startOutEdges = outgoingEdges.get('start') ?? [];
|
||||
if (startOutEdges.length === 0) return [];
|
||||
|
||||
const firstNodeId = startOutEdges[0].target;
|
||||
const visited = new Set<string>();
|
||||
const steps: WorkFlowStep[] = [];
|
||||
|
||||
traverse(firstNodeId, nodeMap, outgoingEdges, visited, steps);
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function traverse(
|
||||
nodeId: string,
|
||||
nodeMap: Map<string, AnyWorkNode>,
|
||||
outgoingEdges: Map<string, AnyWorkEdge[]>,
|
||||
visited: Set<string>,
|
||||
steps: WorkFlowStep[],
|
||||
): void {
|
||||
if (visited.has(nodeId) || nodeId === 'start' || nodeId === 'end') return;
|
||||
visited.add(nodeId);
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
|
||||
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);
|
||||
|
||||
let condition: string | null = null;
|
||||
if (edge.type === 'conditional') {
|
||||
const isElse = outEdges.length >= 2 && index === 0;
|
||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
||||
}
|
||||
|
||||
return { target, condition };
|
||||
});
|
||||
|
||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
||||
steps.push({
|
||||
role: { name, description, identity, prepare, execute, report },
|
||||
transitions,
|
||||
});
|
||||
|
||||
for (const edge of outEdges) {
|
||||
traverse(edge.target, nodeMap, outgoingEdges, visited, steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
WorkFlowRole,
|
||||
WorkFlowTransition,
|
||||
WorkFlowStep,
|
||||
WorkFlowSteps,
|
||||
} from "../../../shared/types.ts";
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
|
||||
export type ValidationError = {
|
||||
nodeId: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
};
|
||||
|
||||
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
const outgoing = buildEdgeMap(edges, 'source');
|
||||
const incoming = buildEdgeMap(edges, 'target');
|
||||
|
||||
const startNodes = nodes.filter(n => n.type === 'start');
|
||||
const endNodes = nodes.filter(n => n.type === 'end');
|
||||
const roleNodes = nodes.filter(n => n.type === 'role');
|
||||
|
||||
validateStartNode(startNodes, outgoing, errors);
|
||||
validateEndNode(endNodes, incoming, outgoing, errors);
|
||||
validateRoleNodes(roleNodes, outgoing, incoming, errors);
|
||||
validateRoleCount(roleNodes, errors);
|
||||
validateReachability(nodes, edges, startNodes, endNodes, errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function buildEdgeMap(
|
||||
edges: AnyWorkEdge[],
|
||||
key: 'source' | 'target',
|
||||
): Map<string, AnyWorkEdge[]> {
|
||||
const map = new Map<string, AnyWorkEdge[]>();
|
||||
for (const edge of edges) {
|
||||
const id = edge[key];
|
||||
if (!map.has(id)) {
|
||||
map.set(id, []);
|
||||
}
|
||||
map.get(id)!.push(edge);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function validateStartNode(
|
||||
startNodes: AnyWorkNode[],
|
||||
outgoing: Map<string, AnyWorkEdge[]>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (startNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 Start 节点' });
|
||||
return;
|
||||
}
|
||||
if (startNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'Start 节点只能有一个' });
|
||||
return;
|
||||
}
|
||||
|
||||
const startId = startNodes[0].id;
|
||||
const outEdges = outgoing.get(startId) ?? [];
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: startId, message: 'Start 节点必须有一个输出连接' });
|
||||
} else if (outEdges.length > 1) {
|
||||
errors.push({ nodeId: startId, message: 'Start 节点只能有一个输出连接' });
|
||||
}
|
||||
}
|
||||
|
||||
function validateEndNode(
|
||||
endNodes: AnyWorkNode[],
|
||||
incoming: Map<string, AnyWorkEdge[]>,
|
||||
outgoing: Map<string, AnyWorkEdge[]>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (endNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 End 节点' });
|
||||
return;
|
||||
}
|
||||
if (endNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'End 节点只能有一个' });
|
||||
return;
|
||||
}
|
||||
|
||||
const endId = endNodes[0].id;
|
||||
const inEdges = incoming.get(endId) ?? [];
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: endId, message: 'End 节点必须有至少一个输入连接' });
|
||||
}
|
||||
|
||||
const outEdges = outgoing.get(endId) ?? [];
|
||||
if (outEdges.length > 0) {
|
||||
errors.push({ nodeId: endId, message: 'End 节点不能有输出连接' });
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoleNodes(
|
||||
roleNodes: AnyWorkNode[],
|
||||
outgoing: Map<string, AnyWorkEdge[]>,
|
||||
incoming: Map<string, AnyWorkEdge[]>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
for (const node of roleNodes) {
|
||||
const inEdges = incoming.get(node.id) ?? [];
|
||||
const outEdges = outgoing.get(node.id) ?? [];
|
||||
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输入连接' });
|
||||
}
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输出连接' });
|
||||
}
|
||||
|
||||
if (outEdges.length > 1) {
|
||||
const conditionalEdges = outEdges.filter(e => e.type === 'conditional');
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
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: '条件边的条件表达式不能为空' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoleCount(
|
||||
roleNodes: AnyWorkNode[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (roleNodes.length < 2) {
|
||||
errors.push({ nodeId: null, message: '工作流至少需要 2 个角色节点' });
|
||||
}
|
||||
}
|
||||
|
||||
function validateReachability(
|
||||
nodes: AnyWorkNode[],
|
||||
edges: AnyWorkEdge[],
|
||||
startNodes: AnyWorkNode[],
|
||||
endNodes: AnyWorkNode[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (startNodes.length !== 1 || endNodes.length !== 1) return;
|
||||
|
||||
const forwardAdj = new Map<string, string[]>();
|
||||
const backwardAdj = new Map<string, string[]>();
|
||||
for (const edge of edges) {
|
||||
if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []);
|
||||
forwardAdj.get(edge.source)!.push(edge.target);
|
||||
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
|
||||
backwardAdj.get(edge.target)!.push(edge.source);
|
||||
}
|
||||
|
||||
const reachableFromStart = bfs(startNodes[0].id, forwardAdj);
|
||||
const reachableFromEnd = bfs(endNodes[0].id, backwardAdj);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'start' || node.type === 'end') continue;
|
||||
if (!reachableFromStart.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点不可从 Start 到达(孤立节点)' });
|
||||
}
|
||||
if (!reachableFromEnd.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点无法到达 End(死端节点)' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const queue = [startId];
|
||||
visited.add(startId);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const next of adj.get(current) ?? []) {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Node, Edge } from '@xyflow/react';
|
||||
|
||||
type AnyKeyBase = { [key: string]: unknown | undefined };
|
||||
|
||||
export type RoleNodeData = AnyKeyBase & {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
|
||||
export type NodeMap = {
|
||||
start: { label: string };
|
||||
end: { label: string };
|
||||
role: RoleNodeData;
|
||||
};
|
||||
|
||||
export type WorkNodeType = keyof NodeMap;
|
||||
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||
export type AnyWorkNode = WorkNode<'start'> | WorkNode<'end'> | WorkNode<'role'>;
|
||||
|
||||
export type ConditionalEdgeData = AnyKeyBase & {
|
||||
condition: string;
|
||||
};
|
||||
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, 'conditional'>;
|
||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
||||
@@ -0,0 +1,31 @@
|
||||
interface Maper<T> { [key: string]: T }
|
||||
type Listen<T> = (data: T) => void;
|
||||
|
||||
export class Eventer<M extends Maper<any>> {
|
||||
private lisenters = {} as { [K in keyof M]: Set<Function> };
|
||||
|
||||
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
|
||||
let set = this.lisenters[key];
|
||||
if (set == undefined) {
|
||||
set = new Set();
|
||||
this.lisenters[key] = set;
|
||||
}
|
||||
|
||||
set.add(lisenter);
|
||||
return () => this.off(key, lisenter);
|
||||
}
|
||||
|
||||
public off<K extends keyof M>(key: K, lisenter?: Listen<M[K]>) {
|
||||
const set = this.lisenters[key];
|
||||
if (set === undefined) return;
|
||||
if (lisenter === undefined) set.clear();
|
||||
else set.delete(lisenter);
|
||||
}
|
||||
|
||||
public emit<K extends keyof M>(key: K, data: M[K]) {
|
||||
const set = this.lisenters[key];
|
||||
if (set === undefined) return;
|
||||
// Todo: maybe implement stoping bubble
|
||||
set.forEach(call => call(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
|
||||
export function uuid() {
|
||||
const now = Date.now();
|
||||
const randon = 1 + Math.random();
|
||||
return Math.round(now * randon).toString(36);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
|
||||
function judge(container: HTMLElement, target: HTMLElement): boolean {
|
||||
if (container === target) {
|
||||
return true;
|
||||
}
|
||||
if (target === document.body) {
|
||||
return false;
|
||||
}
|
||||
let parent = target.parentElement;
|
||||
return parent ? judge(container, parent) : false;
|
||||
}
|
||||
|
||||
export function useClickOutRef<T extends HTMLElement>(
|
||||
callback: () => void,
|
||||
delay = 0,
|
||||
) {
|
||||
const ref = useRef<T>(null);
|
||||
const flag = useRef<boolean>(delay === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!delay) return;
|
||||
const timer = setTimeout(() => {
|
||||
flag.current = true;
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
useEffect(() => {
|
||||
function handle(ev: MouseEvent) {
|
||||
if (!flag.current) return;
|
||||
const container = ref.current;
|
||||
const target = ev.target as HTMLElement;
|
||||
if (container && target) {
|
||||
if (judge(container, target)) return;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handle);
|
||||
return () => document.removeEventListener('click', handle);
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius: 0.625rem;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--success: oklch(0.55 0.15 160);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.75 0.18 75);
|
||||
--warning-foreground: oklch(0.145 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--success: oklch(0.6 0.15 160);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.75 0.18 75);
|
||||
--warning-foreground: oklch(0.145 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router";
|
||||
import { router } from "./router.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(<RouterProvider router={router} />);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Pencil, Eye } from "lucide-react";
|
||||
|
||||
export function DetailPage(): ReactNode {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const editing = location.pathname.endsWith("/edit");
|
||||
const [model, setModel] = useState<FlowModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const nameRef = useRef(name);
|
||||
nameRef.current = name;
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
let cancelled = false;
|
||||
|
||||
fetch(`/api/workflows/${encodeURIComponent(name)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("not found");
|
||||
return res.json() as Promise<WorkFlowSteps>;
|
||||
})
|
||||
.then((steps) => {
|
||||
if (cancelled) return;
|
||||
const m = new FlowModel(steps.length > 0 ? steps : undefined);
|
||||
m.on("save", (savedSteps) => {
|
||||
const n = nameRef.current;
|
||||
if (!n) return;
|
||||
setSaving(true);
|
||||
fetch(`/api/workflows/${encodeURIComponent(n)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(savedSteps),
|
||||
}).then(() => setSaving(false));
|
||||
});
|
||||
setModel(m);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) navigate("/");
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [name, navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const basePath = `/workflow/${encodeURIComponent(name!)}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => navigate("/")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-base font-medium">{name}</h1>
|
||||
<div className="flex-1" />
|
||||
{editing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(basePath)}
|
||||
>
|
||||
<Eye className="size-3.5" data-icon="inline-start" />
|
||||
预览
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`${basePath}/edit`)}
|
||||
>
|
||||
<Pencil className="size-3.5" data-icon="inline-start" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{saving && <span className="text-xs text-muted-foreground">保存中...</span>}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{model && <FlowEditor model={model} readonly={!editing} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
|
||||
const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
{
|
||||
role: {
|
||||
name: "planner",
|
||||
description: "分析需求并制定实施计划",
|
||||
identity: "你是一位资深的技术架构师",
|
||||
prepare: "阅读用户需求,理解项目背景",
|
||||
execute: "制定详细的实施计划和步骤分解",
|
||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||
},
|
||||
transitions: [{ target: "developer", condition: null }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
name: "developer",
|
||||
description: "根据计划编写代码实现",
|
||||
identity: "你是一位经验丰富的全栈开发者",
|
||||
prepare: "阅读计划文档,理解技术要求",
|
||||
execute: "编写高质量的代码实现",
|
||||
report: "输出变更文件列表和实现摘要",
|
||||
},
|
||||
transitions: [{ target: "reviewer", condition: null }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
name: "reviewer",
|
||||
description: "审查代码质量并决定是否通过",
|
||||
identity: "你是一位严谨的代码审查员",
|
||||
prepare: "阅读代码变更和实现摘要",
|
||||
execute: "检查代码质量、安全性和最佳实践",
|
||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||
},
|
||||
transitions: [
|
||||
{ target: "END", condition: null },
|
||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function EditorPage(): ReactNode {
|
||||
const [model] = useState(() => new FlowModel(DEFAULT_STEPS));
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<FlowEditor model={model} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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 {
|
||||
const navigate = useNavigate();
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDesc, setNewDesc] = useState("");
|
||||
|
||||
const fetchWorkflows = useCallback(async () => {
|
||||
const res = await fetch("/api/workflows");
|
||||
const data = await res.json();
|
||||
setWorkflows(data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflows();
|
||||
}, [fetchWorkflows]);
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
await fetch("/api/workflows", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
||||
});
|
||||
setNewName("");
|
||||
setNewDesc("");
|
||||
setCreateOpen(false);
|
||||
fetchWorkflows();
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
await fetch(`/api/workflows/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
fetchWorkflows();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Workflows</h1>
|
||||
<p className="text-muted-foreground mt-1">管理你的工作流定义</p>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger render={<Button />}>
|
||||
<Plus className="size-4" data-icon="inline-start" />
|
||||
新建 Workflow
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 Workflow</DialogTitle>
|
||||
<DialogDescription>输入工作流的名称和描述</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="名称 (kebab-case,如 solve-issue)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="描述"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={!newName.trim()}>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground py-12 text-center">加载中...</div>
|
||||
) : workflows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<Workflow className="mx-auto size-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground mt-4">还没有任何 Workflow</p>
|
||||
<p className="text-muted-foreground/70 text-sm mt-1">点击上方按钮创建第一个工作流</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{workflows.map((wf) => (
|
||||
<Card
|
||||
key={wf.name}
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={() => navigate(`/workflow/${encodeURIComponent(wf.name)}`)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{wf.name}</CardTitle>
|
||||
<CardDescription>{wf.description || "无描述"}</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(wf.name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createBrowserRouter } from "react-router";
|
||||
import { Layout } from "./app.tsx";
|
||||
import { HomePage } from "./pages/home.tsx";
|
||||
import { DetailPage } from "./pages/detail.tsx";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
Component: Layout,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Component: HomePage,
|
||||
},
|
||||
{
|
||||
path: "workflow/:name",
|
||||
Component: DetailPage,
|
||||
},
|
||||
{
|
||||
path: "workflow/:name/edit",
|
||||
Component: DetailPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
查看 [context.md](./context.md) 获取上下文
|
||||
|
||||
<!-- 长任务写在这里 -->
|
||||
|
||||
任务结束后要按需更新 context 文档
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "server", "shared", "vite-dev.ts", "server.ts"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Plugin } from "vite";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createApi } from "./server/api.ts";
|
||||
|
||||
function buildRequest(req: IncomingMessage, body: string | null): Request {
|
||||
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (typeof value === "string") headers.set(key, value);
|
||||
else if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
||||
}
|
||||
return new Request(url, { method: req.method ?? "GET", headers, body });
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string | null> {
|
||||
if (req.method === "GET" || req.method === "HEAD") return null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString();
|
||||
}
|
||||
|
||||
export function elysiaPlugin(): Plugin {
|
||||
const api = createApi();
|
||||
|
||||
return {
|
||||
name: "elysia-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (!req.url?.startsWith("/api")) return next();
|
||||
|
||||
const body = await readBody(req);
|
||||
const request = buildRequest(req, body);
|
||||
const response = await api.handle(request);
|
||||
|
||||
res.statusCode = response.status;
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
res.end(await response.arrayBuffer());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import path from "node:path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { elysiaPlugin } from "./vite-dev.ts";
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), elysiaPlugin()],
|
||||
root: ".",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
@@ -81,7 +81,6 @@ export type StepOutput = {
|
||||
thread: ThreadId;
|
||||
head: CasRef;
|
||||
done: boolean;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
/** uwf thread steps — single step entry */
|
||||
|
||||
Reference in New Issue
Block a user