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:
Kelin
2026-03-16 21:29:34 +08:00
co-authored by Claude Opus 4.6
parent a2c6dce23d
commit 471d6dd35d
80 changed files with 33 additions and 12390 deletions
-27
View File
@@ -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
-8
View File
@@ -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
View File
View File
-14
View File
@@ -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>
-3171
View File
File diff suppressed because it is too large Load Diff
-29
View File
@@ -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

-599
View File
@@ -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();
});
});
});
-161
View File
@@ -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 = "";
}}
/>
</>
);
}
-109
View File
@@ -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>
);
}
-261
View File
@@ -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,
};
}
-86
View File
@@ -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 };
}
-31
View File
@@ -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 "";
}
-79
View File
@@ -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,
};
}
-498
View File
@@ -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,
};
}
-158
View File
@@ -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",
};
}
-123
View File
@@ -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,
]);
}
-35
View File
@@ -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,
};
}
-152
View File
@@ -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,
};
}
-21
View File
@@ -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,
};
}
-23
View File
@@ -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,
};
}
-109
View File
@@ -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,
};
}
-66
View File
@@ -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>
);
}
-242
View File
@@ -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>
);
}
-79
View File
@@ -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">&larr;</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>
);
}
-54
View File
@@ -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">&#x25CF;</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">&#x2261;</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>
);
}
-25
View File
@@ -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;
}
-11
View File
@@ -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);
});
});
-9
View File
@@ -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,
};
-221
View File
@@ -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",
};
-221
View File
@@ -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: "工作流 IDA-Z",
workflow_sort_name_desc: "工作流 IDZ-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 格式的 JSONworkflow_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: "离线",
};
-182
View File
@@ -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: "工作流 IDA-Z",
workflow_sort_name_desc: "工作流 IDZ-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 格式的 JSONworkflow_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: "離線",
};
-46
View File
@@ -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);
});
});
-61
View File
@@ -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,
};
}
-227
View File
@@ -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();
};
}
-23
View File
@@ -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.
}
}
-121
View File
@@ -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");
});
});
-5
View File
@@ -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";
}
-38
View File
@@ -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",
]);
});
});
-62
View File
@@ -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,
];
}
-10
View File
@@ -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>,
);
-21
View File
@@ -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,
}));
});
});
-63
View File
@@ -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;
}
-43
View File
@@ -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`);
}
-41
View File
@@ -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,
}),
});
}
-51
View File
@@ -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 }),
});
}
-7
View File
@@ -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";
-319
View File
@@ -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));
}
-330
View File
@@ -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;
}
-322
View File
@@ -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;
}
-290
View File
@@ -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;
}
}
-316
View File
@@ -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;
}
-250
View File
@@ -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;
}
}
-325
View File
@@ -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
View File
@@ -1 +0,0 @@
import "@testing-library/jest-dom";
-161
View File
@@ -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;
}
-38
View File
@@ -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;
}
-20
View File
@@ -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"]
}
-20
View File
@@ -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,
},
});
+4 -4
View File
@@ -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
+29
View File
@@ -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/"