ralphflow 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/{chunk-TCCMQDVT.js → chunk-DOC64TD6.js} +32 -2
- package/dist/ralphflow.js +584 -24
- package/dist/{server-DOSLU36L.js → server-EX5MWYW4.js} +210 -10
- package/package.json +6 -2
- package/src/dashboard/ui/app.js +203 -0
- package/src/dashboard/ui/archives.js +167 -0
- package/src/dashboard/ui/index.html +2 -3210
- package/src/dashboard/ui/loop-detail.js +880 -0
- package/src/dashboard/ui/notifications.js +151 -0
- package/src/dashboard/ui/prompt-builder.js +362 -0
- package/src/dashboard/ui/sidebar.js +97 -0
- package/src/dashboard/ui/state.js +54 -0
- package/src/dashboard/ui/styles.css +2140 -0
- package/src/dashboard/ui/templates.js +1858 -0
- package/src/dashboard/ui/utils.js +115 -0
- package/src/templates/code-implementation/loops/00-story-loop/prompt.md +73 -11
- package/src/templates/code-implementation/loops/01-tasks-loop/prompt.md +51 -2
- package/src/templates/code-implementation/loops/02-delivery-loop/prompt.md +48 -4
- package/src/templates/research/loops/00-discovery-loop/prompt.md +58 -5
- package/src/templates/research/loops/01-research-loop/prompt.md +44 -2
- package/src/templates/research/loops/02-story-loop/prompt.md +42 -1
- package/src/templates/research/loops/03-document-loop/prompt.md +42 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BUILT_IN_TEMPLATES,
|
|
3
|
+
cloneBuiltInTemplate,
|
|
3
4
|
copyTemplate,
|
|
4
5
|
createCustomTemplate,
|
|
5
6
|
deleteCustomTemplate,
|
|
@@ -14,14 +15,14 @@ import {
|
|
|
14
15
|
resolveFlowDir,
|
|
15
16
|
resolveTemplatePathWithCustom,
|
|
16
17
|
validateTemplateName
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-DOC64TD6.js";
|
|
18
19
|
|
|
19
20
|
// src/dashboard/server.ts
|
|
20
21
|
import { Hono as Hono2 } from "hono";
|
|
21
22
|
import { cors } from "hono/cors";
|
|
22
23
|
import { serve } from "@hono/node-server";
|
|
23
24
|
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
|
|
24
|
-
import { join as join4, dirname } from "path";
|
|
25
|
+
import { join as join4, dirname, extname } from "path";
|
|
25
26
|
import { fileURLToPath } from "url";
|
|
26
27
|
import { WebSocketServer as WebSocketServer3 } from "ws";
|
|
27
28
|
import chalk from "chalk";
|
|
@@ -34,6 +35,7 @@ import { randomUUID } from "crypto";
|
|
|
34
35
|
import { WebSocket } from "ws";
|
|
35
36
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
36
37
|
var notifications = [];
|
|
38
|
+
var decisions = [];
|
|
37
39
|
function broadcastWs(wss, event) {
|
|
38
40
|
if (!wss) return;
|
|
39
41
|
const data = JSON.stringify(event);
|
|
@@ -68,7 +70,9 @@ function createApiRoutes(cwd, port = 4242, wss) {
|
|
|
68
70
|
order: loop.order,
|
|
69
71
|
stages: loop.stages,
|
|
70
72
|
multiAgent: !!(loop.multi_agent && typeof loop.multi_agent === "object" && loop.multi_agent.enabled),
|
|
71
|
-
model: loop.model || null
|
|
73
|
+
model: loop.model || null,
|
|
74
|
+
feeds: loop.feeds || [],
|
|
75
|
+
fed_by: loop.fed_by || []
|
|
72
76
|
}))
|
|
73
77
|
};
|
|
74
78
|
});
|
|
@@ -133,6 +137,11 @@ function createApiRoutes(cwd, port = 4242, wss) {
|
|
|
133
137
|
notifications.splice(i, 1);
|
|
134
138
|
}
|
|
135
139
|
}
|
|
140
|
+
for (let i = decisions.length - 1; i >= 0; i--) {
|
|
141
|
+
if (decisions[i].app === appName) {
|
|
142
|
+
decisions.splice(i, 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
136
145
|
return c.json({ ok: true, appName });
|
|
137
146
|
});
|
|
138
147
|
api.post("/api/apps/:app/archive", (c) => {
|
|
@@ -209,6 +218,11 @@ function createApiRoutes(cwd, port = 4242, wss) {
|
|
|
209
218
|
notifications.splice(i, 1);
|
|
210
219
|
}
|
|
211
220
|
}
|
|
221
|
+
for (let i = decisions.length - 1; i >= 0; i--) {
|
|
222
|
+
if (decisions[i].app === appName) {
|
|
223
|
+
decisions.splice(i, 1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
212
226
|
const archivePath = `.ralph-flow/.archives/${appName}/${archiveTimestamp}`;
|
|
213
227
|
return c.json({ ok: true, archivePath, timestamp: archiveTimestamp });
|
|
214
228
|
} catch (err) {
|
|
@@ -461,6 +475,138 @@ function createApiRoutes(cwd, port = 4242, wss) {
|
|
|
461
475
|
}
|
|
462
476
|
return c.json({ ok: true, templateName: name });
|
|
463
477
|
});
|
|
478
|
+
api.post("/api/templates/:name/clone", async (c) => {
|
|
479
|
+
const sourceName = c.req.param("name");
|
|
480
|
+
if (sourceName.includes("..") || sourceName.includes("/") || sourceName.includes("\\")) {
|
|
481
|
+
return c.json({ error: 'Invalid name: must not contain "..", "/", or "\\"' }, 400);
|
|
482
|
+
}
|
|
483
|
+
if (!BUILT_IN_TEMPLATES.includes(sourceName)) {
|
|
484
|
+
return c.json({ error: `"${sourceName}" is not a built-in template. Only built-in templates can be cloned.` }, 400);
|
|
485
|
+
}
|
|
486
|
+
let body;
|
|
487
|
+
try {
|
|
488
|
+
body = await c.req.json();
|
|
489
|
+
} catch {
|
|
490
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
491
|
+
}
|
|
492
|
+
const { newName } = body;
|
|
493
|
+
if (!newName || newName.trim().length === 0) {
|
|
494
|
+
return c.json({ error: "newName is required" }, 400);
|
|
495
|
+
}
|
|
496
|
+
const validation = validateTemplateName(newName.trim());
|
|
497
|
+
if (!validation.valid) {
|
|
498
|
+
return c.json({ error: validation.error }, 400);
|
|
499
|
+
}
|
|
500
|
+
const customDir = join(cwd, ".ralph-flow", ".templates", newName.trim());
|
|
501
|
+
if (existsSync(customDir)) {
|
|
502
|
+
return c.json({ error: `Template "${newName.trim()}" already exists` }, 409);
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
cloneBuiltInTemplate(cwd, sourceName, newName.trim());
|
|
506
|
+
} catch (err) {
|
|
507
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
508
|
+
if (msg.includes("already exists")) {
|
|
509
|
+
return c.json({ error: msg }, 409);
|
|
510
|
+
}
|
|
511
|
+
return c.json({ error: `Clone failed: ${msg}` }, 500);
|
|
512
|
+
}
|
|
513
|
+
return c.json({
|
|
514
|
+
ok: true,
|
|
515
|
+
source: sourceName,
|
|
516
|
+
templateName: newName.trim(),
|
|
517
|
+
message: `Template "${sourceName}" cloned as "${newName.trim()}"`
|
|
518
|
+
}, 201);
|
|
519
|
+
});
|
|
520
|
+
api.get("/api/templates/:name/config", (c) => {
|
|
521
|
+
const name = c.req.param("name");
|
|
522
|
+
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
|
523
|
+
return c.json({ error: "Invalid name" }, 400);
|
|
524
|
+
}
|
|
525
|
+
let templateDir;
|
|
526
|
+
try {
|
|
527
|
+
templateDir = resolveTemplatePathWithCustom(name, cwd);
|
|
528
|
+
} catch {
|
|
529
|
+
return c.json({ error: `Template "${name}" not found` }, 404);
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const config = loadConfig(templateDir);
|
|
533
|
+
return c.json(config);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
536
|
+
return c.json({ error: msg }, 500);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
api.get("/api/templates/:name/loops/:loopKey/prompt", (c) => {
|
|
540
|
+
const name = c.req.param("name");
|
|
541
|
+
const loopKey = c.req.param("loopKey");
|
|
542
|
+
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
|
543
|
+
return c.json({ error: "Invalid name" }, 400);
|
|
544
|
+
}
|
|
545
|
+
if (loopKey.includes("..") || loopKey.includes("/") || loopKey.includes("\\")) {
|
|
546
|
+
return c.json({ error: "Invalid loop key" }, 400);
|
|
547
|
+
}
|
|
548
|
+
let templateDir;
|
|
549
|
+
try {
|
|
550
|
+
templateDir = resolveTemplatePathWithCustom(name, cwd);
|
|
551
|
+
} catch {
|
|
552
|
+
return c.json({ error: `Template "${name}" not found` }, 404);
|
|
553
|
+
}
|
|
554
|
+
let config;
|
|
555
|
+
try {
|
|
556
|
+
config = loadConfig(templateDir);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
559
|
+
return c.json({ error: msg }, 500);
|
|
560
|
+
}
|
|
561
|
+
const loopConfig = config.loops[loopKey];
|
|
562
|
+
if (!loopConfig) {
|
|
563
|
+
return c.json({ error: `Loop "${loopKey}" not found in template` }, 404);
|
|
564
|
+
}
|
|
565
|
+
const promptPath = resolve(templateDir, "loops", loopConfig.prompt);
|
|
566
|
+
if (!promptPath.startsWith(resolve(templateDir))) {
|
|
567
|
+
return c.json({ error: "Invalid path" }, 403);
|
|
568
|
+
}
|
|
569
|
+
if (!existsSync(promptPath)) {
|
|
570
|
+
return c.json({ error: "prompt.md not found", content: "" }, 404);
|
|
571
|
+
}
|
|
572
|
+
const content = readFileSync(promptPath, "utf-8");
|
|
573
|
+
return c.json({ path: loopConfig.prompt, content });
|
|
574
|
+
});
|
|
575
|
+
api.put("/api/templates/:name/loops/:loopKey/prompt", async (c) => {
|
|
576
|
+
const name = c.req.param("name");
|
|
577
|
+
const loopKey = c.req.param("loopKey");
|
|
578
|
+
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
|
579
|
+
return c.json({ error: "Invalid name" }, 400);
|
|
580
|
+
}
|
|
581
|
+
if (loopKey.includes("..") || loopKey.includes("/") || loopKey.includes("\\")) {
|
|
582
|
+
return c.json({ error: "Invalid loop key" }, 400);
|
|
583
|
+
}
|
|
584
|
+
if (BUILT_IN_TEMPLATES.includes(name)) {
|
|
585
|
+
return c.json({ error: "Cannot modify built-in template prompts" }, 403);
|
|
586
|
+
}
|
|
587
|
+
const customDir = join(cwd, ".ralph-flow", ".templates", name);
|
|
588
|
+
if (!existsSync(customDir) || !existsSync(join(customDir, "ralphflow.yaml"))) {
|
|
589
|
+
return c.json({ error: `Custom template "${name}" not found` }, 404);
|
|
590
|
+
}
|
|
591
|
+
let config;
|
|
592
|
+
try {
|
|
593
|
+
config = loadConfig(customDir);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
596
|
+
return c.json({ error: msg }, 500);
|
|
597
|
+
}
|
|
598
|
+
const loopConfig = config.loops[loopKey];
|
|
599
|
+
if (!loopConfig) {
|
|
600
|
+
return c.json({ error: `Loop "${loopKey}" not found in template` }, 404);
|
|
601
|
+
}
|
|
602
|
+
const promptPath = resolve(customDir, "loops", loopConfig.prompt);
|
|
603
|
+
if (!promptPath.startsWith(resolve(customDir))) {
|
|
604
|
+
return c.json({ error: "Invalid path" }, 403);
|
|
605
|
+
}
|
|
606
|
+
const body = await c.req.json();
|
|
607
|
+
writeFileSync(promptPath, body.content, "utf-8");
|
|
608
|
+
return c.json({ ok: true });
|
|
609
|
+
});
|
|
464
610
|
api.post("/api/notification", async (c) => {
|
|
465
611
|
const app = c.req.query("app") || "unknown";
|
|
466
612
|
const loop = c.req.query("loop") || "unknown";
|
|
@@ -493,6 +639,45 @@ function createApiRoutes(cwd, port = 4242, wss) {
|
|
|
493
639
|
broadcastWs(wss, { type: "notification:dismissed", id });
|
|
494
640
|
return c.json({ ok: true });
|
|
495
641
|
});
|
|
642
|
+
api.post("/api/decision", async (c) => {
|
|
643
|
+
const app = c.req.query("app") || "unknown";
|
|
644
|
+
const loop = c.req.query("loop") || "unknown";
|
|
645
|
+
let body;
|
|
646
|
+
try {
|
|
647
|
+
body = await c.req.json();
|
|
648
|
+
} catch {
|
|
649
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
650
|
+
}
|
|
651
|
+
if (!body.item || !body.decision) {
|
|
652
|
+
return c.json({ error: "item and decision are required" }, 400);
|
|
653
|
+
}
|
|
654
|
+
const decision = {
|
|
655
|
+
id: randomUUID(),
|
|
656
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
657
|
+
app,
|
|
658
|
+
loop,
|
|
659
|
+
item: body.item,
|
|
660
|
+
agent: body.agent || "unknown",
|
|
661
|
+
decision: body.decision,
|
|
662
|
+
reasoning: body.reasoning || ""
|
|
663
|
+
};
|
|
664
|
+
decisions.push(decision);
|
|
665
|
+
broadcastWs(wss, { type: "decision:reported", decision });
|
|
666
|
+
return c.json(decision, 200);
|
|
667
|
+
});
|
|
668
|
+
api.get("/api/decisions", (c) => {
|
|
669
|
+
return c.json(decisions);
|
|
670
|
+
});
|
|
671
|
+
api.delete("/api/decision/:id", (c) => {
|
|
672
|
+
const id = c.req.param("id");
|
|
673
|
+
const idx = decisions.findIndex((d) => d.id === id);
|
|
674
|
+
if (idx === -1) {
|
|
675
|
+
return c.json({ error: "Decision not found" }, 404);
|
|
676
|
+
}
|
|
677
|
+
decisions.splice(idx, 1);
|
|
678
|
+
broadcastWs(wss, { type: "decision:dismissed", id });
|
|
679
|
+
return c.json({ ok: true });
|
|
680
|
+
});
|
|
496
681
|
return api;
|
|
497
682
|
}
|
|
498
683
|
function validatePath(resolvedPath, cwd) {
|
|
@@ -728,19 +913,24 @@ function removeNotificationHook(cwd) {
|
|
|
728
913
|
|
|
729
914
|
// src/dashboard/server.ts
|
|
730
915
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
731
|
-
|
|
916
|
+
var CONTENT_TYPES = {
|
|
917
|
+
".css": "text/css",
|
|
918
|
+
".js": "text/javascript",
|
|
919
|
+
".html": "text/html"
|
|
920
|
+
};
|
|
921
|
+
function resolveUiDir() {
|
|
732
922
|
const candidates = [
|
|
733
|
-
join4(__dirname, "..", "dashboard", "ui"
|
|
923
|
+
join4(__dirname, "..", "dashboard", "ui"),
|
|
734
924
|
// dev: src/dashboard/ -> src/dashboard/ui/
|
|
735
|
-
join4(__dirname, "..", "src", "dashboard", "ui"
|
|
925
|
+
join4(__dirname, "..", "src", "dashboard", "ui")
|
|
736
926
|
// bundled: dist/ -> src/dashboard/ui/
|
|
737
927
|
];
|
|
738
928
|
for (const candidate of candidates) {
|
|
739
|
-
if (existsSync3(candidate)) return candidate;
|
|
929
|
+
if (existsSync3(join4(candidate, "index.html"))) return candidate;
|
|
740
930
|
}
|
|
741
931
|
throw new Error(
|
|
742
932
|
`Dashboard UI not found. Searched:
|
|
743
|
-
${candidates.join("\n")}`
|
|
933
|
+
${candidates.map((c) => join4(c, "index.html")).join("\n")}`
|
|
744
934
|
);
|
|
745
935
|
}
|
|
746
936
|
async function startDashboard(options) {
|
|
@@ -753,11 +943,21 @@ async function startDashboard(options) {
|
|
|
753
943
|
}));
|
|
754
944
|
const apiRoutes = createApiRoutes(cwd, port, wss);
|
|
755
945
|
app.route("/", apiRoutes);
|
|
946
|
+
const uiDir = resolveUiDir();
|
|
756
947
|
app.get("/", (c) => {
|
|
757
|
-
const
|
|
758
|
-
const html = readFileSync3(htmlPath, "utf-8");
|
|
948
|
+
const html = readFileSync3(join4(uiDir, "index.html"), "utf-8");
|
|
759
949
|
return c.html(html);
|
|
760
950
|
});
|
|
951
|
+
app.get("/:file{.+\\.(css|js)$}", (c) => {
|
|
952
|
+
const file = c.req.param("file");
|
|
953
|
+
const filePath = join4(uiDir, file);
|
|
954
|
+
if (!filePath.startsWith(uiDir) || !existsSync3(filePath)) {
|
|
955
|
+
return c.notFound();
|
|
956
|
+
}
|
|
957
|
+
const content = readFileSync3(filePath, "utf-8");
|
|
958
|
+
const contentType = CONTENT_TYPES[extname(filePath)] || "text/plain";
|
|
959
|
+
return c.text(content, 200, { "Content-Type": contentType });
|
|
960
|
+
});
|
|
761
961
|
const server = serve({
|
|
762
962
|
fetch: app.fetch,
|
|
763
963
|
port,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ralphflow",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Multi-agent AI workflow orchestration framework for Claude Code. Define pipelines as loops, coordinate parallel agents, and ship structured work.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"dev": "tsup --watch",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"lint": "eslint src/",
|
|
25
|
+
"docs:dev": "vitepress dev docs",
|
|
26
|
+
"docs:build": "vitepress build docs",
|
|
27
|
+
"docs:preview": "vitepress preview docs",
|
|
25
28
|
"prepublishOnly": "npm run build"
|
|
26
29
|
},
|
|
27
30
|
"keywords": [
|
|
@@ -67,6 +70,7 @@
|
|
|
67
70
|
"@types/node": "^22.0.0",
|
|
68
71
|
"@types/ws": "^8.5.0",
|
|
69
72
|
"tsup": "^8.3.0",
|
|
70
|
-
"typescript": "^5.6.0"
|
|
73
|
+
"typescript": "^5.6.0",
|
|
74
|
+
"vitepress": "^1.6.4"
|
|
71
75
|
}
|
|
72
76
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Application entry point — WebSocket, data fetching, init, action wiring.
|
|
2
|
+
|
|
3
|
+
import { state, dom, actions } from './state.js';
|
|
4
|
+
import { fetchJson } from './utils.js';
|
|
5
|
+
import { renderSidebar, selectApp, selectLoop } from './sidebar.js';
|
|
6
|
+
import { renderContent, loadTracker, openDeleteAppModal, openArchiveAppModal, openCreateAppModal } from './loop-detail.js';
|
|
7
|
+
import {
|
|
8
|
+
fetchNotifications,
|
|
9
|
+
dismissNotification,
|
|
10
|
+
fetchDecisions,
|
|
11
|
+
dismissDecision,
|
|
12
|
+
maybeRequestNotifPermission,
|
|
13
|
+
showBrowserNotification,
|
|
14
|
+
showBrowserDecisionNotification,
|
|
15
|
+
playNotificationChime,
|
|
16
|
+
onFirstInteraction,
|
|
17
|
+
} from './notifications.js';
|
|
18
|
+
import { switchAppTab, loadArchives } from './archives.js';
|
|
19
|
+
import {
|
|
20
|
+
renderTemplatesPage,
|
|
21
|
+
renderTemplateBuilder,
|
|
22
|
+
captureBuilderInputs,
|
|
23
|
+
updateYamlPreview,
|
|
24
|
+
updateMinimapIO,
|
|
25
|
+
fetchTemplates,
|
|
26
|
+
} from './templates.js';
|
|
27
|
+
|
|
28
|
+
// -----------------------------------------------------------------------
|
|
29
|
+
// Wire cross-module actions registry
|
|
30
|
+
// -----------------------------------------------------------------------
|
|
31
|
+
actions.renderSidebar = renderSidebar;
|
|
32
|
+
actions.renderContent = renderContent;
|
|
33
|
+
actions.selectLoop = selectLoop;
|
|
34
|
+
actions.fetchApps = fetchApps;
|
|
35
|
+
actions.fetchAppStatus = fetchAppStatus;
|
|
36
|
+
actions.openCreateAppModal = openCreateAppModal;
|
|
37
|
+
actions.openDeleteAppModal = openDeleteAppModal;
|
|
38
|
+
actions.openArchiveAppModal = openArchiveAppModal;
|
|
39
|
+
actions.renderTemplatesPage = renderTemplatesPage;
|
|
40
|
+
actions.renderTemplateBuilder = renderTemplateBuilder;
|
|
41
|
+
actions.captureBuilderInputs = captureBuilderInputs;
|
|
42
|
+
actions.updateYamlPreview = updateYamlPreview;
|
|
43
|
+
actions.updateMinimapIO = updateMinimapIO;
|
|
44
|
+
actions.fetchTemplates = fetchTemplates;
|
|
45
|
+
actions.switchAppTab = switchAppTab;
|
|
46
|
+
actions.loadArchives = loadArchives;
|
|
47
|
+
actions.dismissNotification = dismissNotification;
|
|
48
|
+
actions.dismissDecision = dismissDecision;
|
|
49
|
+
|
|
50
|
+
// -----------------------------------------------------------------------
|
|
51
|
+
// Host display
|
|
52
|
+
// -----------------------------------------------------------------------
|
|
53
|
+
dom.hostDisplay.textContent = location.host;
|
|
54
|
+
|
|
55
|
+
fetch('/api/context')
|
|
56
|
+
.then(r => r.json())
|
|
57
|
+
.then(ctx => {
|
|
58
|
+
dom.hostDisplay.textContent = ctx.projectName + ' :' + ctx.port;
|
|
59
|
+
})
|
|
60
|
+
.catch(() => { /* keep location.host as fallback */ });
|
|
61
|
+
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
// WebSocket
|
|
64
|
+
// -----------------------------------------------------------------------
|
|
65
|
+
function connectWs() {
|
|
66
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
67
|
+
state.ws = new WebSocket(`${proto}//${location.host}/ws`);
|
|
68
|
+
|
|
69
|
+
state.ws.onopen = () => {
|
|
70
|
+
dom.statusDot.className = 'status-dot connected';
|
|
71
|
+
dom.statusText.textContent = 'Connected';
|
|
72
|
+
state.reconnectDelay = 1000;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
state.ws.onclose = () => {
|
|
76
|
+
dom.statusDot.className = 'status-dot disconnected';
|
|
77
|
+
dom.statusText.textContent = 'Disconnected';
|
|
78
|
+
setTimeout(connectWs, state.reconnectDelay);
|
|
79
|
+
state.reconnectDelay = Math.min(state.reconnectDelay * 2, 30000);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
state.ws.onerror = () => {
|
|
83
|
+
state.ws.close();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
state.ws.onmessage = (e) => {
|
|
87
|
+
const event = JSON.parse(e.data);
|
|
88
|
+
state.eventCounter++;
|
|
89
|
+
dom.eventCountEl.textContent = state.eventCounter;
|
|
90
|
+
dom.lastUpdate.textContent = new Date().toLocaleTimeString();
|
|
91
|
+
handleWsEvent(event);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleWsEvent(event) {
|
|
96
|
+
if (event.type === 'status:full') {
|
|
97
|
+
state.apps = event.apps;
|
|
98
|
+
renderSidebar();
|
|
99
|
+
if (state.selectedApp) {
|
|
100
|
+
const updated = state.apps.find(a => a.appName === state.selectedApp.appName);
|
|
101
|
+
if (updated) {
|
|
102
|
+
state.selectedApp = updated;
|
|
103
|
+
renderContent();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else if (event.type === 'tracker:updated') {
|
|
107
|
+
if (state.selectedApp && state.selectedApp.appName === event.app) {
|
|
108
|
+
const loopEntry = state.selectedApp.loops.find(l => l.key === event.loop);
|
|
109
|
+
if (loopEntry) {
|
|
110
|
+
loopEntry.status = event.status;
|
|
111
|
+
}
|
|
112
|
+
renderContent();
|
|
113
|
+
if (state.selectedLoop === event.loop) {
|
|
114
|
+
loadTracker(event.app, event.loop);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else if (event.type === 'file:changed') {
|
|
118
|
+
if (state.selectedApp && state.selectedApp.appName === event.app) {
|
|
119
|
+
fetchAppStatus(event.app);
|
|
120
|
+
}
|
|
121
|
+
} else if (event.type === 'notification:attention') {
|
|
122
|
+
const n = event.notification;
|
|
123
|
+
state.notificationsList.unshift(n);
|
|
124
|
+
renderSidebar();
|
|
125
|
+
renderContent();
|
|
126
|
+
maybeRequestNotifPermission();
|
|
127
|
+
showBrowserNotification(n);
|
|
128
|
+
playNotificationChime();
|
|
129
|
+
} else if (event.type === 'notification:dismissed') {
|
|
130
|
+
state.notificationsList = state.notificationsList.filter(n => n.id !== event.id);
|
|
131
|
+
renderSidebar();
|
|
132
|
+
renderContent();
|
|
133
|
+
} else if (event.type === 'decision:reported') {
|
|
134
|
+
const d = event.decision;
|
|
135
|
+
state.decisionsList.unshift(d);
|
|
136
|
+
renderSidebar();
|
|
137
|
+
renderContent();
|
|
138
|
+
maybeRequestNotifPermission();
|
|
139
|
+
showBrowserDecisionNotification(d);
|
|
140
|
+
playNotificationChime();
|
|
141
|
+
} else if (event.type === 'decision:dismissed') {
|
|
142
|
+
state.decisionsList = state.decisionsList.filter(d => d.id !== event.id);
|
|
143
|
+
renderSidebar();
|
|
144
|
+
renderContent();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -----------------------------------------------------------------------
|
|
149
|
+
// API
|
|
150
|
+
// -----------------------------------------------------------------------
|
|
151
|
+
async function fetchApps() {
|
|
152
|
+
state.apps = await fetchJson('/api/apps');
|
|
153
|
+
renderSidebar();
|
|
154
|
+
if (state.apps.length > 0 && !state.selectedApp) {
|
|
155
|
+
selectApp(state.apps[0]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function fetchAppStatus(appName) {
|
|
160
|
+
const statuses = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/status`);
|
|
161
|
+
if (state.selectedApp && state.selectedApp.appName === appName) {
|
|
162
|
+
statuses.forEach(s => {
|
|
163
|
+
const loop = state.selectedApp.loops.find(l => l.key === s.key);
|
|
164
|
+
if (loop) loop.status = s;
|
|
165
|
+
});
|
|
166
|
+
renderContent();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Templates nav click handler
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
document.getElementById('templatesNav').addEventListener('click', () => {
|
|
174
|
+
state.currentPage = 'templates';
|
|
175
|
+
state.selectedApp = null;
|
|
176
|
+
state.selectedLoop = null;
|
|
177
|
+
state.showTemplateBuilder = false;
|
|
178
|
+
state.templateBuilderState = null;
|
|
179
|
+
state.editingTemplateName = null;
|
|
180
|
+
state.viewingTemplateName = null;
|
|
181
|
+
state.viewingTemplateConfig = null;
|
|
182
|
+
state.viewingTemplatePrompts = {};
|
|
183
|
+
state.showTemplateWizard = false;
|
|
184
|
+
state.wizardStep = 0;
|
|
185
|
+
state.wizardData = null;
|
|
186
|
+
document.title = 'Templates - RalphFlow Dashboard';
|
|
187
|
+
renderSidebar();
|
|
188
|
+
renderContent();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// -----------------------------------------------------------------------
|
|
192
|
+
// Audio context init on first interaction
|
|
193
|
+
// -----------------------------------------------------------------------
|
|
194
|
+
document.addEventListener('click', onFirstInteraction);
|
|
195
|
+
document.addEventListener('keydown', onFirstInteraction);
|
|
196
|
+
|
|
197
|
+
// -----------------------------------------------------------------------
|
|
198
|
+
// Boot
|
|
199
|
+
// -----------------------------------------------------------------------
|
|
200
|
+
fetchApps();
|
|
201
|
+
fetchNotifications();
|
|
202
|
+
fetchDecisions();
|
|
203
|
+
connectWs();
|