mirror of
https://github.com/HuangYuChuh/ComfyUI_Skills_OpenClaw.git
synced 2026-07-30 03:23:40 +00:00
refactor: separate frontend source into independent repository
Move frontend source code to HuangYuChuh/ComfyUI_Skills_OpenClaw-frontend so that cloning the main repo no longer downloads 1.4M of frontend source. Users only need the pre-built assets in ui/static/. - Remove frontend/ directory (now lives in its own repo) - Add scripts/update_frontend.sh to pull latest release assets - Remove frontend CI job (handled by frontend repo) - Move Gemfile/Gemfile.lock to docs/ and update serve_pages.sh - Clean up .gitignore (remove stale paths and Manus files section) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,30 +30,3 @@ jobs:
|
||||
|
||||
- name: Run Python compile checks
|
||||
run: python -m compileall scripts ui
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
@@ -54,11 +54,3 @@ vendor/bundle/
|
||||
docs/_site/
|
||||
|
||||
# Project specific
|
||||
ui/frontend/dist/
|
||||
ui/frontend/node_modules/
|
||||
|
||||
# Manus Files
|
||||
task_plan.md
|
||||
findings.md
|
||||
progress.md
|
||||
README_EN_STYLE_GUIDE.private.md
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ComfyUI OpenClaw Skill UI</title>
|
||||
<link rel="icon" type="image/png" href="/static/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/static/logo.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-3171
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "comfyui-skill-openclaw-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 898 KiB |
@@ -1,599 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
listServersMock,
|
||||
addServerMock,
|
||||
updateServerMock,
|
||||
toggleServerMock,
|
||||
deleteServerMock,
|
||||
listWorkflowsMock,
|
||||
getWorkflowDetailMock,
|
||||
saveWorkflowMock,
|
||||
toggleWorkflowMock,
|
||||
deleteWorkflowMock,
|
||||
reorderWorkflowsMock,
|
||||
runWorkflowMock,
|
||||
previewTransferExportMock,
|
||||
buildTransferExportMock,
|
||||
previewTransferImportMock,
|
||||
importTransferBundleMock,
|
||||
} = vi.hoisted(() => ({
|
||||
listServersMock: vi.fn(),
|
||||
addServerMock: vi.fn(),
|
||||
updateServerMock: vi.fn(),
|
||||
toggleServerMock: vi.fn(),
|
||||
deleteServerMock: vi.fn(),
|
||||
listWorkflowsMock: vi.fn(),
|
||||
getWorkflowDetailMock: vi.fn(),
|
||||
saveWorkflowMock: vi.fn(),
|
||||
toggleWorkflowMock: vi.fn(),
|
||||
deleteWorkflowMock: vi.fn(),
|
||||
reorderWorkflowsMock: vi.fn(),
|
||||
runWorkflowMock: vi.fn(),
|
||||
previewTransferExportMock: vi.fn(),
|
||||
buildTransferExportMock: vi.fn(),
|
||||
previewTransferImportMock: vi.fn(),
|
||||
importTransferBundleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./services/servers", () => ({
|
||||
listServers: listServersMock,
|
||||
addServer: addServerMock,
|
||||
updateServer: updateServerMock,
|
||||
toggleServer: toggleServerMock,
|
||||
deleteServer: deleteServerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./services/workflows", () => ({
|
||||
listWorkflows: listWorkflowsMock,
|
||||
getWorkflowDetail: getWorkflowDetailMock,
|
||||
saveWorkflow: saveWorkflowMock,
|
||||
toggleWorkflow: toggleWorkflowMock,
|
||||
deleteWorkflow: deleteWorkflowMock,
|
||||
reorderWorkflows: reorderWorkflowsMock,
|
||||
runWorkflow: runWorkflowMock,
|
||||
}));
|
||||
|
||||
vi.mock("./services/transfer", () => ({
|
||||
previewTransferExport: previewTransferExportMock,
|
||||
buildTransferExport: buildTransferExportMock,
|
||||
previewTransferImport: previewTransferImportMock,
|
||||
importTransferBundle: importTransferBundleMock,
|
||||
}));
|
||||
|
||||
vi.mock("./lib/pixelBlastBackground", () => ({
|
||||
initPixelBlastBackground: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const serverFixture = {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
url: "http://127.0.0.1:8188",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
};
|
||||
|
||||
const remoteServerFixture = {
|
||||
id: "remote",
|
||||
name: "Remote",
|
||||
url: "http://10.0.0.1:8188",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
};
|
||||
|
||||
const unsupportedServerFixture = {
|
||||
id: "legacy-remote-v2",
|
||||
name: "Legacy Remote",
|
||||
url: "http://legacy-remote.invalid:8188",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
server_type: "legacy_remote_v2",
|
||||
unsupported: true,
|
||||
unsupported_reason: "Server type \"legacy_remote_v2\" is not supported in this branch.",
|
||||
};
|
||||
|
||||
const workflowFixture = {
|
||||
id: "wf-a",
|
||||
server_id: "local",
|
||||
server_name: "Local",
|
||||
enabled: true,
|
||||
description: "First workflow",
|
||||
updated_at: 10,
|
||||
};
|
||||
|
||||
const workflowApiJson = JSON.stringify({
|
||||
"1": {
|
||||
class_type: "CLIPTextEncode",
|
||||
inputs: {
|
||||
text: "hello world",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const exportPreviewFixture = {
|
||||
portable_only: false,
|
||||
summary: {
|
||||
servers: 1,
|
||||
workflows: 1,
|
||||
warnings: 0,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
server_id: "local",
|
||||
name: "Local",
|
||||
enabled: true,
|
||||
selected: true,
|
||||
workflow_count: 1,
|
||||
workflows: [
|
||||
{
|
||||
workflow_id: "wf-a",
|
||||
enabled: true,
|
||||
description: "First workflow",
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const importPreviewFixture = {
|
||||
validation: {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
},
|
||||
plan: {
|
||||
created_servers: [{ server_id: "remote", reason: "create_server" }],
|
||||
updated_servers: [],
|
||||
created_workflows: [{ server_id: "remote", workflow_id: "wf-b", reason: "create_workflow" }],
|
||||
overwritten_workflows: [],
|
||||
skipped_items: [],
|
||||
warnings: [],
|
||||
apply_environment: false,
|
||||
overwrite_workflows: true,
|
||||
summary: {
|
||||
created_servers: 1,
|
||||
updated_servers: 0,
|
||||
created_workflows: 1,
|
||||
overwritten_workflows: 0,
|
||||
skipped_items: 0,
|
||||
warnings: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const importPreviewFixtureLatest = {
|
||||
validation: {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
},
|
||||
plan: {
|
||||
created_servers: [{ server_id: "remote-latest", reason: "create_server" }],
|
||||
updated_servers: [],
|
||||
created_workflows: [{ server_id: "remote-latest", workflow_id: "wf-latest", reason: "create_workflow" }],
|
||||
overwritten_workflows: [],
|
||||
skipped_items: [],
|
||||
warnings: [],
|
||||
apply_environment: false,
|
||||
overwrite_workflows: true,
|
||||
summary: {
|
||||
created_servers: 1,
|
||||
updated_servers: 0,
|
||||
created_workflows: 1,
|
||||
overwritten_workflows: 0,
|
||||
skipped_items: 0,
|
||||
warnings: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function uploadWorkflowFile(fileName = "workflow_api.json", content = workflowApiJson) {
|
||||
const fileInput = document.getElementById("file-upload") as HTMLInputElement;
|
||||
const file = new File([content], fileName, { type: "application/json" });
|
||||
Object.defineProperty(file, "text", {
|
||||
value: async () => content,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await user.upload(fileInput, file);
|
||||
}
|
||||
|
||||
async function enterEditorWithUploadedWorkflow() {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", { name: "+ New Workflow" });
|
||||
await user.click(screen.getByRole("button", { name: "+ New Workflow" }));
|
||||
await user.type(screen.getByLabelText(/Workflow ID/i), "wf-basic");
|
||||
await uploadWorkflowFile();
|
||||
await screen.findByText("Parsed Input Node List");
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
listServersMock.mockResolvedValue({
|
||||
servers: [serverFixture],
|
||||
default_server: serverFixture.id,
|
||||
});
|
||||
addServerMock.mockResolvedValue({ status: "ok", server: serverFixture });
|
||||
updateServerMock.mockResolvedValue({ status: "ok", server: serverFixture });
|
||||
toggleServerMock.mockResolvedValue({ status: "ok", enabled: true });
|
||||
deleteServerMock.mockResolvedValue({ status: "ok" });
|
||||
listWorkflowsMock.mockResolvedValue({ workflows: [workflowFixture] });
|
||||
getWorkflowDetailMock.mockResolvedValue({
|
||||
workflow_id: workflowFixture.id,
|
||||
server_id: workflowFixture.server_id,
|
||||
description: workflowFixture.description,
|
||||
enabled: workflowFixture.enabled,
|
||||
workflow_data: JSON.parse(workflowApiJson),
|
||||
schema_params: {},
|
||||
});
|
||||
saveWorkflowMock.mockResolvedValue({ status: "ok", workflow_id: "wf-basic" });
|
||||
toggleWorkflowMock.mockResolvedValue({ status: "ok", enabled: true });
|
||||
deleteWorkflowMock.mockResolvedValue({ status: "ok" });
|
||||
reorderWorkflowsMock.mockResolvedValue({ status: "ok", workflow_order: [] });
|
||||
runWorkflowMock.mockResolvedValue({ status: "ok", result: { images: [] } });
|
||||
previewTransferExportMock.mockResolvedValue(exportPreviewFixture);
|
||||
buildTransferExportMock.mockResolvedValue({ bundle: { ok: true }, preview: exportPreviewFixture });
|
||||
previewTransferImportMock.mockResolvedValue(importPreviewFixture);
|
||||
importTransferBundleMock.mockResolvedValue({
|
||||
status: "success",
|
||||
validation: importPreviewFixture.validation,
|
||||
plan: importPreviewFixture.plan,
|
||||
});
|
||||
Object.defineProperty(window.URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(() => "blob:mock"),
|
||||
});
|
||||
Object.defineProperty(window.URL, "revokeObjectURL", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("saves with Ctrl/Cmd+S while editing when no modal is open", async () => {
|
||||
await enterEditorWithUploadedWorkflow();
|
||||
|
||||
fireEvent.keyDown(document, { key: "s", ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveWorkflowMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not save with Ctrl/Cmd+S while a confirm modal is open", async () => {
|
||||
const user = await enterEditorWithUploadedWorkflow();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await screen.findByText("You have unsaved changes in the editor. Leave anyway?");
|
||||
|
||||
fireEvent.keyDown(document, { key: "s", ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveWorkflowMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches from upload zone to mapping section after a workflow file is uploaded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", { name: "+ New Workflow" });
|
||||
await user.click(screen.getByRole("button", { name: "+ New Workflow" }));
|
||||
|
||||
expect(screen.getByText("Drag or click to upload ComfyUI workflow_api.json")).toBeInTheDocument();
|
||||
expect(document.getElementById("mapping-section")).toHaveClass("hidden");
|
||||
|
||||
await user.type(screen.getByLabelText(/Workflow ID/i), "wf-basic");
|
||||
await uploadWorkflowFile();
|
||||
|
||||
await screen.findByText("Parsed Input Node List");
|
||||
expect(screen.queryByText("Drag or click to upload ComfyUI workflow_api.json")).not.toBeInTheDocument();
|
||||
expect(document.getElementById("mapping-section")).not.toHaveClass("hidden");
|
||||
});
|
||||
|
||||
it("opens the editor when a workflow file is dropped onto the empty workflow state", async () => {
|
||||
const user = userEvent.setup();
|
||||
listWorkflowsMock.mockResolvedValue({ workflows: [] });
|
||||
render(<App />);
|
||||
|
||||
await screen.findByText("Drag or click to upload ComfyUI workflow_api.json");
|
||||
const dropzone = screen.getByText("Drag or click to upload ComfyUI workflow_api.json").closest("label") as HTMLElement;
|
||||
const file = new File([workflowApiJson], "workflow_api.json", { type: "application/json" });
|
||||
Object.defineProperty(file, "text", {
|
||||
value: async () => workflowApiJson,
|
||||
});
|
||||
|
||||
fireEvent.drop(dropzone, {
|
||||
dataTransfer: {
|
||||
files: [file],
|
||||
},
|
||||
});
|
||||
|
||||
await screen.findByText("Parsed Input Node List");
|
||||
expect(screen.getByDisplayValue("workflow_api")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No workflow mappings configured yet.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens workflow actions and triggers upload new version", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
const trigger = await screen.findByRole("button", { name: "More actions for workflow wf-a" });
|
||||
await user.click(trigger);
|
||||
|
||||
const menu = trigger.closest(".workflow-more");
|
||||
const uploadItem = within(menu as HTMLElement).getByRole("menuitem", { name: "Upload New Version" });
|
||||
await user.click(uploadItem);
|
||||
|
||||
expect(getWorkflowDetailMock).toHaveBeenCalledWith("local", "wf-a");
|
||||
});
|
||||
|
||||
it("submits a new server using the plain ComfyUI payload shape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", { name: "Add Server" });
|
||||
await user.click(screen.getByRole("button", { name: "Add Server" }));
|
||||
|
||||
await user.type(screen.getByLabelText("Server ID"), "remote");
|
||||
await user.type(screen.getByLabelText("Server Name"), "Remote");
|
||||
|
||||
const urlInput = screen.getByLabelText("Server URL");
|
||||
fireEvent.change(urlInput, { target: { value: "http://10.0.0.1:8188" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save and Connect" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addServerMock).toHaveBeenCalledWith({
|
||||
id: "remote",
|
||||
name: "Remote",
|
||||
url: "http://10.0.0.1:8188",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when legacy unsupported servers are loaded and blocks creating workflows on them", async () => {
|
||||
const user = userEvent.setup();
|
||||
listServersMock.mockResolvedValue({
|
||||
servers: [unsupportedServerFixture],
|
||||
default_server: unsupportedServerFixture.id,
|
||||
});
|
||||
listWorkflowsMock.mockResolvedValue({ workflows: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect((await screen.findAllByText(/Server type "legacy_remote_v2" is not supported in this branch/)).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Legacy Remote (Unsupported)")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "+ New Workflow" }));
|
||||
|
||||
expect(screen.getAllByText(/Server type "legacy_remote_v2" is not supported in this branch/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to another server after deleting the currently selected one", async () => {
|
||||
const user = userEvent.setup();
|
||||
listServersMock
|
||||
.mockResolvedValueOnce({
|
||||
servers: [serverFixture, remoteServerFixture],
|
||||
default_server: serverFixture.id,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
servers: [remoteServerFixture],
|
||||
default_server: remoteServerFixture.id,
|
||||
});
|
||||
listWorkflowsMock.mockResolvedValue({ workflows: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
await screen.findByText("Local");
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await screen.findByText("Delete server local? Data files will NOT be removed.");
|
||||
|
||||
const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
|
||||
await user.click(deleteButtons[deleteButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteServerMock).toHaveBeenCalledWith("local", false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Remote")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Local")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("edits the visible fallback server instead of a stale deleted server id", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.setItem("ui-server", "cloud");
|
||||
listServersMock.mockResolvedValue({
|
||||
servers: [serverFixture],
|
||||
default_server: serverFixture.id,
|
||||
});
|
||||
listWorkflowsMock.mockResolvedValue({ workflows: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
await screen.findByText("Local");
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await screen.findByDisplayValue("Local");
|
||||
|
||||
const nameInput = screen.getByLabelText("Server Name");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "Local Updated");
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateServerMock).toHaveBeenCalledWith("local", {
|
||||
id: "local",
|
||||
name: "Local Updated",
|
||||
url: "http://127.0.0.1:8188",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("restores export config entry in the main shell and downloads a selected bundle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Export Config" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await within(dialog).findByRole("button", { name: "Download Bundle" });
|
||||
await within(dialog).findByText("wf-a");
|
||||
|
||||
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||
await user.click(screen.getByRole("button", { name: "Download Bundle" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(buildTransferExportMock).toHaveBeenCalledWith({
|
||||
servers: [{ server_id: "local", workflow_ids: ["wf-a"] }],
|
||||
});
|
||||
});
|
||||
|
||||
anchorClick.mockRestore();
|
||||
});
|
||||
|
||||
it("shows export-specific copy when export preview fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
previewTransferExportMock.mockRejectedValueOnce(new Error(""));
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Export Config" }));
|
||||
|
||||
expect(await screen.findByText("Failed to preview the export bundle.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the transfer dialog open while an export bundle is loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
const exportDeferred = createDeferred<{ bundle: { ok: boolean }; preview: typeof exportPreviewFixture }>();
|
||||
buildTransferExportMock.mockReturnValue(exportDeferred.promise);
|
||||
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Export Config" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const cancelButton = within(dialog).getByRole("button", { name: "Cancel" });
|
||||
await user.click(within(dialog).getByRole("button", { name: "Download Bundle" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
await user.click(cancelButton);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
exportDeferred.resolve({ bundle: { ok: true }, preview: exportPreviewFixture });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
anchorClick.mockRestore();
|
||||
});
|
||||
|
||||
it("shows export-specific copy when export bundle build fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
buildTransferExportMock.mockRejectedValueOnce(new Error(""));
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Export Config" }));
|
||||
await screen.findByRole("dialog");
|
||||
await user.click(screen.getByRole("button", { name: "Download Bundle" }));
|
||||
|
||||
expect(await screen.findByText("Failed to build the export bundle.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores import config entry in the main shell and imports a selected bundle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", { name: "Import Config" });
|
||||
|
||||
const bundleFile = new File([JSON.stringify({ bundle_type: "openclaw-comfyui-skill" })], "openclaw-skill-export.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
Object.defineProperty(bundleFile, "text", {
|
||||
value: async () => JSON.stringify({ bundle_type: "openclaw-comfyui-skill" }),
|
||||
});
|
||||
|
||||
const importInput = document.getElementById("transfer-import-file") as HTMLInputElement;
|
||||
await user.upload(importInput, bundleFile);
|
||||
await screen.findByRole("button", { name: "Import Bundle" });
|
||||
await user.click(screen.getByRole("button", { name: "Import Bundle" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importTransferBundleMock).toHaveBeenCalledWith({ bundle_type: "openclaw-comfyui-skill" }, false, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the latest import preview when bundle previews resolve out of order", async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstPreview = createDeferred<typeof importPreviewFixture>();
|
||||
const secondPreview = createDeferred<typeof importPreviewFixtureLatest>();
|
||||
previewTransferImportMock
|
||||
.mockReturnValueOnce(firstPreview.promise)
|
||||
.mockReturnValueOnce(secondPreview.promise);
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", { name: "Import Config" });
|
||||
|
||||
const firstBundleContent = JSON.stringify({ bundle_type: "openclaw-comfyui-skill", bundle_id: "first" });
|
||||
const secondBundleContent = JSON.stringify({ bundle_type: "openclaw-comfyui-skill", bundle_id: "second" });
|
||||
const firstBundle = new File([firstBundleContent], "first.json", { type: "application/json" });
|
||||
const secondBundle = new File([secondBundleContent], "second.json", { type: "application/json" });
|
||||
|
||||
Object.defineProperty(firstBundle, "text", {
|
||||
value: async () => firstBundleContent,
|
||||
});
|
||||
Object.defineProperty(secondBundle, "text", {
|
||||
value: async () => secondBundleContent,
|
||||
});
|
||||
|
||||
const importInput = document.getElementById("transfer-import-file") as HTMLInputElement;
|
||||
await user.upload(importInput, firstBundle);
|
||||
await user.upload(importInput, secondBundle);
|
||||
|
||||
secondPreview.resolve(importPreviewFixtureLatest);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("remote-latest/wf-latest")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
firstPreview.resolve(importPreviewFixture);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("remote-latest/wf-latest")).toBeInTheDocument();
|
||||
expect(screen.queryByText("remote/wf-b")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { ConfirmDialog } from "./components/ui/ConfirmDialog";
|
||||
import { ToastViewport } from "./components/ui/ToastViewport";
|
||||
import { EditorView } from "./features/editor/EditorView";
|
||||
import { TransferModal } from "./features/transfer/TransferModal";
|
||||
import { MainShell } from "./app/MainShell";
|
||||
import { useAppController } from "./app/useAppController";
|
||||
|
||||
export default function App() {
|
||||
const controller = useAppController();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastViewport toasts={controller.toasts} onDismiss={controller.dismissToast} />
|
||||
{controller.viewMode === "main" ? (
|
||||
<MainShell
|
||||
language={controller.language}
|
||||
setLanguage={controller.setLanguage}
|
||||
servers={controller.servers}
|
||||
currentServer={controller.currentServer}
|
||||
visibleWorkflows={controller.visibleWorkflows}
|
||||
currentServerWorkflowsCount={controller.currentServerWorkflows.length}
|
||||
serverModalOpen={controller.serverModalOpen}
|
||||
serverModalMode={controller.serverModalMode}
|
||||
serverForm={controller.serverForm}
|
||||
workflowSearch={controller.workflowSearch}
|
||||
workflowSort={controller.workflowSort}
|
||||
onSelectServer={controller.setCurrentServerId}
|
||||
onToggleServer={controller.handleToggleServer}
|
||||
onDeleteServer={controller.requestDeleteServer}
|
||||
onOpenTransferExport={controller.handleOpenTransferExport}
|
||||
onOpenTransferImport={controller.handleOpenTransferImport}
|
||||
onOpenCreateServer={controller.handleAddServer}
|
||||
onOpenEditServer={controller.handleEditServer}
|
||||
onServerFormChange={controller.setServerForm}
|
||||
onCloseServerModal={() => controller.setServerModalOpen(false)}
|
||||
onSubmitServerModal={controller.handleSubmitServerModal}
|
||||
onWorkflowSearchChange={controller.setWorkflowSearch}
|
||||
onWorkflowSortChange={controller.setWorkflowSort}
|
||||
onCreateWorkflow={controller.createWorkflow}
|
||||
onCreateWorkflowFromFile={controller.createWorkflowFromFile}
|
||||
onEditWorkflow={controller.handleEditWorkflow}
|
||||
onDeleteWorkflow={controller.handleDeleteWorkflow}
|
||||
onToggleWorkflow={controller.handleToggleWorkflow}
|
||||
onUploadWorkflowVersion={controller.handleUploadWorkflowVersion}
|
||||
onReorderWorkflows={controller.handleReorderWorkflows}
|
||||
t={controller.t}
|
||||
/>
|
||||
) : (
|
||||
<EditorView
|
||||
workflowId={controller.editorState.workflowId}
|
||||
description={controller.editorState.description}
|
||||
schemaParams={controller.editorState.schemaParams}
|
||||
hasWorkflow={Boolean(controller.editorState.workflowData)}
|
||||
emptyStateMessageKey={controller.editorEmptyStateMessageKey}
|
||||
mode={controller.editorState.editingWorkflowId ? "edit" : "create"}
|
||||
editingWorkflowId={controller.editorState.editingWorkflowId}
|
||||
upgradeSummary={controller.editorState.upgradeSummary}
|
||||
filters={controller.editorFilters}
|
||||
collapsedNodeIds={controller.collapsedNodeIds}
|
||||
expandedParamKeys={controller.expandedParamKeys}
|
||||
groupedNodes={controller.groupedNodes}
|
||||
summaryText={controller.mappingSummaryText}
|
||||
searchInputRef={controller.mappingSearchRef}
|
||||
onBack={controller.handleBackFromEditor}
|
||||
onWorkflowIdChange={controller.handleWorkflowIdChange}
|
||||
onDescriptionChange={(value) => controller.setEditorState((current) => ({ ...current, description: value, hasUnsavedChanges: true }))}
|
||||
onUploadFile={controller.handleEditorUpload}
|
||||
onSave={controller.handleSaveWorkflow}
|
||||
onFilterChange={(next) => controller.setEditorFilters((current) => ({ ...current, ...next }))}
|
||||
onResetFilters={() => controller.setEditorFilters({
|
||||
query: "",
|
||||
exposedOnly: false,
|
||||
requiredOnly: false,
|
||||
nodeSort: "node_id_asc",
|
||||
paramSort: "default",
|
||||
})}
|
||||
onToggleNode={(nodeId) => controller.setCollapsedNodeIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId);
|
||||
return next;
|
||||
})}
|
||||
onToggleParamConfig={(key) => controller.setExpandedParamKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
})}
|
||||
onUpdateParam={controller.updateEditorParam}
|
||||
onApplyRecommended={controller.applyRecommendedExposures}
|
||||
onExposeVisible={controller.exposeVisible}
|
||||
onCollapseAll={(collapsed) => {
|
||||
if (collapsed) {
|
||||
controller.setCollapsedNodeIds(new Set(controller.groupedNodes.map(([nodeId]) => nodeId)));
|
||||
} else {
|
||||
controller.setCollapsedNodeIds(new Set());
|
||||
}
|
||||
}}
|
||||
t={controller.t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={controller.confirmState.open}
|
||||
title={controller.confirmState.title}
|
||||
message={controller.confirmState.message}
|
||||
confirmLabel={controller.confirmState.confirmLabel}
|
||||
cancelLabel={controller.confirmState.cancelLabel}
|
||||
tone={controller.confirmState.tone}
|
||||
checkboxLabel={controller.confirmState.checkboxLabel}
|
||||
checkboxChecked={controller.confirmState.checkboxChecked}
|
||||
onCheckboxChange={(checked) => controller.setConfirmState((current) => ({ ...current, checkboxChecked: checked }))}
|
||||
onCancel={() => controller.resolveConfirm(false)}
|
||||
onConfirm={() => controller.resolveConfirm(true)}
|
||||
/>
|
||||
|
||||
<TransferModal
|
||||
open={controller.transferState.open}
|
||||
mode={controller.transferState.mode}
|
||||
exportPreview={controller.transferState.exportPreview}
|
||||
exportSelection={controller.transferState.exportSelection}
|
||||
expandedServerIds={controller.transferState.expandedServerIds}
|
||||
importPreviewSummary={controller.importPreviewSummary}
|
||||
importSections={controller.importSections}
|
||||
importWarnings={(controller.transferState.importPreview?.plan?.warnings || []).map((warning) => warning.message)}
|
||||
applyEnvironment={controller.transferState.applyEnvironment}
|
||||
loading={controller.transferState.loading}
|
||||
onClose={controller.closeTransferModal}
|
||||
onConfirm={controller.handleConfirmTransfer}
|
||||
onToggleServerSelection={(server) => controller.toggleTransferServerSelection(
|
||||
server.server_id,
|
||||
server.workflows.map((workflow) => workflow.workflow_id),
|
||||
)}
|
||||
onToggleWorkflowSelection={controller.toggleTransferWorkflowSelection}
|
||||
onToggleServerExpanded={controller.toggleTransferServerExpanded}
|
||||
onApplyEnvironmentChange={(checked) => controller.setTransferState((current) => ({ ...current, applyEnvironment: checked }))}
|
||||
t={controller.t}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={controller.versionUploadRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
controller.handleVersionFileChange(event.target.files?.[0] || null);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
id="transfer-import-file"
|
||||
ref={controller.transferImportRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
controller.handleTransferImportFile(event.target.files?.[0] || null);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { CustomSelect } from "../components/ui/CustomSelect";
|
||||
import { ServerManager } from "../features/servers/ServerManager";
|
||||
import { WorkflowManager } from "../features/workflows/WorkflowManager";
|
||||
import type { SaveServerPayload, ServerDto, WorkflowSummaryDto } from "../types/api";
|
||||
import type { Language } from "../i18n";
|
||||
import type { ServerModalMode, TranslateFn } from "./state";
|
||||
|
||||
interface MainShellProps {
|
||||
language: Language;
|
||||
setLanguage: (language: Language) => void;
|
||||
servers: ServerDto[];
|
||||
currentServer: ServerDto | null;
|
||||
visibleWorkflows: WorkflowSummaryDto[];
|
||||
currentServerWorkflowsCount: number;
|
||||
serverModalOpen: boolean;
|
||||
serverModalMode: ServerModalMode;
|
||||
serverForm: SaveServerPayload;
|
||||
workflowSearch: string;
|
||||
workflowSort: string;
|
||||
onSelectServer: (serverId: string) => void;
|
||||
onToggleServer: (server: ServerDto, enabled: boolean) => void;
|
||||
onDeleteServer: (server: ServerDto) => void;
|
||||
onOpenTransferExport: () => void;
|
||||
onOpenTransferImport: () => void;
|
||||
onOpenCreateServer: () => void;
|
||||
onOpenEditServer: (server: ServerDto) => void;
|
||||
onServerFormChange: (next: SaveServerPayload) => void;
|
||||
onCloseServerModal: () => void;
|
||||
onSubmitServerModal: () => void;
|
||||
onWorkflowSearchChange: (value: string) => void;
|
||||
onWorkflowSortChange: (value: string) => void;
|
||||
onCreateWorkflow: () => void;
|
||||
onCreateWorkflowFromFile: (file: File | null) => void;
|
||||
onEditWorkflow: (workflow: WorkflowSummaryDto) => void;
|
||||
onDeleteWorkflow: (workflow: WorkflowSummaryDto) => void;
|
||||
onToggleWorkflow: (workflow: WorkflowSummaryDto, enabled: boolean) => void;
|
||||
onUploadWorkflowVersion: (workflow: WorkflowSummaryDto) => void;
|
||||
onReorderWorkflows: (sourceWorkflowId: string, targetWorkflowId: string, placeAfter: boolean) => void;
|
||||
t: TranslateFn;
|
||||
}
|
||||
|
||||
export function MainShell(props: MainShellProps) {
|
||||
return (
|
||||
<main className="page shell">
|
||||
<header className="page-header">
|
||||
<div className="logo-frame" aria-hidden="true">
|
||||
<img className="logo-image" src="/static/logo.png" alt="ComfyUI OpenClaw logo" />
|
||||
</div>
|
||||
<div className="page-title-group">
|
||||
<h1>{props.t("title")}</h1>
|
||||
<p className="subtitle">{props.t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="page-header-actions">
|
||||
<button type="button" className="btn btn-secondary panel-action-btn" onClick={props.onOpenTransferExport}>
|
||||
{props.t("export_bundle")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary panel-action-btn" onClick={props.onOpenTransferImport}>
|
||||
{props.t("import_bundle")}
|
||||
</button>
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={props.language}
|
||||
options={[
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "zh", label: "简体中文" },
|
||||
{ value: "zh_hant", label: "繁體中文" },
|
||||
]}
|
||||
onChange={(value) => props.setLanguage(value as Language)}
|
||||
ariaLabel="Language selector"
|
||||
className="is-lang-select"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<ServerManager
|
||||
servers={props.servers}
|
||||
currentServerId={props.currentServer?.id || null}
|
||||
onSelectServer={props.onSelectServer}
|
||||
onToggleServer={props.onToggleServer}
|
||||
onDeleteServer={props.onDeleteServer}
|
||||
onOpenCreate={props.onOpenCreateServer}
|
||||
onOpenEdit={props.onOpenEditServer}
|
||||
modalOpen={props.serverModalOpen}
|
||||
modalMode={props.serverModalMode}
|
||||
form={props.serverForm}
|
||||
onFormChange={props.onServerFormChange}
|
||||
onCloseModal={props.onCloseServerModal}
|
||||
onSubmitModal={props.onSubmitServerModal}
|
||||
t={props.t}
|
||||
/>
|
||||
|
||||
<WorkflowManager
|
||||
workflows={props.visibleWorkflows}
|
||||
allWorkflowsForCurrentServer={props.currentServerWorkflowsCount}
|
||||
search={props.workflowSearch}
|
||||
sort={props.workflowSort}
|
||||
onSearchChange={props.onWorkflowSearchChange}
|
||||
onSortChange={props.onWorkflowSortChange}
|
||||
onCreateWorkflow={props.onCreateWorkflow}
|
||||
onCreateWorkflowFromFile={props.onCreateWorkflowFromFile}
|
||||
onEditWorkflow={props.onEditWorkflow}
|
||||
onDeleteWorkflow={props.onDeleteWorkflow}
|
||||
onToggleWorkflow={props.onToggleWorkflow}
|
||||
onUploadWorkflowVersion={props.onUploadWorkflowVersion}
|
||||
onReorderWorkflows={props.onReorderWorkflows}
|
||||
t={props.t}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { applyEditorParamUpdate, applyVisibleExposure } from "../lib/editorState";
|
||||
import { buildFinalSchema, migrateSchemaParams, parseWorkflowUpload, suggestWorkflowId } from "../lib/workflowMapper";
|
||||
import type { EditorState, SchemaParam, SchemaParamMap } from "../types/editor";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import type { WorkflowDetailDto } from "../types/api";
|
||||
import { applyRecommendedExposureSet } from "./editorUtils";
|
||||
import { persistWorkflow } from "./workflowPersistence";
|
||||
import { defaultEditorState } from "./state";
|
||||
import type { TranslateFn, ViewMode } from "./state";
|
||||
import type { MappingNodeGroup } from "../features/editor/types";
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
tone: "primary" | "danger";
|
||||
}
|
||||
|
||||
interface CreateEditorActionsArgs {
|
||||
editorState: EditorState;
|
||||
setEditorState: Dispatch<SetStateAction<EditorState>>;
|
||||
expandedParamKeys: Set<string>;
|
||||
setExpandedParamKeys: Dispatch<SetStateAction<Set<string>>>;
|
||||
groupedNodes: Array<[string, MappingNodeGroup]>;
|
||||
lastAutoWorkflowId: string;
|
||||
setLastAutoWorkflowId: Dispatch<SetStateAction<string>>;
|
||||
currentServer: { unsupported?: boolean; server_type?: string } | null;
|
||||
effectiveServerId: string | null;
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
refreshWorkflows: () => Promise<void>;
|
||||
pushToast: (type: "success" | "error" | "info", message: string) => void;
|
||||
setViewMode: Dispatch<SetStateAction<ViewMode>>;
|
||||
resetEditor: () => void;
|
||||
resetEditorUiState: () => void;
|
||||
t: TranslateFn;
|
||||
pendingVersionTargetRef: MutableRefObject<WorkflowDetailDto | null>;
|
||||
}
|
||||
|
||||
export function createEditorActions(args: CreateEditorActionsArgs) {
|
||||
async function ensureCanLeaveEditor() {
|
||||
if (!args.editorState.hasUnsavedChanges) {
|
||||
return true;
|
||||
}
|
||||
return args.confirm({
|
||||
title: args.t("confirm_action_title"),
|
||||
message: args.t("confirm_unsaved_leave"),
|
||||
confirmLabel: args.t("leave_anyway"),
|
||||
cancelLabel: args.t("cancel"),
|
||||
tone: "primary",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBackFromEditor() {
|
||||
if (!(await ensureCanLeaveEditor())) {
|
||||
return;
|
||||
}
|
||||
args.resetEditor();
|
||||
args.setViewMode("main");
|
||||
}
|
||||
|
||||
async function handleVersionFileChange(file: File | null) {
|
||||
const target = args.pendingVersionTargetRef.current;
|
||||
args.pendingVersionTargetRef.current = null;
|
||||
if (!file || !target) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { buildVersionUpgradeState } = await import("./versionUpgrade");
|
||||
const nextEditorState = buildVersionUpgradeState(target, await file.text());
|
||||
args.setEditorState(nextEditorState);
|
||||
args.resetEditorUiState();
|
||||
args.setViewMode("editor");
|
||||
args.pushToast("success", args.t("workflow_upgrade_summary", {
|
||||
retained: nextEditorState.upgradeSummary?.retained || 0,
|
||||
review: nextEditorState.upgradeSummary?.review || 0,
|
||||
added: nextEditorState.upgradeSummary?.added || 0,
|
||||
removed: nextEditorState.upgradeSummary?.removed || 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_invalid_json"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditorUpload(file: File | null) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!args.effectiveServerId) {
|
||||
args.pushToast("error", args.t("err_select_server_first"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = parseWorkflowUpload(await file.text());
|
||||
const migration = args.editorState.editingWorkflowId
|
||||
? migrateSchemaParams(args.editorState.schemaParams, parsed.schemaParams) as {
|
||||
schemaParams: SchemaParamMap;
|
||||
summary: typeof args.editorState.upgradeSummary;
|
||||
}
|
||||
: null;
|
||||
const suggestedWorkflowId = suggestWorkflowId(parsed.workflowData, file.name);
|
||||
args.setEditorState((current) => ({
|
||||
...current,
|
||||
workflowData: parsed.workflowData,
|
||||
schemaParams: migration?.schemaParams || parsed.schemaParams as SchemaParamMap,
|
||||
workflowId: !current.workflowId || current.workflowId === args.lastAutoWorkflowId ? suggestedWorkflowId : current.workflowId,
|
||||
hasUnsavedChanges: true,
|
||||
upgradeSummary: migration?.summary || null,
|
||||
}));
|
||||
args.setLastAutoWorkflowId(suggestedWorkflowId);
|
||||
args.pushToast("success", args.t("ok_wf_load"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : args.t("err_invalid_json");
|
||||
args.pushToast("error", message.includes("editor workflow") ? args.t("err_ui_workflow_format") : message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkflowFromFile(file: File | null) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (args.currentServer?.unsupported) {
|
||||
args.pushToast("info", args.t("server_unsupported_reason", { type: args.currentServer.server_type || "unknown" }));
|
||||
return;
|
||||
}
|
||||
if (!args.effectiveServerId) {
|
||||
args.pushToast("error", args.t("err_select_server_before_register"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseWorkflowUpload(await file.text());
|
||||
const suggestedWorkflowId = suggestWorkflowId(parsed.workflowData, file.name);
|
||||
|
||||
args.resetEditorUiState();
|
||||
args.setEditorState({
|
||||
...defaultEditorState(),
|
||||
workflowData: parsed.workflowData,
|
||||
schemaParams: parsed.schemaParams as SchemaParamMap,
|
||||
workflowId: suggestedWorkflowId,
|
||||
hasUnsavedChanges: true,
|
||||
});
|
||||
args.setLastAutoWorkflowId(suggestedWorkflowId);
|
||||
args.setViewMode("editor");
|
||||
args.pushToast("success", args.t("ok_wf_load"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : args.t("err_invalid_json");
|
||||
args.pushToast("error", message.includes("editor workflow") ? args.t("err_ui_workflow_format") : message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkflowIdChange(value: string) {
|
||||
args.setEditorState((current) => ({ ...current, workflowId: value, hasUnsavedChanges: true }));
|
||||
if (value.trim() !== args.lastAutoWorkflowId) {
|
||||
args.setLastAutoWorkflowId("");
|
||||
}
|
||||
}
|
||||
|
||||
function updateEditorParam(
|
||||
key: string,
|
||||
field: keyof SchemaParam | "name" | "description" | "required" | "type" | "exposed",
|
||||
value: unknown,
|
||||
) {
|
||||
const next = applyEditorParamUpdate(args.editorState.schemaParams, args.expandedParamKeys, key, field, value);
|
||||
args.setExpandedParamKeys(next.expandedParamKeys);
|
||||
args.setEditorState((current) => ({ ...current, hasUnsavedChanges: true, schemaParams: next.schemaParams }));
|
||||
}
|
||||
|
||||
function applyRecommendedExposures() {
|
||||
const next = applyRecommendedExposureSet(args.editorState.schemaParams);
|
||||
if (!next.changedCount) {
|
||||
args.pushToast("info", args.t("mapping_no_recommended_changes"));
|
||||
return;
|
||||
}
|
||||
args.setEditorState((current) => ({ ...current, schemaParams: next.nextSchemaParams, hasUnsavedChanges: true }));
|
||||
args.pushToast("success", args.t("mapping_apply_recommended_ok", { count: next.changedCount }));
|
||||
}
|
||||
|
||||
function exposeVisible(visible: boolean) {
|
||||
const visibleKeys = args.groupedNodes.flatMap(([, nodeData]) => nodeData.params.map((param) => param.key));
|
||||
if (!visibleKeys.length) {
|
||||
args.pushToast("error", args.t("mapping_no_visible_params"));
|
||||
return;
|
||||
}
|
||||
const next = applyVisibleExposure(args.editorState.schemaParams, args.expandedParamKeys, visibleKeys, visible);
|
||||
if (!next.changedCount) {
|
||||
args.pushToast("info", args.t("mapping_no_batch_changes"));
|
||||
return;
|
||||
}
|
||||
args.setExpandedParamKeys(next.expandedParamKeys);
|
||||
args.setEditorState((current) => ({ ...current, schemaParams: next.schemaParams, hasUnsavedChanges: true }));
|
||||
args.pushToast("success", args.t(visible ? "mapping_expose_visible_ok" : "mapping_unexpose_visible_ok", { count: next.changedCount }));
|
||||
}
|
||||
|
||||
async function handleSaveWorkflow() {
|
||||
if (!args.effectiveServerId) {
|
||||
args.pushToast("error", args.t("err_no_server_selected"));
|
||||
return;
|
||||
}
|
||||
if (!args.editorState.workflowId.trim()) {
|
||||
args.pushToast("error", args.t("err_no_id"));
|
||||
return;
|
||||
}
|
||||
const { finalSchema, exposedCount, missingAlias } = buildFinalSchema(args.editorState.schemaParams);
|
||||
if (missingAlias) {
|
||||
args.pushToast("error", args.t("err_no_alias", { node: missingAlias.node_id, val: missingAlias.field }));
|
||||
return;
|
||||
}
|
||||
if (exposedCount === 0 && !(await args.confirm({
|
||||
title: args.t("confirm_action_title"),
|
||||
message: args.t("warn_no_params"),
|
||||
confirmLabel: args.t("save_anyway"),
|
||||
cancelLabel: args.t("cancel"),
|
||||
tone: "primary",
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
if (!args.editorState.workflowData && !args.editorState.editingWorkflowId) {
|
||||
args.pushToast("error", args.t("err_no_workflow_uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
await persistWorkflow({
|
||||
effectiveServerId: args.effectiveServerId,
|
||||
editorState: args.editorState,
|
||||
finalSchema: finalSchema || {},
|
||||
confirm: args.confirm,
|
||||
refreshWorkflows: args.refreshWorkflows,
|
||||
setEditorState: args.setEditorState,
|
||||
pushToast: args.pushToast,
|
||||
t: args.t,
|
||||
});
|
||||
}
|
||||
|
||||
function createWorkflow() {
|
||||
if (args.currentServer?.unsupported) {
|
||||
args.pushToast("info", args.t("server_unsupported_reason", { type: args.currentServer.server_type || "unknown" }));
|
||||
return;
|
||||
}
|
||||
if (!args.effectiveServerId) {
|
||||
args.pushToast("error", args.t("err_select_server_before_register"));
|
||||
return;
|
||||
}
|
||||
args.resetEditor();
|
||||
args.setViewMode("editor");
|
||||
}
|
||||
|
||||
return {
|
||||
ensureCanLeaveEditor,
|
||||
handleBackFromEditor,
|
||||
handleVersionFileChange,
|
||||
handleEditorUpload,
|
||||
createWorkflowFromFile,
|
||||
handleWorkflowIdChange,
|
||||
updateEditorParam,
|
||||
applyRecommendedExposures,
|
||||
exposeVisible,
|
||||
handleSaveWorkflow,
|
||||
createWorkflow,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { parseWorkflowUpload } from "../lib/workflowMapper";
|
||||
import type { WorkflowDetailDto } from "../types/api";
|
||||
import type { EditorState, SchemaParam, SchemaParamMap } from "../types/editor";
|
||||
|
||||
export function hydrateSchemaParams(
|
||||
workflowData: Record<string, unknown>,
|
||||
savedSchemaParams: Record<string, unknown>,
|
||||
) {
|
||||
const extractedParams = { ...(parseWorkflowUpload(JSON.stringify(workflowData)).schemaParams as SchemaParamMap) };
|
||||
const savedEntries = Object.entries(savedSchemaParams || {});
|
||||
const isUiStateShape = savedEntries.some(([, savedParam]) => savedParam && typeof savedParam === "object" && "exposed" in (savedParam as Record<string, unknown>));
|
||||
|
||||
if (isUiStateShape) {
|
||||
savedEntries.forEach(([key, savedParam]) => {
|
||||
if (!extractedParams[key]) {
|
||||
return;
|
||||
}
|
||||
const saved = savedParam as Record<string, unknown>;
|
||||
extractedParams[key] = {
|
||||
...extractedParams[key],
|
||||
exposed: Boolean(saved.exposed),
|
||||
name: String(saved.name || extractedParams[key].name),
|
||||
type: String(saved.type || extractedParams[key].type) as SchemaParam["type"],
|
||||
required: Boolean(saved.required),
|
||||
description: String(saved.description || ""),
|
||||
default: saved.default ?? extractedParams[key].default,
|
||||
example: saved.example ?? extractedParams[key].example,
|
||||
choices: Array.isArray(saved.choices) ? [...saved.choices] : [...(extractedParams[key].choices || [])],
|
||||
};
|
||||
});
|
||||
return extractedParams;
|
||||
}
|
||||
|
||||
savedEntries.forEach(([name, savedParam]) => {
|
||||
const saved = savedParam as Record<string, unknown>;
|
||||
const key = `${saved.node_id}_${saved.field}`;
|
||||
if (!extractedParams[key]) {
|
||||
return;
|
||||
}
|
||||
extractedParams[key] = {
|
||||
...extractedParams[key],
|
||||
exposed: true,
|
||||
name,
|
||||
type: String(saved.type || extractedParams[key].type) as SchemaParam["type"],
|
||||
required: Boolean(saved.required),
|
||||
description: String(saved.description || ""),
|
||||
default: saved.default ?? extractedParams[key].default,
|
||||
example: saved.example ?? extractedParams[key].example,
|
||||
choices: Array.isArray(saved.choices) ? [...saved.choices] : [...(extractedParams[key].choices || [])],
|
||||
};
|
||||
});
|
||||
|
||||
return extractedParams;
|
||||
}
|
||||
|
||||
export function buildEditorStateFromDetail(detail: WorkflowDetailDto): EditorState {
|
||||
return {
|
||||
workflowData: detail.workflow_data,
|
||||
schemaParams: hydrateSchemaParams(detail.workflow_data as Record<string, unknown>, detail.schema_params as Record<string, unknown>),
|
||||
workflowId: detail.workflow_id,
|
||||
description: detail.description || "",
|
||||
editingWorkflowId: detail.workflow_id,
|
||||
hasUnsavedChanges: false,
|
||||
upgradeSummary: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyRecommendedExposureSet(schemaParams: SchemaParamMap) {
|
||||
const commonFields = new Set(["prompt", "text", "negative_prompt", "seed", "steps", "cfg", "denoise", "width", "height", "batch_size", "filename_prefix"]);
|
||||
let changedCount = 0;
|
||||
const nextSchemaParams = { ...schemaParams };
|
||||
|
||||
Object.entries(nextSchemaParams).forEach(([key, param]) => {
|
||||
if (!commonFields.has(param.field) || param.exposed) {
|
||||
return;
|
||||
}
|
||||
nextSchemaParams[key] = {
|
||||
...param,
|
||||
exposed: true,
|
||||
name: param.name || param.field,
|
||||
};
|
||||
changedCount += 1;
|
||||
});
|
||||
|
||||
return { nextSchemaParams, changedCount };
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { SaveServerPayload } from "../types/api";
|
||||
import type { ServerModalMode, TranslateFn } from "./state";
|
||||
|
||||
export function getNormalizedServerPayload(
|
||||
serverForm: SaveServerPayload,
|
||||
fallbackServerId: string,
|
||||
) {
|
||||
const fallbackName = serverForm.name.trim() || serverForm.id?.trim() || fallbackServerId || "";
|
||||
return {
|
||||
...serverForm,
|
||||
id: serverForm.id?.trim() || "",
|
||||
name: fallbackName,
|
||||
url: serverForm.url.trim(),
|
||||
output_dir: serverForm.output_dir.trim() || "./outputs",
|
||||
};
|
||||
}
|
||||
|
||||
export function validateServerForm(
|
||||
serverForm: SaveServerPayload,
|
||||
serverModalMode: ServerModalMode,
|
||||
t: TranslateFn,
|
||||
) {
|
||||
const normalizedPayload = getNormalizedServerPayload(serverForm, "");
|
||||
if (!normalizedPayload.name) {
|
||||
return t("err_server_name_required");
|
||||
}
|
||||
if (!normalizedPayload.url) {
|
||||
return t(serverModalMode === "edit" ? "err_server_name_id_url_required" : "err_server_name_url_required");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { ToastMessage } from "../components/ui/ToastViewport";
|
||||
import type { SaveServerPayload } from "../types/api";
|
||||
import type { EditorState } from "../types/editor";
|
||||
|
||||
export type ViewMode = "main" | "editor";
|
||||
export type ServerModalMode = "add" | "edit";
|
||||
|
||||
export interface ConfirmState {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
tone: "primary" | "danger";
|
||||
checkboxLabel?: string;
|
||||
checkboxChecked?: boolean;
|
||||
onResolve?: (confirmed: boolean, checked: boolean) => void;
|
||||
}
|
||||
|
||||
export interface EditorFilters {
|
||||
query: string;
|
||||
exposedOnly: boolean;
|
||||
requiredOnly: boolean;
|
||||
nodeSort: string;
|
||||
paramSort: string;
|
||||
}
|
||||
|
||||
export type TranslateFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export function defaultServerForm(): SaveServerPayload {
|
||||
return {
|
||||
id: "",
|
||||
name: "",
|
||||
url: "",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultEditorState(): EditorState {
|
||||
return {
|
||||
workflowData: null,
|
||||
schemaParams: {},
|
||||
workflowId: "",
|
||||
description: "",
|
||||
editingWorkflowId: null,
|
||||
hasUnsavedChanges: false,
|
||||
upgradeSummary: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultEditorFilters(): EditorFilters {
|
||||
return {
|
||||
query: "",
|
||||
exposedOnly: false,
|
||||
requiredOnly: false,
|
||||
nodeSort: "node_id_asc",
|
||||
paramSort: "default",
|
||||
};
|
||||
}
|
||||
|
||||
export function initialConfirmState(): ConfirmState {
|
||||
return {
|
||||
open: false,
|
||||
title: "",
|
||||
message: "",
|
||||
confirmLabel: "",
|
||||
cancelLabel: "",
|
||||
tone: "primary",
|
||||
};
|
||||
}
|
||||
|
||||
export function createToast(type: ToastMessage["type"], message: string): ToastMessage {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
type,
|
||||
message,
|
||||
};
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { normalizeLanguage, translate, type Language } from "../i18n";
|
||||
import { safeReadLocalStorage } from "../lib/storage";
|
||||
import type {
|
||||
TransferExportPreviewDto,
|
||||
TransferImportPreviewDto,
|
||||
TransferPlanItemDto,
|
||||
TransferSelectionPayload,
|
||||
WorkflowDetailDto,
|
||||
WorkflowSummaryDto,
|
||||
} from "../types/api";
|
||||
import { buildEditorStateFromDetail } from "./editorUtils";
|
||||
import { useAppEffects } from "./useAppEffects";
|
||||
import { useAppDerivedState } from "./useAppDerivedState";
|
||||
import { useConfirmState } from "./useConfirmState";
|
||||
import { useServerManagement } from "./useServerManagement";
|
||||
import { useToastState } from "./useToastState";
|
||||
import { createEditorActions } from "./editorActions";
|
||||
import {
|
||||
defaultEditorFilters,
|
||||
defaultEditorState,
|
||||
type ViewMode,
|
||||
} from "./state";
|
||||
import { createWorkflowActions } from "./workflowActions";
|
||||
import { listWorkflows } from "../services/workflows";
|
||||
import { buildTransferExport, importTransferBundle, previewTransferExport, previewTransferImport } from "../services/transfer";
|
||||
|
||||
interface TransferState {
|
||||
open: boolean;
|
||||
mode: "export" | "import" | null;
|
||||
exportPreview: TransferExportPreviewDto | null;
|
||||
exportSelection: TransferSelectionPayload;
|
||||
expandedServerIds: string[];
|
||||
importPreview: TransferImportPreviewDto | null;
|
||||
importBundle: Record<string, unknown> | null;
|
||||
applyEnvironment: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function initialTransferState(): TransferState {
|
||||
return {
|
||||
open: false,
|
||||
mode: null,
|
||||
exportPreview: null,
|
||||
exportSelection: { servers: [] },
|
||||
expandedServerIds: [],
|
||||
importPreview: null,
|
||||
importBundle: null,
|
||||
applyEnvironment: false,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createFullExportSelection(preview: TransferExportPreviewDto): TransferSelectionPayload {
|
||||
return {
|
||||
servers: preview.servers.map((server) => ({
|
||||
server_id: server.server_id,
|
||||
workflow_ids: server.workflows.map((workflow) => workflow.workflow_id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultExpandedServerIds(preview: TransferExportPreviewDto): string[] {
|
||||
return preview.servers.length === 1 ? [preview.servers[0].server_id] : [];
|
||||
}
|
||||
|
||||
function getTransferValidationMessages(detail: unknown): string[] {
|
||||
if (!detail || typeof detail !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const detailRecord = detail as Record<string, unknown>;
|
||||
const errors = Array.isArray(detailRecord.errors)
|
||||
? detailRecord.errors
|
||||
: detailRecord.validation && typeof detailRecord.validation === "object" && Array.isArray((detailRecord.validation as Record<string, unknown>).errors)
|
||||
? (detailRecord.validation as Record<string, unknown>).errors as unknown[]
|
||||
: [];
|
||||
|
||||
return errors
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>).message : ""))
|
||||
.filter((item): item is string => typeof item === "string" && Boolean(item));
|
||||
}
|
||||
|
||||
export function useAppController() {
|
||||
const [language, setLanguage] = useState<Language>(() => normalizeLanguage(safeReadLocalStorage("ui-lang")));
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummaryDto[]>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("main");
|
||||
const [editorState, setEditorState] = useState(defaultEditorState());
|
||||
const [editorFilters, setEditorFilters] = useState(defaultEditorFilters());
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [expandedParamKeys, setExpandedParamKeys] = useState<Set<string>>(new Set());
|
||||
const [workflowSearch, setWorkflowSearch] = useState("");
|
||||
const [workflowSort, setWorkflowSort] = useState("custom");
|
||||
const [lastAutoWorkflowId, setLastAutoWorkflowId] = useState("");
|
||||
const [transferState, setTransferState] = useState<TransferState>(initialTransferState());
|
||||
|
||||
const versionUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const transferImportRef = useRef<HTMLInputElement | null>(null);
|
||||
const pendingVersionTargetRef = useRef<WorkflowDetailDto | null>(null);
|
||||
const mappingSearchRef = useRef<HTMLInputElement | null>(null);
|
||||
const transferSessionRef = useRef(0);
|
||||
const importPreviewRequestRef = useRef(0);
|
||||
const { toasts, dismissToast, pushToast } = useToastState();
|
||||
const { confirmState, setConfirmState, resolveConfirm, confirm } = useConfirmState();
|
||||
const t = (key: string, vars?: Record<string, string | number>) => translate(language, key, vars);
|
||||
const serverManagement = useServerManagement({ t, pushToast, refreshWorkflows, setConfirmState });
|
||||
|
||||
const derived = useAppDerivedState({
|
||||
currentServerId: serverManagement.currentServerId,
|
||||
defaultServerId: serverManagement.defaultServerId,
|
||||
servers: serverManagement.servers,
|
||||
workflows,
|
||||
workflowSearch,
|
||||
workflowSort,
|
||||
editorFilters,
|
||||
schemaParams: editorState.schemaParams,
|
||||
t,
|
||||
});
|
||||
|
||||
function resetEditorUiState() {
|
||||
setCollapsedNodeIds(new Set());
|
||||
setExpandedParamKeys(new Set());
|
||||
setEditorFilters(defaultEditorFilters());
|
||||
setLastAutoWorkflowId("");
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
setEditorState(defaultEditorState());
|
||||
resetEditorUiState();
|
||||
}
|
||||
|
||||
async function openEditor(detail?: WorkflowDetailDto) {
|
||||
resetEditorUiState();
|
||||
setEditorState(detail ? buildEditorStateFromDetail(detail) : defaultEditorState());
|
||||
setViewMode("editor");
|
||||
}
|
||||
|
||||
async function refreshWorkflows() {
|
||||
const data = await listWorkflows();
|
||||
setWorkflows(data.workflows || []);
|
||||
}
|
||||
|
||||
function createTransferSession() {
|
||||
transferSessionRef.current += 1;
|
||||
return transferSessionRef.current;
|
||||
}
|
||||
|
||||
function isCurrentTransferSession(sessionId: number) {
|
||||
return transferSessionRef.current === sessionId;
|
||||
}
|
||||
|
||||
function closeTransferModal() {
|
||||
createTransferSession();
|
||||
setTransferState(initialTransferState());
|
||||
}
|
||||
|
||||
function getTransferErrorMessage(error: unknown, fallbackKey: string) {
|
||||
if (error && typeof error === "object" && "detail" in error) {
|
||||
const messages = getTransferValidationMessages((error as { detail?: unknown }).detail);
|
||||
if (messages.length > 0) {
|
||||
return messages.join("; ");
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return t(fallbackKey);
|
||||
}
|
||||
|
||||
async function handleOpenTransferExport() {
|
||||
const sessionId = createTransferSession();
|
||||
try {
|
||||
const preview = await previewTransferExport();
|
||||
if (!isCurrentTransferSession(sessionId)) {
|
||||
return;
|
||||
}
|
||||
setTransferState({
|
||||
open: true,
|
||||
mode: "export",
|
||||
exportPreview: preview,
|
||||
exportSelection: createFullExportSelection(preview),
|
||||
expandedServerIds: getDefaultExpandedServerIds(preview),
|
||||
importPreview: null,
|
||||
importBundle: null,
|
||||
applyEnvironment: false,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
pushToast("error", getTransferErrorMessage(error, "err_transfer_export_preview"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenTransferImport() {
|
||||
transferImportRef.current?.click();
|
||||
}
|
||||
|
||||
function toggleTransferServerSelection(serverId: string, workflowIds: string[]) {
|
||||
setTransferState((current) => {
|
||||
const existing = current.exportSelection.servers.find((server) => server.server_id === serverId);
|
||||
const nextSelected = existing && existing.workflow_ids.length === workflowIds.length ? [] : workflowIds;
|
||||
return {
|
||||
...current,
|
||||
exportSelection: {
|
||||
servers: current.exportSelection.servers.map((server) => (
|
||||
server.server_id === serverId
|
||||
? { ...server, workflow_ids: nextSelected }
|
||||
: server
|
||||
)),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTransferWorkflowSelection(serverId: string, workflowId: string) {
|
||||
setTransferState((current) => ({
|
||||
...current,
|
||||
exportSelection: {
|
||||
servers: current.exportSelection.servers.map((server) => {
|
||||
if (server.server_id !== serverId) {
|
||||
return server;
|
||||
}
|
||||
const workflowIds = server.workflow_ids.includes(workflowId)
|
||||
? server.workflow_ids.filter((id) => id !== workflowId)
|
||||
: [...server.workflow_ids, workflowId];
|
||||
return { ...server, workflow_ids: workflowIds };
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleTransferServerExpanded(serverId: string) {
|
||||
setTransferState((current) => ({
|
||||
...current,
|
||||
expandedServerIds: current.expandedServerIds.includes(serverId)
|
||||
? current.expandedServerIds.filter((id) => id !== serverId)
|
||||
: [...current.expandedServerIds, serverId],
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleConfirmTransfer() {
|
||||
if (transferState.mode === "export") {
|
||||
const sessionId = transferSessionRef.current;
|
||||
setTransferState((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const response = await buildTransferExport(transferState.exportSelection);
|
||||
if (!isCurrentTransferSession(sessionId)) {
|
||||
return;
|
||||
}
|
||||
const payload = `${JSON.stringify(response.bundle, null, 2)}\n`;
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "openclaw-skill-export.json";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
pushToast("success", t("ok_transfer_export_started"));
|
||||
closeTransferModal();
|
||||
} catch (error) {
|
||||
if (!isCurrentTransferSession(sessionId)) {
|
||||
return;
|
||||
}
|
||||
setTransferState((current) => ({ ...current, loading: false }));
|
||||
pushToast("error", getTransferErrorMessage(error, "err_transfer_export"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transferState.importBundle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = transferSessionRef.current;
|
||||
setTransferState((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const report = await importTransferBundle(transferState.importBundle, transferState.applyEnvironment, true);
|
||||
if (!isCurrentTransferSession(sessionId)) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([serverManagement.loadInitialServers(), refreshWorkflows()]);
|
||||
if (!isCurrentTransferSession(sessionId)) {
|
||||
return;
|
||||
}
|
||||
pushToast("success", t("ok_transfer_import", {
|
||||
servers: report.plan.summary.created_servers,
|
||||
created: report.plan.summary.created_workflows,
|
||||
overwritten: report.plan.summary.overwritten_workflows,
|
||||
}));
|
||||
closeTransferModal();
|
||||
} catch (error) {
|
||||
if (!isCurrentTransferSession(sessionId)) {
|
||||
return;
|
||||
}
|
||||
setTransferState((current) => ({ ...current, loading: false }));
|
||||
pushToast("error", getTransferErrorMessage(error, "err_transfer_import"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransferImportFile(file: File | null) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = importPreviewRequestRef.current + 1;
|
||||
importPreviewRequestRef.current = requestId;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
if (importPreviewRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||
throw new Error(t("err_transfer_invalid_bundle"));
|
||||
}
|
||||
|
||||
const preview = await previewTransferImport(parsed, false, true);
|
||||
if (importPreviewRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
createTransferSession();
|
||||
setTransferState({
|
||||
open: true,
|
||||
mode: "import",
|
||||
exportPreview: null,
|
||||
exportSelection: { servers: [] },
|
||||
expandedServerIds: [],
|
||||
importPreview: preview,
|
||||
importBundle: parsed,
|
||||
applyEnvironment: false,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (importPreviewRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof SyntaxError || (error instanceof Error && error.message === t("err_transfer_invalid_bundle"))) {
|
||||
pushToast("error", t("err_transfer_invalid_bundle"));
|
||||
return;
|
||||
}
|
||||
pushToast("error", getTransferErrorMessage(error, "err_transfer_preview"));
|
||||
}
|
||||
}
|
||||
|
||||
const importSections: Array<{ title: string; items: TransferPlanItemDto[] }> = [
|
||||
{
|
||||
title: t("transfer_section_created_servers"),
|
||||
items: transferState.importPreview?.plan?.created_servers || [],
|
||||
},
|
||||
{
|
||||
title: t("transfer_section_updated_servers"),
|
||||
items: transferState.importPreview?.plan?.updated_servers || [],
|
||||
},
|
||||
{
|
||||
title: t("transfer_section_created_workflows"),
|
||||
items: transferState.importPreview?.plan?.created_workflows || [],
|
||||
},
|
||||
{
|
||||
title: t("transfer_section_overwritten_workflows"),
|
||||
items: transferState.importPreview?.plan?.overwritten_workflows || [],
|
||||
},
|
||||
{
|
||||
title: t("transfer_section_skipped_items"),
|
||||
items: transferState.importPreview?.plan?.skipped_items || [],
|
||||
},
|
||||
];
|
||||
|
||||
const importPreviewSummary = t("transfer_preview_summary", {
|
||||
servers: transferState.importPreview?.plan?.summary.created_servers || 0,
|
||||
updated_servers: transferState.importPreview?.plan?.summary.updated_servers || 0,
|
||||
created: transferState.importPreview?.plan?.summary.created_workflows || 0,
|
||||
overwritten: transferState.importPreview?.plan?.summary.overwritten_workflows || 0,
|
||||
skipped: transferState.importPreview?.plan?.summary.skipped_items || 0,
|
||||
warnings: transferState.importPreview?.plan?.summary.warnings || 0,
|
||||
});
|
||||
|
||||
const editorActions = createEditorActions({
|
||||
editorState,
|
||||
setEditorState,
|
||||
expandedParamKeys,
|
||||
setExpandedParamKeys,
|
||||
groupedNodes: derived.groupedNodes,
|
||||
lastAutoWorkflowId,
|
||||
setLastAutoWorkflowId,
|
||||
currentServer: derived.currentServer,
|
||||
effectiveServerId: derived.effectiveServerId,
|
||||
confirm,
|
||||
refreshWorkflows,
|
||||
pushToast,
|
||||
setViewMode,
|
||||
resetEditor,
|
||||
resetEditorUiState,
|
||||
t,
|
||||
pendingVersionTargetRef,
|
||||
});
|
||||
|
||||
const workflowActions = createWorkflowActions({
|
||||
workflows,
|
||||
setWorkflows,
|
||||
effectiveServerId: derived.effectiveServerId,
|
||||
refreshWorkflows,
|
||||
pushToast,
|
||||
t,
|
||||
confirm,
|
||||
openEditor,
|
||||
ensureCanLeaveEditor: editorActions.ensureCanLeaveEditor,
|
||||
pendingVersionTargetRef,
|
||||
versionUploadRef,
|
||||
});
|
||||
|
||||
useAppEffects({
|
||||
language,
|
||||
toasts,
|
||||
dismissToast,
|
||||
loadInitialServers: serverManagement.loadInitialServers,
|
||||
refreshWorkflows,
|
||||
pushToast,
|
||||
t,
|
||||
viewMode,
|
||||
hasUnsavedChanges: editorState.hasUnsavedChanges,
|
||||
confirmOpen: confirmState.open,
|
||||
serverModalOpen: serverManagement.serverModalOpen,
|
||||
transferModalOpen: transferState.open,
|
||||
editorQuery: editorFilters.query,
|
||||
clearEditorQuery: () => setEditorFilters((current) => ({ ...current, query: "" })),
|
||||
mappingSearchRef,
|
||||
saveWorkflow: editorActions.handleSaveWorkflow,
|
||||
});
|
||||
|
||||
return {
|
||||
language,
|
||||
setLanguage: (value: string) => setLanguage(normalizeLanguage(value)),
|
||||
t,
|
||||
toasts,
|
||||
dismissToast,
|
||||
confirmState,
|
||||
setConfirmState,
|
||||
resolveConfirm,
|
||||
versionUploadRef,
|
||||
transferImportRef,
|
||||
mappingSearchRef,
|
||||
viewMode,
|
||||
editorState,
|
||||
editorFilters,
|
||||
collapsedNodeIds,
|
||||
expandedParamKeys,
|
||||
workflowSearch,
|
||||
workflowSort,
|
||||
servers: serverManagement.servers,
|
||||
...derived,
|
||||
serverModalOpen: serverManagement.serverModalOpen,
|
||||
serverModalMode: serverManagement.serverModalMode,
|
||||
serverForm: serverManagement.serverForm,
|
||||
setCurrentServerId: serverManagement.setCurrentServerId,
|
||||
setServerForm: serverManagement.setServerForm,
|
||||
setWorkflowSearch,
|
||||
setWorkflowSort,
|
||||
setEditorFilters,
|
||||
setCollapsedNodeIds,
|
||||
setExpandedParamKeys,
|
||||
setEditorState,
|
||||
setServerModalOpen: serverManagement.setServerModalOpen,
|
||||
transferState,
|
||||
importSections,
|
||||
importPreviewSummary,
|
||||
handleAddServer: serverManagement.handleAddServer,
|
||||
handleEditServer: serverManagement.handleEditServer,
|
||||
handleSubmitServerModal: serverManagement.handleSubmitServerModal,
|
||||
handleToggleServer: serverManagement.handleToggleServer,
|
||||
requestDeleteServer: serverManagement.requestDeleteServer,
|
||||
handleOpenTransferExport,
|
||||
handleOpenTransferImport,
|
||||
handleTransferImportFile,
|
||||
handleConfirmTransfer,
|
||||
closeTransferModal,
|
||||
toggleTransferServerSelection,
|
||||
toggleTransferWorkflowSelection,
|
||||
toggleTransferServerExpanded,
|
||||
setTransferState,
|
||||
handleBackFromEditor: editorActions.handleBackFromEditor,
|
||||
handleEditorUpload: editorActions.handleEditorUpload,
|
||||
createWorkflowFromFile: editorActions.createWorkflowFromFile,
|
||||
handleSaveWorkflow: editorActions.handleSaveWorkflow,
|
||||
handleWorkflowIdChange: editorActions.handleWorkflowIdChange,
|
||||
updateEditorParam: editorActions.updateEditorParam,
|
||||
applyRecommendedExposures: editorActions.applyRecommendedExposures,
|
||||
exposeVisible: editorActions.exposeVisible,
|
||||
handleEditWorkflow: workflowActions.handleEditWorkflow,
|
||||
handleDeleteWorkflow: workflowActions.handleDeleteWorkflow,
|
||||
handleToggleWorkflow: workflowActions.handleToggleWorkflow,
|
||||
handleUploadWorkflowVersion: workflowActions.handleUploadWorkflowVersion,
|
||||
handleVersionFileChange: editorActions.handleVersionFileChange,
|
||||
handleReorderWorkflows: workflowActions.handleReorderWorkflows,
|
||||
createWorkflow: editorActions.createWorkflow,
|
||||
};
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { groupSchemaParams } from "../lib/workflowMapper";
|
||||
import type { ServerDto, WorkflowSummaryDto } from "../types/api";
|
||||
import type { SchemaParam, SchemaParamMap } from "../types/editor";
|
||||
import type { EditorFilters, TranslateFn } from "./state";
|
||||
|
||||
interface UseAppDerivedStateArgs {
|
||||
currentServerId: string | null;
|
||||
defaultServerId: string | null;
|
||||
servers: ServerDto[];
|
||||
workflows: WorkflowSummaryDto[];
|
||||
workflowSearch: string;
|
||||
workflowSort: string;
|
||||
editorFilters: EditorFilters;
|
||||
schemaParams: SchemaParamMap;
|
||||
t: TranslateFn;
|
||||
}
|
||||
|
||||
export function useAppDerivedState(args: UseAppDerivedStateArgs) {
|
||||
const resolvedCurrentServerId = useMemo(() => {
|
||||
if (args.currentServerId && args.servers.some((server) => server.id === args.currentServerId)) {
|
||||
return args.currentServerId;
|
||||
}
|
||||
if (args.defaultServerId && args.servers.some((server) => server.id === args.defaultServerId)) {
|
||||
return args.defaultServerId;
|
||||
}
|
||||
return args.servers[0]?.id || null;
|
||||
}, [args.currentServerId, args.defaultServerId, args.servers]);
|
||||
|
||||
const currentServer = useMemo(() => {
|
||||
return args.servers.find((server) => server.id === resolvedCurrentServerId) || null;
|
||||
}, [resolvedCurrentServerId, args.servers]);
|
||||
|
||||
const effectiveServerId = currentServer && !currentServer.unsupported ? currentServer.id : null;
|
||||
|
||||
const currentServerWorkflows = useMemo(
|
||||
() => args.workflows.filter((workflow) => workflow.server_id === effectiveServerId),
|
||||
[effectiveServerId, args.workflows],
|
||||
);
|
||||
|
||||
const visibleWorkflows = useMemo(() => {
|
||||
const items = [...currentServerWorkflows];
|
||||
switch (args.workflowSort) {
|
||||
case "updated_desc":
|
||||
items.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
||||
break;
|
||||
case "name_asc":
|
||||
items.sort((a, b) => a.id.localeCompare(b.id));
|
||||
break;
|
||||
case "name_desc":
|
||||
items.sort((a, b) => b.id.localeCompare(a.id));
|
||||
break;
|
||||
case "enabled_first":
|
||||
items.sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) {
|
||||
return a.enabled ? -1 : 1;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const query = args.workflowSearch.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((workflow) => {
|
||||
const haystack = [
|
||||
workflow.id,
|
||||
workflow.description,
|
||||
workflow.server_name,
|
||||
workflow.server_id,
|
||||
workflow.source_label,
|
||||
...(workflow.tags || []),
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [args.workflowSearch, args.workflowSort, currentServerWorkflows]);
|
||||
|
||||
const groupedNodes = useMemo(() => {
|
||||
const query = args.editorFilters.query.trim().toLowerCase();
|
||||
const grouped = groupSchemaParams(args.schemaParams) as Array<[number, { classType: string; params: Array<SchemaParam & { key: string }> }]>;
|
||||
const sortNodes = [...grouped].sort((first, second) => {
|
||||
if (args.editorFilters.nodeSort === "node_id_desc") {
|
||||
return Number(second[0]) - Number(first[0]);
|
||||
}
|
||||
if (args.editorFilters.nodeSort === "class_asc") {
|
||||
return String(first[1].classType).localeCompare(String(second[1].classType));
|
||||
}
|
||||
return Number(first[0]) - Number(second[0]);
|
||||
});
|
||||
|
||||
return sortNodes
|
||||
.map(([nodeId, nodeData]) => {
|
||||
const params = [...nodeData.params]
|
||||
.sort((first, second) => {
|
||||
switch (args.editorFilters.paramSort) {
|
||||
case "field_asc":
|
||||
return first.field.localeCompare(second.field);
|
||||
case "type_asc":
|
||||
return String(first.type).localeCompare(String(second.type));
|
||||
case "exposed_first":
|
||||
if (first.exposed !== second.exposed) {
|
||||
return first.exposed ? -1 : 1;
|
||||
}
|
||||
return first.field.localeCompare(second.field);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.filter((param) => {
|
||||
if (args.editorFilters.exposedOnly && !param.exposed) {
|
||||
return false;
|
||||
}
|
||||
if (args.editorFilters.requiredOnly && !param.required) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [nodeData.classType, String(nodeId), param.field, param.name, param.description, String(param.currentVal ?? "")]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
return [String(nodeId), { classType: nodeData.classType, params }] as [string, { classType: string; params: Array<SchemaParam & { key: string }> }];
|
||||
})
|
||||
.filter(([, nodeData]) => nodeData.params.length > 0);
|
||||
}, [args.editorFilters, args.schemaParams]);
|
||||
|
||||
const mappingSummaryText = useMemo(() => {
|
||||
const totalParams = Object.values(args.schemaParams).length;
|
||||
if (!totalParams) {
|
||||
return "";
|
||||
}
|
||||
const totalExposed = Object.values(args.schemaParams).filter((parameter) => parameter.exposed).length;
|
||||
const visibleParams = groupedNodes.reduce((sum, [, nodeData]) => sum + nodeData.params.length, 0);
|
||||
return args.t("mapping_summary", {
|
||||
visible_params: visibleParams,
|
||||
total_params: totalParams,
|
||||
exposed_params: totalExposed,
|
||||
visible_nodes: groupedNodes.length,
|
||||
});
|
||||
}, [args.schemaParams, groupedNodes, args.t]);
|
||||
|
||||
return {
|
||||
currentServer,
|
||||
effectiveServerId,
|
||||
currentServerWorkflows,
|
||||
visibleWorkflows,
|
||||
groupedNodes,
|
||||
mappingSummaryText,
|
||||
editorEmptyStateMessageKey: Object.keys(args.schemaParams).length === 0 ? "empty_nodes" : "empty_nodes_filtered",
|
||||
};
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { initPixelBlastBackground } from "../lib/pixelBlastBackground";
|
||||
import { safeWriteLocalStorage } from "../lib/storage";
|
||||
import type { ToastMessage } from "../components/ui/ToastViewport";
|
||||
import type { TranslateFn, ViewMode } from "./state";
|
||||
|
||||
interface UseAppEffectsArgs {
|
||||
language: string;
|
||||
toasts: ToastMessage[];
|
||||
dismissToast: (id: string) => void;
|
||||
loadInitialServers: () => Promise<void>;
|
||||
refreshWorkflows: () => Promise<void>;
|
||||
pushToast: (type: "error", message: string) => void;
|
||||
t: TranslateFn;
|
||||
viewMode: ViewMode;
|
||||
hasUnsavedChanges: boolean;
|
||||
confirmOpen: boolean;
|
||||
serverModalOpen: boolean;
|
||||
transferModalOpen: boolean;
|
||||
editorQuery: string;
|
||||
clearEditorQuery: () => void;
|
||||
mappingSearchRef: RefObject<HTMLInputElement | null>;
|
||||
saveWorkflow: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAppEffects(args: UseAppEffectsArgs) {
|
||||
useEffect(() => {
|
||||
safeWriteLocalStorage("ui-lang", args.language);
|
||||
}, [args.language]);
|
||||
|
||||
useEffect(() => {
|
||||
const timerIds = args.toasts.map((toast) => window.setTimeout(() => args.dismissToast(toast.id), 3200));
|
||||
return () => {
|
||||
timerIds.forEach((id) => window.clearTimeout(id));
|
||||
};
|
||||
}, [args.dismissToast, args.toasts]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([args.loadInitialServers(), args.refreshWorkflows()]).catch((error: unknown) => {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_load_cfg"));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => initPixelBlastBackground({
|
||||
variant: "circle",
|
||||
pixelSize: 4,
|
||||
color: "#a0223b",
|
||||
patternScale: 2,
|
||||
patternDensity: 1,
|
||||
pixelSizeJitter: 0,
|
||||
enableRipples: true,
|
||||
rippleSpeed: 0.4,
|
||||
rippleThickness: 0.12,
|
||||
rippleIntensityScale: 1.5,
|
||||
speed: 0.5,
|
||||
edgeFade: 0.25,
|
||||
transparent: true,
|
||||
}) || undefined, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (args.viewMode === "editor") {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}, [args.viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!args.hasUnsavedChanges) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [args.hasUnsavedChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleEditorShortcuts(event: KeyboardEvent) {
|
||||
if (args.viewMode !== "editor" || args.confirmOpen || args.serverModalOpen || args.transferModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isInputLike = Boolean(
|
||||
target
|
||||
&& (target.tagName === "INPUT"
|
||||
|| target.tagName === "TEXTAREA"
|
||||
|| target.tagName === "SELECT"
|
||||
|| target.isContentEditable),
|
||||
);
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
void args.saveWorkflow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInputLike && event.key === "/") {
|
||||
event.preventDefault();
|
||||
args.mappingSearchRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && document.activeElement === args.mappingSearchRef.current && args.editorQuery) {
|
||||
args.clearEditorQuery();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleEditorShortcuts);
|
||||
return () => document.removeEventListener("keydown", handleEditorShortcuts);
|
||||
}, [
|
||||
args.clearEditorQuery,
|
||||
args.confirmOpen,
|
||||
args.editorQuery,
|
||||
args.mappingSearchRef,
|
||||
args.saveWorkflow,
|
||||
args.serverModalOpen,
|
||||
args.transferModalOpen,
|
||||
args.viewMode,
|
||||
]);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { ConfirmState } from "./state";
|
||||
import { initialConfirmState } from "./state";
|
||||
|
||||
export function useConfirmState() {
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState>(initialConfirmState());
|
||||
|
||||
function closeConfirm() {
|
||||
setConfirmState((current) => ({ ...current, open: false, onResolve: undefined }));
|
||||
}
|
||||
|
||||
function resolveConfirm(confirmed: boolean) {
|
||||
const checked = confirmState.checkboxChecked ?? false;
|
||||
const onResolve = confirmState.onResolve;
|
||||
closeConfirm();
|
||||
onResolve?.(confirmed, checked);
|
||||
}
|
||||
|
||||
async function confirm(options: Omit<ConfirmState, "open" | "onResolve">) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setConfirmState({
|
||||
...options,
|
||||
open: true,
|
||||
onResolve: (confirmed) => resolve(confirmed),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
confirmState,
|
||||
setConfirmState,
|
||||
resolveConfirm,
|
||||
confirm,
|
||||
};
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { safeReadLocalStorage, safeRemoveLocalStorage, safeWriteLocalStorage } from "../lib/storage";
|
||||
import { addServer, deleteServer, listServers, toggleServer, updateServer } from "../services/servers";
|
||||
import type { SaveServerPayload, ServerDto } from "../types/api";
|
||||
import type { ConfirmState, ServerModalMode, TranslateFn } from "./state";
|
||||
import { defaultServerForm } from "./state";
|
||||
import { getNormalizedServerPayload, validateServerForm } from "./serverUtils";
|
||||
|
||||
interface UseServerManagementArgs {
|
||||
t: TranslateFn;
|
||||
pushToast: (type: "success" | "error" | "info", message: string) => void;
|
||||
refreshWorkflows: () => Promise<void>;
|
||||
setConfirmState: Dispatch<SetStateAction<ConfirmState>>;
|
||||
}
|
||||
|
||||
export function useServerManagement(args: UseServerManagementArgs) {
|
||||
const [servers, setServers] = useState<ServerDto[]>([]);
|
||||
const [defaultServerId, setDefaultServerId] = useState<string | null>(null);
|
||||
const [currentServerId, setCurrentServerIdState] = useState<string | null>(() => safeReadLocalStorage("ui-server"));
|
||||
const [serverModalOpen, setServerModalOpen] = useState(false);
|
||||
const [serverModalMode, setServerModalMode] = useState<ServerModalMode>("add");
|
||||
const [serverForm, setServerForm] = useState<SaveServerPayload>(defaultServerForm());
|
||||
const warnedUnsupportedServerIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
function setCurrentServerId(serverId: string) {
|
||||
setCurrentServerIdState(serverId);
|
||||
safeWriteLocalStorage("ui-server", serverId);
|
||||
}
|
||||
|
||||
async function loadInitialServers() {
|
||||
const data = await listServers();
|
||||
const nextServers = data.servers || [];
|
||||
const supportedServers = nextServers.filter((server) => !server.unsupported);
|
||||
const preferredServerId = currentServerId && nextServers.some((server) => server.id === currentServerId)
|
||||
? currentServerId
|
||||
: (data.default_server || nextServers[0]?.id || null);
|
||||
const preferredServer = nextServers.find((server) => server.id === preferredServerId) || null;
|
||||
const nextServerId = preferredServer?.unsupported ? (supportedServers[0]?.id || preferredServerId) : preferredServerId;
|
||||
|
||||
nextServers.filter((server) => server.unsupported).forEach((server) => {
|
||||
if (warnedUnsupportedServerIdsRef.current.has(server.id)) {
|
||||
return;
|
||||
}
|
||||
warnedUnsupportedServerIdsRef.current.add(server.id);
|
||||
args.pushToast("info", args.t("server_unsupported_reason", { type: server.server_type || "unknown" }));
|
||||
});
|
||||
|
||||
setServers(nextServers);
|
||||
setDefaultServerId(data.default_server || null);
|
||||
setCurrentServerIdState(nextServerId);
|
||||
if (nextServerId) {
|
||||
safeWriteLocalStorage("ui-server", nextServerId);
|
||||
} else {
|
||||
safeRemoveLocalStorage("ui-server");
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddServer() {
|
||||
setServerModalMode("add");
|
||||
setServerForm(defaultServerForm());
|
||||
setServerModalOpen(true);
|
||||
}
|
||||
|
||||
function handleEditServer(server: ServerDto) {
|
||||
setServerModalMode("edit");
|
||||
setServerForm({ id: server.id, name: server.name, url: server.url, enabled: server.enabled, output_dir: server.output_dir });
|
||||
setServerModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSubmitServerModal() {
|
||||
const errorMessage = validateServerForm(serverForm, serverModalMode, args.t);
|
||||
if (errorMessage) {
|
||||
args.pushToast("error", errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPayload = getNormalizedServerPayload(serverForm, currentServerId || "");
|
||||
const targetServerId = String(serverForm.id || currentServerId || "").trim();
|
||||
try {
|
||||
if (serverModalMode === "add") {
|
||||
const created = await addServer(normalizedPayload);
|
||||
await loadInitialServers();
|
||||
await args.refreshWorkflows();
|
||||
setCurrentServerId(created.server.id);
|
||||
args.pushToast("success", args.t("ok_add_server"));
|
||||
} else if (targetServerId) {
|
||||
await updateServer(targetServerId, normalizedPayload);
|
||||
await loadInitialServers();
|
||||
await args.refreshWorkflows();
|
||||
args.pushToast("success", args.t("ok_save_cfg"));
|
||||
}
|
||||
setServerModalOpen(false);
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_add_server"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleServer(server: ServerDto, enabled: boolean) {
|
||||
try {
|
||||
await toggleServer(server.id, { enabled });
|
||||
await loadInitialServers();
|
||||
await args.refreshWorkflows();
|
||||
args.pushToast("success", args.t(enabled ? "ok_toggle_server_enabled" : "ok_toggle_server_disabled", { id: server.name || server.id }));
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_toggle_server"));
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteServer(server: ServerDto) {
|
||||
args.setConfirmState({
|
||||
open: true,
|
||||
title: args.t("confirm_action_title"),
|
||||
message: args.t("del_server_confirm", { id: server.id }),
|
||||
confirmLabel: args.t("delete"),
|
||||
cancelLabel: args.t("cancel"),
|
||||
tone: "danger",
|
||||
checkboxLabel: args.t("delete_server_data_checkbox"),
|
||||
checkboxChecked: false,
|
||||
onResolve: async (confirmed, checked) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteServer(server.id, checked);
|
||||
await loadInitialServers();
|
||||
await args.refreshWorkflows();
|
||||
args.pushToast("success", args.t(checked ? "ok_del_server_with_data" : "ok_del_server_keep_data"));
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_del_server"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
servers,
|
||||
defaultServerId,
|
||||
currentServerId,
|
||||
serverModalOpen,
|
||||
serverModalMode,
|
||||
serverForm,
|
||||
setServerForm,
|
||||
setServerModalOpen,
|
||||
setCurrentServerId,
|
||||
loadInitialServers,
|
||||
handleAddServer,
|
||||
handleEditServer,
|
||||
handleSubmitServerModal,
|
||||
handleToggleServer,
|
||||
requestDeleteServer,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { ToastMessage } from "../components/ui/ToastViewport";
|
||||
import { createToast } from "./state";
|
||||
|
||||
export function useToastState() {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
function pushToast(type: ToastMessage["type"], message: string) {
|
||||
setToasts((current) => [...current.slice(-3), createToast(type, message)]);
|
||||
}
|
||||
|
||||
function dismissToast(id: string) {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
dismissToast,
|
||||
pushToast,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { migrateSchemaParams, parseWorkflowUpload } from "../lib/workflowMapper";
|
||||
import type { WorkflowDetailDto } from "../types/api";
|
||||
import type { EditorState, SchemaParamMap } from "../types/editor";
|
||||
import { hydrateSchemaParams } from "./editorUtils";
|
||||
|
||||
export function buildVersionUpgradeState(target: WorkflowDetailDto, fileContent: string): EditorState {
|
||||
const parsed = parseWorkflowUpload(fileContent);
|
||||
const previousSchemaParams = hydrateSchemaParams(target.workflow_data, target.schema_params as Record<string, unknown>);
|
||||
const migration = migrateSchemaParams(previousSchemaParams, parsed.schemaParams) as {
|
||||
schemaParams: SchemaParamMap;
|
||||
summary: EditorState["upgradeSummary"];
|
||||
};
|
||||
|
||||
return {
|
||||
workflowData: parsed.workflowData,
|
||||
schemaParams: migration.schemaParams,
|
||||
workflowId: target.workflow_id,
|
||||
description: target.description || "",
|
||||
editingWorkflowId: target.workflow_id,
|
||||
hasUnsavedChanges: true,
|
||||
upgradeSummary: migration.summary,
|
||||
};
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { deleteWorkflow, getWorkflowDetail, reorderWorkflows, toggleWorkflow } from "../services/workflows";
|
||||
import { reorderWorkflowCollection, restoreWorkflowOrder } from "../lib/workflowOrder";
|
||||
import type { WorkflowDetailDto, WorkflowSummaryDto } from "../types/api";
|
||||
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
|
||||
import type { TranslateFn } from "./state";
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
tone: "primary" | "danger";
|
||||
}
|
||||
|
||||
interface CreateWorkflowActionsArgs {
|
||||
workflows: WorkflowSummaryDto[];
|
||||
setWorkflows: Dispatch<SetStateAction<WorkflowSummaryDto[]>>;
|
||||
effectiveServerId: string | null;
|
||||
refreshWorkflows: () => Promise<void>;
|
||||
pushToast: (type: "success" | "error", message: string) => void;
|
||||
t: TranslateFn;
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
openEditor: (detail?: WorkflowDetailDto) => Promise<void>;
|
||||
ensureCanLeaveEditor: () => Promise<boolean>;
|
||||
pendingVersionTargetRef: MutableRefObject<WorkflowDetailDto | null>;
|
||||
versionUploadRef: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export function createWorkflowActions(args: CreateWorkflowActionsArgs) {
|
||||
async function handleToggleWorkflow(workflow: WorkflowSummaryDto, enabled: boolean) {
|
||||
try {
|
||||
await toggleWorkflow(workflow.server_id, workflow.id, { enabled });
|
||||
await args.refreshWorkflows();
|
||||
args.pushToast("success", args.t(enabled ? "ok_toggle_wf_enabled" : "ok_toggle_wf_disabled", { id: workflow.id }));
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_toggle_wf"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteWorkflow(workflow: WorkflowSummaryDto) {
|
||||
if (!(await args.confirm({
|
||||
title: args.t("confirm_action_title"),
|
||||
message: args.t("del_wf_confirm", { id: workflow.id }),
|
||||
confirmLabel: args.t("delete"),
|
||||
cancelLabel: args.t("cancel"),
|
||||
tone: "danger",
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkflow(workflow.server_id, workflow.id);
|
||||
await args.refreshWorkflows();
|
||||
args.pushToast("success", args.t("ok_del_wf", { id: workflow.id }));
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_del_wf"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditWorkflow(workflow: WorkflowSummaryDto) {
|
||||
try {
|
||||
await args.openEditor(await getWorkflowDetail(workflow.server_id, workflow.id));
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_load_saved_wf"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadWorkflowVersion(workflow: WorkflowSummaryDto) {
|
||||
if (!(await args.ensureCanLeaveEditor())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
args.pendingVersionTargetRef.current = await getWorkflowDetail(workflow.server_id, workflow.id);
|
||||
args.versionUploadRef.current?.click();
|
||||
} catch (error) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_load_saved_wf"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReorderWorkflows(sourceWorkflowId: string, targetWorkflowId: string, placeAfter: boolean) {
|
||||
if (!args.effectiveServerId || sourceWorkflowId === targetWorkflowId) {
|
||||
return;
|
||||
}
|
||||
const reordered = reorderWorkflowCollection(
|
||||
args.workflows,
|
||||
args.effectiveServerId,
|
||||
sourceWorkflowId,
|
||||
targetWorkflowId,
|
||||
placeAfter,
|
||||
);
|
||||
if (!reordered) {
|
||||
return;
|
||||
}
|
||||
args.setWorkflows(reordered.nextWorkflows);
|
||||
try {
|
||||
await reorderWorkflows(args.effectiveServerId, { workflow_ids: reordered.reorderedIds });
|
||||
} catch (error) {
|
||||
args.setWorkflows((current) => restoreWorkflowOrder(current, args.effectiveServerId as string, reordered.previousIds));
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_reorder_workflows"));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleToggleWorkflow,
|
||||
handleDeleteWorkflow,
|
||||
handleEditWorkflow,
|
||||
handleUploadWorkflowVersion,
|
||||
handleReorderWorkflows,
|
||||
};
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ApiError } from "../services/http";
|
||||
import { saveWorkflow } from "../services/workflows";
|
||||
import type { EditorState } from "../types/editor";
|
||||
import type { TranslateFn } from "./state";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface PersistWorkflowArgs {
|
||||
effectiveServerId: string;
|
||||
editorState: EditorState;
|
||||
finalSchema: Record<string, unknown>;
|
||||
confirm: (options: {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
tone: "primary" | "danger";
|
||||
}) => Promise<boolean>;
|
||||
refreshWorkflows: () => Promise<void>;
|
||||
setEditorState: Dispatch<SetStateAction<EditorState>>;
|
||||
pushToast: (type: "success" | "error", message: string) => void;
|
||||
t: TranslateFn;
|
||||
}
|
||||
|
||||
export async function persistWorkflow(args: PersistWorkflowArgs) {
|
||||
const payload = {
|
||||
workflow_id: args.editorState.workflowId,
|
||||
server_id: args.effectiveServerId,
|
||||
original_workflow_id: args.editorState.editingWorkflowId,
|
||||
description: args.editorState.description,
|
||||
workflow_data: args.editorState.workflowData,
|
||||
schema_params: args.finalSchema || {},
|
||||
ui_schema_params: args.editorState.schemaParams,
|
||||
};
|
||||
|
||||
try {
|
||||
await saveWorkflow(args.effectiveServerId, { ...payload, overwrite_existing: false });
|
||||
await args.refreshWorkflows();
|
||||
args.setEditorState((current) => ({ ...current, editingWorkflowId: current.workflowId, hasUnsavedChanges: false }));
|
||||
args.pushToast("success", args.t("ok_save_wf"));
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 409) {
|
||||
args.pushToast("error", error instanceof Error ? error.message : args.t("err_save_wf"));
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await args.confirm({
|
||||
title: args.t("confirm_action_title"),
|
||||
message: args.t("warn_overwrite_wf", { id: args.editorState.workflowId }),
|
||||
confirmLabel: args.t("overwrite"),
|
||||
cancelLabel: args.t("cancel"),
|
||||
tone: "danger",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveWorkflow(args.effectiveServerId, { ...payload, overwrite_existing: true });
|
||||
await args.refreshWorkflows();
|
||||
args.setEditorState((current) => ({ ...current, editingWorkflowId: current.workflowId, hasUnsavedChanges: false }));
|
||||
args.pushToast("success", args.t("ok_save_wf"));
|
||||
} catch (saveError) {
|
||||
args.pushToast("error", saveError instanceof Error ? saveError.message : args.t("err_save_wf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
tone?: "primary" | "danger";
|
||||
checkboxLabel?: string;
|
||||
checkboxChecked?: boolean;
|
||||
onCheckboxChange?: (checked: boolean) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={props.open}
|
||||
title={props.title}
|
||||
onClose={props.onCancel}
|
||||
initialFocusRef={confirmButtonRef}
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>{props.cancelLabel}</button>
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
type="button"
|
||||
className={`btn ${props.tone === "danger" ? "btn-danger" : "btn-primary"}`}
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className="confirm-modal-message">{props.message}</p>
|
||||
{props.checkboxLabel ? (
|
||||
<label className="checkbox-inline confirm-modal-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(props.checkboxChecked)}
|
||||
onChange={(event) => props.onCheckboxChange?.(event.target.checked)}
|
||||
/>
|
||||
<span>{props.checkboxLabel}</span>
|
||||
</label>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface CustomSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string;
|
||||
options: CustomSelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface MenuPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function findEnabledOptionIndex(options: CustomSelectOption[], currentValue: string) {
|
||||
const enabledOptions = options.filter((option) => !option.disabled);
|
||||
const currentIndex = enabledOptions.findIndex((option) => option.value === currentValue);
|
||||
return {
|
||||
enabledOptions,
|
||||
currentIndex: currentIndex >= 0 ? currentIndex : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
className = "",
|
||||
disabled = false,
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<MenuPosition | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuId = useId();
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) ?? options[0] ?? null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
function updatePosition() {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 8,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function clearHostClass() {
|
||||
document.querySelectorAll(".custom-select-host-open").forEach((element) => {
|
||||
element.classList.remove("custom-select-host-open");
|
||||
});
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
clearHostClass();
|
||||
return;
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
const host = rootRef.current?.closest(".card, .page-header, .server-config-container");
|
||||
if (host instanceof HTMLElement) {
|
||||
clearHostClass();
|
||||
host.classList.add("custom-select-host-open");
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearHostClass();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (rootRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if ((target as HTMLElement | null)?.closest?.(`[data-custom-select-menu="${menuId}"]`)) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function onKeyDown(event: globalThis.KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
updatePosition();
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
window.addEventListener("scroll", onViewportChange, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
window.removeEventListener("scroll", onViewportChange, true);
|
||||
};
|
||||
}, [menuId, open]);
|
||||
|
||||
useEffect(() => () => clearHostClass(), []);
|
||||
|
||||
function selectNext(direction: 1 | -1) {
|
||||
const { enabledOptions, currentIndex } = findEnabledOptionIndex(options, value);
|
||||
if (!enabledOptions.length) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = (currentIndex + direction + enabledOptions.length) % enabledOptions.length;
|
||||
onChange(enabledOptions[nextIndex].value);
|
||||
}
|
||||
|
||||
function onTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectNext(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
selectNext(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`custom-select ${open ? "is-open" : ""} ${disabled ? "is-disabled" : ""} ${className}`.trim()}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="custom-select-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={menuId}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen((current) => !current)}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span className="custom-select-value">{selectedOption?.label ?? ""}</span>
|
||||
<span className="custom-select-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && position
|
||||
? createPortal(
|
||||
<div
|
||||
id={menuId}
|
||||
className="custom-select-menu is-open"
|
||||
role="listbox"
|
||||
data-custom-select-menu={menuId}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`,
|
||||
}}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`custom-select-option ${selected ? "is-selected" : ""}`.trim()}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
closeMenu();
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useId, useState } from "react";
|
||||
|
||||
interface InfoTooltipProps {
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function InfoTooltip(props: InfoTooltipProps) {
|
||||
const tooltipId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="info-tooltip-trigger"
|
||||
aria-label={props.label}
|
||||
aria-describedby={open ? tooltipId : undefined}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
<span aria-hidden="true">?</span>
|
||||
</button>
|
||||
<span
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className={`info-tooltip-popup${open ? " is-open" : ""}`}
|
||||
>
|
||||
{props.content}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useEffect, useRef, type PropsWithChildren, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface ModalProps extends PropsWithChildren {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
actions?: ReactNode;
|
||||
width?: "normal" | "wide";
|
||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function Modal({ open, title, onClose, actions, width = "normal", initialFocusRef, children }: ModalProps) {
|
||||
const latestOnCloseRef = useRef(onClose);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
latestOnCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const activeElement = document.activeElement;
|
||||
if (cardRef.current?.contains(activeElement)) {
|
||||
return;
|
||||
}
|
||||
initialFocusRef?.current?.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [initialFocusRef, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
latestOnCloseRef.current();
|
||||
}
|
||||
}
|
||||
|
||||
document.body.classList.add("modal-open");
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.body.classList.remove("modal-open");
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={cardRef} className={`modal-card ${width === "wide" ? "modal-card-wide" : ""}`} role="dialog" aria-modal="true">
|
||||
<div className="modal-header">
|
||||
<h3 className="card-title">{title}</h3>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{actions ? <div className="modal-actions">{actions}</div> : null}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
|
||||
describe("ToastViewport", () => {
|
||||
it("pauses auto-dismiss while hovered and resumes after leave", () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<ToastViewport
|
||||
toasts={[{ id: "toast-1", type: "success", message: "Saved" }]}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toast = screen.getByText("Saved").closest(".toast");
|
||||
expect(toast).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
fireEvent.mouseEnter(toast!);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.mouseLeave(toast!);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1199);
|
||||
});
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(261);
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledWith("toast-1");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("applies the closing state before dismissing", () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<ToastViewport
|
||||
toasts={[{ id: "toast-2", type: "info", message: "Heads up" }]}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toast = screen.getByText("Heads up").closest(".toast") as HTMLElement;
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close notification" }));
|
||||
});
|
||||
expect(toast).toHaveClass("closing");
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(260);
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledWith("toast-2");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: "success" | "error" | "info";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastViewportProps {
|
||||
toasts: ToastMessage[];
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const TOAST_META = {
|
||||
success: { icon: "✓", title: "Success" },
|
||||
error: { icon: "!", title: "Error" },
|
||||
info: { icon: "i", title: "Notice" },
|
||||
} as const;
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: ToastMessage; onDismiss: (id: string) => void }) {
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const dismissTimerRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
const remainingRef = useRef<number>(3200);
|
||||
const closeRef = useRef<() => void>(() => undefined);
|
||||
const closingRef = useRef(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const meta = TOAST_META[toast.type];
|
||||
|
||||
useEffect(() => {
|
||||
closingRef.current = false;
|
||||
setClosing(false);
|
||||
|
||||
function closeToast() {
|
||||
if (dismissTimerRef.current || closingRef.current) {
|
||||
return;
|
||||
}
|
||||
closingRef.current = true;
|
||||
setClosing(true);
|
||||
dismissTimerRef.current = window.setTimeout(() => {
|
||||
onDismiss(toast.id);
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function startCloseTimer(duration: number) {
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
startTimeRef.current = Date.now();
|
||||
closeTimerRef.current = window.setTimeout(closeToast, duration);
|
||||
}
|
||||
|
||||
closeRef.current = closeToast;
|
||||
remainingRef.current = 3200;
|
||||
startCloseTimer(remainingRef.current);
|
||||
|
||||
return () => {
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
if (dismissTimerRef.current) {
|
||||
window.clearTimeout(dismissTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [onDismiss, toast.id]);
|
||||
|
||||
function pauseTimer() {
|
||||
if (closing) {
|
||||
return;
|
||||
}
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startTimeRef.current));
|
||||
}
|
||||
|
||||
function resumeTimer() {
|
||||
if (closing) {
|
||||
return;
|
||||
}
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
startTimeRef.current = Date.now();
|
||||
closeTimerRef.current = window.setTimeout(() => closeRef.current(), remainingRef.current);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`toast ${toast.type} ${closing ? "closing" : ""}`.trim()}
|
||||
role={toast.type === "error" ? "alert" : "status"}
|
||||
aria-live={toast.type === "error" ? "assertive" : "polite"}
|
||||
onMouseEnter={pauseTimer}
|
||||
onMouseLeave={resumeTimer}
|
||||
>
|
||||
<span className="toast-icon" aria-hidden="true">{meta.icon}</span>
|
||||
<div className="toast-body">
|
||||
<p className="toast-title">{meta.title}</p>
|
||||
<p className="toast-message">{toast.message}</p>
|
||||
</div>
|
||||
<button type="button" className="toast-close" aria-label="Close notification" onClick={() => closeRef.current()}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastViewport({ toasts, onDismiss }: ToastViewportProps) {
|
||||
return (
|
||||
<div id="toast-container" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => <ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { CustomSelect } from "../../components/ui/CustomSelect";
|
||||
import { MappingNodeCard } from "./MappingNodeCard";
|
||||
import type { EditorViewProps } from "./types";
|
||||
|
||||
type EditorMappingSectionProps = Pick<
|
||||
EditorViewProps,
|
||||
| "hasWorkflow"
|
||||
| "upgradeSummary"
|
||||
| "filters"
|
||||
| "searchInputRef"
|
||||
| "onFilterChange"
|
||||
| "onApplyRecommended"
|
||||
| "onExposeVisible"
|
||||
| "onCollapseAll"
|
||||
| "onResetFilters"
|
||||
| "summaryText"
|
||||
| "groupedNodes"
|
||||
| "emptyStateMessageKey"
|
||||
| "collapsedNodeIds"
|
||||
| "expandedParamKeys"
|
||||
| "onToggleNode"
|
||||
| "onToggleParamConfig"
|
||||
| "onUpdateParam"
|
||||
| "mode"
|
||||
| "onSave"
|
||||
| "t"
|
||||
>;
|
||||
|
||||
export function EditorMappingSection(props: EditorMappingSectionProps) {
|
||||
return (
|
||||
<section id="mapping-section" className={`card${props.hasWorkflow ? "" : " hidden"}`} aria-labelledby="mapping-title">
|
||||
<h2 id="mapping-title" className="card-title">{props.t("parsed_input")}</h2>
|
||||
{props.upgradeSummary ? (
|
||||
<div className="upgrade-summary-banner">
|
||||
<div className="upgrade-summary-title">{props.t("workflow_upgrade_ready")}</div>
|
||||
<div className="upgrade-summary-meta">
|
||||
{props.t("workflow_upgrade_summary", {
|
||||
retained: props.upgradeSummary.retained,
|
||||
review: props.upgradeSummary.review,
|
||||
added: props.upgradeSummary.added,
|
||||
removed: props.upgradeSummary.removed,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mapping-toolbar">
|
||||
<div className="mapping-toolbar-top">
|
||||
<input
|
||||
ref={props.searchInputRef}
|
||||
id="mapping-search"
|
||||
className="input-field"
|
||||
value={props.filters.query}
|
||||
onChange={(event) => props.onFilterChange({ query: event.target.value })}
|
||||
placeholder={props.t("mapping_search_placeholder")}
|
||||
/>
|
||||
<CustomSelect
|
||||
value={props.filters.nodeSort}
|
||||
options={[
|
||||
{ value: "node_id_asc", label: props.t("mapping_sort_node_id_asc") },
|
||||
{ value: "node_id_desc", label: props.t("mapping_sort_node_id_desc") },
|
||||
{ value: "class_asc", label: props.t("mapping_sort_class_asc") },
|
||||
]}
|
||||
onChange={(value) => props.onFilterChange({ nodeSort: value })}
|
||||
ariaLabel={props.t("mapping_sort_node_id_asc")}
|
||||
className="is-mapping-sort-select"
|
||||
/>
|
||||
<CustomSelect
|
||||
value={props.filters.paramSort}
|
||||
options={[
|
||||
{ value: "default", label: props.t("mapping_sort_param_default") },
|
||||
{ value: "field_asc", label: props.t("mapping_sort_param_name") },
|
||||
{ value: "type_asc", label: props.t("mapping_sort_param_type") },
|
||||
{ value: "exposed_first", label: props.t("mapping_sort_param_exposed") },
|
||||
]}
|
||||
onChange={(value) => props.onFilterChange({ paramSort: value })}
|
||||
ariaLabel={props.t("mapping_sort_param_default")}
|
||||
className="is-mapping-sort-select"
|
||||
/>
|
||||
</div>
|
||||
<div className="mapping-toolbar-bottom">
|
||||
<label className="checkbox-inline mapping-exposed-only-label" htmlFor="mapping-exposed-only">
|
||||
<input
|
||||
id="mapping-exposed-only"
|
||||
type="checkbox"
|
||||
checked={props.filters.exposedOnly}
|
||||
onChange={(event) => props.onFilterChange({ exposedOnly: event.target.checked })}
|
||||
/>
|
||||
<span>{props.t("mapping_exposed_only")}</span>
|
||||
</label>
|
||||
<label className="checkbox-inline mapping-exposed-only-label" htmlFor="mapping-required-only">
|
||||
<input
|
||||
id="mapping-required-only"
|
||||
type="checkbox"
|
||||
checked={props.filters.requiredOnly}
|
||||
onChange={(event) => props.onFilterChange({ requiredOnly: event.target.checked })}
|
||||
/>
|
||||
<span>{props.t("mapping_required_only")}</span>
|
||||
</label>
|
||||
<div className="mapping-toolbar-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onApplyRecommended}>{props.t("mapping_apply_recommended")}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => props.onExposeVisible(true)}>{props.t("mapping_expose_visible")}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => props.onExposeVisible(false)}>{props.t("mapping_unexpose_visible")}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => props.onCollapseAll(true)}>{props.t("mapping_collapse_all")}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => props.onCollapseAll(false)}>{props.t("mapping_expand_all")}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onResetFilters}>{props.t("mapping_reset_filters")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="mapping-summary" className="section-meta">{props.summaryText}</p>
|
||||
<div id="nodes-container" className="nodes-container" aria-live="polite">
|
||||
{props.groupedNodes.length === 0 ? (
|
||||
<div className="empty-state">{props.t(props.emptyStateMessageKey)}</div>
|
||||
) : props.groupedNodes.map(([nodeId, nodeData]) => (
|
||||
<MappingNodeCard
|
||||
key={nodeId}
|
||||
nodeId={nodeId}
|
||||
classType={nodeData.classType}
|
||||
params={nodeData.params}
|
||||
collapsed={props.collapsedNodeIds.has(nodeId)}
|
||||
expandedParamKeys={props.expandedParamKeys}
|
||||
onToggleNode={props.onToggleNode}
|
||||
onToggleParamConfig={props.onToggleParamConfig}
|
||||
onUpdateParam={props.onUpdateParam}
|
||||
t={props.t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mapping-savebar">
|
||||
<p className="mapping-shortcut-hint">{props.t("mapping_shortcuts_hint")}</p>
|
||||
<button type="button" className="btn btn-wide btn-accent" onClick={props.onSave}>
|
||||
{props.mode === "edit" ? props.t("save_workflow_edit") : props.t("save_workflow")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { EditorMappingSection } from "./EditorMappingSection";
|
||||
import { EditorWorkflowInfoCard } from "./EditorWorkflowInfoCard";
|
||||
import type { EditorViewProps } from "./types";
|
||||
|
||||
export function EditorView(props: EditorViewProps) {
|
||||
const editorStep = !props.workflowId ? 1 : (!props.hasWorkflow ? 2 : 3);
|
||||
|
||||
return (
|
||||
<main className="page shell">
|
||||
<nav className="editor-nav">
|
||||
<button type="button" className="btn btn-secondary editor-back-btn" onClick={props.onBack}>
|
||||
<span aria-hidden="true">←</span> <span>{props.t("back")}</span>
|
||||
</button>
|
||||
<div className="editor-nav-title">
|
||||
<p className="editor-mode-badge">
|
||||
{props.mode === "edit" ? `${props.t("editor_mode_editing")}: ${props.editingWorkflowId || props.workflowId}` : props.t("editor_mode_create")}
|
||||
</p>
|
||||
<p className="editor-progress-hint">
|
||||
{editorStep === 1
|
||||
? props.t("editor_step_1_hint")
|
||||
: editorStep === 2
|
||||
? props.t("editor_step_2_hint")
|
||||
: props.t("editor_step_3_hint")}
|
||||
</p>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<ol className="editor-stepper" aria-label="Workflow setup steps">
|
||||
<li className={`editor-step ${editorStep === 1 ? "is-active" : editorStep > 1 ? "is-done" : ""}`}>
|
||||
<span className="editor-step-index">1</span>
|
||||
<span className="editor-step-label">{props.t("editor_step_1")}</span>
|
||||
</li>
|
||||
<li className={`editor-step ${editorStep === 2 ? "is-active" : editorStep > 2 ? "is-done" : ""}`}>
|
||||
<span className="editor-step-index">2</span>
|
||||
<span className="editor-step-label">{props.t("editor_step_2")}</span>
|
||||
</li>
|
||||
<li className={`editor-step ${editorStep === 3 ? "is-active" : ""}`}>
|
||||
<span className="editor-step-index">3</span>
|
||||
<span className="editor-step-label">{props.t("editor_step_3")}</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<EditorWorkflowInfoCard
|
||||
workflowId={props.workflowId}
|
||||
description={props.description}
|
||||
hasWorkflow={props.hasWorkflow}
|
||||
onWorkflowIdChange={props.onWorkflowIdChange}
|
||||
onDescriptionChange={props.onDescriptionChange}
|
||||
onUploadFile={props.onUploadFile}
|
||||
t={props.t}
|
||||
/>
|
||||
|
||||
<EditorMappingSection {...props} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState, type ChangeEvent } from "react";
|
||||
|
||||
interface EditorWorkflowInfoCardProps {
|
||||
workflowId: string;
|
||||
description: string;
|
||||
hasWorkflow: boolean;
|
||||
onWorkflowIdChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onUploadFile: (file: File | null) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function EditorWorkflowInfoCard(props: EditorWorkflowInfoCardProps) {
|
||||
const [uploadDragActive, setUploadDragActive] = useState(false);
|
||||
|
||||
function onFileInputChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
props.onUploadFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" aria-labelledby="editor-info-title">
|
||||
<h2 id="editor-info-title" className="card-title">{props.t("wf_basic_info")}</h2>
|
||||
<div className="editor-info-grid">
|
||||
<div className="form-group editor-info-field no-margin">
|
||||
<label htmlFor="wf-id">{props.t("wf_id_label")}</label>
|
||||
<input
|
||||
id="wf-id"
|
||||
className="input-field editor-info-input"
|
||||
value={props.workflowId}
|
||||
onChange={(event) => props.onWorkflowIdChange(event.target.value)}
|
||||
placeholder={props.t("wf_id_placeholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group editor-info-field no-margin">
|
||||
<label htmlFor="wf-desc">{props.t("wf_desc_label")}</label>
|
||||
<input
|
||||
id="wf-desc"
|
||||
className="input-field editor-info-input"
|
||||
value={props.description}
|
||||
onChange={(event) => props.onDescriptionChange(event.target.value)}
|
||||
placeholder={props.t("wf_desc_placeholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!props.hasWorkflow ? (
|
||||
<label
|
||||
id="upload-zone"
|
||||
className={`upload-zone${uploadDragActive ? " is-dragging" : ""}`}
|
||||
htmlFor="file-upload"
|
||||
tabIndex={0}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setUploadDragActive(true);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setUploadDragActive(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
setUploadDragActive(false);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setUploadDragActive(false);
|
||||
props.onUploadFile(event.dataTransfer.files?.[0] || null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById("file-upload");
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input id="file-upload" type="file" accept=".json" onChange={onFileInputChange} />
|
||||
<span className="upload-title">{props.t("drag_upload")}</span>
|
||||
<span className="upload-subtitle">{props.t("after_upload")}</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<aside className="upload-reminder" aria-live="polite">
|
||||
<h3 className="upload-reminder-title">{props.t("upload_reminder_title")}</h3>
|
||||
<ul className="upload-reminder-list">
|
||||
<li>{props.t("upload_reminder_api")}</li>
|
||||
<li>{props.t("upload_reminder_how")}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { CustomSelect } from "../../components/ui/CustomSelect";
|
||||
import type { SchemaParam } from "../../types/editor";
|
||||
import type { MappingParam } from "./types";
|
||||
|
||||
function renderTypeOptions(t: (key: string) => string) {
|
||||
return [
|
||||
{ value: "string", label: t("type_str") },
|
||||
{ value: "int", label: t("type_int") },
|
||||
{ value: "float", label: t("type_float") },
|
||||
{ value: "boolean", label: t("type_bool") },
|
||||
];
|
||||
}
|
||||
|
||||
interface MappingNodeCardProps {
|
||||
nodeId: string;
|
||||
classType: string;
|
||||
params: MappingParam[];
|
||||
collapsed: boolean;
|
||||
expandedParamKeys: Set<string>;
|
||||
onToggleNode: (nodeId: string) => void;
|
||||
onToggleParamConfig: (key: string) => void;
|
||||
onUpdateParam: (
|
||||
key: string,
|
||||
field: keyof SchemaParam | "name" | "exposed" | "description" | "required" | "type",
|
||||
value: unknown,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
export function MappingNodeCard(props: MappingNodeCardProps) {
|
||||
return (
|
||||
<article className={`node-card${props.collapsed ? " is-collapsed" : ""}`}>
|
||||
<div className="node-header">
|
||||
<div className="node-title">
|
||||
<span className="status-dot" aria-hidden="true">●</span>
|
||||
<span>{props.classType}</span>
|
||||
</div>
|
||||
<div className="node-header-right">
|
||||
<span className="status-badge">{props.t("node_id")}: {props.nodeId}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon small node-collapse-btn"
|
||||
aria-label={props.t("toggle_node", { id: props.nodeId })}
|
||||
onClick={() => props.onToggleNode(props.nodeId)}
|
||||
>
|
||||
{props.collapsed ? "▸" : "▾"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!props.collapsed ? (
|
||||
<div className="node-body">
|
||||
{props.params.map((parameter) => {
|
||||
const expanded = parameter.exposed && props.expandedParamKeys.has(parameter.key);
|
||||
return (
|
||||
<div key={parameter.key} className={`param-row${parameter.exposed ? " active" : ""}`}>
|
||||
<div className="param-main">
|
||||
<label className="toggle-switch" aria-label={parameter.field}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameter.exposed}
|
||||
onChange={(event) => props.onUpdateParam(parameter.key, "exposed", event.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
<div className="param-main-copy">
|
||||
<div className={`param-title${parameter.exposed ? " active" : ""}`}>
|
||||
<span>{parameter.field}</span>
|
||||
{parameter.migrationStatus ? (
|
||||
<span className={`param-status-badge is-${parameter.migrationStatus}`}>
|
||||
{props.t(`migration_status_${parameter.migrationStatus}`)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="param-meta">{props.t("curr_val")}: {String(parameter.currentVal ?? "")}</div>
|
||||
{parameter.migrationStatus === "review" ? (
|
||||
<div className="param-meta param-meta-emphasis">{props.t("migration_review_hint")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{parameter.exposed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon small param-config-toggle"
|
||||
aria-label={props.t("toggle_param_config", { field: parameter.field })}
|
||||
onClick={() => props.onToggleParamConfig(parameter.key)}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{parameter.exposed && expanded ? (
|
||||
<div className="param-config is-expanded">
|
||||
<div>
|
||||
<label>{props.t("alias")}</label>
|
||||
<input
|
||||
value={parameter.name}
|
||||
onChange={(event) => props.onUpdateParam(parameter.key, "name", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{props.t("ai_desc")}</label>
|
||||
<input
|
||||
value={parameter.description}
|
||||
onChange={(event) => props.onUpdateParam(parameter.key, "description", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{props.t("type")}</label>
|
||||
<CustomSelect
|
||||
value={parameter.type}
|
||||
options={renderTypeOptions(props.t)}
|
||||
onChange={(value) => props.onUpdateParam(parameter.key, "type", value)}
|
||||
ariaLabel={props.t("type")}
|
||||
className="is-mapping-sort-select"
|
||||
/>
|
||||
<label className="checkbox-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameter.required}
|
||||
onChange={(event) => props.onUpdateParam(parameter.key, "required", event.target.checked)}
|
||||
/>
|
||||
<span>{props.t("required")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { SchemaParam, SchemaParamMap, UpgradeSummary } from "../../types/editor";
|
||||
|
||||
export interface MappingParam extends SchemaParam {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface MappingNodeGroup {
|
||||
classType: string;
|
||||
params: MappingParam[];
|
||||
}
|
||||
|
||||
export interface EditorFiltersState {
|
||||
query: string;
|
||||
exposedOnly: boolean;
|
||||
requiredOnly: boolean;
|
||||
nodeSort: string;
|
||||
paramSort: string;
|
||||
}
|
||||
|
||||
export interface EditorViewProps {
|
||||
workflowId: string;
|
||||
description: string;
|
||||
schemaParams: SchemaParamMap;
|
||||
hasWorkflow: boolean;
|
||||
emptyStateMessageKey: string;
|
||||
mode: "create" | "edit";
|
||||
editingWorkflowId?: string | null;
|
||||
upgradeSummary: UpgradeSummary | null;
|
||||
filters: EditorFiltersState;
|
||||
collapsedNodeIds: Set<string>;
|
||||
expandedParamKeys: Set<string>;
|
||||
groupedNodes: Array<[string, MappingNodeGroup]>;
|
||||
summaryText: string;
|
||||
searchInputRef: RefObject<HTMLInputElement | null>;
|
||||
onBack: () => void;
|
||||
onWorkflowIdChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onUploadFile: (file: File | null) => void;
|
||||
onSave: () => void;
|
||||
onFilterChange: (next: Partial<EditorFiltersState>) => void;
|
||||
onResetFilters: () => void;
|
||||
onToggleNode: (nodeId: string) => void;
|
||||
onToggleParamConfig: (key: string) => void;
|
||||
onUpdateParam: (
|
||||
key: string,
|
||||
field: keyof SchemaParam | "name" | "exposed" | "description" | "required" | "type",
|
||||
value: unknown,
|
||||
) => void;
|
||||
onApplyRecommended: () => void;
|
||||
onExposeVisible: (exposed: boolean) => void;
|
||||
onCollapseAll: (collapsed: boolean) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ServerManager } from "./ServerManager";
|
||||
import type { SaveServerPayload, ServerDto } from "../../types/api";
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
server_manager: "Server Manager",
|
||||
add_server_toggle: "Add Server",
|
||||
no_servers: "No servers yet",
|
||||
create_first_server: "Create First Server",
|
||||
current_server_title: "Current Server",
|
||||
select_server: "Select Server",
|
||||
server_enabled: "Enabled",
|
||||
server_disabled: "Disabled",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
edit_server_modal_title: "Edit Server",
|
||||
add_server_modal_title: "Add Server",
|
||||
cancel: "Cancel",
|
||||
save_server_changes: "Save changes",
|
||||
save_and_connect: "Save and Connect",
|
||||
server_id_label: "Server ID",
|
||||
new_server_id_placeholder: "new-server-id",
|
||||
server_id_help: "Optional. Leave blank to auto-generate. It cannot be changed later.",
|
||||
server_name: "Server Name",
|
||||
new_server_name_placeholder: "Local Server",
|
||||
server_name_help: "Name is display-only and can be changed later.",
|
||||
server_url_label: "Server URL",
|
||||
server_unsupported_short: "(Unsupported)",
|
||||
server_unsupported_reason: "Server type \"{type}\" is not supported in this branch. Remove or migrate this server before using it.",
|
||||
server_url_help_comfyui: "Directly calls the standard ComfyUI endpoints: `/prompt`, `/history/{id}`, and `/view`.",
|
||||
new_server_url_placeholder: "http://127.0.0.1:8188",
|
||||
server_output_dir: "Output Directory",
|
||||
};
|
||||
|
||||
function t(key: string, vars?: Record<string, string | number>) {
|
||||
return (messages[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => String(vars?.[token] ?? ""));
|
||||
}
|
||||
|
||||
const defaultForm: SaveServerPayload = {
|
||||
id: "",
|
||||
name: "",
|
||||
url: "",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
};
|
||||
|
||||
const serverFixture: ServerDto = {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
url: "http://127.0.0.1:8188",
|
||||
enabled: true,
|
||||
output_dir: "./outputs",
|
||||
};
|
||||
|
||||
function Harness({
|
||||
servers = [],
|
||||
modalMode = "add",
|
||||
initialForm = defaultForm,
|
||||
}: {
|
||||
servers?: ServerDto[];
|
||||
modalMode?: "add" | "edit";
|
||||
initialForm?: SaveServerPayload;
|
||||
}) {
|
||||
const [form, setForm] = useState<SaveServerPayload>(initialForm);
|
||||
|
||||
return (
|
||||
<ServerManager
|
||||
servers={servers}
|
||||
currentServerId={servers[0]?.id ?? null}
|
||||
onSelectServer={vi.fn()}
|
||||
onToggleServer={vi.fn()}
|
||||
onDeleteServer={vi.fn()}
|
||||
onOpenCreate={vi.fn()}
|
||||
onOpenEdit={vi.fn()}
|
||||
modalOpen
|
||||
modalMode={modalMode}
|
||||
form={form}
|
||||
onFormChange={setForm}
|
||||
onCloseModal={vi.fn()}
|
||||
onSubmitModal={vi.fn()}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ServerManager", () => {
|
||||
it("seeds the default ComfyUI URL for a new server", async () => {
|
||||
render(<Harness initialForm={{ ...defaultForm }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Server URL")).toHaveValue("http://127.0.0.1:8188");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reinsert the default URL after the user clears and types a remote URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initialForm={{ ...defaultForm }} />);
|
||||
|
||||
const urlInput = await screen.findByLabelText("Server URL");
|
||||
await user.clear(urlInput);
|
||||
await user.type(urlInput, "http://10.0.0.1:8188");
|
||||
|
||||
expect(urlInput).toHaveValue("http://10.0.0.1:8188");
|
||||
});
|
||||
|
||||
it("focuses the server id field when the add modal opens", async () => {
|
||||
render(<Harness initialForm={defaultForm} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Server ID")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses the server name field when the edit modal opens", async () => {
|
||||
render(
|
||||
<Harness
|
||||
servers={[serverFixture]}
|
||||
modalMode="edit"
|
||||
initialForm={{
|
||||
...defaultForm,
|
||||
id: serverFixture.id,
|
||||
name: serverFixture.name,
|
||||
url: serverFixture.url,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Server Name")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders static current server text instead of a selector when only one server exists", () => {
|
||||
render(<Harness servers={[serverFixture]} modalMode="edit" initialForm={defaultForm} />);
|
||||
|
||||
const serverCard = screen.getByText("Current Server").closest(".server-main-left") as HTMLElement;
|
||||
expect(within(serverCard).getByText("Local")).toBeInTheDocument();
|
||||
expect(serverCard.querySelector(".server-selector-static")).not.toBeNull();
|
||||
expect(within(serverCard).queryByRole("button", { name: "Select Server" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not jump focus back to server id while typing in the server name field", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initialForm={defaultForm} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Server ID")).toHaveFocus();
|
||||
});
|
||||
|
||||
const serverNameInput = screen.getByLabelText("Server Name");
|
||||
await user.click(serverNameInput);
|
||||
await user.type(serverNameInput, "Remote Server");
|
||||
|
||||
expect(serverNameInput).toHaveFocus();
|
||||
expect(serverNameInput).toHaveValue("Remote Server");
|
||||
expect(screen.getByLabelText("Server ID")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { CustomSelect } from "../../components/ui/CustomSelect";
|
||||
import { Modal } from "../../components/ui/Modal";
|
||||
import { getServerStatus } from "../../services/servers";
|
||||
import type { SaveServerPayload, ServerDto } from "../../types/api";
|
||||
|
||||
const DEFAULT_COMFYUI_URL = "http://127.0.0.1:8188";
|
||||
|
||||
interface ServerManagerProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
servers: ServerDto[];
|
||||
currentServerId: string | null;
|
||||
onSelectServer: (serverId: string) => void;
|
||||
onToggleServer: (server: ServerDto, enabled: boolean) => void;
|
||||
onDeleteServer: (server: ServerDto) => void;
|
||||
onOpenCreate: () => void;
|
||||
onOpenEdit: (server: ServerDto) => void;
|
||||
modalOpen: boolean;
|
||||
modalMode: "add" | "edit";
|
||||
form: SaveServerPayload;
|
||||
onFormChange: (next: SaveServerPayload) => void;
|
||||
onCloseModal: () => void;
|
||||
onSubmitModal: () => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
function EditIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="lucide lucide-pencil"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: "online" | "offline" | "checking" }) {
|
||||
let color = "#a3a3a3";
|
||||
if (status === "online") color = "#22c55e";
|
||||
else if (status === "offline") color = "#ef4444";
|
||||
return (
|
||||
<span
|
||||
title={status}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
marginRight: 6,
|
||||
flexShrink: 0,
|
||||
animation: status === "checking" ? "pulse 1.5s infinite" : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ServerRunStatus = "online" | "offline" | "checking";
|
||||
|
||||
export function ServerManager(props: ServerManagerProps) {
|
||||
const currentServer = props.servers.find((server) => server.id === props.currentServerId) || null;
|
||||
const currentServerWarning = currentServer?.unsupported
|
||||
? props.t("server_unsupported_reason", { type: currentServer.server_type || "unknown" })
|
||||
: "";
|
||||
const selectedServerLabel = currentServer?.name || currentServer?.id || "";
|
||||
const serverOptions = props.servers.map((server) => ({
|
||||
value: server.id,
|
||||
label: `${server.name || server.id}${server.unsupported ? ` ${props.t("server_unsupported_short")}` : ""}`,
|
||||
}));
|
||||
const serverIdInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const serverNameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const hasSeededDefaultUrlRef = useRef(false);
|
||||
|
||||
const [serverStatus, setServerStatus] = useState<ServerRunStatus>("checking");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkStatus() {
|
||||
if (!props.currentServerId) return;
|
||||
try {
|
||||
const result = await getServerStatus(props.currentServerId);
|
||||
if (!cancelled) setServerStatus(result.status);
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus("offline");
|
||||
}
|
||||
}
|
||||
|
||||
setServerStatus("checking");
|
||||
checkStatus();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.currentServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.modalOpen) {
|
||||
hasSeededDefaultUrlRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasSeededDefaultUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
hasSeededDefaultUrlRef.current = true;
|
||||
if (props.modalMode === "add" && !props.form.url) {
|
||||
props.onFormChange({ ...props.form, url: DEFAULT_COMFYUI_URL });
|
||||
}
|
||||
}, [props.form, props.modalMode, props.modalOpen, props.onFormChange]);
|
||||
|
||||
function update<K extends keyof SaveServerPayload>(key: K, value: SaveServerPayload[K]) {
|
||||
props.onFormChange({ ...props.form, [key]: value });
|
||||
}
|
||||
|
||||
function onInputChange<K extends keyof SaveServerPayload>(key: K) {
|
||||
return (event: ChangeEvent<HTMLInputElement>) => update(key, event.target.value as SaveServerPayload[K]);
|
||||
}
|
||||
|
||||
function statusLabel(): string {
|
||||
if (serverStatus === "checking") return "...";
|
||||
return props.t(serverStatus === "online" ? "server_status_online" : "server_status_offline");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" aria-labelledby="server-manager-title">
|
||||
<div className="section-header panel-toolbar">
|
||||
<div className="panel-title-wrap">
|
||||
<h2 id="server-manager-title" className="card-title">{props.t("server_manager")}</h2>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<button type="button" className="btn btn-secondary panel-action-btn" onClick={props.onOpenCreate}>
|
||||
{props.t("add_server_toggle")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.servers.length === 0 ? (
|
||||
<div className="server-empty-state">
|
||||
<p className="section-meta">{props.t("no_servers")}</p>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onOpenCreate}>
|
||||
{props.t("create_first_server")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-config-container card card-nested">
|
||||
<div className="server-main-row">
|
||||
<div className="server-main-left">
|
||||
<span className="section-meta" style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<StatusDot status={serverStatus} />
|
||||
{props.t("current_server_title")}
|
||||
<span style={{ fontSize: "0.85em", opacity: 0.7 }}>
|
||||
({statusLabel()})
|
||||
</span>
|
||||
</span>
|
||||
<div className="server-selector-wrapper">
|
||||
{props.servers.length === 1 ? (
|
||||
<div className="server-selector-static" aria-label={props.t("select_server")}>
|
||||
{selectedServerLabel}{currentServer?.unsupported ? ` ${props.t("server_unsupported_short")}` : ""}
|
||||
</div>
|
||||
) : (
|
||||
<CustomSelect
|
||||
value={props.currentServerId || ""}
|
||||
options={serverOptions}
|
||||
ariaLabel={props.t("select_server")}
|
||||
className="is-server-select"
|
||||
onChange={props.onSelectServer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentServerWarning ? <p className="form-help">{currentServerWarning}</p> : null}
|
||||
</div>
|
||||
|
||||
{currentServer ? (
|
||||
<div id="current-server-actions" className="server-header-controls">
|
||||
<div className="server-status-toggle">
|
||||
{/* Agent visibility toggle */}
|
||||
<label className="toggle-inline" title={props.t("server_agent_visibility_hint")} style={{ margin: 0 }}>
|
||||
<span className={currentServer.enabled ? "status-on" : "status-off"}>
|
||||
{currentServer.enabled ? props.t("server_agent_visible") : props.t("server_agent_hidden")}
|
||||
</span>
|
||||
<div className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentServer.enabled}
|
||||
onChange={(event) => props.onToggleServer(currentServer, event.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon server-action-btn"
|
||||
aria-label={props.t("edit")}
|
||||
onClick={() => props.onOpenEdit(currentServer)}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon server-action-btn server-delete-btn"
|
||||
aria-label={props.t("delete")}
|
||||
onClick={() => props.onDeleteServer(currentServer)}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={props.modalOpen}
|
||||
title={props.modalMode === "edit" ? props.t("edit_server_modal_title") : props.t("add_server_modal_title")}
|
||||
onClose={props.onCloseModal}
|
||||
initialFocusRef={props.modalMode === "edit" ? serverNameInputRef : serverIdInputRef}
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onCloseModal}>{props.t("cancel")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={props.onSubmitModal}>
|
||||
{props.modalMode === "edit" ? props.t("save_server_changes") : props.t("save_and_connect")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="modal-grid">
|
||||
<div id="modal-server-id-group" className="form-group form-group-half">
|
||||
<label htmlFor="modal-server-id">{props.t("server_id_label")}</label>
|
||||
<input
|
||||
ref={serverIdInputRef}
|
||||
id="modal-server-id"
|
||||
type="text"
|
||||
className="input-field"
|
||||
value={props.form.id ?? ""}
|
||||
disabled={props.modalMode === "edit"}
|
||||
onChange={onInputChange("id")}
|
||||
placeholder={props.t("new_server_id_placeholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="form-help">{props.t("server_id_help")}</p>
|
||||
</div>
|
||||
<div className="form-group form-group-half">
|
||||
<label htmlFor="modal-server-name">{props.t("server_name")}</label>
|
||||
<input
|
||||
ref={serverNameInputRef}
|
||||
id="modal-server-name"
|
||||
type="text"
|
||||
className="input-field"
|
||||
value={props.form.name}
|
||||
onChange={onInputChange("name")}
|
||||
placeholder={props.t("new_server_name_placeholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="form-help">{props.t("server_name_help")}</p>
|
||||
</div>
|
||||
<div className="form-group form-group-full">
|
||||
<label htmlFor="modal-server-url">{props.t("server_url_label")}</label>
|
||||
<input
|
||||
id="modal-server-url"
|
||||
type="text"
|
||||
className="input-field"
|
||||
value={props.form.url}
|
||||
onChange={onInputChange("url")}
|
||||
placeholder={props.t("new_server_url_placeholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="form-help">{props.t("server_url_help_comfyui")}</p>
|
||||
</div>
|
||||
<div className="form-group form-group-full">
|
||||
<label htmlFor="modal-server-output">{props.t("server_output_dir")}</label>
|
||||
<input
|
||||
id="modal-server-output"
|
||||
type="text"
|
||||
className="input-field"
|
||||
value={props.form.output_dir}
|
||||
onChange={onInputChange("output_dir")}
|
||||
placeholder="./outputs"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Modal } from "../../components/ui/Modal";
|
||||
import type {
|
||||
TransferExportPreviewDto,
|
||||
TransferExportPreviewServerDto,
|
||||
TransferPlanItemDto,
|
||||
TransferSelectionPayload,
|
||||
} from "../../types/api";
|
||||
|
||||
type TransferMode = "export" | "import" | null;
|
||||
|
||||
interface TransferModalProps {
|
||||
open: boolean;
|
||||
mode: TransferMode;
|
||||
exportPreview: TransferExportPreviewDto | null;
|
||||
exportSelection: TransferSelectionPayload;
|
||||
expandedServerIds: string[];
|
||||
importPreviewSummary: string;
|
||||
importSections: Array<{ title: string; items: TransferPlanItemDto[] }>;
|
||||
importWarnings: string[];
|
||||
applyEnvironment: boolean;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onToggleServerSelection: (server: TransferExportPreviewServerDto) => void;
|
||||
onToggleWorkflowSelection: (serverId: string, workflowId: string) => void;
|
||||
onToggleServerExpanded: (serverId: string) => void;
|
||||
onApplyEnvironmentChange: (checked: boolean) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
function TransferWarningBox({ warnings, t }: { warnings: string[]; t: TransferModalProps["t"] }) {
|
||||
if (warnings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="transfer-warning-list">
|
||||
<p className="transfer-warning-title">{t("transfer_warning_title")}</p>
|
||||
{warnings.map((warning) => (
|
||||
<p key={warning} className="transfer-warning-item">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferExportServer({
|
||||
server,
|
||||
selectedWorkflowIds,
|
||||
expanded,
|
||||
onToggleServerSelection,
|
||||
onToggleWorkflowSelection,
|
||||
onToggleExpanded,
|
||||
t,
|
||||
}: {
|
||||
server: TransferExportPreviewServerDto;
|
||||
selectedWorkflowIds: string[];
|
||||
expanded: boolean;
|
||||
onToggleServerSelection: (server: TransferExportPreviewServerDto) => void;
|
||||
onToggleWorkflowSelection: (serverId: string, workflowId: string) => void;
|
||||
onToggleExpanded: (serverId: string) => void;
|
||||
t: TransferModalProps["t"];
|
||||
}) {
|
||||
const total = server.workflows.length;
|
||||
const selectedCount = selectedWorkflowIds.length;
|
||||
const allSelected = total > 0 && selectedCount === total;
|
||||
const partiallySelected = selectedCount > 0 && selectedCount < total;
|
||||
const serverCheckboxRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverCheckboxRef.current) {
|
||||
serverCheckboxRef.current.indeterminate = partiallySelected;
|
||||
}
|
||||
}, [partiallySelected]);
|
||||
|
||||
return (
|
||||
<article className={`transfer-export-server ${expanded ? "is-open" : ""}`}>
|
||||
<div className="transfer-export-server-head">
|
||||
<label className="transfer-export-item transfer-export-item-summary">
|
||||
<input
|
||||
ref={serverCheckboxRef}
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => onToggleServerSelection(server)}
|
||||
/>
|
||||
<span className="transfer-export-item-copy">
|
||||
<span className="transfer-export-server-title-row">
|
||||
<span>{server.name || server.server_id}</span>
|
||||
{!server.enabled ? <span className="transfer-chip transfer-chip-muted">{t("export_server_disabled")}</span> : null}
|
||||
</span>
|
||||
<span className="transfer-export-item-meta">
|
||||
{t("export_selected_count", { selected: selectedCount, total })}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon transfer-export-toggle"
|
||||
aria-label={t("export_toggle_server", { server: server.name || server.server_id })}
|
||||
onClick={() => onToggleExpanded(server.server_id)}
|
||||
>
|
||||
<span className="transfer-export-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="transfer-export-workflows-wrap">
|
||||
<div className="transfer-export-workflows">
|
||||
{server.workflows.map((workflow) => (
|
||||
<label key={`${server.server_id}-${workflow.workflow_id}`} className="transfer-export-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowIds.includes(workflow.workflow_id)}
|
||||
onChange={() => onToggleWorkflowSelection(server.server_id, workflow.workflow_id)}
|
||||
/>
|
||||
<span className="transfer-export-item-copy">
|
||||
<span className="transfer-export-workflow-title-row">
|
||||
<span>{workflow.workflow_id}</span>
|
||||
{!workflow.enabled ? <span className="transfer-chip transfer-chip-muted">{t("export_workflow_disabled")}</span> : null}
|
||||
</span>
|
||||
{workflow.description ? <span className="transfer-export-item-meta">{workflow.description}</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferImportSection({
|
||||
title,
|
||||
items,
|
||||
t,
|
||||
}: {
|
||||
title: string;
|
||||
items: TransferPlanItemDto[];
|
||||
t: TransferModalProps["t"];
|
||||
}) {
|
||||
return (
|
||||
<section className="transfer-section">
|
||||
<h4 className="transfer-section-title">{title}</h4>
|
||||
{items.length === 0 ? (
|
||||
<p className="transfer-empty">{t("transfer_section_empty")}</p>
|
||||
) : (
|
||||
<ul className="transfer-list">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.server_id}-${item.workflow_id || "server"}-${index}`}>
|
||||
{item.workflow_id ? `${item.server_id}/${item.workflow_id}` : item.server_id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferModal(props: TransferModalProps) {
|
||||
const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const handleClose = props.loading ? () => undefined : props.onClose;
|
||||
const selectedWorkflowCount = useMemo(
|
||||
() => props.exportSelection.servers.reduce((count, server) => count + server.workflow_ids.length, 0),
|
||||
[props.exportSelection],
|
||||
);
|
||||
const exportWarnings = props.exportPreview?.warnings.map((warning) => warning.message) || [];
|
||||
const importConfirmDisabled = props.loading;
|
||||
const exportConfirmDisabled = props.loading || selectedWorkflowCount === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={props.open}
|
||||
width="wide"
|
||||
title={props.mode === "export" ? props.t("export_bundle_title") : props.t("import_bundle_preview_title")}
|
||||
onClose={handleClose}
|
||||
initialFocusRef={confirmButtonRef}
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={handleClose}>{props.t("cancel")}</button>
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={props.mode === "export" ? exportConfirmDisabled : importConfirmDisabled}
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.mode === "export" ? props.t("export_bundle_confirm") : props.t("import_bundle_confirm")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{props.mode === "export" ? (
|
||||
<div className="transfer-modal-body">
|
||||
<section className="transfer-panel">
|
||||
<p className="transfer-hint">{props.t("export_panel_hint")}</p>
|
||||
<p className="confirm-modal-message">
|
||||
{props.t("export_preview_summary", {
|
||||
servers: props.exportSelection.servers.filter((server) => server.workflow_ids.length > 0).length,
|
||||
workflows: selectedWorkflowCount,
|
||||
warnings: props.exportPreview?.summary.warnings || 0,
|
||||
})}
|
||||
</p>
|
||||
<div className="transfer-export-tree">
|
||||
{(props.exportPreview?.servers || []).map((server) => {
|
||||
const selected = props.exportSelection.servers.find((item) => item.server_id === server.server_id);
|
||||
return (
|
||||
<TransferExportServer
|
||||
key={server.server_id}
|
||||
server={server}
|
||||
selectedWorkflowIds={selected?.workflow_ids || []}
|
||||
expanded={props.expandedServerIds.includes(server.server_id)}
|
||||
onToggleServerSelection={props.onToggleServerSelection}
|
||||
onToggleWorkflowSelection={props.onToggleWorkflowSelection}
|
||||
onToggleExpanded={props.onToggleServerExpanded}
|
||||
t={props.t}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TransferWarningBox warnings={exportWarnings} t={props.t} />
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="transfer-modal-body">
|
||||
<section className="transfer-panel">
|
||||
<p className="confirm-modal-message">{props.importPreviewSummary}</p>
|
||||
<div className="transfer-sections">
|
||||
{props.importSections.map((section) => (
|
||||
<TransferImportSection key={section.title} title={section.title} items={section.items} t={props.t} />
|
||||
))}
|
||||
</div>
|
||||
<label className="checkbox-inline confirm-modal-checkbox" htmlFor="transfer-import-apply-environment">
|
||||
<input
|
||||
id="transfer-import-apply-environment"
|
||||
type="checkbox"
|
||||
checked={props.applyEnvironment}
|
||||
onChange={(event) => props.onApplyEnvironmentChange(event.target.checked)}
|
||||
/>
|
||||
<span>{props.t("transfer_apply_environment")}</span>
|
||||
</label>
|
||||
<TransferWarningBox warnings={props.importWarnings} t={props.t} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkflowManager } from "./WorkflowManager";
|
||||
import type { WorkflowSummaryDto } from "../../types/api";
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
workflow_manager: "Workflow Manager",
|
||||
workflow_count: "{count} workflows",
|
||||
workflow_count_filtered: "{visible} of {total} workflows",
|
||||
workflow_sort_custom: "Custom",
|
||||
workflow_sort_recent: "Recently updated",
|
||||
workflow_sort_name_asc: "Name A-Z",
|
||||
workflow_sort_name_desc: "Name Z-A",
|
||||
workflow_sort_enabled: "Enabled first",
|
||||
workflow_search_placeholder: "Search workflows",
|
||||
register_new_short: "+ New Workflow",
|
||||
drag_upload: "Drag or click to upload ComfyUI workflow_api.json",
|
||||
after_upload: "After upload, you can remap parameters by node.",
|
||||
workflow_more_actions: "More actions for workflow {id}",
|
||||
upload_new_version: "Upload New Version",
|
||||
delete: "Delete",
|
||||
edit_workflow: "Edit workflow {id}",
|
||||
toggle_workflow: "Toggle workflow {id}",
|
||||
wf_enabled: "Enabled",
|
||||
wf_disabled: "Disabled",
|
||||
workflow_drag_handle: "Drag to reorder workflow {id}",
|
||||
};
|
||||
|
||||
function t(key: string, vars?: Record<string, string | number>) {
|
||||
return (messages[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => String(vars?.[token] ?? ""));
|
||||
}
|
||||
|
||||
const workflows: WorkflowSummaryDto[] = [
|
||||
{
|
||||
id: "wf-a",
|
||||
server_id: "server-1",
|
||||
server_name: "Remote",
|
||||
enabled: true,
|
||||
description: "First workflow",
|
||||
updated_at: 10,
|
||||
},
|
||||
{
|
||||
id: "wf-b",
|
||||
server_id: "server-1",
|
||||
server_name: "Remote",
|
||||
enabled: true,
|
||||
description: "Second workflow",
|
||||
updated_at: 20,
|
||||
},
|
||||
];
|
||||
|
||||
function renderWorkflowManager(overrides: Partial<ComponentProps<typeof WorkflowManager>> = {}) {
|
||||
const props: ComponentProps<typeof WorkflowManager> = {
|
||||
workflows,
|
||||
allWorkflowsForCurrentServer: workflows.length,
|
||||
search: "",
|
||||
sort: "custom",
|
||||
onSearchChange: vi.fn(),
|
||||
onSortChange: vi.fn(),
|
||||
onCreateWorkflow: vi.fn(),
|
||||
onCreateWorkflowFromFile: vi.fn(),
|
||||
onEditWorkflow: vi.fn(),
|
||||
onDeleteWorkflow: vi.fn(),
|
||||
onToggleWorkflow: vi.fn(),
|
||||
onUploadWorkflowVersion: vi.fn(),
|
||||
onReorderWorkflows: vi.fn(),
|
||||
t,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
...render(<WorkflowManager {...props} />),
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
function createDataTransfer() {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
effectAllowed: "all",
|
||||
setData: vi.fn((type: string, value: string) => {
|
||||
data.set(type, value);
|
||||
}),
|
||||
getData: vi.fn((type: string) => data.get(type) ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowManager", () => {
|
||||
it("opens the more menu, closes it on outside click, and triggers upload action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderWorkflowManager();
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "More actions for workflow wf-a" });
|
||||
const menu = trigger.closest(".workflow-more")?.querySelector(".workflow-more-menu") as HTMLElement;
|
||||
|
||||
expect(menu).toHaveClass("hidden");
|
||||
|
||||
await user.click(trigger);
|
||||
expect(menu).not.toHaveClass("hidden");
|
||||
|
||||
fireEvent.click(document.body);
|
||||
expect(menu).toHaveClass("hidden");
|
||||
|
||||
await user.click(trigger);
|
||||
const uploadItem = trigger
|
||||
.closest(".workflow-more")
|
||||
?.querySelector('.workflow-more-item[role="menuitem"]') as HTMLElement;
|
||||
await user.click(uploadItem);
|
||||
|
||||
expect(props.onUploadWorkflowVersion).toHaveBeenCalledWith(workflows[0]);
|
||||
expect(menu).toHaveClass("hidden");
|
||||
});
|
||||
|
||||
it("reorders workflows when dragged onto another workflow above the midpoint", () => {
|
||||
const { props, container } = renderWorkflowManager();
|
||||
const handle = screen.getByRole("button", { name: "Drag to reorder workflow wf-a" });
|
||||
const target = container.querySelector('[data-workflow-id="wf-b"]') as HTMLElement;
|
||||
const dataTransfer = createDataTransfer();
|
||||
|
||||
Object.defineProperty(target, "getBoundingClientRect", {
|
||||
value: () => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 300,
|
||||
bottom: 100,
|
||||
width: 300,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.dragStart(handle, { dataTransfer });
|
||||
fireEvent.drop(target, { dataTransfer, clientY: 20 });
|
||||
|
||||
expect(props.onReorderWorkflows).toHaveBeenCalledWith("wf-a", "wf-b", false);
|
||||
});
|
||||
|
||||
it("disables drag reordering while a search filter is active", () => {
|
||||
const { props, container } = renderWorkflowManager({ search: "wf" });
|
||||
const handle = screen.getByRole("button", { name: "Drag to reorder workflow wf-a" });
|
||||
const target = container.querySelector('[data-workflow-id="wf-b"]') as HTMLElement;
|
||||
const dataTransfer = createDataTransfer();
|
||||
|
||||
expect(handle).toHaveClass("is-disabled");
|
||||
expect(handle).toHaveProperty("draggable", false);
|
||||
|
||||
fireEvent.dragStart(handle, { dataTransfer });
|
||||
fireEvent.drop(target, { dataTransfer, clientY: 80 });
|
||||
|
||||
expect(props.onReorderWorkflows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render an empty workflow summary pill when there are no workflows", () => {
|
||||
renderWorkflowManager({
|
||||
workflows: [],
|
||||
allWorkflowsForCurrentServer: 0,
|
||||
});
|
||||
|
||||
expect(document.querySelector(".panel-meta")).toBeNull();
|
||||
});
|
||||
|
||||
it("uploads a workflow file from the empty state dropzone", async () => {
|
||||
const { props } = renderWorkflowManager({
|
||||
workflows: [],
|
||||
allWorkflowsForCurrentServer: 0,
|
||||
});
|
||||
|
||||
const dropzone = screen.getByText("Drag or click to upload ComfyUI workflow_api.json").closest("label") as HTMLElement;
|
||||
const file = new File(['{"1":{"class_type":"CLIPTextEncode","inputs":{"text":"hello"}}}'], "workflow_api.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
|
||||
fireEvent.drop(dropzone, {
|
||||
dataTransfer: {
|
||||
files: [file],
|
||||
},
|
||||
});
|
||||
|
||||
expect(props.onCreateWorkflowFromFile).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
@@ -1,341 +0,0 @@
|
||||
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
|
||||
import { CustomSelect } from "../../components/ui/CustomSelect";
|
||||
import type { WorkflowSummaryDto } from "../../types/api";
|
||||
|
||||
interface WorkflowManagerProps {
|
||||
workflows: WorkflowSummaryDto[];
|
||||
allWorkflowsForCurrentServer: number;
|
||||
search: string;
|
||||
sort: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onCreateWorkflow: () => void;
|
||||
onCreateWorkflowFromFile: (file: File | null) => void;
|
||||
onEditWorkflow: (workflow: WorkflowSummaryDto) => void;
|
||||
onDeleteWorkflow: (workflow: WorkflowSummaryDto) => void;
|
||||
onToggleWorkflow: (workflow: WorkflowSummaryDto, enabled: boolean) => void;
|
||||
onUploadWorkflowVersion: (workflow: WorkflowSummaryDto) => void;
|
||||
onReorderWorkflows: (sourceWorkflowId: string, targetWorkflowId: string, placeAfter: boolean) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
function EditIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="icon icon-tabler icons-tabler-outline icon-tabler-edit"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1" />
|
||||
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415" />
|
||||
<path d="M16 5l3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="icon icon-tabler icons-tabler-outline icon-tabler-dots-vertical"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="icon icon-tabler icons-tabler-outline icon-tabler-upload"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M7 9l5 -5l5 5" />
|
||||
<path d="M12 4l0 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="icon icon-tabler icons-tabler-outline icon-tabler-trash"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 7l16 0" />
|
||||
<path d="M10 11l0 6" />
|
||||
<path d="M14 11l0 6" />
|
||||
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
|
||||
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowManager(props: WorkflowManagerProps) {
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [emptyUploadDragActive, setEmptyUploadDragActive] = useState(false);
|
||||
const dragEnabled = props.sort === "custom" && !props.search.trim();
|
||||
|
||||
function onEmptyUploadInputChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
props.onCreateWorkflowFromFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target?.closest(".workflow-more")) {
|
||||
setOpenMenuId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setOpenMenuId(null);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const summary = props.allWorkflowsForCurrentServer === props.workflows.length
|
||||
? props.t("workflow_count", { count: props.workflows.length })
|
||||
: props.t("workflow_count_filtered", { visible: props.workflows.length, total: props.allWorkflowsForCurrentServer });
|
||||
|
||||
const sortOptions = useMemo(() => [
|
||||
{ value: "custom", label: props.t("workflow_sort_custom") },
|
||||
{ value: "updated_desc", label: props.t("workflow_sort_recent") },
|
||||
{ value: "name_asc", label: props.t("workflow_sort_name_asc") },
|
||||
{ value: "name_desc", label: props.t("workflow_sort_name_desc") },
|
||||
{ value: "enabled_first", label: props.t("workflow_sort_enabled") },
|
||||
], [props.t]);
|
||||
|
||||
return (
|
||||
<section className="card" aria-labelledby="workflow-manager-title">
|
||||
<div className="section-header panel-toolbar">
|
||||
<div className="panel-title-wrap">
|
||||
<h2 id="workflow-manager-title" className="card-title">{props.t("workflow_manager")}</h2>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{props.allWorkflowsForCurrentServer ? <p className="section-meta panel-meta">{summary}</p> : null}
|
||||
<button type="button" className="btn btn-secondary panel-action-btn" onClick={props.onCreateWorkflow}>
|
||||
{props.t("register_new_short")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workflow-toolbar">
|
||||
<input
|
||||
id="workflow-search"
|
||||
className="input-field"
|
||||
value={props.search}
|
||||
onChange={(event) => props.onSearchChange(event.target.value)}
|
||||
placeholder={props.t("workflow_search_placeholder")}
|
||||
/>
|
||||
<CustomSelect
|
||||
value={props.sort}
|
||||
options={sortOptions}
|
||||
ariaLabel={props.t("workflow_sort_custom")}
|
||||
className="is-server-select"
|
||||
onChange={props.onSortChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="workflow-list" aria-live="polite">
|
||||
{props.workflows.length === 0 ? (
|
||||
props.allWorkflowsForCurrentServer ? (
|
||||
<div className="empty-state">{props.t("no_workflows_match")}</div>
|
||||
) : (
|
||||
<label
|
||||
className={`upload-zone workflow-empty-upload${emptyUploadDragActive ? " is-dragging" : ""}`}
|
||||
htmlFor="workflow-empty-upload"
|
||||
tabIndex={0}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setEmptyUploadDragActive(true);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setEmptyUploadDragActive(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
setEmptyUploadDragActive(false);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setEmptyUploadDragActive(false);
|
||||
props.onCreateWorkflowFromFile(event.dataTransfer.files?.[0] || null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById("workflow-empty-upload");
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input id="workflow-empty-upload" type="file" accept=".json" onChange={onEmptyUploadInputChange} />
|
||||
<span className="upload-title">{props.t("drag_upload")}</span>
|
||||
<span className="upload-subtitle">{props.t("after_upload")}</span>
|
||||
</label>
|
||||
)
|
||||
) : props.workflows.map((workflow) => (
|
||||
<article
|
||||
key={`${workflow.server_id}-${workflow.id}`}
|
||||
className={`workflow-item ${dragEnabled ? "is-reorderable" : ""}`}
|
||||
data-workflow-id={workflow.id}
|
||||
data-server-id={workflow.server_id}
|
||||
onDragOver={(event) => {
|
||||
if (!dragEnabled) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
(event.currentTarget as HTMLElement).classList.add("is-drop-target");
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
(event.currentTarget as HTMLElement).classList.remove("is-drop-target");
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!dragEnabled) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
target.classList.remove("is-drop-target");
|
||||
const sourceWorkflowId = event.dataTransfer.getData("text/plain");
|
||||
if (!sourceWorkflowId || sourceWorkflowId === workflow.id) {
|
||||
return;
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
props.onReorderWorkflows(sourceWorkflowId, workflow.id, event.clientY > rect.top + rect.height / 2);
|
||||
}}
|
||||
>
|
||||
<div className="workflow-main">
|
||||
<div className="workflow-name-row">
|
||||
<span className={`status-dot ${workflow.enabled ? "" : "is-disabled"}`} aria-hidden="true">●</span>
|
||||
<span className="workflow-name">{workflow.id}</span>
|
||||
<span className="workflow-server-tag">{workflow.server_name || workflow.server_id}</span>
|
||||
</div>
|
||||
{workflow.description ? <p className="workflow-desc">{workflow.description}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="workflow-actions">
|
||||
{props.sort === "custom" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-icon workflow-drag-handle ${dragEnabled ? "" : "is-disabled"}`}
|
||||
draggable={dragEnabled}
|
||||
aria-label={props.t("workflow_drag_handle", { id: workflow.id })}
|
||||
title={props.t("workflow_drag_handle", { id: workflow.id })}
|
||||
tabIndex={-1}
|
||||
onDragStart={(event) => {
|
||||
if (!dragEnabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.currentTarget.closest(".workflow-item")?.classList.add("is-dragging");
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", workflow.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.currentTarget.closest(".workflow-item")?.classList.remove("is-dragging");
|
||||
document.querySelectorAll(".workflow-item.is-drop-target").forEach((item) => item.classList.remove("is-drop-target"));
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">≡</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="workflow-status-toggle">
|
||||
<label className="toggle-inline" aria-label={props.t("toggle_workflow", { id: workflow.id })}>
|
||||
<span className={`workflow-enabled-label ${workflow.enabled ? "status-on" : "status-off"}`}>
|
||||
{workflow.enabled ? props.t("wf_enabled") : props.t("wf_disabled")}
|
||||
</span>
|
||||
<div className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={workflow.enabled}
|
||||
onChange={(event) => {
|
||||
setOpenMenuId(null);
|
||||
props.onToggleWorkflow(workflow, event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon workflow-action-btn workflow-action-edit"
|
||||
aria-label={props.t("edit_workflow", { id: workflow.id })}
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
props.onEditWorkflow(workflow);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
|
||||
<div className={`workflow-more ${openMenuId === workflow.id ? "is-open" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon workflow-action-btn workflow-more-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openMenuId === workflow.id}
|
||||
aria-label={props.t("workflow_more_actions", { id: workflow.id })}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenMenuId((current) => current === workflow.id ? null : workflow.id);
|
||||
}}
|
||||
>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
<div className={`workflow-more-menu ${openMenuId === workflow.id ? "" : "hidden"}`} role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="workflow-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
props.onUploadWorkflowVersion(workflow);
|
||||
}}
|
||||
>
|
||||
<UploadIcon />
|
||||
<span>{props.t("upload_new_version")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="workflow-more-item workflow-more-item-danger"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
props.onDeleteWorkflow(workflow);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{props.t("delete")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { messages } from "./messages";
|
||||
|
||||
export type Language = "en" | "zh" | "zh_hant";
|
||||
|
||||
export function normalizeLanguage(language?: string | null): Language {
|
||||
if (!language) {
|
||||
return "en";
|
||||
}
|
||||
const value = String(language).toLowerCase();
|
||||
if (value === "zh" || value === "zh-cn" || value === "zh_hans") {
|
||||
return "zh";
|
||||
}
|
||||
if (value === "zh_hant" || value === "zh-tw" || value === "zh-hant") {
|
||||
return "zh_hant";
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function translate(language: Language, key: string, vars: Record<string, string | number> = {}) {
|
||||
let text = messages[language]?.[key] ?? messages.en[key] ?? key;
|
||||
for (const [variable, value] of Object.entries(vars)) {
|
||||
text = text.replace(`{${variable}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { messages } from "./messages";
|
||||
|
||||
describe("i18n messages", () => {
|
||||
it("keeps the same translation keys across languages", () => {
|
||||
const baseKeys = Object.keys(messages.en).sort();
|
||||
|
||||
expect(Object.keys(messages.zh).sort()).toEqual(baseKeys);
|
||||
expect(Object.keys(messages.zh_hant).sort()).toEqual(baseKeys);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { enMessages } from "./messages/en";
|
||||
import { zhMessages } from "./messages/zh";
|
||||
import { zhHantMessages } from "./messages/zhHant";
|
||||
|
||||
export const messages = {
|
||||
en: enMessages,
|
||||
zh: zhMessages,
|
||||
zh_hant: zhHantMessages,
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
export const enMessages = {
|
||||
title: "ComfyUI OpenClaw Skill",
|
||||
subtitle: "Dynamically parse workflow graphs and build visual parameter mappings for agents.",
|
||||
lang_btn: "中/EN",
|
||||
global_config: "Global Config",
|
||||
server_url: "ComfyUI Server URL",
|
||||
output_dir: "Image Output Directory",
|
||||
save_config: "Save Config",
|
||||
workflow_manager: "Workflows Manager",
|
||||
workflow_search_placeholder: "Search workflows by name or description",
|
||||
workflow_sort_custom: "Custom order",
|
||||
workflow_sort_recent: "Recently updated",
|
||||
workflow_sort_name_asc: "Workflow ID (A-Z)",
|
||||
workflow_sort_name_desc: "Workflow ID (Z-A)",
|
||||
workflow_sort_enabled: "Enabled first",
|
||||
workflow_drag_handle: "Drag to reorder workflow {id}",
|
||||
workflow_more_actions: "More actions for workflow {id}",
|
||||
no_workflows: "No workflow mappings configured yet.",
|
||||
no_workflows_match: "No workflows matched the current search.",
|
||||
register_new: "Register New Workflow (Upload & Mapping)",
|
||||
drag_upload: "Drag or click to upload ComfyUI workflow_api.json",
|
||||
after_upload: "After upload, you can remap parameters by node.",
|
||||
upload_reminder_title: "Upload Reminder",
|
||||
upload_reminder_api: "Please export the workflow as ComfyUI API format JSON (workflow_api.json), not the editor workflow JSON.",
|
||||
upload_reminder_how: "In ComfyUI, use the API format export option before uploading here.",
|
||||
upload_reminder_path: "After saving, files will be written to data/<server_id>/<workflow_id>/workflow.json and data/<server_id>/<workflow_id>/schema.json.",
|
||||
upload_reminder_local_path: "Browsers usually only expose the selected file name during upload, not the full local path.",
|
||||
wf_id_label: "Workflow ID (letters/numbers recommended)",
|
||||
wf_id_placeholder: "e.g. sd_basic",
|
||||
wf_desc_label: "Description (for agent use)",
|
||||
wf_desc_placeholder: "e.g. Generate anime-style base images",
|
||||
register_new_short: "+ New Workflow",
|
||||
back: "Back",
|
||||
wf_basic_info: "Workflow Info",
|
||||
parsed_input: "Parsed Input Node List",
|
||||
save_workflow: "Save Workflow and Mapping Schema",
|
||||
alias: "Alias (exposed name)",
|
||||
ai_desc: "AI Description",
|
||||
ai_desc_placeholder: "e.g. High-quality landscape prompt",
|
||||
type: "Type",
|
||||
type_str: "String",
|
||||
type_int: "Integer",
|
||||
type_float: "Float",
|
||||
type_bool: "Boolean",
|
||||
required: "Required for model",
|
||||
curr_val: "Current value",
|
||||
node_id: "Node ID",
|
||||
err_load_cfg: "Failed to load global config.",
|
||||
ok_save_cfg: "Global config saved successfully!",
|
||||
err_save_cfg: "Failed to save config.",
|
||||
del_wf_confirm: "Delete workflow {id}?",
|
||||
ok_del_wf: "Workflow \"{id}\" deleted.",
|
||||
err_del_wf: "Failed to delete workflow.",
|
||||
ok_wf_load: "Workflow loaded! Please select parameters to expose.",
|
||||
err_invalid_json: "Invalid JSON file.",
|
||||
err_no_id: "Please provide a Workflow ID.",
|
||||
err_no_alias: "Exposed parameter is missing an alias (Node {node} / {val})",
|
||||
warn_no_params: "You have not exposed any parameters. Save anyway?",
|
||||
warn_overwrite_wf: "Workflow ID \"{id}\" already exists. Overwrite it?",
|
||||
ok_save_wf: "Saved successfully. The agent can now call this workflow.",
|
||||
err_save_wf: "Failed to save workflow.",
|
||||
wf_enabled: "Enabled",
|
||||
wf_disabled: "Disabled",
|
||||
ok_toggle_wf: "Status updated.",
|
||||
ok_toggle_wf_enabled: "Workflow \"{id}\" enabled.",
|
||||
ok_toggle_wf_disabled: "Workflow \"{id}\" disabled.",
|
||||
err_toggle_wf: "Failed to update status.",
|
||||
err_load_workflows: "Failed to load workflow list.",
|
||||
err_reorder_workflows: "Failed to save workflow order.",
|
||||
workflow_count: "{count} workflows",
|
||||
workflow_count_filtered: "{visible} shown / {total} workflows",
|
||||
loading: "Loading...",
|
||||
toggle_workflow: "Enable or disable workflow {id}",
|
||||
delete_workflow: "Delete workflow {id}",
|
||||
edit_workflow: "Edit workflow {id}",
|
||||
upload_new_version: "Upload New Version",
|
||||
more: "More",
|
||||
edit: "Edit",
|
||||
ok_load_saved_wf: "Saved workflow loaded. You can now adjust its parameter mapping.",
|
||||
err_load_saved_wf: "Failed to load saved workflow details.",
|
||||
save_workflow_edit: "Save Workflow Changes",
|
||||
editor_mode_editing: "Editing saved workflow",
|
||||
editor_mode_create: "Create new workflow mapping",
|
||||
empty_nodes: "Upload a workflow JSON to start mapping parameters.",
|
||||
err_ui_workflow_format: "This file looks like a ComfyUI editor workflow, not workflow_api.json. Please export the API format workflow JSON and upload that file.",
|
||||
err_no_mappable_params: "This workflow JSON was read successfully, but no configurable scalar inputs were found.",
|
||||
server_manager: "Server Manager",
|
||||
export_bundle: "Export Config",
|
||||
import_bundle: "Import Config",
|
||||
export_bundle_title: "Export Config",
|
||||
import_bundle_preview_title: "Import Preview",
|
||||
export_bundle_confirm: "Download Bundle",
|
||||
import_bundle_confirm: "Import Bundle",
|
||||
export_panel_hint: "Choose which servers and workflows to export. Everything is selected by default.",
|
||||
export_preview_summary: "This export will include {servers} servers and {workflows} workflows. Warnings: {warnings}.",
|
||||
export_server_workflow_count: "{count} workflows",
|
||||
export_workflow_disabled: "Disabled",
|
||||
export_server_disabled: "Server disabled",
|
||||
export_selected_count: "{selected}/{total} selected",
|
||||
export_toggle_server: "Toggle workflows for {server}",
|
||||
transfer_section_created_servers: "Servers to add",
|
||||
transfer_section_updated_servers: "Servers to update",
|
||||
transfer_section_created_workflows: "Workflows to import",
|
||||
transfer_section_overwritten_workflows: "Workflows to overwrite",
|
||||
transfer_section_skipped_items: "Items to skip",
|
||||
transfer_section_empty: "No items in this section.",
|
||||
transfer_warning_title: "Warnings",
|
||||
ok_transfer_export_started: "Bundle download started.",
|
||||
ok_transfer_import: "Import complete. Added {servers} servers, imported {created} workflows, overwritten {overwritten} workflows.",
|
||||
err_transfer_invalid_bundle: "This file is not a valid bundle JSON.",
|
||||
err_transfer_export_preview: "Failed to preview the export bundle.",
|
||||
err_transfer_export: "Failed to build the export bundle.",
|
||||
err_transfer_preview: "Failed to preview the import bundle.",
|
||||
err_transfer_import: "Failed to import the bundle.",
|
||||
transfer_apply_environment: "Also apply default server, URL, and output directory from the bundle",
|
||||
transfer_preview_summary: "This import will add {servers} servers, update {updated_servers} servers, import {created} workflows, overwrite {overwritten} workflows, and skip {skipped} items. Warnings: {warnings}. Continue?",
|
||||
add_server: "Add Server",
|
||||
server_name: "Server Name",
|
||||
server_id_label: "Server ID",
|
||||
server_id_help: "Optional. Leave blank to auto-generate. It cannot be changed later.",
|
||||
server_name_help: "Name is display-only and can be changed later.",
|
||||
server_type_comfyui: "ComfyUI",
|
||||
server_type_hint_comfyui: "Use this for local or self-hosted ComfyUI instances that expose the standard `/prompt` API.",
|
||||
server_unsupported_short: "(Unsupported)",
|
||||
server_unsupported_reason: "Server type \"{type}\" is not supported in this branch. Remove or migrate this server before using it.",
|
||||
server_url_label: "Server URL",
|
||||
server_url_help_comfyui: "Directly calls the standard ComfyUI endpoints: `/prompt`, `/history/{id}`, and `/view`.",
|
||||
server_output_dir: "Output Directory",
|
||||
no_servers: "No servers configured.",
|
||||
ok_add_server: "Server added successfully.",
|
||||
err_add_server: "Failed to add server.",
|
||||
ok_toggle_server: "Server status updated.",
|
||||
ok_toggle_server_enabled: "Server \"{id}\" enabled.",
|
||||
ok_toggle_server_disabled: "Server \"{id}\" disabled.",
|
||||
err_toggle_server: "Failed to update server status.",
|
||||
del_server_confirm: "Delete server {id}? Data files will NOT be removed.",
|
||||
ok_del_server: "Server removed.",
|
||||
ok_del_server_keep_data: "Server removed. Local data was kept.",
|
||||
ok_del_server_with_data: "Server removed. Local data was deleted.",
|
||||
err_del_server: "Failed to remove server.",
|
||||
delete_server_data_checkbox: "Also delete this server's local data in data/",
|
||||
server_enabled: "Enabled",
|
||||
server_disabled: "Disabled",
|
||||
server_agent_visible: "Visible to Agent",
|
||||
server_agent_hidden: "Hidden from Agent",
|
||||
server_agent_visibility_hint: "Controls whether agents can see and use this server",
|
||||
select_server: "Select Server",
|
||||
current_server_config: "Server Config",
|
||||
current_server_title: "Current Server",
|
||||
no_server_selected_short: "No server selected",
|
||||
add_server_toggle: "Add Server",
|
||||
add_server_close: "Close",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
confirm_action_title: "Confirm Action",
|
||||
delete: "Delete",
|
||||
overwrite: "Overwrite",
|
||||
leave_anyway: "Leave",
|
||||
save_anyway: "Save Anyway",
|
||||
add_server_panel_title: "Add New Server",
|
||||
add_server_modal_title: "Add Server",
|
||||
edit_server_modal_title: "Edit Server",
|
||||
save_and_connect: "Save and Connect",
|
||||
save_server_changes: "Save Changes",
|
||||
create_first_server: "Create First Server",
|
||||
new_server_name_placeholder: "Local Server",
|
||||
new_server_id_placeholder: "new-server-id",
|
||||
new_server_url_placeholder: "http://10.0.0.1:8188",
|
||||
err_server_id_url_required: "Server ID and URL are required.",
|
||||
err_server_name_id_url_required: "Server ID, name, and URL are required.",
|
||||
err_server_name_url_required: "Server name and URL are required.",
|
||||
err_server_name_required: "Server name is required.",
|
||||
err_select_server_first: "Please add/select a server first.",
|
||||
err_select_server_before_register: "Please add/select a server to register a workflow.",
|
||||
err_no_server_selected: "No server selected.",
|
||||
err_no_workflow_uploaded: "No workflow data uploaded. Please upload a workflow JSON.",
|
||||
editor_step_1: "Basic Info",
|
||||
editor_step_2: "Upload Workflow",
|
||||
editor_step_3: "Map Parameters",
|
||||
editor_step_1_hint: "Step 1: Fill workflow basic info.",
|
||||
editor_step_2_hint: "Step 2: Upload workflow_api.json to parse parameters.",
|
||||
editor_step_3_hint: "Step 3: Review parameter mapping and save.",
|
||||
mapping_search_placeholder: "Search by node, field, alias or description",
|
||||
mapping_sort_node_id_asc: "Node ID (asc)",
|
||||
mapping_sort_node_id_desc: "Node ID (desc)",
|
||||
mapping_sort_class_asc: "Node type (A-Z)",
|
||||
mapping_sort_param_default: "Parameter default",
|
||||
mapping_sort_param_name: "Parameter name (A-Z)",
|
||||
mapping_sort_param_type: "Type (A-Z)",
|
||||
mapping_sort_param_exposed: "Exposed first",
|
||||
mapping_exposed_only: "Only show exposed parameters",
|
||||
mapping_required_only: "Only show required parameters",
|
||||
mapping_apply_recommended: "Auto expose recommended",
|
||||
mapping_expose_visible: "Expose visible",
|
||||
mapping_unexpose_visible: "Unexpose visible",
|
||||
mapping_collapse_configs: "Collapse parameter configs",
|
||||
mapping_expand_configs: "Expand parameter configs",
|
||||
mapping_collapse_all: "Collapse all nodes",
|
||||
mapping_expand_all: "Expand all nodes",
|
||||
mapping_reset_filters: "Reset filters",
|
||||
mapping_summary: "Showing {visible_params}/{total_params} params in {visible_nodes} nodes, exposed: {exposed_params}",
|
||||
mapping_apply_recommended_ok: "Recommended parameters exposed: {count}",
|
||||
mapping_no_recommended_changes: "No additional recommended parameters were found.",
|
||||
mapping_expose_visible_ok: "Visible parameters exposed: {count}",
|
||||
mapping_unexpose_visible_ok: "Visible parameters unexposed: {count}",
|
||||
mapping_no_visible_params: "No visible parameters available for this action.",
|
||||
mapping_no_batch_changes: "No parameters required updates.",
|
||||
empty_nodes_filtered: "No parameters matched current filters.",
|
||||
mapping_shortcuts_hint: "Shortcuts: Ctrl/Cmd+S save, / focus search, Esc clear search",
|
||||
toggle_node: "Toggle node {id}",
|
||||
toggle_param_config: "Toggle parameter config for {field}",
|
||||
confirm_unsaved_leave: "You have unsaved changes in the editor. Leave anyway?",
|
||||
workflow_upgrade_ready: "New version loaded. Review retained, new, and changed parameters before saving.",
|
||||
workflow_upgrade_summary: "{retained} retained, {review} need review, {added} new, {removed} removed",
|
||||
migration_status_retained: "Retained",
|
||||
migration_status_review: "Review",
|
||||
migration_status_new: "New",
|
||||
migration_review_hint: "This parameter changed and should be reviewed before saving.",
|
||||
server_status_online: "Online",
|
||||
server_status_offline: "Offline",
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
export const zhMessages = {
|
||||
title: "ComfyUI OpenClaw Skill",
|
||||
subtitle: "动态解析工作流图,为 Agent 构建可视化参数映射表。",
|
||||
lang_btn: "EN/中",
|
||||
global_config: "全局配置",
|
||||
server_url: "ComfyUI 服务器地址",
|
||||
output_dir: "图片输出目录",
|
||||
save_config: "保存配置",
|
||||
workflow_manager: "工作流管理器",
|
||||
workflow_search_placeholder: "按工作流名称或描述搜索",
|
||||
workflow_sort_custom: "自定义顺序",
|
||||
workflow_sort_recent: "最近更新",
|
||||
workflow_sort_name_asc: "工作流 ID(A-Z)",
|
||||
workflow_sort_name_desc: "工作流 ID(Z-A)",
|
||||
workflow_sort_enabled: "已启用优先",
|
||||
workflow_drag_handle: "拖动以重新排序工作流 {id}",
|
||||
workflow_more_actions: "工作流 {id} 的更多操作",
|
||||
no_workflows: "暂未配置任何工作流映射。",
|
||||
no_workflows_match: "当前搜索条件下没有匹配的工作流。",
|
||||
register_new: "注册新工作流 (上传 & 映射)",
|
||||
register_new_short: "+ 新增工作流",
|
||||
back: "返回",
|
||||
wf_basic_info: "基础信息",
|
||||
drag_upload: "拖动或点击上传 ComfyUI workflow_api.json",
|
||||
after_upload: "上传后,你可以针对特定节点暴露重连关联参数。",
|
||||
upload_reminder_title: "上传提醒",
|
||||
upload_reminder_api: "请先从 ComfyUI 导出 API 格式的 JSON(workflow_api.json),不要上传编辑器工作流 JSON。",
|
||||
upload_reminder_how: "请在 ComfyUI 中使用 API Format 导出功能后,再上传到这里。",
|
||||
upload_reminder_path: "保存成功后,文件会写入 data/<server_id>/<workflow_id>/workflow.json 和 data/<server_id>/<workflow_id>/schema.json。",
|
||||
upload_reminder_local_path: "浏览器上传时通常只会暴露文件名,不会显示本地完整路径。",
|
||||
wf_id_label: "工作流 ID (建议字母/数字)",
|
||||
wf_id_placeholder: "例:sd_basic",
|
||||
wf_desc_label: "描述说明 (供 Agent 阅读)",
|
||||
wf_desc_placeholder: "例:生成动漫画风的基础图像",
|
||||
parsed_input: "已解析输入节点列表",
|
||||
save_workflow: "保存工作流及其映射表",
|
||||
alias: "别名 (暴露给 AI 的变量名)",
|
||||
ai_desc: "参数描述 (供 AI 理解)",
|
||||
ai_desc_placeholder: "例:用来控制人物造型的高质量提示词",
|
||||
type: "数据类型",
|
||||
type_str: "文本 (String)",
|
||||
type_int: "整数 (Integer)",
|
||||
type_float: "小数 (Float)",
|
||||
type_bool: "布尔 (Boolean)",
|
||||
required: "生成必须",
|
||||
curr_val: "当前本地缓存值",
|
||||
node_id: "节点标识",
|
||||
err_load_cfg: "加载全局配置失败。",
|
||||
ok_save_cfg: "全局配置保存成功!",
|
||||
err_save_cfg: "保存配置失败。",
|
||||
del_wf_confirm: "确认删除该工作流 {id} 吗?",
|
||||
ok_del_wf: "工作流 \"{id}\" 已被删除。",
|
||||
err_del_wf: "删除工作流失败。",
|
||||
ok_wf_load: "读取成功!请勾选你想要对外暴露的参数。",
|
||||
err_invalid_json: "无法解析的 JSON 文件格式。",
|
||||
err_no_id: "必须填写一个工作流 ID 才能保存。",
|
||||
err_no_alias: "您勾选暴露的参数缺少别名名称 (节点 {node} / 字段 {val})",
|
||||
warn_no_params: "您没有暴露任何参数。仍然强行保存吗?",
|
||||
warn_overwrite_wf: "工作流 ID「{id}」已存在。是否覆盖它?",
|
||||
ok_save_wf: "已保存!Agent 当前可以调度此任务了。",
|
||||
err_save_wf: "保存工作流失败。",
|
||||
wf_enabled: "已启用",
|
||||
wf_disabled: "已禁用",
|
||||
ok_toggle_wf: "状态已更新。",
|
||||
ok_toggle_wf_enabled: "工作流「{id}」已启用。",
|
||||
ok_toggle_wf_disabled: "工作流「{id}」已禁用。",
|
||||
err_toggle_wf: "更新状态失败。",
|
||||
err_load_workflows: "加载工作流列表失败。",
|
||||
err_reorder_workflows: "保存工作流顺序失败。",
|
||||
workflow_count: "共 {count} 个工作流",
|
||||
workflow_count_filtered: "显示 {visible} / 共 {total} 个工作流",
|
||||
loading: "加载中...",
|
||||
toggle_workflow: "启用或禁用工作流 {id}",
|
||||
delete_workflow: "删除工作流 {id}",
|
||||
edit_workflow: "编辑工作流 {id}",
|
||||
upload_new_version: "上传新版本",
|
||||
more: "更多",
|
||||
edit: "编辑",
|
||||
ok_load_saved_wf: "已载入已保存工作流,你现在可以修改参数映射。",
|
||||
err_load_saved_wf: "加载已保存工作流详情失败。",
|
||||
save_workflow_edit: "保存工作流修改",
|
||||
editor_mode_editing: "正在编辑已保存工作流",
|
||||
editor_mode_create: "创建新的工作流映射",
|
||||
empty_nodes: "上传 workflow JSON 后,即可开始配置参数映射。",
|
||||
err_ui_workflow_format: "这个文件看起来是 ComfyUI 的编辑器工作流,不是 workflow_api.json。请导出 API 格式的工作流 JSON 后再上传。",
|
||||
err_no_mappable_params: "这个 workflow JSON 已成功读取,但没有找到可配置的标量输入参数。",
|
||||
server_manager: "服务器管理",
|
||||
export_bundle: "导出配置",
|
||||
import_bundle: "导入配置",
|
||||
export_bundle_title: "导出配置",
|
||||
import_bundle_preview_title: "导入预览",
|
||||
export_bundle_confirm: "下载 Bundle",
|
||||
import_bundle_confirm: "导入 Bundle",
|
||||
export_panel_hint: "选择要导出的服务器和工作流。默认全部选中。",
|
||||
export_preview_summary: "本次导出将包含 {servers} 个服务器和 {workflows} 个工作流。警告:{warnings}。",
|
||||
export_server_workflow_count: "{count} 个工作流",
|
||||
export_workflow_disabled: "已禁用",
|
||||
export_server_disabled: "服务器已禁用",
|
||||
export_selected_count: "已选 {selected}/{total}",
|
||||
export_toggle_server: "展开或折叠 {server} 的工作流",
|
||||
transfer_section_created_servers: "将新增的服务器",
|
||||
transfer_section_updated_servers: "将更新的服务器",
|
||||
transfer_section_created_workflows: "将导入的工作流",
|
||||
transfer_section_overwritten_workflows: "将覆盖的工作流",
|
||||
transfer_section_skipped_items: "将跳过的项目",
|
||||
transfer_section_empty: "此分组没有项目。",
|
||||
transfer_warning_title: "警告",
|
||||
ok_transfer_export_started: "Bundle 已开始下载。",
|
||||
ok_transfer_import: "导入完成。新增 {servers} 个服务器,导入 {created} 个工作流,覆盖 {overwritten} 个工作流。",
|
||||
err_transfer_invalid_bundle: "该文件不是有效的 bundle JSON。",
|
||||
err_transfer_export_preview: "预检导出 bundle 失败。",
|
||||
err_transfer_export: "构建导出 bundle 失败。",
|
||||
err_transfer_preview: "预检导入 bundle 失败。",
|
||||
err_transfer_import: "导入 bundle 失败。",
|
||||
transfer_apply_environment: "同时应用 bundle 中的默认服务器、URL 和输出目录",
|
||||
transfer_preview_summary: "本次导入将新增 {servers} 个服务器、更新 {updated_servers} 个服务器、导入 {created} 个工作流、覆盖 {overwritten} 个工作流,并跳过 {skipped} 个项目。警告:{warnings}。是否继续?",
|
||||
add_server: "添加服务器",
|
||||
server_name: "服务器名称",
|
||||
server_id_label: "服务器 ID",
|
||||
server_id_help: "可选,不填写则自动生成,后续不可修改。",
|
||||
server_name_help: "名称仅用于显示,后续可以随时修改。",
|
||||
server_type_comfyui: "ComfyUI",
|
||||
server_type_hint_comfyui: "用于本地或自托管的 ComfyUI 实例,调用标准 `/prompt` API。",
|
||||
server_unsupported_short: "(不支持)",
|
||||
server_unsupported_reason: "当前分支不支持服务器类型“{type}”。请先迁移或删除该服务器配置,再继续使用。",
|
||||
server_url_label: "服务器地址",
|
||||
server_url_help_comfyui: "直接调用标准 ComfyUI 端点:`/prompt`、`/history/{id}` 和 `/view`。",
|
||||
server_output_dir: "输出目录",
|
||||
no_servers: "暂无配置的服务器。",
|
||||
ok_add_server: "服务器添加成功。",
|
||||
err_add_server: "添加服务器失败。",
|
||||
ok_toggle_server: "服务器状态已更新。",
|
||||
ok_toggle_server_enabled: "服务器「{id}」已启用。",
|
||||
ok_toggle_server_disabled: "服务器「{id}」已禁用。",
|
||||
err_toggle_server: "更新服务器状态失败。",
|
||||
del_server_confirm: "确认删除服务器 {id}?数据文件不会被删除。",
|
||||
ok_del_server: "服务器已移除。",
|
||||
ok_del_server_keep_data: "服务器已移除,本地 data 数据已保留。",
|
||||
ok_del_server_with_data: "服务器已移除,本地 data 数据已删除。",
|
||||
err_del_server: "移除服务器失败。",
|
||||
delete_server_data_checkbox: "同时删除该服务器在 data/ 中的本地数据",
|
||||
server_enabled: "已启用",
|
||||
server_disabled: "已禁用",
|
||||
server_agent_visible: "对 Agent 可见",
|
||||
server_agent_hidden: "对 Agent 隐藏",
|
||||
server_agent_visibility_hint: "控制 Agent 是否可以发现和使用此服务器",
|
||||
select_server: "选择服务器",
|
||||
current_server_config: "服务器配置",
|
||||
current_server_title: "当前服务器",
|
||||
no_server_selected_short: "未选择服务器",
|
||||
add_server_toggle: "添加服务器",
|
||||
add_server_close: "关闭",
|
||||
cancel: "取消",
|
||||
confirm: "确认",
|
||||
confirm_action_title: "确认操作",
|
||||
delete: "删除",
|
||||
overwrite: "覆盖",
|
||||
leave_anyway: "仍然离开",
|
||||
save_anyway: "仍然保存",
|
||||
add_server_panel_title: "添加新服务器",
|
||||
add_server_modal_title: "添加服务器",
|
||||
edit_server_modal_title: "编辑服务器",
|
||||
save_and_connect: "保存并连接",
|
||||
save_server_changes: "保存修改",
|
||||
create_first_server: "创建第一个服务器",
|
||||
new_server_name_placeholder: "本地服务器",
|
||||
new_server_id_placeholder: "new-server-id",
|
||||
new_server_url_placeholder: "http://10.0.0.1:8188",
|
||||
err_server_id_url_required: "必须填写服务器 ID 和地址。",
|
||||
err_server_name_id_url_required: "必须填写服务器 ID、名称和地址。",
|
||||
err_server_name_url_required: "必须填写服务器名称和地址。",
|
||||
err_server_name_required: "必须填写服务器名称。",
|
||||
err_select_server_first: "请先添加或选择一个服务器。",
|
||||
err_select_server_before_register: "请先添加或选择服务器,再注册工作流。",
|
||||
err_no_server_selected: "当前未选择服务器。",
|
||||
err_no_workflow_uploaded: "未上传工作流数据,请先上传 workflow JSON。",
|
||||
editor_step_1: "基础信息",
|
||||
editor_step_2: "上传工作流",
|
||||
editor_step_3: "参数映射",
|
||||
editor_step_1_hint: "第 1 步:填写工作流基础信息。",
|
||||
editor_step_2_hint: "第 2 步:上传 workflow_api.json 解析参数。",
|
||||
editor_step_3_hint: "第 3 步:检查参数映射并保存。",
|
||||
mapping_search_placeholder: "按节点、字段、别名或描述搜索",
|
||||
mapping_sort_node_id_asc: "节点 ID(升序)",
|
||||
mapping_sort_node_id_desc: "节点 ID(降序)",
|
||||
mapping_sort_class_asc: "节点类型(A-Z)",
|
||||
mapping_sort_param_default: "参数默认顺序",
|
||||
mapping_sort_param_name: "参数名(A-Z)",
|
||||
mapping_sort_param_type: "类型(A-Z)",
|
||||
mapping_sort_param_exposed: "已暴露优先",
|
||||
mapping_exposed_only: "仅显示已暴露参数",
|
||||
mapping_required_only: "仅显示必填参数",
|
||||
mapping_apply_recommended: "一键暴露推荐参数",
|
||||
mapping_expose_visible: "暴露当前可见参数",
|
||||
mapping_unexpose_visible: "取消暴露当前可见参数",
|
||||
mapping_collapse_configs: "折叠全部参数配置",
|
||||
mapping_expand_configs: "展开全部参数配置",
|
||||
mapping_collapse_all: "折叠全部节点",
|
||||
mapping_expand_all: "展开全部节点",
|
||||
mapping_reset_filters: "重置筛选",
|
||||
mapping_summary: "当前显示 {visible_nodes} 个节点中的 {visible_params}/{total_params} 个参数,已暴露 {exposed_params} 个",
|
||||
mapping_apply_recommended_ok: "已暴露推荐参数 {count} 项",
|
||||
mapping_no_recommended_changes: "没有新增可暴露的推荐参数。",
|
||||
mapping_expose_visible_ok: "已暴露当前可见参数 {count} 项",
|
||||
mapping_unexpose_visible_ok: "已取消暴露当前可见参数 {count} 项",
|
||||
mapping_no_visible_params: "当前没有可执行此操作的可见参数。",
|
||||
mapping_no_batch_changes: "没有需要更新的参数。",
|
||||
empty_nodes_filtered: "当前筛选条件下没有匹配参数。",
|
||||
mapping_shortcuts_hint: "快捷键:Ctrl/Cmd+S 保存,/ 聚焦搜索,Esc 清空搜索",
|
||||
toggle_node: "折叠或展开节点 {id}",
|
||||
toggle_param_config: "折叠或展开参数配置 {field}",
|
||||
confirm_unsaved_leave: "编辑页有未保存更改,仍要离开吗?",
|
||||
workflow_upgrade_ready: "已载入新版本,请先检查保留、新增和待确认的参数后再保存。",
|
||||
workflow_upgrade_summary: "已保留 {retained} 个,待确认 {review} 个,新增 {added} 个,移除 {removed} 个",
|
||||
migration_status_retained: "已保留",
|
||||
migration_status_review: "待确认",
|
||||
migration_status_new: "新增",
|
||||
migration_review_hint: "这个参数发生了变化,保存前建议重新检查。",
|
||||
server_status_online: "在线",
|
||||
server_status_offline: "离线",
|
||||
};
|
||||
@@ -1,182 +0,0 @@
|
||||
import { zhMessages } from "./zh";
|
||||
|
||||
export const zhHantMessages = {
|
||||
...zhMessages,
|
||||
subtitle: "動態解析工作流圖,為 Agent 構建可視化參數映射表。",
|
||||
global_config: "全域配置",
|
||||
save_config: "儲存配置",
|
||||
workflow_manager: "工作流管理器",
|
||||
workflow_search_placeholder: "按工作流名稱或描述搜尋",
|
||||
workflow_sort_custom: "自定義順序",
|
||||
workflow_sort_recent: "最近更新",
|
||||
workflow_sort_name_asc: "工作流 ID(A-Z)",
|
||||
workflow_sort_name_desc: "工作流 ID(Z-A)",
|
||||
workflow_sort_enabled: "已啟用優先",
|
||||
workflow_drag_handle: "拖動以重新排序工作流 {id}",
|
||||
workflow_more_actions: "工作流 {id} 的更多操作",
|
||||
no_workflows: "暫未配置任何工作流映射。",
|
||||
no_workflows_match: "當前搜尋條件下沒有匹配的工作流。",
|
||||
register_new: "註冊新工作流 (上傳 & 映射)",
|
||||
register_new_short: "+ 新增工作流",
|
||||
back: "返回",
|
||||
wf_basic_info: "基礎資訊",
|
||||
drag_upload: "拖動或點擊上傳 ComfyUI workflow_api.json",
|
||||
after_upload: "上傳後,你可以針對特定節點暴露重連關聯參數。",
|
||||
upload_reminder_title: "上傳提醒",
|
||||
upload_reminder_api: "請先從 ComfyUI 匯出 API 格式的 JSON(workflow_api.json),不要上傳編輯器工作流 JSON。",
|
||||
upload_reminder_how: "請在 ComfyUI 中使用 API Format 匯出功能後,再上傳到這裡。",
|
||||
wf_desc_label: "描述說明 (供 Agent 閱讀)",
|
||||
wf_desc_placeholder: "例:生成動漫畫風的基礎圖像",
|
||||
parsed_input: "已解析輸入節點列表",
|
||||
save_workflow: "儲存工作流及其映射表",
|
||||
alias: "別名 (暴露給 AI 的變量名)",
|
||||
ai_desc: "參數描述 (供 AI 理解)",
|
||||
type: "資料類型",
|
||||
type_int: "整數 (Integer)",
|
||||
type_float: "小數 (Float)",
|
||||
type_bool: "布林 (Boolean)",
|
||||
curr_val: "當前本地快取值",
|
||||
node_id: "節點標識",
|
||||
err_load_cfg: "載入全域配置失敗。",
|
||||
ok_save_cfg: "全域配置儲存成功!",
|
||||
err_save_cfg: "儲存配置失敗。",
|
||||
del_wf_confirm: "確認刪除該工作流 {id} 嗎?",
|
||||
ok_del_wf: "工作流 \"{id}\" 已被刪除。",
|
||||
err_del_wf: "刪除工作流失敗。",
|
||||
ok_wf_load: "讀取成功!請勾選你想要對外暴露的參數。",
|
||||
err_invalid_json: "無法解析的 JSON 檔案格式。",
|
||||
err_no_id: "必須填寫一個工作流 ID 才能儲存。",
|
||||
err_no_alias: "您勾選暴露的參數缺少別名名稱 (節點 {node} / 欄位 {val})",
|
||||
warn_no_params: "您沒有暴露任何參數。仍然強制儲存嗎?",
|
||||
warn_overwrite_wf: "工作流 ID「{id}」已存在。是否覆蓋它?",
|
||||
ok_save_wf: "已儲存!Agent 現在可以調度此任務了。",
|
||||
err_save_wf: "儲存工作流失敗。",
|
||||
wf_enabled: "已啟用",
|
||||
wf_disabled: "已停用",
|
||||
ok_toggle_wf: "狀態已更新。",
|
||||
ok_toggle_wf_enabled: "工作流「{id}」已啟用。",
|
||||
ok_toggle_wf_disabled: "工作流「{id}」已停用。",
|
||||
err_toggle_wf: "更新狀態失敗。",
|
||||
err_load_workflows: "載入工作流列表失敗。",
|
||||
err_reorder_workflows: "儲存工作流順序失敗。",
|
||||
workflow_count: "共 {count} 個工作流",
|
||||
workflow_count_filtered: "顯示 {visible} / 共 {total} 個工作流",
|
||||
loading: "載入中...",
|
||||
toggle_workflow: "啟用或停用工作流 {id}",
|
||||
delete_workflow: "刪除工作流 {id}",
|
||||
edit_workflow: "編輯工作流 {id}",
|
||||
upload_new_version: "上傳新版本",
|
||||
more: "更多",
|
||||
edit: "編輯",
|
||||
ok_load_saved_wf: "已載入已儲存工作流,你現在可以修改參數映射。",
|
||||
err_load_saved_wf: "載入已儲存工作流詳情失敗。",
|
||||
save_workflow_edit: "儲存工作流修改",
|
||||
editor_mode_editing: "正在編輯已儲存工作流",
|
||||
editor_mode_create: "建立新的工作流映射",
|
||||
empty_nodes: "上傳 workflow JSON 後,即可開始配置參數映射。",
|
||||
err_ui_workflow_format: "這個檔案看起來是 ComfyUI 的編輯器工作流,不是 workflow_api.json。請匯出 API 格式的工作流 JSON 後再上傳。",
|
||||
err_no_mappable_params: "這個 workflow JSON 已成功讀取,但沒有找到可配置的標量輸入參數。",
|
||||
server_manager: "伺服器管理",
|
||||
add_server: "添加伺服器",
|
||||
server_name: "伺服器名稱",
|
||||
server_id_label: "伺服器 ID",
|
||||
server_id_help: "可選,不填寫則自動生成,後續不可修改。",
|
||||
server_name_help: "名稱僅用於顯示,後續可以隨時修改。",
|
||||
server_type_comfyui: "ComfyUI",
|
||||
server_type_hint_comfyui: "用於本地或自託管的 ComfyUI 實例,調用標準 `/prompt` API。",
|
||||
server_unsupported_short: "(不支援)",
|
||||
server_unsupported_reason: "目前分支不支援伺服器類型「{type}」。請先遷移或刪除該伺服器配置,再繼續使用。",
|
||||
server_url_label: "伺服器地址",
|
||||
server_url_help_comfyui: "直接調用標準 ComfyUI 端點:`/prompt`、`/history/{id}` 與 `/view`。",
|
||||
server_output_dir: "輸出目錄",
|
||||
no_servers: "暫無配置的伺服器。",
|
||||
ok_add_server: "伺服器添加成功。",
|
||||
err_add_server: "添加伺服器失敗。",
|
||||
ok_toggle_server: "伺服器狀態已更新。",
|
||||
ok_toggle_server_enabled: "伺服器「{id}」已啟用。",
|
||||
ok_toggle_server_disabled: "伺服器「{id}」已停用。",
|
||||
err_toggle_server: "更新伺服器狀態失敗。",
|
||||
del_server_confirm: "確認刪除伺服器 {id}?資料檔案不會被刪除。",
|
||||
ok_del_server: "伺服器已移除。",
|
||||
ok_del_server_keep_data: "伺服器已移除,本地 data 資料已保留。",
|
||||
ok_del_server_with_data: "伺服器已移除,本地 data 資料已刪除。",
|
||||
err_del_server: "移除伺服器失敗。",
|
||||
delete_server_data_checkbox: "同時刪除該伺服器在 data/ 中的本地資料",
|
||||
server_enabled: "已啟用",
|
||||
server_disabled: "已停用",
|
||||
server_agent_visible: "對 Agent 可見",
|
||||
server_agent_hidden: "對 Agent 隱藏",
|
||||
server_agent_visibility_hint: "控制 Agent 是否可以發現和使用此伺服器",
|
||||
select_server: "選擇伺服器",
|
||||
current_server_config: "伺服器配置",
|
||||
current_server_title: "當前伺服器",
|
||||
no_server_selected_short: "未選擇伺服器",
|
||||
add_server_toggle: "添加伺服器",
|
||||
add_server_close: "關閉",
|
||||
cancel: "取消",
|
||||
confirm: "確認",
|
||||
confirm_action_title: "確認操作",
|
||||
delete: "刪除",
|
||||
overwrite: "覆蓋",
|
||||
leave_anyway: "仍然離開",
|
||||
save_anyway: "仍然儲存",
|
||||
add_server_panel_title: "添加新伺服器",
|
||||
add_server_modal_title: "添加伺服器",
|
||||
edit_server_modal_title: "編輯伺服器",
|
||||
save_and_connect: "儲存並連線",
|
||||
save_server_changes: "儲存修改",
|
||||
create_first_server: "建立第一個伺服器",
|
||||
new_server_name_placeholder: "本地伺服器",
|
||||
err_server_id_url_required: "必須填寫伺服器 ID 和地址。",
|
||||
err_server_name_id_url_required: "必須填寫伺服器 ID、名稱和地址。",
|
||||
err_server_name_url_required: "必須填寫伺服器名稱和地址。",
|
||||
err_server_name_required: "必須填寫伺服器名稱。",
|
||||
err_select_server_first: "請先添加或選擇一個伺服器。",
|
||||
err_select_server_before_register: "請先添加或選擇伺服器,再註冊工作流。",
|
||||
err_no_server_selected: "當前未選擇伺服器。",
|
||||
err_no_workflow_uploaded: "未上傳工作流資料,請先上傳 workflow JSON。",
|
||||
editor_step_1: "基礎資訊",
|
||||
editor_step_2: "上傳工作流",
|
||||
editor_step_3: "參數映射",
|
||||
editor_step_1_hint: "第 1 步:填寫工作流基礎資訊。",
|
||||
editor_step_2_hint: "第 2 步:上傳 workflow_api.json 解析參數。",
|
||||
editor_step_3_hint: "第 3 步:檢查參數映射並儲存。",
|
||||
mapping_search_placeholder: "按節點、欄位、別名或描述搜尋",
|
||||
mapping_sort_node_id_asc: "節點 ID(升序)",
|
||||
mapping_sort_node_id_desc: "節點 ID(降序)",
|
||||
mapping_sort_class_asc: "節點類型(A-Z)",
|
||||
mapping_sort_param_default: "參數預設順序",
|
||||
mapping_sort_param_name: "參數名(A-Z)",
|
||||
mapping_sort_param_type: "類型(A-Z)",
|
||||
mapping_sort_param_exposed: "已暴露優先",
|
||||
mapping_exposed_only: "僅顯示已暴露參數",
|
||||
mapping_required_only: "僅顯示必填參數",
|
||||
mapping_apply_recommended: "一鍵暴露推薦參數",
|
||||
mapping_expose_visible: "暴露當前可見參數",
|
||||
mapping_unexpose_visible: "取消暴露當前可見參數",
|
||||
mapping_collapse_configs: "摺疊全部參數配置",
|
||||
mapping_expand_configs: "展開全部參數配置",
|
||||
mapping_collapse_all: "摺疊全部節點",
|
||||
mapping_expand_all: "展開全部節點",
|
||||
mapping_reset_filters: "重置篩選",
|
||||
mapping_summary: "當前顯示 {visible_nodes} 個節點中的 {visible_params}/{total_params} 個參數,已暴露 {exposed_params} 個",
|
||||
mapping_apply_recommended_ok: "已暴露推薦參數 {count} 項",
|
||||
mapping_no_recommended_changes: "沒有新增可暴露的推薦參數。",
|
||||
mapping_expose_visible_ok: "已暴露當前可見參數 {count} 項",
|
||||
mapping_unexpose_visible_ok: "已取消暴露當前可見參數 {count} 項",
|
||||
mapping_no_visible_params: "當前沒有可執行此操作的可見參數。",
|
||||
mapping_no_batch_changes: "沒有需要更新的參數。",
|
||||
empty_nodes_filtered: "當前篩選條件下沒有匹配參數。",
|
||||
mapping_shortcuts_hint: "快捷鍵:Ctrl/Cmd+S 儲存,/ 聚焦搜尋,Esc 清空搜尋",
|
||||
toggle_node: "摺疊或展開節點 {id}",
|
||||
toggle_param_config: "摺疊或展開參數配置 {field}",
|
||||
confirm_unsaved_leave: "編輯頁有未儲存更改,仍要離開嗎?",
|
||||
workflow_upgrade_ready: "已載入新版本,請先檢查保留、新增與待確認的參數後再儲存。",
|
||||
workflow_upgrade_summary: "已保留 {retained} 個,待確認 {review} 個,新增 {added} 個,移除 {removed} 個",
|
||||
migration_status_retained: "已保留",
|
||||
migration_status_review: "待確認",
|
||||
migration_status_new: "新增",
|
||||
migration_review_hint: "這個參數已發生變化,建議儲存前重新檢查。",
|
||||
server_status_online: "在線",
|
||||
server_status_offline: "離線",
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyEditorParamUpdate, applyVisibleExposure } from "./editorState";
|
||||
import type { SchemaParamMap } from "../types/editor";
|
||||
|
||||
const schemaParams: SchemaParamMap = {
|
||||
"1_text": {
|
||||
exposed: true,
|
||||
node_id: 1,
|
||||
field: "text",
|
||||
name: "prompt_1",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Prompt",
|
||||
choices: [],
|
||||
currentVal: "hello",
|
||||
nodeClass: "CLIPTextEncode",
|
||||
},
|
||||
"2_seed": {
|
||||
exposed: true,
|
||||
node_id: 2,
|
||||
field: "seed",
|
||||
name: "seed",
|
||||
type: "int",
|
||||
required: false,
|
||||
description: "Seed",
|
||||
choices: [],
|
||||
currentVal: 1,
|
||||
nodeClass: "KSampler",
|
||||
},
|
||||
};
|
||||
|
||||
describe("editorState helpers", () => {
|
||||
it("clears expanded state when an exposed param is toggled off", () => {
|
||||
const result = applyEditorParamUpdate(schemaParams, new Set(["1_text"]), "1_text", "exposed", false);
|
||||
expect(result.schemaParams["1_text"].exposed).toBe(false);
|
||||
expect(result.expandedParamKeys.has("1_text")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears expanded state for params hidden by bulk unexpose", () => {
|
||||
const result = applyVisibleExposure(schemaParams, new Set(["1_text", "2_seed"]), ["1_text"], false);
|
||||
expect(result.changedCount).toBe(1);
|
||||
expect(result.schemaParams["1_text"].exposed).toBe(false);
|
||||
expect(result.expandedParamKeys.has("1_text")).toBe(false);
|
||||
expect(result.expandedParamKeys.has("2_seed")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { SchemaParam, SchemaParamMap } from "../types/editor";
|
||||
|
||||
export function applyEditorParamUpdate(
|
||||
schemaParams: SchemaParamMap,
|
||||
expandedParamKeys: Set<string>,
|
||||
key: string,
|
||||
field: keyof SchemaParam | "name" | "description" | "required" | "type" | "exposed",
|
||||
value: unknown,
|
||||
) {
|
||||
const nextSchemaParams: SchemaParamMap = {
|
||||
...schemaParams,
|
||||
[key]: {
|
||||
...schemaParams[key],
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
|
||||
const nextExpandedParamKeys = new Set(expandedParamKeys);
|
||||
if (field === "exposed" && value === false) {
|
||||
nextExpandedParamKeys.delete(key);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaParams: nextSchemaParams,
|
||||
expandedParamKeys: nextExpandedParamKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyVisibleExposure(
|
||||
schemaParams: SchemaParamMap,
|
||||
expandedParamKeys: Set<string>,
|
||||
visibleKeys: string[],
|
||||
exposed: boolean,
|
||||
) {
|
||||
const nextSchemaParams: SchemaParamMap = { ...schemaParams };
|
||||
const nextExpandedParamKeys = new Set(expandedParamKeys);
|
||||
let changedCount = 0;
|
||||
|
||||
visibleKeys.forEach((key) => {
|
||||
const param = nextSchemaParams[key];
|
||||
if (!param || param.exposed === exposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextSchemaParams[key] = {
|
||||
...param,
|
||||
exposed,
|
||||
name: exposed ? (param.name || param.field) : param.name,
|
||||
};
|
||||
if (!exposed) {
|
||||
nextExpandedParamKeys.delete(key);
|
||||
}
|
||||
changedCount += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
schemaParams: nextSchemaParams,
|
||||
expandedParamKeys: nextExpandedParamKeys,
|
||||
changedCount,
|
||||
};
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const cleaned = (hex || "").replace("#", "").trim();
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) {
|
||||
return { r: 160, g: 34, b: 59 };
|
||||
}
|
||||
return {
|
||||
r: Number.parseInt(cleaned.slice(0, 2), 16),
|
||||
g: Number.parseInt(cleaned.slice(2, 4), 16),
|
||||
b: Number.parseInt(cleaned.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, x: number) {
|
||||
const t = clamp((x - edge0) / Math.max(1e-6, edge1 - edge0), 0, 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
const BAYER_4 = [
|
||||
[0, 8, 2, 10],
|
||||
[12, 4, 14, 6],
|
||||
[3, 11, 1, 9],
|
||||
[15, 7, 13, 5],
|
||||
];
|
||||
|
||||
function bayerThreshold(x: number, y: number) {
|
||||
return (BAYER_4[y & 3][x & 3] + 0.5) / 16;
|
||||
}
|
||||
|
||||
export function initPixelBlastBackground(options: Partial<{
|
||||
variant: "circle" | "square";
|
||||
pixelSize: number;
|
||||
color: string;
|
||||
patternScale: number;
|
||||
patternDensity: number;
|
||||
pixelSizeJitter: number;
|
||||
enableRipples: boolean;
|
||||
rippleSpeed: number;
|
||||
rippleThickness: number;
|
||||
rippleIntensityScale: number;
|
||||
liquid: boolean;
|
||||
liquidStrength: number;
|
||||
liquidRadius: number;
|
||||
liquidWobbleSpeed: number;
|
||||
speed: number;
|
||||
edgeFade: number;
|
||||
transparent: boolean;
|
||||
}> = {}) {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = {
|
||||
variant: "circle" as const,
|
||||
pixelSize: 4,
|
||||
color: "#a0223b",
|
||||
patternScale: 2,
|
||||
patternDensity: 1,
|
||||
pixelSizeJitter: 0,
|
||||
enableRipples: true,
|
||||
rippleSpeed: 0.4,
|
||||
rippleThickness: 0.12,
|
||||
rippleIntensityScale: 1.5,
|
||||
liquid: false,
|
||||
liquidStrength: 0.12,
|
||||
liquidRadius: 1.2,
|
||||
liquidWobbleSpeed: 5,
|
||||
speed: 0.5,
|
||||
edgeFade: 0.25,
|
||||
transparent: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const rgb = hexToRgb(config.color);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "pixel-blast-bg";
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
document.body.prepend(canvas);
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: true, desynchronized: true });
|
||||
if (!ctx) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let rafId: number | null = null;
|
||||
let lastFrame = 0;
|
||||
const targetFrameMs = reduced ? 1000 / 12 : 1000 / 28;
|
||||
|
||||
const blobs = Array.from({ length: 6 }).map((_, index) => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: 0.13 + Math.random() * 0.2,
|
||||
vx: (Math.random() * 2 - 1) * (0.00022 + index * 0.00004),
|
||||
vy: (Math.random() * 2 - 1) * (0.00022 + index * 0.00004),
|
||||
}));
|
||||
|
||||
function resize() {
|
||||
width = Math.max(1, window.innerWidth);
|
||||
height = Math.max(1, window.innerHeight);
|
||||
const dpr = clamp(window.devicePixelRatio || 1, 1, 2);
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
cols = Math.ceil(width / config.pixelSize);
|
||||
rows = Math.ceil(height / config.pixelSize);
|
||||
}
|
||||
|
||||
function updateBlobs(dt: number) {
|
||||
const speedScale = config.speed * (reduced ? 0.45 : 1);
|
||||
blobs.forEach((blob, index) => {
|
||||
const wobble = config.liquid
|
||||
? Math.sin(performance.now() * 0.001 * config.liquidWobbleSpeed + index) * config.liquidStrength * 0.0005
|
||||
: 0;
|
||||
blob.x += (blob.vx + wobble) * dt * speedScale;
|
||||
blob.y += (blob.vy - wobble) * dt * speedScale;
|
||||
|
||||
if (blob.x < -0.15 || blob.x > 1.15) {
|
||||
blob.vx *= -1;
|
||||
}
|
||||
if (blob.y < -0.15 || blob.y > 1.15) {
|
||||
blob.vy *= -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sampleField(nx: number, ny: number, timeSec: number) {
|
||||
let value = 0;
|
||||
for (const blob of blobs) {
|
||||
const dx = nx - blob.x;
|
||||
const dy = ny - blob.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
const r2 = blob.r * blob.r;
|
||||
value += Math.exp(-d2 / (r2 * (config.patternScale * 0.9)));
|
||||
}
|
||||
|
||||
value = (value / blobs.length) * 1.9 * config.patternDensity;
|
||||
|
||||
if (config.enableRipples) {
|
||||
const dx = nx - 0.5;
|
||||
const dy = ny - 0.5;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const wavePhase = distance * 17 - timeSec * (2.3 * config.rippleSpeed);
|
||||
const thickness = clamp(config.rippleThickness, 0.04, 0.45);
|
||||
const envelope = Math.exp(-distance * (4.2 / Math.max(0.1, thickness)));
|
||||
value += Math.sin(wavePhase) * envelope * 0.2 * config.rippleIntensityScale;
|
||||
}
|
||||
|
||||
const edge = Math.min(nx, 1 - nx, ny, 1 - ny);
|
||||
return value * smoothstep(0, clamp(config.edgeFade, 0.04, 0.49), edge);
|
||||
}
|
||||
|
||||
function draw(now: number) {
|
||||
if (now - lastFrame < targetFrameMs) {
|
||||
rafId = window.requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = Math.min(40, now - (lastFrame || now));
|
||||
lastFrame = now;
|
||||
|
||||
if (!config.transparent) {
|
||||
ctx.fillStyle = "rgba(9, 11, 15, 1)";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
updateBlobs(dt);
|
||||
|
||||
const seconds = now * 0.001;
|
||||
const baseAlpha = reduced ? 0.16 : 0.24;
|
||||
for (let gy = 0; gy < rows; gy += 1) {
|
||||
for (let gx = 0; gx < cols; gx += 1) {
|
||||
const nx = (gx + 0.5) / cols;
|
||||
const ny = (gy + 0.5) / rows;
|
||||
const field = sampleField(nx, ny, seconds);
|
||||
if (field <= bayerThreshold(gx, gy) * 0.95) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jitter = config.pixelSizeJitter > 0 ? (Math.random() - 0.5) * config.pixelSizeJitter : 0;
|
||||
const px = gx * config.pixelSize;
|
||||
const py = gy * config.pixelSize;
|
||||
const size = Math.max(1, config.pixelSize + jitter);
|
||||
const alpha = clamp(baseAlpha + field * 0.18, 0.08, 0.62);
|
||||
ctx.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||
|
||||
if (config.variant === "circle") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(px + size * 0.5, py + size * 0.5, size * 0.38, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(px, py, size, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rafId = window.requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
resize();
|
||||
}
|
||||
|
||||
resize();
|
||||
rafId = window.requestAnimationFrame(draw);
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
window.removeEventListener("resize", onResize);
|
||||
canvas.remove();
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export function safeReadLocalStorage(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeWriteLocalStorage(key: string, value: string) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Ignore private-mode / embedded storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function safeRemoveLocalStorage(key: string) {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore private-mode / embedded storage failures.
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFinalSchema,
|
||||
migrateSchemaParams,
|
||||
parseWorkflowUpload,
|
||||
suggestWorkflowId,
|
||||
} from "./workflowMapper";
|
||||
import type { SchemaParamMap } from "../types/editor";
|
||||
|
||||
describe("workflowMapper", () => {
|
||||
it("parses api workflows and auto-exposes prompt params", () => {
|
||||
const result = parseWorkflowUpload(JSON.stringify({
|
||||
"1": {
|
||||
class_type: "CLIPTextEncode",
|
||||
inputs: { text: "hello world" },
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.workflowData["1"]).toBeDefined();
|
||||
expect(result.schemaParams["1_text"]).toMatchObject({
|
||||
exposed: true,
|
||||
required: true,
|
||||
name: "prompt_1",
|
||||
type: "string",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects editor workflow json", () => {
|
||||
expect(() => parseWorkflowUpload(JSON.stringify({
|
||||
nodes: [],
|
||||
links: [],
|
||||
}))).toThrow(/editor workflow format/i);
|
||||
});
|
||||
|
||||
it("suggests workflow ids from metadata or file names", () => {
|
||||
expect(suggestWorkflowId({ metadata: { title: "My Fancy Flow" } })).toBe("My-Fancy-Flow");
|
||||
expect(suggestWorkflowId({}, "starter.flow.json")).toBe("starter-flow");
|
||||
});
|
||||
|
||||
it("migrates matching params and flags fallback matches for review", () => {
|
||||
const previous: SchemaParamMap = {
|
||||
"1_text": {
|
||||
exposed: true,
|
||||
node_id: 1,
|
||||
field: "text",
|
||||
name: "prompt",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "prompt",
|
||||
choices: [],
|
||||
currentVal: "old",
|
||||
nodeClass: "CLIPTextEncode",
|
||||
},
|
||||
};
|
||||
const next: SchemaParamMap = {
|
||||
"9_text": {
|
||||
exposed: false,
|
||||
node_id: 9,
|
||||
field: "text",
|
||||
name: "text",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "",
|
||||
choices: [],
|
||||
currentVal: "new",
|
||||
nodeClass: "CLIPTextEncode",
|
||||
},
|
||||
};
|
||||
|
||||
const migration = migrateSchemaParams(previous, next);
|
||||
expect(migration.summary.review).toBe(1);
|
||||
expect(migration.schemaParams["9_text"]).toMatchObject({
|
||||
exposed: true,
|
||||
name: "prompt",
|
||||
migrationStatus: "review",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds final schema and reports missing aliases", () => {
|
||||
const valid = buildFinalSchema({
|
||||
"1_text": {
|
||||
exposed: true,
|
||||
node_id: 1,
|
||||
field: "text",
|
||||
name: "prompt",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Prompt",
|
||||
choices: [],
|
||||
currentVal: "hello",
|
||||
nodeClass: "CLIPTextEncode",
|
||||
},
|
||||
});
|
||||
expect(valid.finalSchema).toEqual({
|
||||
prompt: {
|
||||
node_id: 1,
|
||||
field: "text",
|
||||
required: true,
|
||||
type: "string",
|
||||
description: "Prompt",
|
||||
},
|
||||
});
|
||||
|
||||
const invalid = buildFinalSchema({
|
||||
"1_seed": {
|
||||
exposed: true,
|
||||
node_id: 1,
|
||||
field: "seed",
|
||||
name: "",
|
||||
type: "int",
|
||||
required: false,
|
||||
description: "",
|
||||
choices: [],
|
||||
currentVal: 1,
|
||||
nodeClass: "KSampler",
|
||||
},
|
||||
});
|
||||
expect(invalid.finalSchema).toBeNull();
|
||||
expect(invalid.missingAlias?.field).toBe("seed");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export { parseWorkflowUpload } from "./workflowMapper/parseWorkflowUpload";
|
||||
export { extractSchemaParams } from "./workflowMapper/schemaExtraction";
|
||||
export { suggestWorkflowId } from "./workflowMapper/suggestWorkflowId";
|
||||
export { migrateSchemaParams } from "./workflowMapper/schemaMigration";
|
||||
export { groupSchemaParams, buildFinalSchema } from "./workflowMapper/schemaView";
|
||||
@@ -1,30 +0,0 @@
|
||||
import { extractSchemaParams } from "./schemaExtraction";
|
||||
import type { SchemaParamMap } from "../../types/editor";
|
||||
|
||||
function isEditorWorkflow(workflowData: unknown) {
|
||||
return Boolean(
|
||||
workflowData
|
||||
&& typeof workflowData === "object"
|
||||
&& !Array.isArray(workflowData)
|
||||
&& Array.isArray((workflowData as { nodes?: unknown[] }).nodes)
|
||||
&& Array.isArray((workflowData as { links?: unknown[] }).links),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseWorkflowUpload(fileContent: string): {
|
||||
workflowData: Record<string, unknown>;
|
||||
schemaParams: SchemaParamMap;
|
||||
} {
|
||||
const workflowData = JSON.parse(fileContent) as Record<string, unknown>;
|
||||
|
||||
if (isEditorWorkflow(workflowData)) {
|
||||
const error = new Error("Unsupported ComfyUI editor workflow format") as Error & { code?: string };
|
||||
error.code = "EDITOR_WORKFLOW_FORMAT";
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
workflowData,
|
||||
schemaParams: extractSchemaParams(workflowData),
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { SchemaParamMap, SchemaParam } from "../../types/editor";
|
||||
|
||||
type SchemaParamType = SchemaParam["type"];
|
||||
|
||||
function getTypeGuess(value: unknown): SchemaParamType {
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? "int" : "float";
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return "boolean";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
function getAutoMapping(nodeClass: string, field: string, nodeId: string) {
|
||||
if (nodeClass.includes("KSampler")) {
|
||||
if (field === "seed") {
|
||||
return { exposed: true, required: false, name: field, description: "Random seed (for reproducibility)" };
|
||||
}
|
||||
if (field === "steps") {
|
||||
return { exposed: true, required: false, name: field, description: "Generation steps" };
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeClass.includes("CLIPTextEncode") || nodeClass.includes("Text") || nodeClass.includes("Prompt")) {
|
||||
if (field === "text" || field === "prompt") {
|
||||
return { exposed: true, required: true, name: `prompt_${nodeId}`, description: "Text prompt description" };
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeClass === "EmptyLatentImage" && ["width", "height", "batch_size"].includes(field)) {
|
||||
return { exposed: true, required: false, name: field, description: `Image ${field}` };
|
||||
}
|
||||
|
||||
if (nodeClass === "SaveImage" && field === "filename_prefix") {
|
||||
return { exposed: true, required: false, name: field, description: "Output file prefix" };
|
||||
}
|
||||
|
||||
if (nodeClass === "LightCCDoubaoImageNode") {
|
||||
if (field === "prompt") {
|
||||
return { exposed: true, required: true, name: field, description: "Positive image prompt" };
|
||||
}
|
||||
if (field === "size") {
|
||||
return { exposed: true, required: false, name: field, description: "e.g., 1:1,2048x2048" };
|
||||
}
|
||||
if (field === "seed") {
|
||||
return { exposed: true, required: false, name: field, description: "Random seed" };
|
||||
}
|
||||
if (field === "num") {
|
||||
return { exposed: true, required: false, name: field, description: "Number of images to generate" };
|
||||
}
|
||||
}
|
||||
|
||||
return { exposed: false, required: false, name: field, description: "" };
|
||||
}
|
||||
|
||||
export function extractSchemaParams(workflowData: Record<string, unknown>): SchemaParamMap {
|
||||
const schemaParams: SchemaParamMap = {};
|
||||
|
||||
Object.entries(workflowData).forEach(([nodeId, nodeObject]) => {
|
||||
const nodeRecord = nodeObject as { inputs?: Record<string, unknown>; class_type?: string } | null;
|
||||
if (!nodeRecord?.inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeClass = nodeRecord.class_type || "";
|
||||
Object.entries(nodeRecord.inputs).forEach(([field, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoMapping = getAutoMapping(nodeClass, field, nodeId);
|
||||
schemaParams[`${nodeId}_${field}`] = {
|
||||
exposed: autoMapping.exposed,
|
||||
node_id: Number.parseInt(nodeId, 10),
|
||||
field,
|
||||
name: autoMapping.name,
|
||||
type: getTypeGuess(value),
|
||||
required: autoMapping.required,
|
||||
description: autoMapping.description,
|
||||
default: value,
|
||||
example: value,
|
||||
choices: [],
|
||||
currentVal: value,
|
||||
nodeClass: nodeClass || "UnknownNode",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return schemaParams;
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import type { SchemaParam, SchemaParamMap, UpgradeSummary } from "../../types/editor";
|
||||
|
||||
function normalizeCompareText(value: unknown) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, "");
|
||||
}
|
||||
|
||||
type CandidateEntry = [string, SchemaParam];
|
||||
|
||||
function buildFallbackCandidateIndex(previousSchemaParams: SchemaParamMap) {
|
||||
const indexes = {
|
||||
byFieldAndClass: new Map<string, CandidateEntry[]>(),
|
||||
byFieldAndType: new Map<string, CandidateEntry[]>(),
|
||||
byAliasAndType: new Map<string, CandidateEntry[]>(),
|
||||
};
|
||||
|
||||
Object.entries(previousSchemaParams || {}).forEach(([key, parameter]) => {
|
||||
const field = normalizeCompareText(parameter.field);
|
||||
const nodeClass = normalizeCompareText(parameter.nodeClass);
|
||||
const type = normalizeCompareText(parameter.type);
|
||||
const alias = normalizeCompareText(parameter.name);
|
||||
|
||||
const fieldAndClassKey = `${field}|${nodeClass}`;
|
||||
const fieldAndTypeKey = `${field}|${type}`;
|
||||
const aliasAndTypeKey = `${alias}|${type}`;
|
||||
|
||||
if (!indexes.byFieldAndClass.has(fieldAndClassKey)) {
|
||||
indexes.byFieldAndClass.set(fieldAndClassKey, []);
|
||||
}
|
||||
indexes.byFieldAndClass.get(fieldAndClassKey)?.push([key, parameter]);
|
||||
|
||||
if (!indexes.byFieldAndType.has(fieldAndTypeKey)) {
|
||||
indexes.byFieldAndType.set(fieldAndTypeKey, []);
|
||||
}
|
||||
indexes.byFieldAndType.get(fieldAndTypeKey)?.push([key, parameter]);
|
||||
|
||||
if (!alias) {
|
||||
return;
|
||||
}
|
||||
if (!indexes.byAliasAndType.has(aliasAndTypeKey)) {
|
||||
indexes.byAliasAndType.set(aliasAndTypeKey, []);
|
||||
}
|
||||
indexes.byAliasAndType.get(aliasAndTypeKey)?.push([key, parameter]);
|
||||
});
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function getUniqueFallbackMatch(
|
||||
parameter: SchemaParam,
|
||||
candidateIndex: ReturnType<typeof buildFallbackCandidateIndex>,
|
||||
matchedPreviousKeys: Set<string>,
|
||||
) {
|
||||
const field = normalizeCompareText(parameter.field);
|
||||
const nodeClass = normalizeCompareText(parameter.nodeClass);
|
||||
const type = normalizeCompareText(parameter.type);
|
||||
const alias = normalizeCompareText(parameter.name);
|
||||
const candidates: CandidateEntry[] = [];
|
||||
|
||||
[
|
||||
candidateIndex.byFieldAndClass.get(`${field}|${nodeClass}`),
|
||||
candidateIndex.byFieldAndType.get(`${field}|${type}`),
|
||||
alias ? candidateIndex.byAliasAndType.get(`${alias}|${type}`) : null,
|
||||
].forEach((entries) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
return;
|
||||
}
|
||||
entries.forEach((entry) => candidates.push(entry));
|
||||
});
|
||||
|
||||
const uniqueCandidates: CandidateEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
candidates
|
||||
.filter(([key]) => !matchedPreviousKeys.has(key))
|
||||
.forEach(([key, candidate]) => {
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
uniqueCandidates.push([key, candidate]);
|
||||
});
|
||||
|
||||
if (uniqueCandidates.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return uniqueCandidates[0];
|
||||
}
|
||||
|
||||
function mergeSchemaParam(
|
||||
nextParam: SchemaParam,
|
||||
previousParam: SchemaParam,
|
||||
migrationStatus: string,
|
||||
migrationReason = "",
|
||||
): SchemaParam {
|
||||
const merged: SchemaParam = {
|
||||
...nextParam,
|
||||
exposed: Boolean(previousParam.exposed),
|
||||
name: previousParam.name || nextParam.name,
|
||||
type: previousParam.type || nextParam.type,
|
||||
required: Boolean(previousParam.required),
|
||||
description: previousParam.description || "",
|
||||
default: previousParam.default ?? nextParam.default,
|
||||
example: previousParam.example ?? nextParam.example,
|
||||
choices: Array.isArray(previousParam.choices) ? [...previousParam.choices] : [...(nextParam.choices || [])],
|
||||
migrationStatus,
|
||||
migrationReason,
|
||||
};
|
||||
|
||||
if (previousParam.type && nextParam.type && previousParam.type !== nextParam.type) {
|
||||
merged.type = nextParam.type;
|
||||
merged.migrationStatus = "review";
|
||||
merged.migrationReason = "type_changed";
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function migrateSchemaParams(previousSchemaParams: SchemaParamMap, nextSchemaParams: SchemaParamMap) {
|
||||
const matchedPreviousKeys = new Set<string>();
|
||||
const candidateIndex = buildFallbackCandidateIndex(previousSchemaParams);
|
||||
const mergedSchemaParams: SchemaParamMap = {};
|
||||
const summary: UpgradeSummary = {
|
||||
retained: 0,
|
||||
review: 0,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
matched: [],
|
||||
addedKeys: [],
|
||||
removedKeys: [],
|
||||
};
|
||||
|
||||
Object.entries(nextSchemaParams || {}).forEach(([key, nextParam]) => {
|
||||
const previousParam = previousSchemaParams?.[key];
|
||||
if (previousParam) {
|
||||
matchedPreviousKeys.add(key);
|
||||
mergedSchemaParams[key] = mergeSchemaParam(nextParam, previousParam, "retained");
|
||||
summary.retained += 1;
|
||||
summary.matched?.push({ previousKey: key, nextKey: key, status: "retained" });
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackMatch = getUniqueFallbackMatch(nextParam, candidateIndex, matchedPreviousKeys);
|
||||
if (fallbackMatch) {
|
||||
const [previousKey, matchedParam] = fallbackMatch;
|
||||
matchedPreviousKeys.add(previousKey);
|
||||
mergedSchemaParams[key] = mergeSchemaParam(nextParam, matchedParam, "review", "fallback_match");
|
||||
summary.review += 1;
|
||||
summary.matched?.push({ previousKey, nextKey: key, status: "review" });
|
||||
return;
|
||||
}
|
||||
|
||||
mergedSchemaParams[key] = {
|
||||
...nextParam,
|
||||
migrationStatus: "new",
|
||||
migrationReason: "new_param",
|
||||
};
|
||||
summary.added += 1;
|
||||
summary.addedKeys?.push(key);
|
||||
});
|
||||
|
||||
Object.keys(previousSchemaParams || {}).forEach((key) => {
|
||||
if (matchedPreviousKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
summary.removed += 1;
|
||||
summary.removedKeys?.push(key);
|
||||
});
|
||||
|
||||
return {
|
||||
schemaParams: mergedSchemaParams,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { SchemaParam, SchemaParamMap } from "../../types/editor";
|
||||
|
||||
export function groupSchemaParams(schemaParams: SchemaParamMap) {
|
||||
const grouped = new Map<number, { classType: string; params: Array<SchemaParam & { key: string }> }>();
|
||||
|
||||
Object.entries(schemaParams).forEach(([key, value]) => {
|
||||
if (!grouped.has(value.node_id)) {
|
||||
grouped.set(value.node_id, {
|
||||
classType: value.nodeClass,
|
||||
params: [],
|
||||
});
|
||||
}
|
||||
grouped.get(value.node_id)?.params.push({ key, ...value });
|
||||
});
|
||||
|
||||
return Array.from(grouped.entries()).sort((first, second) => Number(first[0]) - Number(second[0]));
|
||||
}
|
||||
|
||||
type FinalSchemaField = {
|
||||
node_id: number;
|
||||
field: string;
|
||||
required: boolean;
|
||||
type: SchemaParam["type"];
|
||||
description: string;
|
||||
default?: unknown;
|
||||
example?: unknown;
|
||||
choices?: unknown[];
|
||||
};
|
||||
|
||||
export function buildFinalSchema(schemaParams: SchemaParamMap) {
|
||||
const finalSchema: Record<string, FinalSchemaField> = {};
|
||||
let exposedCount = 0;
|
||||
|
||||
for (const parameter of Object.values(schemaParams)) {
|
||||
if (!parameter.exposed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
exposedCount += 1;
|
||||
if (!parameter.name || !parameter.name.trim()) {
|
||||
return {
|
||||
finalSchema: null,
|
||||
exposedCount,
|
||||
missingAlias: parameter,
|
||||
};
|
||||
}
|
||||
|
||||
const key = parameter.name.trim();
|
||||
finalSchema[key] = {
|
||||
node_id: parameter.node_id,
|
||||
field: parameter.field,
|
||||
required: Boolean(parameter.required),
|
||||
type: parameter.type,
|
||||
description: parameter.description || "",
|
||||
};
|
||||
|
||||
if (parameter.default !== undefined) {
|
||||
finalSchema[key].default = parameter.default;
|
||||
}
|
||||
if (parameter.example !== undefined) {
|
||||
finalSchema[key].example = parameter.example;
|
||||
}
|
||||
if (Array.isArray(parameter.choices) && parameter.choices.length) {
|
||||
finalSchema[key].choices = parameter.choices;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
finalSchema,
|
||||
exposedCount,
|
||||
missingAlias: null,
|
||||
};
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
function getBaseFileName(fileName: string) {
|
||||
if (!fileName || typeof fileName !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fileName.replace(/\.[^.]+$/, "").trim();
|
||||
}
|
||||
|
||||
function getFirstNodeTitle(workflowData: Record<string, unknown>) {
|
||||
if (!workflowData || typeof workflowData !== "object" || Array.isArray(workflowData)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const nodeObject of Object.values(workflowData)) {
|
||||
const title = (nodeObject as { _meta?: { title?: string } } | null)?._meta?.title;
|
||||
if (typeof title === "string" && title.trim()) {
|
||||
return title.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeWorkflowIdCandidate(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[./\\]+/g, "-")
|
||||
.replace(/[^\p{L}\p{N}_-]+/gu, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "");
|
||||
}
|
||||
|
||||
export function suggestWorkflowId(workflowData: Record<string, unknown>, fileName = "") {
|
||||
const workflowMeta = workflowData as {
|
||||
workflow_name?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
_meta?: { title?: string };
|
||||
extra?: { workflow_name?: string; name?: string; title?: string };
|
||||
metadata?: { workflow_name?: string; name?: string; title?: string };
|
||||
};
|
||||
|
||||
const candidates = [
|
||||
workflowMeta.workflow_name,
|
||||
workflowMeta.name,
|
||||
workflowMeta.title,
|
||||
workflowMeta._meta?.title,
|
||||
workflowMeta.extra?.workflow_name,
|
||||
workflowMeta.extra?.name,
|
||||
workflowMeta.extra?.title,
|
||||
workflowMeta.metadata?.workflow_name,
|
||||
workflowMeta.metadata?.name,
|
||||
workflowMeta.metadata?.title,
|
||||
getBaseFileName(fileName),
|
||||
getFirstNodeTitle(workflowData),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeWorkflowIdCandidate(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return "workflow";
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderWorkflowCollection, restoreWorkflowOrder } from "./workflowOrder";
|
||||
import type { WorkflowSummaryDto } from "../types/api";
|
||||
|
||||
const workflows: WorkflowSummaryDto[] = [
|
||||
{ id: "a", server_id: "s1", server_name: "S1", enabled: true, description: "", updated_at: 1 },
|
||||
{ id: "b", server_id: "s1", server_name: "S1", enabled: true, description: "", updated_at: 2 },
|
||||
{ id: "c", server_id: "s1", server_name: "S1", enabled: false, description: "", updated_at: 3 },
|
||||
{ id: "x", server_id: "s2", server_name: "S2", enabled: true, description: "", updated_at: 4 },
|
||||
];
|
||||
|
||||
describe("workflowOrder", () => {
|
||||
it("reorders only the target server workflows", () => {
|
||||
const result = reorderWorkflowCollection(workflows, "s1", "a", "c", true);
|
||||
expect(result?.reorderedIds).toEqual(["b", "c", "a"]);
|
||||
expect(result?.nextWorkflows.map((workflow) => `${workflow.server_id}:${workflow.id}`)).toEqual([
|
||||
"s2:x",
|
||||
"s1:b",
|
||||
"s1:c",
|
||||
"s1:a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores previous order while preserving the latest workflow objects", () => {
|
||||
const current: WorkflowSummaryDto[] = [
|
||||
{ id: "x", server_id: "s2", server_name: "S2", enabled: true, description: "", updated_at: 4 },
|
||||
{ id: "c", server_id: "s1", server_name: "S1", enabled: true, description: "changed", updated_at: 30 },
|
||||
{ id: "b", server_id: "s1", server_name: "S1", enabled: false, description: "latest", updated_at: 20 },
|
||||
];
|
||||
|
||||
const restored = restoreWorkflowOrder(current, "s1", ["a", "b", "c"]);
|
||||
expect(restored.map((workflow) => `${workflow.server_id}:${workflow.id}:${workflow.enabled}:${workflow.description}`)).toEqual([
|
||||
"s2:x:true:",
|
||||
"s1:b:false:latest",
|
||||
"s1:c:true:changed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { WorkflowSummaryDto } from "../types/api";
|
||||
|
||||
export function reorderWorkflowCollection(
|
||||
workflows: WorkflowSummaryDto[],
|
||||
serverId: string,
|
||||
sourceWorkflowId: string,
|
||||
targetWorkflowId: string,
|
||||
placeAfter: boolean,
|
||||
) {
|
||||
const serverWorkflows = workflows.filter((workflow) => workflow.server_id === serverId);
|
||||
const sourceIndex = serverWorkflows.findIndex((workflow) => workflow.id === sourceWorkflowId);
|
||||
const targetIndex = serverWorkflows.findIndex((workflow) => workflow.id === targetWorkflowId);
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reorderedServerWorkflows = [...serverWorkflows];
|
||||
const [movedWorkflow] = reorderedServerWorkflows.splice(sourceIndex, 1);
|
||||
const adjustedTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
||||
const insertIndex = placeAfter ? adjustedTargetIndex + 1 : adjustedTargetIndex;
|
||||
reorderedServerWorkflows.splice(insertIndex, 0, movedWorkflow);
|
||||
|
||||
return {
|
||||
nextWorkflows: [
|
||||
...workflows.filter((workflow) => workflow.server_id !== serverId),
|
||||
...reorderedServerWorkflows,
|
||||
],
|
||||
reorderedIds: reorderedServerWorkflows.map((workflow) => workflow.id),
|
||||
previousIds: serverWorkflows.map((workflow) => workflow.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreWorkflowOrder(
|
||||
workflows: WorkflowSummaryDto[],
|
||||
serverId: string,
|
||||
orderedIds: string[],
|
||||
) {
|
||||
const serverWorkflows = workflows.filter((workflow) => workflow.server_id === serverId);
|
||||
const byId = new Map(serverWorkflows.map((workflow) => [workflow.id, workflow]));
|
||||
const restoredServerWorkflows: WorkflowSummaryDto[] = [];
|
||||
|
||||
orderedIds.forEach((workflowId) => {
|
||||
const workflow = byId.get(workflowId);
|
||||
if (workflow) {
|
||||
restoredServerWorkflows.push(workflow);
|
||||
byId.delete(workflowId);
|
||||
}
|
||||
});
|
||||
|
||||
serverWorkflows.forEach((workflow) => {
|
||||
if (byId.has(workflow.id)) {
|
||||
restoredServerWorkflows.push(workflow);
|
||||
byId.delete(workflow.id);
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
...workflows.filter((workflow) => workflow.server_id !== serverId),
|
||||
...restoredServerWorkflows,
|
||||
];
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { requestJson } from "./http";
|
||||
|
||||
describe("requestJson", () => {
|
||||
it("parses validation errors into a single message", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||
detail: [
|
||||
{ msg: "Server name is required" },
|
||||
{ msg: "URL is required" },
|
||||
],
|
||||
}), {
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})));
|
||||
|
||||
await expect(requestJson("/api/test")).rejects.toEqual(expect.objectContaining({
|
||||
message: "Server name is required; URL is required",
|
||||
status: 422,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
detail?: unknown;
|
||||
code?: string;
|
||||
|
||||
constructor(message: string, options: { status?: number; detail?: unknown; code?: string } = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = options.status;
|
||||
this.detail = options.detail;
|
||||
this.code = options.code;
|
||||
}
|
||||
}
|
||||
|
||||
function parseValidationMessage(detail: unknown): string | null {
|
||||
if (!Array.isArray(detail)) {
|
||||
return null;
|
||||
}
|
||||
const message = detail
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>).msg || (item as Record<string, unknown>).message : null))
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return message || null;
|
||||
}
|
||||
|
||||
export async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payloadObject = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : null;
|
||||
const message =
|
||||
parseValidationMessage(payloadObject?.detail) ||
|
||||
(payloadObject?.detail as string | undefined) ||
|
||||
(payloadObject?.message as string | undefined) ||
|
||||
(typeof payload === "string" ? payload : undefined) ||
|
||||
response.statusText ||
|
||||
"Request failed";
|
||||
|
||||
throw new ApiError(message, {
|
||||
status: response.status,
|
||||
detail: payloadObject?.detail,
|
||||
code: payloadObject?.code as string | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (payload ?? {}) as T;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { SaveServerPayload, ServerDto, TogglePayload } from "../types/api";
|
||||
import { requestJson } from "./http";
|
||||
|
||||
export interface ServersResponse {
|
||||
servers: ServerDto[];
|
||||
default_server: string | null;
|
||||
}
|
||||
|
||||
export function listServers() {
|
||||
return requestJson<ServersResponse>("/api/servers");
|
||||
}
|
||||
|
||||
export function addServer(payload: SaveServerPayload) {
|
||||
return requestJson<{ status: string; server: ServerDto }>("/api/servers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateServer(serverId: string, payload: SaveServerPayload) {
|
||||
return requestJson<{ status: string; server: ServerDto }>(`/api/servers/${encodeURIComponent(serverId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleServer(serverId: string, payload: TogglePayload) {
|
||||
return requestJson<{ status: string; enabled: boolean }>(`/api/servers/${encodeURIComponent(serverId)}/toggle`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteServer(serverId: string, deleteData: boolean) {
|
||||
return requestJson<{ status: string }>(`/api/servers/${encodeURIComponent(serverId)}?delete_data=${deleteData ? "true" : "false"}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function getServerStatus(serverId: string) {
|
||||
return requestJson<{ server_id: string; status: "online" | "offline"; url: string }>(`/api/servers/${encodeURIComponent(serverId)}/status`);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type {
|
||||
TransferExportBuildResponseDto,
|
||||
TransferExportPreviewDto,
|
||||
TransferImportPreviewDto,
|
||||
TransferImportResponseDto,
|
||||
TransferSelectionPayload,
|
||||
} from "../types/api";
|
||||
import { requestJson } from "./http";
|
||||
|
||||
export function previewTransferExport() {
|
||||
return requestJson<TransferExportPreviewDto>("/api/transfer/export/preview");
|
||||
}
|
||||
|
||||
export function buildTransferExport(selection: TransferSelectionPayload) {
|
||||
return requestJson<TransferExportBuildResponseDto>("/api/transfer/export/build", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ selection }),
|
||||
});
|
||||
}
|
||||
|
||||
export function previewTransferImport(bundle: Record<string, unknown>, applyEnvironment = false, overwriteWorkflows = true) {
|
||||
return requestJson<TransferImportPreviewDto>("/api/transfer/import/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
bundle,
|
||||
apply_environment: applyEnvironment,
|
||||
overwrite_workflows: overwriteWorkflows,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function importTransferBundle(bundle: Record<string, unknown>, applyEnvironment = false, overwriteWorkflows = true) {
|
||||
return requestJson<TransferImportResponseDto>("/api/transfer/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
bundle,
|
||||
apply_environment: applyEnvironment,
|
||||
overwrite_workflows: overwriteWorkflows,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type {
|
||||
RunWorkflowResponseDto,
|
||||
SaveWorkflowPayload,
|
||||
TogglePayload,
|
||||
WorkflowDetailDto,
|
||||
WorkflowOrderPayload,
|
||||
WorkflowSummaryDto,
|
||||
} from "../types/api";
|
||||
import { requestJson } from "./http";
|
||||
|
||||
export function listWorkflows() {
|
||||
return requestJson<{ workflows: WorkflowSummaryDto[] }>("/api/workflows");
|
||||
}
|
||||
|
||||
export function getWorkflowDetail(serverId: string, workflowId: string) {
|
||||
return requestJson<WorkflowDetailDto>(`/api/servers/${encodeURIComponent(serverId)}/workflow/${encodeURIComponent(workflowId)}`);
|
||||
}
|
||||
|
||||
export function saveWorkflow(serverId: string, payload: SaveWorkflowPayload) {
|
||||
return requestJson<{ status: string; workflow_id: string }>(`/api/servers/${encodeURIComponent(serverId)}/workflow/save`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleWorkflow(serverId: string, workflowId: string, payload: TogglePayload) {
|
||||
return requestJson<{ status: string; enabled: boolean }>(`/api/servers/${encodeURIComponent(serverId)}/workflow/${encodeURIComponent(workflowId)}/toggle`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteWorkflow(serverId: string, workflowId: string) {
|
||||
return requestJson<{ status: string }>(`/api/servers/${encodeURIComponent(serverId)}/workflow/${encodeURIComponent(workflowId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderWorkflows(serverId: string, payload: WorkflowOrderPayload) {
|
||||
return requestJson<{ status: string; workflow_order: string[] }>(`/api/servers/${encodeURIComponent(serverId)}/workflows/reorder`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function runWorkflow(serverId: string, workflowId: string, args: Record<string, unknown>) {
|
||||
return requestJson<RunWorkflowResponseDto>(`/api/servers/${encodeURIComponent(serverId)}/workflow/${encodeURIComponent(workflowId)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/workflows.css";
|
||||
@import "./styles/editor.css";
|
||||
@import "./styles/transfer.css";
|
||||
@import "./styles/feedback.css";
|
||||
@@ -1,319 +0,0 @@
|
||||
:root {
|
||||
--bg: #090b0f;
|
||||
--surface: rgba(17, 19, 24, 0.78);
|
||||
--surface-strong: #111318;
|
||||
--surface-soft: #0d1016;
|
||||
--card-border: #1f2937;
|
||||
--card-border-strong: #374151;
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--primary: #e11d48;
|
||||
--primary-hover: #be123c;
|
||||
--primary-soft: rgba(225, 29, 72, 0.14);
|
||||
--danger: #f43f5e;
|
||||
--danger-hover: #e11d48;
|
||||
--success: #10b981;
|
||||
--warning: #fbbf24;
|
||||
--hover-color: rgba(255, 255, 255, 0.03);
|
||||
--shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.25);
|
||||
--radius-lg: 1rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-sm: 0.5rem;
|
||||
--focus-ring: 0 0 0 3px rgba(225, 29, 72, 0.35);
|
||||
--space-1: 8px;
|
||||
--space-2: 12px;
|
||||
--space-3: 16px;
|
||||
--space-4: 20px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
color: var(--text-main);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#pixel-blast-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
label[for],
|
||||
.upload-zone {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select,
|
||||
textarea,
|
||||
.input-field {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-main);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
.input-field:focus,
|
||||
button:focus-visible,
|
||||
.upload-zone:focus-visible,
|
||||
.toggle-switch input:focus-visible + .slider,
|
||||
.custom-select-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 10px 38px 10px 14px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.custom-select-value {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-select-chevron {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.custom-select-menu {
|
||||
z-index: 1001;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 11, 22, 0.96);
|
||||
box-shadow: 0 22px 48px rgba(2, 6, 23, 0.36);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.custom-select-option {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.custom-select-option:hover,
|
||||
.custom-select-option:focus-visible,
|
||||
.custom-select-option.is-selected {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.custom-select.is-disabled .custom-select-trigger {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.label-with-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.label-with-help label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-tooltip-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.info-tooltip-trigger:hover {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.info-tooltip-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 1200;
|
||||
width: min(320px, calc(100vw - 64px));
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 12px;
|
||||
background: rgba(7, 11, 22, 0.96);
|
||||
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.34);
|
||||
color: var(--text-main);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.info-tooltip-popup::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.info-tooltip-popup.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.input-action-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-field-with-action {
|
||||
padding-right: 56px;
|
||||
}
|
||||
|
||||
.input-action-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-action-btn:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin-top: 6px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.section-meta,
|
||||
.upload-subtitle,
|
||||
.param-meta,
|
||||
.workflow-desc,
|
||||
.workflow-status,
|
||||
.template-item-meta,
|
||||
.template-item-desc,
|
||||
.form-help,
|
||||
.mapping-shortcut-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(100%, 1080px);
|
||||
margin: 0 auto;
|
||||
padding: calc(var(--space-6) + var(--space-5)) var(--space-4) 56px;
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: calc(var(--space-6) + var(--space-1));
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
.custom-select-option {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-select-option:hover,
|
||||
.custom-select-option:focus-visible {
|
||||
outline: none;
|
||||
background: rgba(225, 29, 72, 0.12);
|
||||
color: #fff1f2;
|
||||
}
|
||||
|
||||
.custom-select-option.is-selected {
|
||||
background: linear-gradient(180deg, rgba(225, 29, 72, 0.18) 0%, rgba(190, 24, 93, 0.14) 100%);
|
||||
color: #ffe4e6;
|
||||
box-shadow: inset 0 0 0 1px rgba(251, 113, 133, 0.2);
|
||||
}
|
||||
|
||||
.custom-select.is-disabled .custom-select-trigger,
|
||||
.custom-select-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
@keyframes customSelectFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.server-selector-wrapper {
|
||||
width: clamp(180px, 24vw, 260px);
|
||||
min-width: 180px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.server-selector-static {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(180deg, rgba(20, 24, 32, 0.94) 0%, rgba(11, 15, 21, 0.98) 100%);
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-config-container {
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.server-main-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.server-main-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-main-left .section-meta {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.server-empty-state {
|
||||
margin-top: var(--space-4);
|
||||
padding: 22px;
|
||||
border: 1px dashed rgba(156, 163, 175, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.server-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.server-status-toggle,
|
||||
.workflow-status-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
|
||||
font-size: 14px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.workflow-status-toggle {
|
||||
border-radius: 6px;
|
||||
border-color: rgba(148, 163, 184, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.workflow-status-toggle:hover,
|
||||
.workflow-status-toggle:focus-within {
|
||||
border-color: rgba(203, 213, 225, 0.58);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.server-status-toggle .toggle-inline,
|
||||
.workflow-status-toggle .toggle-inline {
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.server-status-toggle .status-on,
|
||||
.server-status-toggle .status-off,
|
||||
.workflow-enabled-label {
|
||||
min-width: 78px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.status-on,
|
||||
.workflow-enabled-label.status-on {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
border-color: rgba(16, 185, 129, 0.32);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.status-off,
|
||||
.workflow-enabled-label.status-off {
|
||||
background: rgba(100, 116, 139, 0.18);
|
||||
border-color: rgba(100, 116, 139, 0.34);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.server-action-btn,
|
||||
.workflow-action-btn {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.44);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #e5e7eb;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.server-action-btn:hover,
|
||||
.server-action-btn:focus-visible,
|
||||
.workflow-action-btn:hover,
|
||||
.workflow-action-btn:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(203, 213, 225, 0.58);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.server-action-btn svg,
|
||||
.workflow-action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.workflow-action-btn {
|
||||
border-color: rgba(148, 163, 184, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.server-delete-btn {
|
||||
border-color: rgba(239, 68, 68, 0.42);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.server-delete-btn:hover,
|
||||
.server-delete-btn:focus-visible {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(251, 113, 133, 0.6);
|
||||
box-shadow: 0 0 0 1px rgba(251, 113, 133, 0.14);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 6, 23, 0.62);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
z-index: 2400;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(100%, 560px);
|
||||
max-height: calc(100vh - 40px);
|
||||
margin: auto 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(225, 29, 72, 0.35);
|
||||
background: linear-gradient(180deg, rgba(17, 19, 24, 0.98) 0%, rgba(12, 15, 22, 0.98) 100%);
|
||||
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-card-wide {
|
||||
width: min(100%, 760px);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: var(--space-3) var(--space-4) var(--space-1);
|
||||
}
|
||||
|
||||
.modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.form-group-half {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.form-group-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 14px var(--space-4) 18px;
|
||||
border-top: 1px solid var(--card-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.confirm-modal-message {
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.confirm-modal-checkbox {
|
||||
margin-top: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn,
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.12s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
.workflow-more-item svg,
|
||||
.workflow-action-edit svg,
|
||||
.workflow-more-trigger svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-more-item:hover,
|
||||
.workflow-more-item:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.workflow-more-item-danger {
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.workflow-more-item-danger:hover,
|
||||
.workflow-more-item-danger:focus-visible {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.workflow-drag-handle {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
color: rgba(226, 232, 240, 0.78);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.workflow-drag-handle.is-disabled {
|
||||
opacity: 0.42;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.upgrade-summary-banner {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(251, 113, 133, 0.24);
|
||||
border-radius: 14px;
|
||||
background: rgba(225, 29, 72, 0.08);
|
||||
}
|
||||
|
||||
.upgrade-summary-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.upgrade-summary-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.param-status-badge.is-retained {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
border-color: rgba(16, 185, 129, 0.32);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.param-status-badge.is-review {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
border-color: rgba(245, 158, 11, 0.32);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.param-status-badge.is-new {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
border-color: rgba(96, 165, 250, 0.32);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.param-meta-emphasis {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-style: dashed;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
color: var(--success);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status-dot.is-disabled {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 180px;
|
||||
padding: 28px 20px;
|
||||
border: 2px dashed var(--card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-zone:hover,
|
||||
.upload-zone.is-dragging {
|
||||
border-color: var(--primary);
|
||||
background: rgba(225, 29, 72, 0.08);
|
||||
}
|
||||
|
||||
.upload-zone input[type="file"] {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-reminder {
|
||||
margin-top: 16px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.upload-reminder-title {
|
||||
margin-bottom: 10px;
|
||||
color: #fde68a;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upload-reminder-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.upload-reminder-list li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.node-header-right,
|
||||
.node-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.node-card.is-collapsed .node-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mapping-savebar {
|
||||
position: sticky;
|
||||
bottom: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(13, 16, 22, 0.94);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.param-row + .param-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.param-row.active {
|
||||
border-color: rgba(225, 29, 72, 0.55);
|
||||
background: rgba(225, 29, 72, 0.09);
|
||||
}
|
||||
|
||||
.param-main {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.param-main-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.param-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.param-title.active {
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.param-config {
|
||||
display: none;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.param-config.is-expanded {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.param-config-toggle {
|
||||
flex-shrink: 0;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
margin-left: auto;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.checkbox-inline input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: #374151;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
|
||||
.toggle-switch input:checked + .slider {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 2800;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 13px 14px 15px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.12), transparent 48%), rgba(10, 13, 20, 0.88);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 20px 34px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
animation: slideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-color: rgba(16, 185, 129, 0.36);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: rgba(244, 63, 94, 0.36);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-color: rgba(59, 130, 246, 0.36);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-top: 1px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.06));
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.toast.success .toast-icon {
|
||||
background: linear-gradient(135deg, #10b981, #047857);
|
||||
}
|
||||
|
||||
.toast.error .toast-icon {
|
||||
background: linear-gradient(135deg, #fb7185, #be123c);
|
||||
}
|
||||
|
||||
.toast.info .toast-icon {
|
||||
background: linear-gradient(135deg, #60a5fa, #2563eb);
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 0.79rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-main);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
margin-top: -2px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-close:hover,
|
||||
.toast-close:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.template-source-btn.is-active {
|
||||
border-color: rgba(251, 113, 133, 0.56);
|
||||
background: rgba(225, 29, 72, 0.16);
|
||||
color: #ffe4e6;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.template-item-actions {
|
||||
margin-top: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.template-item-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.template-item-desc {
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(120%) scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 820px) {
|
||||
.param-row {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.param-main {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.param-config {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr) minmax(180px, 0.9fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shell {
|
||||
padding: 52px 16px 40px;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.section-header,
|
||||
.workflow-item,
|
||||
.node-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.grid-2,
|
||||
.editor-info-grid,
|
||||
.workflow-list,
|
||||
.editor-stepper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workflow-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.server-main-row,
|
||||
.server-main-left,
|
||||
.server-empty-state,
|
||||
.mapping-toolbar-top,
|
||||
.mapping-toolbar-bottom {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel-actions,
|
||||
.server-header-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.server-status-toggle {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.mapping-toolbar-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-toolbar-top > #mapping-search,
|
||||
.workflow-toolbar > #workflow-search,
|
||||
.workflow-toolbar .custom-select,
|
||||
.mapping-toolbar-top .custom-select.is-mapping-sort-select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.form-group-half,
|
||||
.form-group-full {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
|
||||
.page-header.custom-select-host-open,
|
||||
.card.custom-select-host-open,
|
||||
.server-config-container.custom-select-host-open {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.editor-nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.editor-nav-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-back-btn {
|
||||
font-size: 0.95rem;
|
||||
padding: 8px 16px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.editor-progress-hint {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.editor-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.editor-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.editor-step.is-active {
|
||||
border-color: var(--primary);
|
||||
color: #ffe4e6;
|
||||
background: rgba(225, 29, 72, 0.12);
|
||||
}
|
||||
|
||||
.editor-step.is-done {
|
||||
border-color: rgba(16, 185, 129, 0.45);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.editor-step-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-step-label {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr);
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.editor-info-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-info-field label {
|
||||
margin-bottom: 0;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.editor-info-input {
|
||||
min-height: 50px;
|
||||
padding: 12px 14px;
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: linear-gradient(180deg, rgba(20, 24, 32, 0.94) 0%, rgba(11, 15, 21, 0.98) 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.editor-info-input:hover {
|
||||
border-color: rgba(225, 29, 72, 0.4);
|
||||
background: linear-gradient(180deg, rgba(29, 34, 45, 0.96) 0%, rgba(15, 19, 27, 0.98) 100%);
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo-frame {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 3px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(104, 18, 35, 0.78), rgba(157, 57, 77, 0.46) 45%, rgba(255, 255, 255, 0.14) 100%);
|
||||
overflow: hidden;
|
||||
clip-path: path("M 17 0 C 8 0 0 8 0 17 L 0 45 C 0 54 8 62 17 62 L 45 62 C 54 62 62 54 62 45 L 62 17 C 62 8 54 0 45 0 Z");
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: block;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
clip-path: path("M 15 0 C 7 0 0 7 0 15 L 0 41 C 0 49 7 56 15 56 L 41 56 C 49 56 56 49 56 41 L 56 15 C 56 7 49 0 41 0 Z");
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.8rem, 3vw, 2.1rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(148, 163, 184, 0.32);
|
||||
}
|
||||
|
||||
.card-nested {
|
||||
background: var(--surface-soft);
|
||||
border-color: var(--card-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card-title,
|
||||
.section-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
min-height: 48px;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.panel-title-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panel-action-btn {
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-select.is-lang-select {
|
||||
width: 136px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.custom-select.is-lang-select .custom-select-trigger {
|
||||
min-height: 42px;
|
||||
padding: 8px 36px 8px 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.custom-select.is-server-select .custom-select-trigger {
|
||||
min-height: 40px;
|
||||
padding: 8px 36px 8px 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.custom-select.is-server-select .custom-select-chevron {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.custom-select.is-mapping-sort-select .custom-select-trigger {
|
||||
min-height: 42px;
|
||||
padding: 8px 34px 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.custom-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 10px 42px 10px 14px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(180deg, rgba(20, 24, 32, 0.94) 0%, rgba(11, 15, 21, 0.98) 100%);
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-select-trigger:hover,
|
||||
.custom-select.is-open .custom-select-trigger {
|
||||
border-color: rgba(225, 29, 72, 0.55);
|
||||
background: linear-gradient(180deg, rgba(29, 34, 45, 0.96) 0%, rgba(15, 19, 27, 0.98) 100%);
|
||||
}
|
||||
|
||||
.custom-select-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-select-chevron {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-right: 2px solid rgba(226, 232, 240, 0.72);
|
||||
border-bottom: 2px solid rgba(226, 232, 240, 0.72);
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
transition: transform 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.custom-select.is-open .custom-select-chevron {
|
||||
transform: translateY(-30%) rotate(-135deg);
|
||||
border-color: #fff1f2;
|
||||
}
|
||||
|
||||
.custom-select-menu {
|
||||
z-index: 2600;
|
||||
display: none;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(225, 29, 72, 0.32);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(16, 20, 27, 0.98) 0%, rgba(10, 14, 20, 0.99) 100%);
|
||||
box-shadow: 0 24px 52px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(225, 29, 72, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
max-height: min(320px, 48vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.custom-select-menu.is-open {
|
||||
display: block;
|
||||
animation: customSelectFadeIn 0.16s ease-out;
|
||||
}
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
.page-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.transfer-modal-body,
|
||||
.transfer-panel,
|
||||
.transfer-sections,
|
||||
.transfer-warning-list,
|
||||
.transfer-export-tree {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.transfer-modal-body {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.transfer-export-server {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
|
||||
padding: 12px;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.transfer-export-server:hover {
|
||||
border-color: rgba(225, 29, 72, 0.34);
|
||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.transfer-export-server.is-open {
|
||||
border-color: rgba(225, 29, 72, 0.46);
|
||||
}
|
||||
|
||||
.transfer-export-server-head {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.transfer-export-item-summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.transfer-export-toggle {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
padding: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.transfer-export-chevron {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.transfer-export-server.is-open .transfer-export-chevron {
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
.transfer-hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.transfer-export-workflows-wrap {
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transition: max-height 0.22s ease, opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.transfer-export-server.is-open .transfer-export-workflows-wrap {
|
||||
opacity: 1;
|
||||
max-height: 1000px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.transfer-export-workflows {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.transfer-export-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transfer-export-item-summary .transfer-export-item-copy {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.transfer-export-item:hover {
|
||||
border-color: rgba(225, 29, 72, 0.24);
|
||||
background: rgba(225, 29, 72, 0.08);
|
||||
}
|
||||
|
||||
.transfer-export-item input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
margin: 2px 0 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.52);
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.transfer-export-item input[type="checkbox"]::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
transform: scale(0);
|
||||
transition: transform 0.12s ease;
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
.transfer-export-item input[type="checkbox"]:checked,
|
||||
.transfer-export-item input[type="checkbox"]:indeterminate {
|
||||
border-color: rgba(251, 113, 133, 0.74);
|
||||
background: linear-gradient(180deg, rgba(225, 29, 72, 0.96), rgba(190, 24, 93, 0.9));
|
||||
}
|
||||
|
||||
.transfer-export-item input[type="checkbox"]:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.transfer-export-item input[type="checkbox"]:indeterminate::before {
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.transfer-export-item-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.transfer-export-server-title-row,
|
||||
.transfer-export-workflow-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.transfer-export-item-meta,
|
||||
.transfer-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.transfer-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.transfer-chip-muted {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.transfer-section {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.transfer-section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.transfer-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.transfer-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.transfer-warning-list {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.32);
|
||||
border-radius: 14px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.transfer-warning-title {
|
||||
margin: 0;
|
||||
color: #fde68a;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.transfer-warning-item {
|
||||
margin: 0;
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header-actions > .btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.transfer-export-server-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.transfer-export-toggle {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
.btn-icon.small,
|
||||
.small {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn:hover,
|
||||
button:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn:active,
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
button:disabled,
|
||||
[data-loading="true"] {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.btn-wide {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
margin-top: 28px;
|
||||
border: 1px solid rgba(157, 57, 77, 0.42);
|
||||
background: linear-gradient(135deg, rgba(10, 10, 12, 0.96) 0%, rgba(43, 10, 18, 0.98) 38%, rgba(90, 21, 39, 0.98) 72%, rgba(126, 32, 53, 0.98) 100%);
|
||||
color: #fdf2f4;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 10px 24px rgba(76, 14, 29, 0.28);
|
||||
}
|
||||
|
||||
.btn-accent:hover,
|
||||
.btn-accent:focus-visible {
|
||||
background: linear-gradient(135deg, rgba(8, 8, 10, 0.98) 0%, rgba(54, 12, 22, 0.99) 36%, rgba(112, 26, 47, 0.99) 74%, rgba(146, 36, 60, 0.99) 100%);
|
||||
border-color: rgba(190, 74, 99, 0.58);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 0 1px rgba(157, 57, 77, 0.18), 0 14px 30px rgba(76, 14, 29, 0.34);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--card-border);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.btn-secondary:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: rgba(244, 63, 94, 0.6);
|
||||
background: linear-gradient(180deg, rgba(225, 29, 72, 0.96) 0%, rgba(190, 24, 93, 0.96) 100%);
|
||||
color: #fff1f2;
|
||||
box-shadow: 0 16px 28px rgba(136, 19, 55, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover,
|
||||
.btn-danger:focus-visible {
|
||||
border-color: rgba(251, 113, 133, 0.78);
|
||||
box-shadow: 0 18px 34px rgba(136, 19, 55, 0.36);
|
||||
}
|
||||
|
||||
.btn-danger-soft {
|
||||
border: 1px solid rgba(244, 63, 94, 0.28);
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.btn-danger-soft:hover,
|
||||
.btn-danger-soft:focus-visible {
|
||||
background: rgba(244, 63, 94, 0.14);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.editor-mode-badge,
|
||||
.status-badge,
|
||||
.template-badge,
|
||||
.param-status-badge,
|
||||
.workflow-meta-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-mode-badge {
|
||||
background: rgba(225, 29, 72, 0.15);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
background: #1f2937;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.workflow-meta-tag,
|
||||
.template-badge {
|
||||
background: rgba(225, 29, 72, 0.12);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.workflow-list,
|
||||
.nodes-container,
|
||||
.template-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.workflow-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workflow-toolbar > #workflow-search {
|
||||
flex: 1 1 360px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.workflow-toolbar .custom-select {
|
||||
width: 192px;
|
||||
flex: 0 0 192px;
|
||||
}
|
||||
|
||||
.mapping-toolbar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mapping-toolbar-top,
|
||||
.mapping-toolbar-bottom,
|
||||
.mapping-toolbar-actions,
|
||||
.template-source-toggle,
|
||||
.template-item-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mapping-toolbar-top > #mapping-search {
|
||||
flex: 1 1 360px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.mapping-toolbar-top .custom-select.is-mapping-sort-select {
|
||||
width: 152px;
|
||||
flex: 0 0 152px;
|
||||
}
|
||||
|
||||
.mapping-toolbar-actions {
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.mapping-exposed-only-label {
|
||||
margin: 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.workflow-item,
|
||||
.node-card,
|
||||
.empty-state,
|
||||
.template-item {
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.workflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.workflow-item.is-reorderable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.workflow-item.is-reorderable:hover {
|
||||
border-color: rgba(225, 29, 72, 0.26);
|
||||
box-shadow: 0 16px 36px rgba(2, 6, 23, 0.24);
|
||||
}
|
||||
|
||||
.workflow-item.is-dragging {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.workflow-item.is-drop-target {
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.22), 0 18px 40px rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.workflow-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.workflow-name-row,
|
||||
.template-tag-row,
|
||||
.template-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workflow-name,
|
||||
.template-item-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.workflow-server-tag {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-desc {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workflow-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-action-edit,
|
||||
.workflow-more-trigger {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
border-color: rgba(148, 163, 184, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.workflow-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workflow-more.is-open {
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.workflow-more-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 11, 22, 0.96);
|
||||
box-shadow: 0 22px 48px rgba(2, 6, 23, 0.36);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.workflow-more-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
padding: 0 12px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -1,161 +0,0 @@
|
||||
export interface ServerDto {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
output_dir: string;
|
||||
server_type?: string;
|
||||
unsupported?: boolean;
|
||||
unsupported_reason?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowSummaryDto {
|
||||
id: string;
|
||||
server_id: string;
|
||||
server_name: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
updated_at: number;
|
||||
origin?: string;
|
||||
source_label?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowDetailDto {
|
||||
workflow_id: string;
|
||||
server_id: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
workflow_data: Record<string, unknown>;
|
||||
schema_params: Record<string, unknown>;
|
||||
origin?: string;
|
||||
source_label?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface RunWorkflowResponseDto {
|
||||
status: string;
|
||||
result: {
|
||||
status?: string;
|
||||
server?: string;
|
||||
prompt_id?: string;
|
||||
images?: string[];
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TogglePayload {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SaveWorkflowPayload {
|
||||
workflow_id: string;
|
||||
server_id: string;
|
||||
original_workflow_id: string | null;
|
||||
description: string;
|
||||
workflow_data: Record<string, unknown> | null;
|
||||
schema_params: Record<string, unknown>;
|
||||
ui_schema_params: Record<string, unknown>;
|
||||
overwrite_existing: boolean;
|
||||
}
|
||||
|
||||
export interface SaveServerPayload {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
output_dir: string;
|
||||
}
|
||||
|
||||
export interface WorkflowOrderPayload {
|
||||
workflow_ids: string[];
|
||||
}
|
||||
|
||||
export interface ValidationIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TransferSelectionServerPayload {
|
||||
server_id: string;
|
||||
workflow_ids: string[];
|
||||
}
|
||||
|
||||
export interface TransferSelectionPayload {
|
||||
servers: TransferSelectionServerPayload[];
|
||||
}
|
||||
|
||||
export interface TransferExportPreviewWorkflowDto {
|
||||
workflow_id: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface TransferExportPreviewServerDto {
|
||||
server_id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
selected: boolean;
|
||||
workflow_count: number;
|
||||
workflows: TransferExportPreviewWorkflowDto[];
|
||||
}
|
||||
|
||||
export interface TransferExportPreviewDto {
|
||||
portable_only: boolean;
|
||||
summary: {
|
||||
servers: number;
|
||||
workflows: number;
|
||||
warnings: number;
|
||||
};
|
||||
servers: TransferExportPreviewServerDto[];
|
||||
warnings: ValidationIssueDto[];
|
||||
}
|
||||
|
||||
export interface TransferExportBuildResponseDto {
|
||||
bundle: Record<string, unknown>;
|
||||
preview: TransferExportPreviewDto;
|
||||
}
|
||||
|
||||
export interface TransferPlanItemDto {
|
||||
server_id: string;
|
||||
workflow_id?: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface TransferPlanDto {
|
||||
created_servers: TransferPlanItemDto[];
|
||||
updated_servers: TransferPlanItemDto[];
|
||||
created_workflows: TransferPlanItemDto[];
|
||||
overwritten_workflows: TransferPlanItemDto[];
|
||||
skipped_items: TransferPlanItemDto[];
|
||||
warnings: ValidationIssueDto[];
|
||||
apply_environment: boolean;
|
||||
overwrite_workflows: boolean;
|
||||
summary: {
|
||||
created_servers: number;
|
||||
updated_servers: number;
|
||||
created_workflows: number;
|
||||
overwritten_workflows: number;
|
||||
skipped_items: number;
|
||||
warnings: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransferValidationDto {
|
||||
valid: boolean;
|
||||
errors: ValidationIssueDto[];
|
||||
warnings: ValidationIssueDto[];
|
||||
}
|
||||
|
||||
export interface TransferImportPreviewDto {
|
||||
validation: TransferValidationDto;
|
||||
plan: TransferPlanDto | null;
|
||||
}
|
||||
|
||||
export interface TransferImportResponseDto {
|
||||
status: string;
|
||||
validation: TransferValidationDto;
|
||||
plan: TransferPlanDto;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface SchemaParam {
|
||||
exposed: boolean;
|
||||
node_id: number;
|
||||
field: string;
|
||||
name: string;
|
||||
type: "string" | "int" | "float" | "boolean";
|
||||
required: boolean;
|
||||
description: string;
|
||||
default?: unknown;
|
||||
example?: unknown;
|
||||
choices: unknown[];
|
||||
currentVal: unknown;
|
||||
nodeClass: string;
|
||||
migrationStatus?: string;
|
||||
migrationReason?: string;
|
||||
}
|
||||
|
||||
export type SchemaParamMap = Record<string, SchemaParam>;
|
||||
|
||||
export interface UpgradeSummary {
|
||||
retained: number;
|
||||
review: number;
|
||||
added: number;
|
||||
removed: number;
|
||||
matched?: Array<{ previousKey: string; nextKey: string; status: string }>;
|
||||
addedKeys?: string[];
|
||||
removedKeys?: string[];
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
workflowData: Record<string, unknown> | null;
|
||||
schemaParams: SchemaParamMap;
|
||||
workflowId: string;
|
||||
description: string;
|
||||
editingWorkflowId: string | null;
|
||||
hasUnsavedChanges: boolean;
|
||||
upgradeSummary: UpgradeSummary | null;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowJs": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"baseUrl": "./src",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/static/",
|
||||
publicDir: resolve(__dirname, "public"),
|
||||
build: {
|
||||
outDir: resolve(__dirname, "../ui/static"),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: resolve(__dirname, "src/test/setup.ts"),
|
||||
css: true,
|
||||
testTimeout: 20000,
|
||||
},
|
||||
});
|
||||
@@ -7,13 +7,13 @@ if [[ -x /opt/homebrew/opt/ruby/bin/ruby ]]; then
|
||||
export PATH="/opt/homebrew/opt/ruby/bin:$PATH"
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
cd "$ROOT_DIR/docs"
|
||||
|
||||
bundle config set --local path "vendor/bundle"
|
||||
bundle config set --local path "../vendor/bundle"
|
||||
bundle install
|
||||
bundle exec jekyll serve \
|
||||
--source docs \
|
||||
--destination docs/_site \
|
||||
--source . \
|
||||
--destination _site \
|
||||
--host 127.0.0.1 \
|
||||
--port 4000 \
|
||||
--livereload
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO="HuangYuChuh/ComfyUI_Skills_OpenClaw-frontend"
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
STATIC_DIR="$ROOT_DIR/ui/static"
|
||||
|
||||
echo "Fetching latest release from $REPO ..."
|
||||
|
||||
DOWNLOAD_URL=$(gh release view --repo "$REPO" --json assets \
|
||||
--jq '.assets[] | select(.name == "frontend-dist.tar.gz") | .url')
|
||||
|
||||
if [[ -z "$DOWNLOAD_URL" ]]; then
|
||||
echo "Error: frontend-dist.tar.gz not found in the latest release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
echo "Downloading frontend-dist.tar.gz ..."
|
||||
gh release download --repo "$REPO" --pattern "frontend-dist.tar.gz" --dir "$TMP_DIR"
|
||||
|
||||
echo "Extracting to $STATIC_DIR ..."
|
||||
rm -rf "$STATIC_DIR"
|
||||
mkdir -p "$STATIC_DIR"
|
||||
tar -xzf "$TMP_DIR/frontend-dist.tar.gz" -C "$STATIC_DIR"
|
||||
|
||||
echo "Done. Frontend assets updated in ui/static/"
|
||||
Reference in New Issue
Block a user