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
|
@@ -0,0 +1,1858 @@
|
|
|
1
|
+
// Template listing, builder UI, drag-and-drop, YAML preview, save/load/delete/clone.
|
|
2
|
+
|
|
3
|
+
import { state, dom, actions } from './state.js';
|
|
4
|
+
import { fetchJson, esc, formatModelName } from './utils.js';
|
|
5
|
+
import {
|
|
6
|
+
syncStageConfigs,
|
|
7
|
+
createEmptyLoop,
|
|
8
|
+
initTemplateBuilderState,
|
|
9
|
+
renderPromptConfigForm,
|
|
10
|
+
PROMPT_CAPABILITIES,
|
|
11
|
+
bindPromptConfigFormEvents,
|
|
12
|
+
capturePromptConfigFormInputs,
|
|
13
|
+
} from './prompt-builder.js';
|
|
14
|
+
|
|
15
|
+
export async function fetchTemplates() {
|
|
16
|
+
try {
|
|
17
|
+
state.templatesList = await fetchJson('/api/templates');
|
|
18
|
+
} catch {
|
|
19
|
+
state.templatesList = [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function renderTemplatesPage() {
|
|
24
|
+
if (state.showTemplateWizard) {
|
|
25
|
+
renderTemplateWizard();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (state.showTemplateBuilder) {
|
|
30
|
+
renderTemplateBuilder();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (state.viewingTemplateName) {
|
|
35
|
+
renderTemplateDetail();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await fetchTemplates();
|
|
40
|
+
|
|
41
|
+
let html = '<div class="templates-header">';
|
|
42
|
+
html += '<h2>Templates</h2>';
|
|
43
|
+
html += '<button class="btn btn-primary" id="createTemplateBtn">Create Template</button>';
|
|
44
|
+
html += '</div>';
|
|
45
|
+
|
|
46
|
+
if (state.templatesList.length === 0) {
|
|
47
|
+
html += '<div class="content-empty">No templates found</div>';
|
|
48
|
+
} else {
|
|
49
|
+
html += '<div class="template-grid">';
|
|
50
|
+
for (const tpl of state.templatesList) {
|
|
51
|
+
html += `<div class="template-card" data-view-template="${esc(tpl.name)}" style="cursor:pointer">
|
|
52
|
+
<div class="template-card-header">
|
|
53
|
+
<span class="template-card-name">${esc(tpl.name)}</span>
|
|
54
|
+
<span class="template-card-type ${tpl.type}">${esc(tpl.type)}</span>
|
|
55
|
+
</div>
|
|
56
|
+
${tpl.description ? `<div class="template-card-desc">${esc(tpl.description)}</div>` : ''}
|
|
57
|
+
<div class="template-card-meta">
|
|
58
|
+
<span>${tpl.loopCount} loop${tpl.loopCount !== 1 ? 's' : ''}</span>
|
|
59
|
+
<span class="template-card-actions" data-stop-prop="true">
|
|
60
|
+
${tpl.type === 'custom' ? `<button class="btn" style="font-size:11px;padding:2px 8px" data-edit-template="${esc(tpl.name)}">Edit</button><button class="btn btn-danger" style="font-size:11px;padding:2px 8px;margin-left:4px" data-delete-template="${esc(tpl.name)}">Delete</button>` : `<button class="btn" style="font-size:11px;padding:2px 8px" data-clone-template="${esc(tpl.name)}">Clone</button>`}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
</div>`;
|
|
64
|
+
}
|
|
65
|
+
html += '</div>';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
dom.content.innerHTML = html;
|
|
69
|
+
|
|
70
|
+
const createBtn = document.getElementById('createTemplateBtn');
|
|
71
|
+
if (createBtn) {
|
|
72
|
+
createBtn.addEventListener('click', () => {
|
|
73
|
+
state.showTemplateWizard = true;
|
|
74
|
+
state.wizardStep = 0;
|
|
75
|
+
state.wizardData = { description: '', loops: [{ name: '', stages: '' }], multiAgent: false, multiAgentLoop: -1, maxAgents: 3, claudeArgs: '', skipPermissions: true };
|
|
76
|
+
renderTemplatesPage();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Template card click → open detail view
|
|
81
|
+
dom.content.querySelectorAll('[data-view-template]').forEach(card => {
|
|
82
|
+
card.addEventListener('click', (e) => {
|
|
83
|
+
// Don't open detail view if an action button was clicked
|
|
84
|
+
if (e.target.closest('[data-stop-prop]') || e.target.closest('button')) return;
|
|
85
|
+
openTemplateDetail(card.dataset.viewTemplate);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
dom.content.querySelectorAll('[data-edit-template]').forEach(btn => {
|
|
90
|
+
btn.addEventListener('click', (e) => {
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
loadTemplateForEdit(btn.dataset.editTemplate);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
dom.content.querySelectorAll('[data-delete-template]').forEach(btn => {
|
|
97
|
+
btn.addEventListener('click', (e) => {
|
|
98
|
+
e.stopPropagation();
|
|
99
|
+
openDeleteTemplateModal(btn.dataset.deleteTemplate);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
dom.content.querySelectorAll('[data-clone-template]').forEach(btn => {
|
|
104
|
+
btn.addEventListener('click', (e) => {
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
openCloneTemplateModal(btn.dataset.cloneTemplate);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -----------------------------------------------------------------------
|
|
112
|
+
// Conversational template wizard
|
|
113
|
+
// -----------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function captureWizardInputs() {
|
|
116
|
+
const wd = state.wizardData;
|
|
117
|
+
if (!wd) return;
|
|
118
|
+
const step = state.wizardStep;
|
|
119
|
+
|
|
120
|
+
if (step === 0) {
|
|
121
|
+
const descEl = document.getElementById('wizardDesc');
|
|
122
|
+
if (descEl) wd.description = descEl.value;
|
|
123
|
+
} else if (step === 1) {
|
|
124
|
+
// Capture all loop rows
|
|
125
|
+
document.querySelectorAll('.wizard-loop-row').forEach((row, i) => {
|
|
126
|
+
if (!wd.loops[i]) wd.loops[i] = { name: '', stages: '' };
|
|
127
|
+
const nameEl = row.querySelector('.wizard-loop-name');
|
|
128
|
+
const stagesEl = row.querySelector('.wizard-loop-stages');
|
|
129
|
+
if (nameEl) wd.loops[i].name = nameEl.value;
|
|
130
|
+
if (stagesEl) wd.loops[i].stages = stagesEl.value;
|
|
131
|
+
});
|
|
132
|
+
} else if (step === 2) {
|
|
133
|
+
const maToggle = document.getElementById('wizardMultiAgent');
|
|
134
|
+
if (maToggle) wd.multiAgent = maToggle.checked;
|
|
135
|
+
const maLoop = document.getElementById('wizardMultiAgentLoop');
|
|
136
|
+
if (maLoop) wd.multiAgentLoop = parseInt(maLoop.value);
|
|
137
|
+
const maxAgents = document.getElementById('wizardMaxAgents');
|
|
138
|
+
if (maxAgents) wd.maxAgents = parseInt(maxAgents.value) || 3;
|
|
139
|
+
const argsEl = document.getElementById('wizardClaudeArgs');
|
|
140
|
+
if (argsEl) wd.claudeArgs = argsEl.value;
|
|
141
|
+
const skipEl = document.getElementById('wizardSkipPerms');
|
|
142
|
+
if (skipEl) wd.skipPermissions = skipEl.checked;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function inferTemplateNameFromDescription(desc) {
|
|
147
|
+
if (!desc) return 'my-pipeline';
|
|
148
|
+
return desc
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
151
|
+
.trim()
|
|
152
|
+
.split(/\s+/)
|
|
153
|
+
.slice(0, 3)
|
|
154
|
+
.join('-') || 'my-pipeline';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildWizardDefinition() {
|
|
158
|
+
const wd = state.wizardData;
|
|
159
|
+
const name = inferTemplateNameFromDescription(wd.description);
|
|
160
|
+
const loops = wd.loops.filter(l => l.name.trim()).map((l, i, arr) => {
|
|
161
|
+
const stages = l.stages.split(',').map(s => s.trim()).filter(Boolean);
|
|
162
|
+
const def = {
|
|
163
|
+
name: l.name.trim(),
|
|
164
|
+
stages: stages.length > 0 ? stages : ['analyze', 'execute', 'verify'],
|
|
165
|
+
completion: l.name.trim().toUpperCase().replace(/\s+/g, '_') + '_COMPLETE',
|
|
166
|
+
};
|
|
167
|
+
// Auto-wire I/O: each loop's output = its name, next loop's input = previous output
|
|
168
|
+
const outFile = l.name.trim().toLowerCase().replace(/\s+/g, '-') + '.md';
|
|
169
|
+
if (i < arr.length - 1) {
|
|
170
|
+
def.feeds = [outFile];
|
|
171
|
+
}
|
|
172
|
+
if (i > 0) {
|
|
173
|
+
const prevOut = arr[i - 1].name.trim().toLowerCase().replace(/\s+/g, '-') + '.md';
|
|
174
|
+
def.fed_by = [prevOut];
|
|
175
|
+
}
|
|
176
|
+
// Multi-agent
|
|
177
|
+
if (wd.multiAgent && wd.multiAgentLoop === i) {
|
|
178
|
+
def.multi_agent = { max_agents: wd.maxAgents, strategy: 'parallel' };
|
|
179
|
+
}
|
|
180
|
+
// Claude args
|
|
181
|
+
const argsList = (wd.claudeArgs || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
182
|
+
if (argsList.length > 0) def.claude_args = argsList;
|
|
183
|
+
if (wd.skipPermissions === false) def.skip_permissions = false;
|
|
184
|
+
return def;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (loops.length === 0) {
|
|
188
|
+
loops.push({ name: 'default', stages: ['analyze', 'execute', 'verify'], completion: 'ALL DONE' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { name, description: wd.description.trim(), loops };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderWizardPipelinePreview() {
|
|
195
|
+
const wd = state.wizardData;
|
|
196
|
+
const loops = wd.loops.filter(l => l.name.trim());
|
|
197
|
+
if (loops.length === 0) return '<div class="wizard-preview-empty">Add loops to see a preview</div>';
|
|
198
|
+
|
|
199
|
+
let html = '<div class="wizard-pipeline-preview">';
|
|
200
|
+
loops.forEach((l, i) => {
|
|
201
|
+
if (i > 0) {
|
|
202
|
+
const prevOut = loops[i - 1].name.trim().toLowerCase().replace(/\s+/g, '-') + '.md';
|
|
203
|
+
html += `<div class="wizard-preview-connector"><div class="builder-minimap-connector-line"></div><span class="builder-minimap-connector-file">${esc(prevOut)}</span></div>`;
|
|
204
|
+
}
|
|
205
|
+
const stages = l.stages.split(',').map(s => s.trim()).filter(Boolean);
|
|
206
|
+
const stageDisplay = stages.length > 0 ? stages.join(' → ') : '<em>default stages</em>';
|
|
207
|
+
const isMultiAgent = wd.multiAgent && wd.multiAgentLoop === i;
|
|
208
|
+
html += `<div class="wizard-preview-node${isMultiAgent ? ' multi-agent' : ''}">`;
|
|
209
|
+
html += `<span class="minimap-label">${esc(l.name.trim())}</span>`;
|
|
210
|
+
html += `<span class="wizard-preview-stages">${stageDisplay}</span>`;
|
|
211
|
+
if (isMultiAgent) html += `<span class="wizard-preview-badge">multi-agent</span>`;
|
|
212
|
+
html += '</div>';
|
|
213
|
+
});
|
|
214
|
+
html += '</div>';
|
|
215
|
+
return html;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderTemplateWizard() {
|
|
219
|
+
const wd = state.wizardData;
|
|
220
|
+
const step = state.wizardStep;
|
|
221
|
+
const totalSteps = 4; // 0-3
|
|
222
|
+
|
|
223
|
+
let html = '';
|
|
224
|
+
html += '<div class="templates-header">';
|
|
225
|
+
html += `<div style="display:flex;align-items:center;gap:12px"><button class="btn btn-muted" id="wizardBackBtn" style="padding:4px 10px">← Back</button><h2>Create Template</h2></div>`;
|
|
226
|
+
html += '</div>';
|
|
227
|
+
|
|
228
|
+
// Progress bar
|
|
229
|
+
html += '<div class="wizard-progress">';
|
|
230
|
+
for (let i = 0; i < totalSteps; i++) {
|
|
231
|
+
html += `<div class="wizard-progress-step${i <= step ? ' active' : ''}${i < step ? ' done' : ''}">
|
|
232
|
+
<span class="wizard-step-dot">${i < step ? '✓' : i + 1}</span>
|
|
233
|
+
<span class="wizard-step-label">${['Describe', 'Define Steps', 'Options', 'Review'][i]}</span>
|
|
234
|
+
</div>`;
|
|
235
|
+
if (i < totalSteps - 1) html += '<div class="wizard-progress-line' + (i < step ? ' done' : '') + '"></div>';
|
|
236
|
+
}
|
|
237
|
+
html += '</div>';
|
|
238
|
+
|
|
239
|
+
html += '<div class="wizard-content">';
|
|
240
|
+
|
|
241
|
+
if (step === 0) {
|
|
242
|
+
// Step 1: What kind of flow?
|
|
243
|
+
html += '<div class="wizard-step-card">';
|
|
244
|
+
html += '<h3 class="wizard-question">What are you building?</h3>';
|
|
245
|
+
html += '<p class="wizard-hint">Describe your workflow in plain text. For example: "Break down user stories into tasks, implement them in code, then verify and document the changes."</p>';
|
|
246
|
+
html += `<textarea class="wizard-textarea" id="wizardDesc" placeholder="Describe your pipeline..." autofocus>${esc(wd.description)}</textarea>`;
|
|
247
|
+
html += '</div>';
|
|
248
|
+
} else if (step === 1) {
|
|
249
|
+
// Step 2: Define pipeline steps
|
|
250
|
+
html += '<div class="wizard-step-card">';
|
|
251
|
+
if (wd.description) {
|
|
252
|
+
html += `<div class="wizard-description-ref"><span class="wizard-description-ref-label">Your description</span><p>${esc(wd.description)}</p></div>`;
|
|
253
|
+
}
|
|
254
|
+
html += '<h3 class="wizard-question">Define your pipeline steps</h3>';
|
|
255
|
+
html += '<p class="wizard-hint">Each step becomes a loop. For each, give it a name and optionally list its stages (comma-separated). Stages define what the AI agent does in each iteration cycle.</p>';
|
|
256
|
+
html += '<div class="wizard-loops-list" id="wizardLoopsList">';
|
|
257
|
+
wd.loops.forEach((l, i) => {
|
|
258
|
+
html += `<div class="wizard-loop-row" data-loop-idx="${i}">
|
|
259
|
+
<span class="wizard-loop-number">${i + 1}</span>
|
|
260
|
+
<div class="wizard-loop-fields">
|
|
261
|
+
<input class="form-input wizard-loop-name" type="text" value="${esc(l.name)}" placeholder="e.g. Story, Tasks, Verify" autocomplete="off">
|
|
262
|
+
<input class="form-input wizard-loop-stages" type="text" value="${esc(l.stages)}" placeholder="Stages (e.g. analyze, execute, verify)" autocomplete="off">
|
|
263
|
+
</div>
|
|
264
|
+
${wd.loops.length > 1 ? `<button class="wizard-loop-remove" data-remove-wizard-loop="${i}" title="Remove">×</button>` : ''}
|
|
265
|
+
</div>`;
|
|
266
|
+
});
|
|
267
|
+
html += '</div>';
|
|
268
|
+
html += '<button class="btn btn-muted" id="wizardAddLoop" style="margin-top:8px">+ Add Step</button>';
|
|
269
|
+
html += '</div>';
|
|
270
|
+
|
|
271
|
+
// Live pipeline preview
|
|
272
|
+
html += '<div class="wizard-step-card">';
|
|
273
|
+
html += '<div class="builder-section-title">Pipeline Preview</div>';
|
|
274
|
+
html += renderWizardPipelinePreview();
|
|
275
|
+
html += '</div>';
|
|
276
|
+
} else if (step === 2) {
|
|
277
|
+
// Step 3: Special requirements
|
|
278
|
+
html += '<div class="wizard-step-card">';
|
|
279
|
+
html += '<h3 class="wizard-question">Any special requirements?</h3>';
|
|
280
|
+
html += '<p class="wizard-hint">Configure multi-agent coordination, CLI flags, and permissions. All optional — defaults work for most cases.</p>';
|
|
281
|
+
|
|
282
|
+
// Multi-agent toggle
|
|
283
|
+
html += '<div class="wizard-option">';
|
|
284
|
+
html += '<div class="wizard-option-header">';
|
|
285
|
+
html += '<label class="wizard-option-label">Multi-agent coordination</label>';
|
|
286
|
+
html += `<div class="toggle-wrap"><input class="toggle-input" id="wizardMultiAgent" type="checkbox" ${wd.multiAgent ? 'checked' : ''}><span class="toggle-label">${wd.multiAgent ? 'On' : 'Off'}</span></div>`;
|
|
287
|
+
html += '</div>';
|
|
288
|
+
html += '<div class="wizard-option-desc">Run multiple AI agents in parallel on a single loop, coordinating via tracker locks.</div>';
|
|
289
|
+
|
|
290
|
+
if (wd.multiAgent) {
|
|
291
|
+
html += '<div class="wizard-sub-options">';
|
|
292
|
+
html += '<div class="form-group"><label class="form-label">Which loop?</label>';
|
|
293
|
+
html += '<select class="form-select" id="wizardMultiAgentLoop">';
|
|
294
|
+
wd.loops.forEach((l, i) => {
|
|
295
|
+
const label = l.name.trim() || `Loop ${i + 1}`;
|
|
296
|
+
html += `<option value="${i}"${wd.multiAgentLoop === i ? ' selected' : ''}>${esc(label)}</option>`;
|
|
297
|
+
});
|
|
298
|
+
html += '</select></div>';
|
|
299
|
+
html += `<div class="form-group"><label class="form-label">Max agents</label><input class="form-input" id="wizardMaxAgents" type="number" min="2" max="10" value="${wd.maxAgents}"></div>`;
|
|
300
|
+
html += '</div>';
|
|
301
|
+
}
|
|
302
|
+
html += '</div>';
|
|
303
|
+
|
|
304
|
+
// Claude CLI Args
|
|
305
|
+
html += '<div class="wizard-option">';
|
|
306
|
+
html += `<div class="form-group"><label class="form-label">Claude CLI args <span class="form-hint">Extra flags for all loops</span></label>
|
|
307
|
+
<input class="form-input" id="wizardClaudeArgs" type="text" value="${esc(wd.claudeArgs)}" placeholder="e.g. --chrome, --verbose" autocomplete="off"></div>`;
|
|
308
|
+
html += '</div>';
|
|
309
|
+
|
|
310
|
+
// Skip permissions
|
|
311
|
+
html += '<div class="wizard-option">';
|
|
312
|
+
html += '<div class="wizard-option-header">';
|
|
313
|
+
html += '<label class="wizard-option-label">Skip permissions</label>';
|
|
314
|
+
html += `<div class="toggle-wrap"><input class="toggle-input" id="wizardSkipPerms" type="checkbox" ${wd.skipPermissions !== false ? 'checked' : ''}><span class="toggle-label">${wd.skipPermissions !== false ? 'On' : 'Off'}</span></div>`;
|
|
315
|
+
html += '</div>';
|
|
316
|
+
html += '<div class="wizard-option-desc">Add <code>--dangerously-skip-permissions</code> to Claude sessions.</div>';
|
|
317
|
+
html += '</div>';
|
|
318
|
+
html += '</div>';
|
|
319
|
+
} else if (step === 3) {
|
|
320
|
+
// Step 4: Review & Generate
|
|
321
|
+
const def = buildWizardDefinition();
|
|
322
|
+
const jsonStr = JSON.stringify(def, null, 2);
|
|
323
|
+
const cliCmd = `npx ralphflow create-template --config '${JSON.stringify(def)}'`;
|
|
324
|
+
|
|
325
|
+
html += '<div class="wizard-step-card">';
|
|
326
|
+
html += '<h3 class="wizard-question">Review & Create</h3>';
|
|
327
|
+
html += '<p class="wizard-hint">Your template is ready. Review the pipeline below, then create it directly or copy the CLI command.</p>';
|
|
328
|
+
html += '</div>';
|
|
329
|
+
|
|
330
|
+
// Pipeline preview
|
|
331
|
+
html += '<div class="wizard-step-card">';
|
|
332
|
+
html += '<div class="builder-section-title">Pipeline</div>';
|
|
333
|
+
html += renderWizardPipelinePreview();
|
|
334
|
+
html += '</div>';
|
|
335
|
+
|
|
336
|
+
// CLI command
|
|
337
|
+
html += '<div class="wizard-step-card">';
|
|
338
|
+
html += '<div class="builder-section-title">CLI Command</div>';
|
|
339
|
+
html += `<pre class="wizard-cli-command" id="wizardCliCommand">${esc(cliCmd)}</pre>`;
|
|
340
|
+
html += '<div class="wizard-actions-row">';
|
|
341
|
+
html += '<button class="btn" id="wizardCopyBtn">Copy to Clipboard</button>';
|
|
342
|
+
html += '<button class="btn btn-primary" id="wizardCreateBtn">Create Directly</button>';
|
|
343
|
+
html += '</div>';
|
|
344
|
+
html += '</div>';
|
|
345
|
+
|
|
346
|
+
// JSON preview (collapsible)
|
|
347
|
+
html += '<div class="wizard-step-card">';
|
|
348
|
+
html += '<button class="yaml-toggle" id="wizardJsonToggle"><span class="yaml-toggle-icon">›</span> Template Definition (JSON)</button>';
|
|
349
|
+
html += `<pre class="yaml-preview" id="wizardJsonPreview" style="display:none">${esc(jsonStr)}</pre>`;
|
|
350
|
+
html += '</div>';
|
|
351
|
+
|
|
352
|
+
// Advanced: open in full builder
|
|
353
|
+
html += '<div class="wizard-step-card" style="border:none;background:none;padding:8px 0">';
|
|
354
|
+
html += '<button class="btn btn-muted" id="wizardOpenBuilder" style="font-size:12px">Open in Full Builder for Fine-Tuning</button>';
|
|
355
|
+
html += '</div>';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
html += '</div>'; // close wizard-content
|
|
359
|
+
|
|
360
|
+
// Navigation buttons
|
|
361
|
+
html += '<div class="wizard-nav">';
|
|
362
|
+
if (step > 0) {
|
|
363
|
+
html += '<button class="btn" id="wizardPrevBtn">← Previous</button>';
|
|
364
|
+
} else {
|
|
365
|
+
html += '<span></span>';
|
|
366
|
+
}
|
|
367
|
+
if (step < totalSteps - 1) {
|
|
368
|
+
const nextDisabled = step === 0 && !wd.description.trim();
|
|
369
|
+
html += `<button class="btn btn-primary" id="wizardNextBtn"${nextDisabled ? ' disabled' : ''}>Next →</button>`;
|
|
370
|
+
}
|
|
371
|
+
html += '</div>';
|
|
372
|
+
|
|
373
|
+
dom.content.innerHTML = html;
|
|
374
|
+
bindWizardEvents();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function bindWizardEvents() {
|
|
378
|
+
const step = state.wizardStep;
|
|
379
|
+
|
|
380
|
+
// Back to templates list
|
|
381
|
+
const backBtn = document.getElementById('wizardBackBtn');
|
|
382
|
+
if (backBtn) backBtn.addEventListener('click', () => {
|
|
383
|
+
state.showTemplateWizard = false;
|
|
384
|
+
state.wizardData = null;
|
|
385
|
+
state.wizardStep = 0;
|
|
386
|
+
renderTemplatesPage();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Previous step
|
|
390
|
+
const prevBtn = document.getElementById('wizardPrevBtn');
|
|
391
|
+
if (prevBtn) prevBtn.addEventListener('click', () => {
|
|
392
|
+
captureWizardInputs();
|
|
393
|
+
state.wizardStep--;
|
|
394
|
+
renderTemplatesPage();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Next step
|
|
398
|
+
const nextBtn = document.getElementById('wizardNextBtn');
|
|
399
|
+
if (nextBtn) nextBtn.addEventListener('click', () => {
|
|
400
|
+
captureWizardInputs();
|
|
401
|
+
// Validate current step
|
|
402
|
+
const wd = state.wizardData;
|
|
403
|
+
if (step === 0 && !wd.description.trim()) return;
|
|
404
|
+
if (step === 1) {
|
|
405
|
+
const validLoops = wd.loops.filter(l => l.name.trim());
|
|
406
|
+
if (validLoops.length === 0) {
|
|
407
|
+
alert('Add at least one pipeline step with a name');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
state.wizardStep++;
|
|
412
|
+
renderTemplatesPage();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Step 0: description textarea enables/disables Next
|
|
416
|
+
if (step === 0) {
|
|
417
|
+
const descEl = document.getElementById('wizardDesc');
|
|
418
|
+
if (descEl) {
|
|
419
|
+
descEl.addEventListener('input', () => {
|
|
420
|
+
state.wizardData.description = descEl.value;
|
|
421
|
+
const nextBtnEl = document.getElementById('wizardNextBtn');
|
|
422
|
+
if (nextBtnEl) nextBtnEl.disabled = !descEl.value.trim();
|
|
423
|
+
});
|
|
424
|
+
// Auto-focus
|
|
425
|
+
setTimeout(() => descEl.focus(), 50);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Step 1: loop management
|
|
430
|
+
if (step === 1) {
|
|
431
|
+
// Live-update pipeline preview on input change
|
|
432
|
+
const updatePreview = () => {
|
|
433
|
+
captureWizardInputs();
|
|
434
|
+
const previewContainer = dom.content.querySelector('.wizard-pipeline-preview, .wizard-preview-empty');
|
|
435
|
+
if (previewContainer) {
|
|
436
|
+
const wrapper = previewContainer.parentElement;
|
|
437
|
+
const titleEl = wrapper.querySelector('.builder-section-title');
|
|
438
|
+
const newHtml = renderWizardPipelinePreview();
|
|
439
|
+
// Replace content after the title
|
|
440
|
+
const temp = document.createElement('div');
|
|
441
|
+
temp.innerHTML = newHtml;
|
|
442
|
+
if (previewContainer) previewContainer.replaceWith(temp.firstElementChild || temp);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
document.querySelectorAll('.wizard-loop-name, .wizard-loop-stages').forEach(input => {
|
|
447
|
+
input.addEventListener('input', updatePreview);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Add loop button
|
|
451
|
+
const addLoopBtn = document.getElementById('wizardAddLoop');
|
|
452
|
+
if (addLoopBtn) addLoopBtn.addEventListener('click', () => {
|
|
453
|
+
captureWizardInputs();
|
|
454
|
+
state.wizardData.loops.push({ name: '', stages: '' });
|
|
455
|
+
renderTemplatesPage();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Remove loop buttons
|
|
459
|
+
document.querySelectorAll('[data-remove-wizard-loop]').forEach(btn => {
|
|
460
|
+
btn.addEventListener('click', () => {
|
|
461
|
+
captureWizardInputs();
|
|
462
|
+
const idx = parseInt(btn.dataset.removeWizardLoop);
|
|
463
|
+
state.wizardData.loops.splice(idx, 1);
|
|
464
|
+
if (state.wizardData.multiAgentLoop >= state.wizardData.loops.length) {
|
|
465
|
+
state.wizardData.multiAgentLoop = Math.max(0, state.wizardData.loops.length - 1);
|
|
466
|
+
}
|
|
467
|
+
renderTemplatesPage();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Step 2: toggle events
|
|
473
|
+
if (step === 2) {
|
|
474
|
+
const maToggle = document.getElementById('wizardMultiAgent');
|
|
475
|
+
if (maToggle) {
|
|
476
|
+
maToggle.addEventListener('change', () => {
|
|
477
|
+
state.wizardData.multiAgent = maToggle.checked;
|
|
478
|
+
const label = maToggle.parentElement.querySelector('.toggle-label');
|
|
479
|
+
if (label) label.textContent = maToggle.checked ? 'On' : 'Off';
|
|
480
|
+
// If multi-agent just turned on, default to the loop with most work (usually index 1 or 0)
|
|
481
|
+
if (maToggle.checked && state.wizardData.multiAgentLoop < 0) {
|
|
482
|
+
state.wizardData.multiAgentLoop = Math.min(1, state.wizardData.loops.length - 1);
|
|
483
|
+
}
|
|
484
|
+
renderTemplatesPage();
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const skipToggle = document.getElementById('wizardSkipPerms');
|
|
489
|
+
if (skipToggle) {
|
|
490
|
+
skipToggle.addEventListener('change', () => {
|
|
491
|
+
state.wizardData.skipPermissions = skipToggle.checked;
|
|
492
|
+
const label = skipToggle.parentElement.querySelector('.toggle-label');
|
|
493
|
+
if (label) label.textContent = skipToggle.checked ? 'On' : 'Off';
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Step 3: Review actions
|
|
499
|
+
if (step === 3) {
|
|
500
|
+
const copyBtn = document.getElementById('wizardCopyBtn');
|
|
501
|
+
if (copyBtn) copyBtn.addEventListener('click', () => {
|
|
502
|
+
const cmdEl = document.getElementById('wizardCliCommand');
|
|
503
|
+
if (cmdEl) {
|
|
504
|
+
navigator.clipboard.writeText(cmdEl.textContent).then(() => {
|
|
505
|
+
copyBtn.textContent = 'Copied!';
|
|
506
|
+
setTimeout(() => { copyBtn.textContent = 'Copy to Clipboard'; }, 2000);
|
|
507
|
+
}).catch(() => {
|
|
508
|
+
// Fallback: select text
|
|
509
|
+
const range = document.createRange();
|
|
510
|
+
range.selectNodeContents(cmdEl);
|
|
511
|
+
const sel = window.getSelection();
|
|
512
|
+
sel.removeAllRanges();
|
|
513
|
+
sel.addRange(range);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const createBtn = document.getElementById('wizardCreateBtn');
|
|
519
|
+
if (createBtn) createBtn.addEventListener('click', async () => {
|
|
520
|
+
createBtn.disabled = true;
|
|
521
|
+
createBtn.textContent = 'Creating...';
|
|
522
|
+
try {
|
|
523
|
+
const def = buildWizardDefinition();
|
|
524
|
+
const res = await fetch('/api/templates', {
|
|
525
|
+
method: 'POST',
|
|
526
|
+
headers: { 'Content-Type': 'application/json' },
|
|
527
|
+
body: JSON.stringify(def)
|
|
528
|
+
});
|
|
529
|
+
const data = await res.json();
|
|
530
|
+
if (!res.ok) {
|
|
531
|
+
alert(data.error || 'Failed to create template');
|
|
532
|
+
createBtn.disabled = false;
|
|
533
|
+
createBtn.textContent = 'Create Directly';
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
state.showTemplateWizard = false;
|
|
537
|
+
state.wizardData = null;
|
|
538
|
+
state.wizardStep = 0;
|
|
539
|
+
state.templatesList = [];
|
|
540
|
+
renderTemplatesPage();
|
|
541
|
+
} catch {
|
|
542
|
+
alert('Network error — could not reach server');
|
|
543
|
+
createBtn.disabled = false;
|
|
544
|
+
createBtn.textContent = 'Create Directly';
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// JSON preview toggle
|
|
549
|
+
const jsonToggle = document.getElementById('wizardJsonToggle');
|
|
550
|
+
if (jsonToggle) jsonToggle.addEventListener('click', () => {
|
|
551
|
+
const preview = document.getElementById('wizardJsonPreview');
|
|
552
|
+
const icon = jsonToggle.querySelector('.yaml-toggle-icon');
|
|
553
|
+
if (preview) {
|
|
554
|
+
const visible = preview.style.display !== 'none';
|
|
555
|
+
preview.style.display = visible ? 'none' : 'block';
|
|
556
|
+
if (icon) icon.textContent = visible ? '\u203A' : '\u2304';
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Open in full builder
|
|
561
|
+
const openBuilderBtn = document.getElementById('wizardOpenBuilder');
|
|
562
|
+
if (openBuilderBtn) openBuilderBtn.addEventListener('click', () => {
|
|
563
|
+
captureWizardInputs();
|
|
564
|
+
const def = buildWizardDefinition();
|
|
565
|
+
// Convert wizard definition to builder state
|
|
566
|
+
const loops = def.loops.map(l => {
|
|
567
|
+
const loopState = createEmptyLoop();
|
|
568
|
+
loopState.name = l.name;
|
|
569
|
+
loopState.stages = l.stages;
|
|
570
|
+
loopState.completion = l.completion;
|
|
571
|
+
loopState.inputFiles = (l.fed_by || []).join(', ');
|
|
572
|
+
loopState.outputFiles = (l.feeds || []).join(', ');
|
|
573
|
+
loopState.claudeArgs = (l.claude_args || []).join(', ');
|
|
574
|
+
if (l.skip_permissions === false) loopState.skipPermissions = false;
|
|
575
|
+
if (l.multi_agent) {
|
|
576
|
+
loopState.multi_agent = true;
|
|
577
|
+
loopState.max_agents = l.multi_agent.max_agents || 3;
|
|
578
|
+
loopState.strategy = l.multi_agent.strategy || 'parallel';
|
|
579
|
+
}
|
|
580
|
+
loopState._outputAutoFilled = false;
|
|
581
|
+
loopState._inputAutoFilled = false;
|
|
582
|
+
syncStageConfigs(loopState);
|
|
583
|
+
return loopState;
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
state.showTemplateWizard = false;
|
|
587
|
+
state.wizardData = null;
|
|
588
|
+
state.wizardStep = 0;
|
|
589
|
+
state.showTemplateBuilder = true;
|
|
590
|
+
state.editingTemplateName = null;
|
|
591
|
+
state.selectedBuilderLoop = 0;
|
|
592
|
+
state.templateBuilderState = {
|
|
593
|
+
name: def.name,
|
|
594
|
+
description: def.description,
|
|
595
|
+
loops: loops.length > 0 ? loops : [createEmptyLoop()]
|
|
596
|
+
};
|
|
597
|
+
renderTemplatesPage();
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// -----------------------------------------------------------------------
|
|
603
|
+
// Template detail/preview page
|
|
604
|
+
// -----------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
async function openTemplateDetail(templateName) {
|
|
607
|
+
state.viewingTemplateName = templateName;
|
|
608
|
+
state.viewingTemplateConfig = null;
|
|
609
|
+
state.viewingTemplatePrompts = {};
|
|
610
|
+
|
|
611
|
+
// Show loading state immediately
|
|
612
|
+
dom.content.innerHTML = '<div class="content-empty">Loading template...</div>';
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
const config = await fetchJson('/api/templates/' + encodeURIComponent(templateName) + '/config');
|
|
616
|
+
state.viewingTemplateConfig = config;
|
|
617
|
+
|
|
618
|
+
// Load prompts for all loops
|
|
619
|
+
const sortedLoops = Object.entries(config.loops)
|
|
620
|
+
.sort(([, a], [, b]) => a.order - b.order);
|
|
621
|
+
|
|
622
|
+
for (const [loopKey] of sortedLoops) {
|
|
623
|
+
try {
|
|
624
|
+
const promptData = await fetchJson('/api/templates/' + encodeURIComponent(templateName) + '/loops/' + encodeURIComponent(loopKey) + '/prompt');
|
|
625
|
+
state.viewingTemplatePrompts[loopKey] = promptData.content || '';
|
|
626
|
+
} catch {
|
|
627
|
+
state.viewingTemplatePrompts[loopKey] = '';
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
renderTemplateDetail();
|
|
632
|
+
} catch {
|
|
633
|
+
dom.content.innerHTML = '<div class="content-empty">Failed to load template</div>';
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function renderTemplateDetail() {
|
|
638
|
+
const config = state.viewingTemplateConfig;
|
|
639
|
+
const templateName = state.viewingTemplateName;
|
|
640
|
+
if (!config || !templateName) return;
|
|
641
|
+
|
|
642
|
+
const tpl = state.templatesList.find(t => t.name === templateName);
|
|
643
|
+
const isCustom = tpl ? tpl.type === 'custom' : false;
|
|
644
|
+
|
|
645
|
+
const sortedLoops = Object.entries(config.loops)
|
|
646
|
+
.sort(([, a], [, b]) => a.order - b.order);
|
|
647
|
+
|
|
648
|
+
let html = '';
|
|
649
|
+
|
|
650
|
+
// Header
|
|
651
|
+
html += '<div class="templates-header">';
|
|
652
|
+
html += `<div style="display:flex;align-items:center;gap:12px">
|
|
653
|
+
<button class="btn btn-muted" id="detailBackBtn" style="padding:4px 10px">← Back</button>
|
|
654
|
+
<h2>${esc(config.name || templateName)}</h2>
|
|
655
|
+
<span class="template-card-type ${isCustom ? 'custom' : 'built-in'}">${isCustom ? 'custom' : 'built-in'}</span>
|
|
656
|
+
</div>`;
|
|
657
|
+
if (isCustom) {
|
|
658
|
+
html += `<button class="btn" id="detailEditBtn" style="font-size:12px;padding:4px 12px">Edit in Builder</button>`;
|
|
659
|
+
}
|
|
660
|
+
html += '</div>';
|
|
661
|
+
|
|
662
|
+
if (config.description) {
|
|
663
|
+
html += `<div style="color:var(--text-dim);font-size:13px;margin-bottom:20px">${esc(config.description)}</div>`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Pipeline minimap (read-only)
|
|
667
|
+
html += '<div class="section" style="margin-bottom:24px">';
|
|
668
|
+
html += '<div class="builder-section-title">Pipeline</div>';
|
|
669
|
+
html += '<div class="detail-pipeline">';
|
|
670
|
+
sortedLoops.forEach(([loopKey, loop], i) => {
|
|
671
|
+
if (i > 0) {
|
|
672
|
+
const prevFeeds = sortedLoops[i - 1][1].feeds || [];
|
|
673
|
+
const curFedBy = loop.fed_by || [];
|
|
674
|
+
const shared = prevFeeds.filter(f => curFedBy.includes(f));
|
|
675
|
+
if (shared.length > 0) {
|
|
676
|
+
html += `<div class="pipeline-connector-wrap">
|
|
677
|
+
<div class="pipeline-connector"></div>
|
|
678
|
+
<span class="connector-file" title="${esc(shared.join(', '))}">${esc(shared.join(', '))}</span>
|
|
679
|
+
</div>`;
|
|
680
|
+
} else {
|
|
681
|
+
html += '<div class="pipeline-connector"></div>';
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const modelDisplay = formatModelName(loop.model);
|
|
685
|
+
const fedBy = loop.fed_by || [];
|
|
686
|
+
const feeds = loop.feeds || [];
|
|
687
|
+
const inputIo = fedBy.length > 0 ? `<div class="node-io node-io-in">${fedBy.map(f => `<span class="node-io-label" title="input: ${esc(f)}">${esc(f)}</span>`).join('')}</div>` : '';
|
|
688
|
+
const outputIo = feeds.length > 0 ? `<div class="node-io node-io-out">${feeds.map(f => `<span class="node-io-label" title="output: ${esc(f)}">${esc(f)}</span>`).join('')}</div>` : '';
|
|
689
|
+
html += `<div class="pipeline-node" data-detail-loop="${esc(loopKey)}">
|
|
690
|
+
${inputIo}
|
|
691
|
+
<span class="node-name">${esc(loop.name)}</span>
|
|
692
|
+
<span class="node-status-row">
|
|
693
|
+
<span class="node-model${modelDisplay ? '' : ' node-model-default'}">${modelDisplay ? esc(modelDisplay) : 'default'}</span>
|
|
694
|
+
</span>
|
|
695
|
+
${outputIo}
|
|
696
|
+
</div>`;
|
|
697
|
+
});
|
|
698
|
+
html += '</div></div>';
|
|
699
|
+
|
|
700
|
+
// Loop detail cards
|
|
701
|
+
html += '<div class="detail-loops">';
|
|
702
|
+
sortedLoops.forEach(([loopKey, loop], i) => {
|
|
703
|
+
const stages = loop.stages || [];
|
|
704
|
+
const fedBy = loop.fed_by || [];
|
|
705
|
+
const feeds = loop.feeds || [];
|
|
706
|
+
const modelDisplay = formatModelName(loop.model);
|
|
707
|
+
const promptContent = state.viewingTemplatePrompts[loopKey] || '';
|
|
708
|
+
|
|
709
|
+
html += `<div class="detail-loop-card" id="detail-loop-${esc(loopKey)}">`;
|
|
710
|
+
html += `<div class="detail-loop-header">
|
|
711
|
+
<h3>${esc(loop.name)}</h3>
|
|
712
|
+
<span class="detail-loop-index">Loop ${i + 1}</span>
|
|
713
|
+
</div>`;
|
|
714
|
+
|
|
715
|
+
// Config summary
|
|
716
|
+
html += '<div class="detail-loop-meta">';
|
|
717
|
+
if (modelDisplay) {
|
|
718
|
+
html += `<div class="detail-meta-item"><span class="detail-meta-label">Model</span><span class="detail-meta-value">${esc(modelDisplay)}</span></div>`;
|
|
719
|
+
}
|
|
720
|
+
if (stages.length > 0) {
|
|
721
|
+
html += `<div class="detail-meta-item"><span class="detail-meta-label">Stages</span><span class="detail-meta-value">${stages.map(s => esc(s)).join(' → ')}</span></div>`;
|
|
722
|
+
}
|
|
723
|
+
if (loop.completion) {
|
|
724
|
+
html += `<div class="detail-meta-item"><span class="detail-meta-label">Completion</span><span class="detail-meta-value" style="font-family:var(--mono);font-size:11px">${esc(loop.completion)}</span></div>`;
|
|
725
|
+
}
|
|
726
|
+
if (fedBy.length > 0) {
|
|
727
|
+
html += `<div class="detail-meta-item"><span class="detail-meta-label">Input</span><span class="detail-meta-value">${fedBy.map(f => `<code style="font-family:var(--mono);font-size:11px">${esc(f)}</code>`).join(', ')}</span></div>`;
|
|
728
|
+
}
|
|
729
|
+
if (feeds.length > 0) {
|
|
730
|
+
html += `<div class="detail-meta-item"><span class="detail-meta-label">Output</span><span class="detail-meta-value">${feeds.map(f => `<code style="font-family:var(--mono);font-size:11px">${esc(f)}</code>`).join(', ')}</span></div>`;
|
|
731
|
+
}
|
|
732
|
+
if (loop.claude_args && loop.claude_args.length > 0) {
|
|
733
|
+
html += `<div class="detail-meta-item"><span class="detail-meta-label">CLI Args</span><span class="detail-meta-value" style="font-family:var(--mono);font-size:11px">${loop.claude_args.map(a => esc(a)).join(' ')}</span></div>`;
|
|
734
|
+
}
|
|
735
|
+
html += '</div>';
|
|
736
|
+
|
|
737
|
+
// Prompt
|
|
738
|
+
html += '<div class="detail-prompt-section">';
|
|
739
|
+
html += `<div class="detail-prompt-header">
|
|
740
|
+
<span class="detail-meta-label">Prompt</span>
|
|
741
|
+
${isCustom ? `<span class="detail-prompt-actions">
|
|
742
|
+
<button class="btn btn-primary detail-save-prompt" data-save-loop="${esc(loopKey)}" style="font-size:11px;padding:3px 10px" disabled>Save</button>
|
|
743
|
+
<span class="save-ok detail-save-ok" data-save-ok="${esc(loopKey)}" style="display:none">Saved</span>
|
|
744
|
+
</span>` : ''}
|
|
745
|
+
</div>`;
|
|
746
|
+
html += `<textarea class="detail-prompt-editor${isCustom ? '' : ' readonly'}" data-detail-prompt="${esc(loopKey)}" ${isCustom ? '' : 'readonly'} placeholder="No prompt content">${esc(promptContent)}</textarea>`;
|
|
747
|
+
html += '</div>';
|
|
748
|
+
|
|
749
|
+
html += '</div>'; // close detail-loop-card
|
|
750
|
+
});
|
|
751
|
+
html += '</div>';
|
|
752
|
+
|
|
753
|
+
dom.content.innerHTML = html;
|
|
754
|
+
|
|
755
|
+
// Bind events
|
|
756
|
+
const backBtn = document.getElementById('detailBackBtn');
|
|
757
|
+
if (backBtn) {
|
|
758
|
+
backBtn.addEventListener('click', () => {
|
|
759
|
+
state.viewingTemplateName = null;
|
|
760
|
+
state.viewingTemplateConfig = null;
|
|
761
|
+
state.viewingTemplatePrompts = {};
|
|
762
|
+
renderTemplatesPage();
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const editBtn = document.getElementById('detailEditBtn');
|
|
767
|
+
if (editBtn) {
|
|
768
|
+
editBtn.addEventListener('click', () => {
|
|
769
|
+
const name = state.viewingTemplateName;
|
|
770
|
+
state.viewingTemplateName = null;
|
|
771
|
+
state.viewingTemplateConfig = null;
|
|
772
|
+
state.viewingTemplatePrompts = {};
|
|
773
|
+
loadTemplateForEdit(name);
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Pipeline node click → scroll to loop card
|
|
778
|
+
dom.content.querySelectorAll('[data-detail-loop]').forEach(node => {
|
|
779
|
+
node.style.cursor = 'pointer';
|
|
780
|
+
node.addEventListener('click', () => {
|
|
781
|
+
const loopKey = node.dataset.detailLoop;
|
|
782
|
+
const card = document.getElementById('detail-loop-' + loopKey);
|
|
783
|
+
if (card) {
|
|
784
|
+
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
785
|
+
card.classList.add('highlighted');
|
|
786
|
+
setTimeout(() => card.classList.remove('highlighted'), 1500);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Prompt editing (custom templates only)
|
|
792
|
+
if (isCustom) {
|
|
793
|
+
dom.content.querySelectorAll('.detail-prompt-editor:not(.readonly)').forEach(textarea => {
|
|
794
|
+
const loopKey = textarea.dataset.detailPrompt;
|
|
795
|
+
const originalContent = state.viewingTemplatePrompts[loopKey] || '';
|
|
796
|
+
|
|
797
|
+
textarea.addEventListener('input', () => {
|
|
798
|
+
const saveBtn = dom.content.querySelector(`.detail-save-prompt[data-save-loop="${loopKey}"]`);
|
|
799
|
+
if (saveBtn) saveBtn.disabled = textarea.value === originalContent;
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
textarea.addEventListener('keydown', (e) => {
|
|
803
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
804
|
+
e.preventDefault();
|
|
805
|
+
saveDetailPrompt(loopKey, textarea);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
dom.content.querySelectorAll('.detail-save-prompt').forEach(btn => {
|
|
811
|
+
btn.addEventListener('click', () => {
|
|
812
|
+
const loopKey = btn.dataset.saveLoop;
|
|
813
|
+
const textarea = dom.content.querySelector(`.detail-prompt-editor[data-detail-prompt="${loopKey}"]`);
|
|
814
|
+
if (textarea) saveDetailPrompt(loopKey, textarea);
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function saveDetailPrompt(loopKey, textarea) {
|
|
821
|
+
const templateName = state.viewingTemplateName;
|
|
822
|
+
if (!templateName) return;
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
const res = await fetch('/api/templates/' + encodeURIComponent(templateName) + '/loops/' + encodeURIComponent(loopKey) + '/prompt', {
|
|
826
|
+
method: 'PUT',
|
|
827
|
+
headers: { 'Content-Type': 'application/json' },
|
|
828
|
+
body: JSON.stringify({ content: textarea.value }),
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
if (!res.ok) {
|
|
832
|
+
alert('Failed to save prompt');
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
state.viewingTemplatePrompts[loopKey] = textarea.value;
|
|
837
|
+
const saveBtn = dom.content.querySelector(`.detail-save-prompt[data-save-loop="${loopKey}"]`);
|
|
838
|
+
if (saveBtn) saveBtn.disabled = true;
|
|
839
|
+
const saveOk = dom.content.querySelector(`.detail-save-ok[data-save-ok="${loopKey}"]`);
|
|
840
|
+
if (saveOk) {
|
|
841
|
+
saveOk.style.display = 'inline';
|
|
842
|
+
setTimeout(() => { saveOk.style.display = 'none'; }, 2000);
|
|
843
|
+
}
|
|
844
|
+
} catch {
|
|
845
|
+
alert('Failed to save prompt');
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export function renderTemplateBuilder() {
|
|
850
|
+
const tbs = state.templateBuilderState;
|
|
851
|
+
// Clamp selectedBuilderLoop to valid range
|
|
852
|
+
if (state.selectedBuilderLoop >= tbs.loops.length) state.selectedBuilderLoop = Math.max(0, tbs.loops.length - 1);
|
|
853
|
+
const si = state.selectedBuilderLoop;
|
|
854
|
+
const loop = tbs.loops[si];
|
|
855
|
+
let html = '';
|
|
856
|
+
|
|
857
|
+
html += '<div class="templates-header">';
|
|
858
|
+
html += `<div style="display:flex;align-items:center;gap:12px"><button class="btn btn-muted" id="builderBackBtn" style="padding:4px 10px">← Back</button><h2>${state.editingTemplateName ? 'Edit Template' : 'Create Template'}</h2></div>`;
|
|
859
|
+
html += '</div>';
|
|
860
|
+
html += '<div style="color:var(--text-dim);font-size:13px;margin-bottom:16px;line-height:1.5">A template defines a multi-step pipeline where each loop processes data through defined stages. Loops are connected via input/output files — one loop\'s output becomes the next loop\'s input. Each loop runs an AI agent that cycles through its stages until the completion string is detected.</div>';
|
|
861
|
+
|
|
862
|
+
html += '<div class="template-builder">';
|
|
863
|
+
|
|
864
|
+
// === Section 1: Overview (left column) ===
|
|
865
|
+
html += '<div class="builder-col-overview">';
|
|
866
|
+
|
|
867
|
+
// Basic info
|
|
868
|
+
html += '<div class="builder-section">';
|
|
869
|
+
html += '<div class="builder-section-title">Basic Info</div>';
|
|
870
|
+
html += `<div class="form-group"><label class="form-label">Template Name <span class="form-hint">Unique identifier for this pipeline</span></label>
|
|
871
|
+
<input class="form-input" id="tplName" type="text" value="${esc(tbs.name)}" placeholder="my-pipeline" autocomplete="off"></div>`;
|
|
872
|
+
html += `<div class="form-group"><label class="form-label">Description <span class="form-hint">What does this pipeline accomplish?</span></label>
|
|
873
|
+
<input class="form-input" id="tplDesc" type="text" value="${esc(tbs.description)}" placeholder="e.g. Break down stories into tasks and implement them" autocomplete="off"></div>`;
|
|
874
|
+
html += '</div>';
|
|
875
|
+
|
|
876
|
+
// Pipeline minimap (doubles as loop selector)
|
|
877
|
+
html += '<div class="builder-section">';
|
|
878
|
+
html += '<div class="builder-section-title">Pipeline</div>';
|
|
879
|
+
html += '<div class="builder-minimap" id="builderMinimap" style="flex-wrap:wrap">';
|
|
880
|
+
tbs.loops.forEach((lp, i) => {
|
|
881
|
+
if (i > 0) {
|
|
882
|
+
// Check for shared file between previous loop's output and this loop's input
|
|
883
|
+
const prevOut = (tbs.loops[i - 1].outputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
884
|
+
const curIn = (lp.inputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
885
|
+
const shared = prevOut.filter(f => curIn.includes(f));
|
|
886
|
+
html += '<div class="builder-minimap-connector" data-connector-idx="' + i + '">';
|
|
887
|
+
if (shared.length > 0) {
|
|
888
|
+
html += `<span class="builder-minimap-connector-file" title="${esc(shared.join(', '))}">${esc(shared[0])}${shared.length > 1 ? '+' + (shared.length - 1) : ''}</span>`;
|
|
889
|
+
}
|
|
890
|
+
html += '<div class="builder-minimap-connector-line"></div>';
|
|
891
|
+
html += '</div>';
|
|
892
|
+
}
|
|
893
|
+
const label = lp.name || `Loop ${i + 1}`;
|
|
894
|
+
const inFiles = (lp.inputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
895
|
+
const outFiles = (lp.outputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
896
|
+
html += `<div class="builder-minimap-node${i === si ? ' active' : ''}" data-minimap-idx="${i}" draggable="true">`;
|
|
897
|
+
if (inFiles.length > 0) {
|
|
898
|
+
html += `<span class="minimap-io minimap-io-in" data-minimap-io="in-${i}"><span class="minimap-io-arrow">→</span><span class="minimap-io-label" title="${esc(inFiles.join(', '))}">${esc(inFiles[0])}${inFiles.length > 1 ? '+' + (inFiles.length - 1) : ''}</span></span>`;
|
|
899
|
+
}
|
|
900
|
+
html += `<span class="minimap-label">${esc(label)}</span>`;
|
|
901
|
+
html += `<span class="minimap-index">${i + 1}</span>`;
|
|
902
|
+
if (outFiles.length > 0) {
|
|
903
|
+
html += `<span class="minimap-io minimap-io-out" data-minimap-io="out-${i}"><span class="minimap-io-label" title="${esc(outFiles.join(', '))}">${esc(outFiles[0])}${outFiles.length > 1 ? '+' + (outFiles.length - 1) : ''}</span><span class="minimap-io-arrow">→</span></span>`;
|
|
904
|
+
}
|
|
905
|
+
html += `</div>`;
|
|
906
|
+
});
|
|
907
|
+
if (tbs.loops.length > 0) {
|
|
908
|
+
html += '<div class="builder-minimap-connector"><div class="builder-minimap-connector-line"></div></div>';
|
|
909
|
+
}
|
|
910
|
+
html += '<button class="builder-minimap-add" id="minimapAddBtn" title="Add loop">+</button>';
|
|
911
|
+
html += '</div></div>';
|
|
912
|
+
|
|
913
|
+
// YAML Preview (collapsed by default)
|
|
914
|
+
html += '<div class="builder-section yaml-preview-section">';
|
|
915
|
+
html += `<button class="yaml-toggle" id="yamlToggleBtn"><span class="yaml-toggle-icon">›</span> YAML Preview</button>`;
|
|
916
|
+
html += `<pre class="yaml-preview" id="yamlPreview" style="display:none">${esc(generateYamlPreview(tbs))}</pre>`;
|
|
917
|
+
html += '</div>';
|
|
918
|
+
|
|
919
|
+
html += '</div>'; // close builder-col-overview
|
|
920
|
+
|
|
921
|
+
// === Section 2: Selected loop config (right top) ===
|
|
922
|
+
html += '<div class="builder-col-config">';
|
|
923
|
+
|
|
924
|
+
if (loop) {
|
|
925
|
+
syncStageConfigs(loop);
|
|
926
|
+
html += '<div class="builder-section">';
|
|
927
|
+
html += `<div class="builder-section-title" style="display:flex;align-items:center;justify-content:space-between">
|
|
928
|
+
<span>Loop ${si + 1}: ${esc(loop.name || 'Untitled')}</span>
|
|
929
|
+
${tbs.loops.length > 1 ? `<button class="loop-card-remove" data-remove-loop="${si}" title="Remove loop">×</button>` : ''}
|
|
930
|
+
</div>`;
|
|
931
|
+
|
|
932
|
+
html += `<div class="loop-card" data-loop-index="${si}">`;
|
|
933
|
+
html += '<div class="loop-card-grid">';
|
|
934
|
+
|
|
935
|
+
// Name
|
|
936
|
+
html += `<div class="form-group"><label class="form-label">Name <span class="form-hint">e.g. Story, Tasks, Test</span></label>
|
|
937
|
+
<input class="form-input loop-input" data-loop-idx="${si}" data-field="name" type="text" value="${esc(loop.name)}" placeholder="Story" autocomplete="off"></div>`;
|
|
938
|
+
|
|
939
|
+
// Model
|
|
940
|
+
html += `<div class="form-group"><label class="form-label">Model <span class="form-hint">AI model for this loop</span></label>
|
|
941
|
+
<select class="form-select loop-input" data-loop-idx="${si}" data-field="model">
|
|
942
|
+
<option value="claude-sonnet-4-6"${loop.model === 'claude-sonnet-4-6' ? ' selected' : ''}>claude-sonnet-4-6</option>
|
|
943
|
+
<option value="claude-opus-4-6"${loop.model === 'claude-opus-4-6' ? ' selected' : ''}>claude-opus-4-6</option>
|
|
944
|
+
<option value="claude-haiku-4-5-20251001"${loop.model === 'claude-haiku-4-5-20251001' ? ' selected' : ''}>claude-haiku-4-5-20251001</option>
|
|
945
|
+
</select></div>`;
|
|
946
|
+
|
|
947
|
+
// Input files
|
|
948
|
+
const prevLoop = si > 0 ? tbs.loops[si - 1] : null;
|
|
949
|
+
const suggestedInput = prevLoop ? (prevLoop.outputFiles || '').trim() : '';
|
|
950
|
+
html += `<div class="form-group"><label class="form-label">Input Files <span class="form-hint">Files this loop reads from</span></label>
|
|
951
|
+
<input class="form-input loop-input" data-loop-idx="${si}" data-field="inputFiles" type="text" value="${esc(loop.inputFiles || '')}" placeholder="${suggestedInput ? esc(suggestedInput) : 'e.g. stories.md'}" autocomplete="off"></div>`;
|
|
952
|
+
|
|
953
|
+
// Output files
|
|
954
|
+
const outputPlaceholder = loop.name ? loop.name.toLowerCase().replace(/\s+/g, '-') + '.md' : 'e.g. tasks.md';
|
|
955
|
+
html += `<div class="form-group"><label class="form-label">Output Files <span class="form-hint">Files this loop writes to</span></label>
|
|
956
|
+
<input class="form-input loop-input" data-loop-idx="${si}" data-field="outputFiles" type="text" value="${esc(loop.outputFiles || '')}" placeholder="${esc(outputPlaceholder)}" autocomplete="off"></div>`;
|
|
957
|
+
|
|
958
|
+
// Completion string
|
|
959
|
+
html += `<div class="form-group loop-card-full"><label class="form-label">Completion String <span class="form-hint">Signal that this loop is done</span></label>
|
|
960
|
+
<input class="form-input loop-input" data-loop-idx="${si}" data-field="completion" type="text" value="${esc(loop.completion)}" placeholder="LOOP COMPLETE" autocomplete="off">
|
|
961
|
+
<div class="form-field-note">When this string appears in the tracker, the process is automatically stopped via <code>kill -INT $PPID</code></div></div>`;
|
|
962
|
+
|
|
963
|
+
// Claude CLI Args
|
|
964
|
+
html += `<div class="form-group loop-card-full"><label class="form-label">Claude CLI Args <span class="form-hint">Extra flags passed to Claude (comma-separated)</span></label>
|
|
965
|
+
<input class="form-input loop-input" data-loop-idx="${si}" data-field="claudeArgs" type="text" value="${esc(loop.claudeArgs || '')}" placeholder="e.g. --chrome, --verbose" autocomplete="off"></div>`;
|
|
966
|
+
|
|
967
|
+
// Skip Permissions toggle
|
|
968
|
+
html += `<div class="form-group"><label class="form-label">Skip Permissions <span class="form-hint">Add --dangerously-skip-permissions</span></label>
|
|
969
|
+
<div class="toggle-wrap"><input class="toggle-input loop-toggle" data-loop-idx="${si}" data-field="skipPermissions" type="checkbox" ${loop.skipPermissions !== false ? 'checked' : ''}><span class="toggle-label">${loop.skipPermissions !== false ? 'On' : 'Off'}</span></div></div>`;
|
|
970
|
+
|
|
971
|
+
html += '</div>'; // close loop-card-grid
|
|
972
|
+
|
|
973
|
+
// Stages section — each stage gets its own card
|
|
974
|
+
html += '<div class="stages-section">';
|
|
975
|
+
html += '<label class="form-label">Stages <span class="form-hint">Define the steps this loop cycles through on each iteration</span></label>';
|
|
976
|
+
loop.stageConfigs.forEach((sc, sci) => {
|
|
977
|
+
html += `<div class="stage-config-card">`;
|
|
978
|
+
html += `<div class="stage-card-header">`;
|
|
979
|
+
html += `<span class="stage-card-number">${sci + 1}</span>`;
|
|
980
|
+
html += `<input class="form-input stage-name-input" data-loop-idx="${si}" data-stage-idx="${sci}" value="${esc(sc.name)}" placeholder="Stage name" autocomplete="off">`;
|
|
981
|
+
html += `<button class="stage-card-remove" data-loop-idx="${si}" data-stage-idx="${sci}" title="Remove stage">×</button>`;
|
|
982
|
+
html += `</div>`;
|
|
983
|
+
html += `<textarea class="form-input stage-desc-input" data-loop-idx="${si}" data-stage-idx="${sci}" placeholder="What should this stage do? e.g. Read input files, explore codebase, identify scope...">${esc(sc.description)}</textarea>`;
|
|
984
|
+
html += `</div>`;
|
|
985
|
+
});
|
|
986
|
+
html += `<button class="btn btn-muted add-stage-btn" data-add-stage="${si}">+ Add Stage</button>`;
|
|
987
|
+
html += '</div>';
|
|
988
|
+
|
|
989
|
+
html += '</div>'; // close loop-card
|
|
990
|
+
html += '</div>'; // close builder-section
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
html += '</div>'; // close builder-col-config
|
|
994
|
+
|
|
995
|
+
// === Section 3: Selected loop prompt (right bottom) ===
|
|
996
|
+
html += '<div class="builder-col-prompt">';
|
|
997
|
+
|
|
998
|
+
if (loop) {
|
|
999
|
+
const showForm = loop.showPromptForm && !loop.prompt.trim();
|
|
1000
|
+
|
|
1001
|
+
html += '<div class="builder-section">';
|
|
1002
|
+
html += `<div class="builder-section-title" style="display:flex;align-items:center;justify-content:space-between">
|
|
1003
|
+
<span>Prompt</span>
|
|
1004
|
+
${loop.prompt.trim() ? `<button class="btn btn-muted" data-show-prompt-form="${si}" style="font-size:11px;padding:4px 10px">Generate New</button>` : ''}
|
|
1005
|
+
</div>`;
|
|
1006
|
+
html += '<div class="prompt-section" style="margin-top:0;padding-top:0;border-top:none">';
|
|
1007
|
+
|
|
1008
|
+
if (showForm) {
|
|
1009
|
+
html += renderPromptConfigForm(si, loop, tbs.loops);
|
|
1010
|
+
} else {
|
|
1011
|
+
html += `<textarea class="prompt-textarea" data-prompt-idx="${si}" placeholder="Add your prompt here...">${esc(loop.prompt)}</textarea>`;
|
|
1012
|
+
html += `<div class="prompt-toolbar">`;
|
|
1013
|
+
html += `<button class="btn btn-muted" data-show-prompt-form="${si}" style="font-size:11px;padding:4px 10px">Regenerate</button>`;
|
|
1014
|
+
html += `</div>`;
|
|
1015
|
+
}
|
|
1016
|
+
html += '</div>';
|
|
1017
|
+
html += '</div>';
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
html += '</div>'; // close builder-col-prompt
|
|
1021
|
+
|
|
1022
|
+
// === Actions (spans full width) ===
|
|
1023
|
+
html += '<div class="builder-col-actions">';
|
|
1024
|
+
html += '<div class="builder-actions">';
|
|
1025
|
+
html += '<button class="btn" id="builderCancelBtn">Cancel</button>';
|
|
1026
|
+
html += `<button class="btn btn-primary" id="builderSaveBtn">${state.editingTemplateName ? 'Update Template' : 'Save Template'}</button>`;
|
|
1027
|
+
html += '</div>';
|
|
1028
|
+
html += '</div>';
|
|
1029
|
+
|
|
1030
|
+
html += '</div>'; // close template-builder
|
|
1031
|
+
|
|
1032
|
+
dom.content.innerHTML = html;
|
|
1033
|
+
bindTemplateBuilderEvents();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function bindTemplateBuilderEvents() {
|
|
1037
|
+
const backBtn = document.getElementById('builderBackBtn');
|
|
1038
|
+
if (backBtn) backBtn.addEventListener('click', () => {
|
|
1039
|
+
state.showTemplateBuilder = false;
|
|
1040
|
+
state.templateBuilderState = null;
|
|
1041
|
+
state.editingTemplateName = null;
|
|
1042
|
+
renderTemplatesPage();
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
const cancelBtn = document.getElementById('builderCancelBtn');
|
|
1046
|
+
if (cancelBtn) cancelBtn.addEventListener('click', () => {
|
|
1047
|
+
state.showTemplateBuilder = false;
|
|
1048
|
+
state.templateBuilderState = null;
|
|
1049
|
+
state.editingTemplateName = null;
|
|
1050
|
+
renderTemplatesPage();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
const saveBtn = document.getElementById('builderSaveBtn');
|
|
1054
|
+
if (saveBtn) saveBtn.addEventListener('click', saveTemplate);
|
|
1055
|
+
|
|
1056
|
+
// Template name/desc inputs
|
|
1057
|
+
const tplName = document.getElementById('tplName');
|
|
1058
|
+
const tplDesc = document.getElementById('tplDesc');
|
|
1059
|
+
if (tplName) tplName.addEventListener('input', () => {
|
|
1060
|
+
state.templateBuilderState.name = tplName.value;
|
|
1061
|
+
updateYamlPreview();
|
|
1062
|
+
});
|
|
1063
|
+
if (tplDesc) tplDesc.addEventListener('input', () => {
|
|
1064
|
+
state.templateBuilderState.description = tplDesc.value;
|
|
1065
|
+
updateYamlPreview();
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// Loop text inputs
|
|
1069
|
+
dom.content.querySelectorAll('.loop-input').forEach(input => {
|
|
1070
|
+
const idx = parseInt(input.dataset.loopIdx);
|
|
1071
|
+
const field = input.dataset.field;
|
|
1072
|
+
const evtType = input.tagName === 'SELECT' ? 'change' : 'input';
|
|
1073
|
+
|
|
1074
|
+
input.addEventListener(evtType, () => {
|
|
1075
|
+
const loop = state.templateBuilderState.loops[idx];
|
|
1076
|
+
if (!loop) return;
|
|
1077
|
+
loop[field] = input.value;
|
|
1078
|
+
// Live-update minimap label when loop name changes
|
|
1079
|
+
if (field === 'name') {
|
|
1080
|
+
const minimapNode = dom.content.querySelector(`.builder-minimap-node[data-minimap-idx="${idx}"] .minimap-label`);
|
|
1081
|
+
if (minimapNode) minimapNode.textContent = input.value || `Loop ${idx + 1}`;
|
|
1082
|
+
// Auto-populate output file from loop name
|
|
1083
|
+
if (loop._outputAutoFilled !== false) {
|
|
1084
|
+
const autoOut = input.value ? input.value.toLowerCase().replace(/\s+/g, '-') + '.md' : '';
|
|
1085
|
+
loop.outputFiles = autoOut;
|
|
1086
|
+
const outEl = dom.content.querySelector(`.loop-input[data-loop-idx="${idx}"][data-field="outputFiles"]`);
|
|
1087
|
+
if (outEl) outEl.value = autoOut;
|
|
1088
|
+
// Cascade to next loop's input
|
|
1089
|
+
if (idx < state.templateBuilderState.loops.length - 1) {
|
|
1090
|
+
const nextLoop = state.templateBuilderState.loops[idx + 1];
|
|
1091
|
+
if (nextLoop._inputAutoFilled !== false) {
|
|
1092
|
+
nextLoop.inputFiles = autoOut;
|
|
1093
|
+
const nextEl = dom.content.querySelector(`.loop-input[data-loop-idx="${idx + 1}"][data-field="inputFiles"]`);
|
|
1094
|
+
if (nextEl) nextEl.value = autoOut;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
updateMinimapIO();
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (field === 'outputFiles') {
|
|
1101
|
+
loop._outputAutoFilled = false;
|
|
1102
|
+
// Auto-populate next loop's input if not manually edited
|
|
1103
|
+
if (idx < state.templateBuilderState.loops.length - 1) {
|
|
1104
|
+
const nextLoop = state.templateBuilderState.loops[idx + 1];
|
|
1105
|
+
if (nextLoop._inputAutoFilled !== false) {
|
|
1106
|
+
nextLoop.inputFiles = input.value;
|
|
1107
|
+
const nextEl = dom.content.querySelector(`.loop-input[data-loop-idx="${idx + 1}"][data-field="inputFiles"]`);
|
|
1108
|
+
if (nextEl) nextEl.value = input.value;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
updateMinimapIO();
|
|
1112
|
+
}
|
|
1113
|
+
if (field === 'inputFiles') {
|
|
1114
|
+
loop._inputAutoFilled = false;
|
|
1115
|
+
updateMinimapIO();
|
|
1116
|
+
}
|
|
1117
|
+
updateYamlPreview();
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// Toggle inputs (checkboxes like skipPermissions)
|
|
1122
|
+
dom.content.querySelectorAll('.loop-toggle').forEach(input => {
|
|
1123
|
+
input.addEventListener('change', () => {
|
|
1124
|
+
const idx = parseInt(input.dataset.loopIdx);
|
|
1125
|
+
const field = input.dataset.field;
|
|
1126
|
+
const loop = state.templateBuilderState.loops[idx];
|
|
1127
|
+
if (!loop) return;
|
|
1128
|
+
loop[field] = input.checked;
|
|
1129
|
+
const text = input.parentElement.querySelector('.toggle-label');
|
|
1130
|
+
if (text) text.textContent = input.checked ? 'On' : 'Off';
|
|
1131
|
+
updateYamlPreview();
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// Stage name inputs
|
|
1136
|
+
dom.content.querySelectorAll('.stage-name-input').forEach(input => {
|
|
1137
|
+
input.addEventListener('input', () => {
|
|
1138
|
+
const loopIdx = parseInt(input.dataset.loopIdx);
|
|
1139
|
+
const stageIdx = parseInt(input.dataset.stageIdx);
|
|
1140
|
+
const loop = state.templateBuilderState.loops[loopIdx];
|
|
1141
|
+
if (loop) {
|
|
1142
|
+
if (loop.stageConfigs[stageIdx]) loop.stageConfigs[stageIdx].name = input.value;
|
|
1143
|
+
if (loop.stages[stageIdx] !== undefined) loop.stages[stageIdx] = input.value;
|
|
1144
|
+
updateYamlPreview();
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// Stage description textareas
|
|
1150
|
+
dom.content.querySelectorAll('.stage-desc-input').forEach(textarea => {
|
|
1151
|
+
textarea.addEventListener('input', () => {
|
|
1152
|
+
const loopIdx = parseInt(textarea.dataset.loopIdx);
|
|
1153
|
+
const stageIdx = parseInt(textarea.dataset.stageIdx);
|
|
1154
|
+
const loop = state.templateBuilderState.loops[loopIdx];
|
|
1155
|
+
if (loop && loop.stageConfigs[stageIdx]) {
|
|
1156
|
+
loop.stageConfigs[stageIdx].description = textarea.value;
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// Stage card remove buttons
|
|
1162
|
+
dom.content.querySelectorAll('.stage-card-remove').forEach(btn => {
|
|
1163
|
+
btn.addEventListener('click', () => {
|
|
1164
|
+
captureBuilderInputs();
|
|
1165
|
+
const loopIdx = parseInt(btn.dataset.loopIdx);
|
|
1166
|
+
const stageIdx = parseInt(btn.dataset.stageIdx);
|
|
1167
|
+
const loop = state.templateBuilderState.loops[loopIdx];
|
|
1168
|
+
if (loop) {
|
|
1169
|
+
loop.stages.splice(stageIdx, 1);
|
|
1170
|
+
loop.stageConfigs.splice(stageIdx, 1);
|
|
1171
|
+
renderTemplateBuilder();
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
// Add stage button
|
|
1177
|
+
dom.content.querySelectorAll('.add-stage-btn').forEach(btn => {
|
|
1178
|
+
btn.addEventListener('click', () => {
|
|
1179
|
+
captureBuilderInputs();
|
|
1180
|
+
const loopIdx = parseInt(btn.dataset.addStage);
|
|
1181
|
+
const loop = state.templateBuilderState.loops[loopIdx];
|
|
1182
|
+
if (loop) {
|
|
1183
|
+
const caps = {};
|
|
1184
|
+
PROMPT_CAPABILITIES.forEach(c => { caps[c.id] = false; });
|
|
1185
|
+
loop.stages.push('');
|
|
1186
|
+
loop.stageConfigs.push({ name: '', description: '', capabilities: caps });
|
|
1187
|
+
renderTemplateBuilder();
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
// Remove loop buttons
|
|
1193
|
+
dom.content.querySelectorAll('[data-remove-loop]').forEach(btn => {
|
|
1194
|
+
btn.addEventListener('click', () => {
|
|
1195
|
+
captureBuilderInputs();
|
|
1196
|
+
const idx = parseInt(btn.dataset.removeLoop);
|
|
1197
|
+
state.templateBuilderState.loops.splice(idx, 1);
|
|
1198
|
+
if (idx === state.selectedBuilderLoop) {
|
|
1199
|
+
state.selectedBuilderLoop = Math.max(0, idx - 1);
|
|
1200
|
+
} else if (idx < state.selectedBuilderLoop) {
|
|
1201
|
+
state.selectedBuilderLoop--;
|
|
1202
|
+
}
|
|
1203
|
+
renderTemplateBuilder();
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
// YAML preview toggle
|
|
1208
|
+
const yamlToggle = document.getElementById('yamlToggleBtn');
|
|
1209
|
+
if (yamlToggle) {
|
|
1210
|
+
yamlToggle.addEventListener('click', () => {
|
|
1211
|
+
const preview = document.getElementById('yamlPreview');
|
|
1212
|
+
const icon = yamlToggle.querySelector('.yaml-toggle-icon');
|
|
1213
|
+
if (preview) {
|
|
1214
|
+
const visible = preview.style.display !== 'none';
|
|
1215
|
+
preview.style.display = visible ? 'none' : 'block';
|
|
1216
|
+
if (icon) icon.textContent = visible ? '\u203A' : '\u2304';
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Prompt textarea input
|
|
1222
|
+
dom.content.querySelectorAll('.prompt-textarea').forEach(textarea => {
|
|
1223
|
+
textarea.addEventListener('input', () => {
|
|
1224
|
+
const idx = parseInt(textarea.dataset.promptIdx);
|
|
1225
|
+
state.templateBuilderState.loops[idx].prompt = textarea.value;
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// Minimap: click node → select that loop (capture inputs first, then re-render)
|
|
1230
|
+
dom.content.querySelectorAll('.builder-minimap-node').forEach(node => {
|
|
1231
|
+
node.addEventListener('click', () => {
|
|
1232
|
+
const idx = parseInt(node.dataset.minimapIdx);
|
|
1233
|
+
if (idx === state.selectedBuilderLoop) return;
|
|
1234
|
+
captureBuilderInputs();
|
|
1235
|
+
state.selectedBuilderLoop = idx;
|
|
1236
|
+
renderTemplateBuilder();
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// Minimap: add button
|
|
1241
|
+
const minimapAddBtn = document.getElementById('minimapAddBtn');
|
|
1242
|
+
if (minimapAddBtn) {
|
|
1243
|
+
minimapAddBtn.addEventListener('click', () => {
|
|
1244
|
+
captureBuilderInputs();
|
|
1245
|
+
const loops = state.templateBuilderState.loops;
|
|
1246
|
+
const newLoop = createEmptyLoop();
|
|
1247
|
+
// Auto-fill input from previous loop's output
|
|
1248
|
+
if (loops.length > 0) {
|
|
1249
|
+
const prevOutput = (loops[loops.length - 1].outputFiles || '').trim();
|
|
1250
|
+
if (prevOutput) {
|
|
1251
|
+
newLoop.inputFiles = prevOutput;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
loops.push(newLoop);
|
|
1255
|
+
state.selectedBuilderLoop = loops.length - 1;
|
|
1256
|
+
renderTemplateBuilder();
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Drag-and-drop reordering for minimap nodes and loop cards
|
|
1261
|
+
setupBuilderDragAndDrop();
|
|
1262
|
+
|
|
1263
|
+
// Prompt config form events
|
|
1264
|
+
bindPromptConfigFormEvents();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function setupBuilderDragAndDrop() {
|
|
1268
|
+
let dragSrcIdx = null;
|
|
1269
|
+
|
|
1270
|
+
function reorderLoops(fromIdx, toIdx) {
|
|
1271
|
+
if (fromIdx === toIdx) return;
|
|
1272
|
+
captureBuilderInputs();
|
|
1273
|
+
const loops = state.templateBuilderState.loops;
|
|
1274
|
+
const [moved] = loops.splice(fromIdx, 1);
|
|
1275
|
+
loops.splice(toIdx, 0, moved);
|
|
1276
|
+
// Update selectedBuilderLoop to follow the same loop object
|
|
1277
|
+
if (state.selectedBuilderLoop === fromIdx) {
|
|
1278
|
+
state.selectedBuilderLoop = toIdx;
|
|
1279
|
+
} else {
|
|
1280
|
+
let newSel = state.selectedBuilderLoop;
|
|
1281
|
+
if (fromIdx < newSel) newSel--;
|
|
1282
|
+
if (toIdx <= newSel) newSel++;
|
|
1283
|
+
state.selectedBuilderLoop = newSel;
|
|
1284
|
+
}
|
|
1285
|
+
renderTemplateBuilder();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Minimap node drag events
|
|
1289
|
+
dom.content.querySelectorAll('.builder-minimap-node[draggable="true"]').forEach(node => {
|
|
1290
|
+
node.addEventListener('dragstart', (e) => {
|
|
1291
|
+
dragSrcIdx = parseInt(node.dataset.minimapIdx);
|
|
1292
|
+
node.classList.add('dragging');
|
|
1293
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1294
|
+
e.dataTransfer.setData('text/plain', dragSrcIdx);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
node.addEventListener('dragend', () => {
|
|
1298
|
+
node.classList.remove('dragging');
|
|
1299
|
+
dom.content.querySelectorAll('.builder-minimap-node.drag-over').forEach(n => n.classList.remove('drag-over'));
|
|
1300
|
+
dragSrcIdx = null;
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
node.addEventListener('dragover', (e) => {
|
|
1304
|
+
e.preventDefault();
|
|
1305
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1306
|
+
const targetIdx = parseInt(node.dataset.minimapIdx);
|
|
1307
|
+
if (targetIdx !== dragSrcIdx) {
|
|
1308
|
+
node.classList.add('drag-over');
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
node.addEventListener('dragleave', () => {
|
|
1313
|
+
node.classList.remove('drag-over');
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
node.addEventListener('drop', (e) => {
|
|
1317
|
+
e.preventDefault();
|
|
1318
|
+
node.classList.remove('drag-over');
|
|
1319
|
+
const toIdx = parseInt(node.dataset.minimapIdx);
|
|
1320
|
+
if (dragSrcIdx !== null && dragSrcIdx !== toIdx) {
|
|
1321
|
+
reorderLoops(dragSrcIdx, toIdx);
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Capture current input values into state before re-render
|
|
1328
|
+
export function captureBuilderInputs() {
|
|
1329
|
+
const tbs = state.templateBuilderState;
|
|
1330
|
+
if (!tbs) return;
|
|
1331
|
+
const tplName = document.getElementById('tplName');
|
|
1332
|
+
const tplDesc = document.getElementById('tplDesc');
|
|
1333
|
+
if (tplName) tbs.name = tplName.value;
|
|
1334
|
+
if (tplDesc) tbs.description = tplDesc.value;
|
|
1335
|
+
|
|
1336
|
+
dom.content.querySelectorAll('.loop-input').forEach(input => {
|
|
1337
|
+
const idx = parseInt(input.dataset.loopIdx);
|
|
1338
|
+
const field = input.dataset.field;
|
|
1339
|
+
const loop = tbs.loops[idx];
|
|
1340
|
+
if (!loop) return;
|
|
1341
|
+
loop[field] = input.value;
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
// Capture toggle inputs (checkboxes)
|
|
1345
|
+
dom.content.querySelectorAll('.loop-toggle').forEach(input => {
|
|
1346
|
+
const idx = parseInt(input.dataset.loopIdx);
|
|
1347
|
+
const field = input.dataset.field;
|
|
1348
|
+
const loop = tbs.loops[idx];
|
|
1349
|
+
if (loop) loop[field] = input.checked;
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// Capture stage card inputs
|
|
1353
|
+
dom.content.querySelectorAll('.stage-name-input').forEach(input => {
|
|
1354
|
+
const loopIdx = parseInt(input.dataset.loopIdx);
|
|
1355
|
+
const stageIdx = parseInt(input.dataset.stageIdx);
|
|
1356
|
+
const loop = tbs.loops[loopIdx];
|
|
1357
|
+
if (loop) {
|
|
1358
|
+
if (loop.stageConfigs[stageIdx]) loop.stageConfigs[stageIdx].name = input.value;
|
|
1359
|
+
if (loop.stages[stageIdx] !== undefined) loop.stages[stageIdx] = input.value;
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
dom.content.querySelectorAll('.stage-desc-input').forEach(textarea => {
|
|
1363
|
+
const loopIdx = parseInt(textarea.dataset.loopIdx);
|
|
1364
|
+
const stageIdx = parseInt(textarea.dataset.stageIdx);
|
|
1365
|
+
const loop = tbs.loops[loopIdx];
|
|
1366
|
+
if (loop && loop.stageConfigs[stageIdx]) {
|
|
1367
|
+
loop.stageConfigs[stageIdx].description = textarea.value;
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
// Capture prompt textareas
|
|
1372
|
+
dom.content.querySelectorAll('.prompt-textarea').forEach(textarea => {
|
|
1373
|
+
const idx = parseInt(textarea.dataset.promptIdx);
|
|
1374
|
+
if (tbs.loops[idx]) {
|
|
1375
|
+
tbs.loops[idx].prompt = textarea.value;
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
// Capture prompt config form inputs
|
|
1380
|
+
capturePromptConfigFormInputs();
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
export function updateYamlPreview() {
|
|
1384
|
+
const preview = document.getElementById('yamlPreview');
|
|
1385
|
+
if (preview && state.templateBuilderState) {
|
|
1386
|
+
preview.textContent = generateYamlPreview(state.templateBuilderState);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
export function updateMinimapIO() {
|
|
1391
|
+
if (!state.templateBuilderState) return;
|
|
1392
|
+
const tbs = state.templateBuilderState;
|
|
1393
|
+
tbs.loops.forEach((lp, i) => {
|
|
1394
|
+
const node = dom.content.querySelector(`.builder-minimap-node[data-minimap-idx="${i}"]`);
|
|
1395
|
+
if (!node) return;
|
|
1396
|
+
const inFiles = (lp.inputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1397
|
+
const outFiles = (lp.outputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1398
|
+
// Update or create input file label
|
|
1399
|
+
let inEl = node.querySelector('.minimap-io-in');
|
|
1400
|
+
if (inFiles.length > 0) {
|
|
1401
|
+
const inHtml = `<span class="minimap-io-arrow">→</span><span class="minimap-io-label" title="${esc(inFiles.join(', '))}">${esc(inFiles[0])}${inFiles.length > 1 ? '+' + (inFiles.length - 1) : ''}</span>`;
|
|
1402
|
+
if (inEl) {
|
|
1403
|
+
inEl.innerHTML = inHtml;
|
|
1404
|
+
} else {
|
|
1405
|
+
inEl = document.createElement('span');
|
|
1406
|
+
inEl.className = 'minimap-io minimap-io-in';
|
|
1407
|
+
inEl.setAttribute('data-minimap-io', 'in-' + i);
|
|
1408
|
+
inEl.innerHTML = inHtml;
|
|
1409
|
+
node.insertBefore(inEl, node.firstChild);
|
|
1410
|
+
}
|
|
1411
|
+
} else if (inEl) {
|
|
1412
|
+
inEl.remove();
|
|
1413
|
+
}
|
|
1414
|
+
// Update or create output file label
|
|
1415
|
+
let outEl = node.querySelector('.minimap-io-out');
|
|
1416
|
+
if (outFiles.length > 0) {
|
|
1417
|
+
const outHtml = `<span class="minimap-io-label" title="${esc(outFiles.join(', '))}">${esc(outFiles[0])}${outFiles.length > 1 ? '+' + (outFiles.length - 1) : ''}</span><span class="minimap-io-arrow">→</span>`;
|
|
1418
|
+
if (outEl) {
|
|
1419
|
+
outEl.innerHTML = outHtml;
|
|
1420
|
+
} else {
|
|
1421
|
+
outEl = document.createElement('span');
|
|
1422
|
+
outEl.className = 'minimap-io minimap-io-out';
|
|
1423
|
+
outEl.setAttribute('data-minimap-io', 'out-' + i);
|
|
1424
|
+
outEl.innerHTML = outHtml;
|
|
1425
|
+
node.appendChild(outEl);
|
|
1426
|
+
}
|
|
1427
|
+
} else if (outEl) {
|
|
1428
|
+
outEl.remove();
|
|
1429
|
+
}
|
|
1430
|
+
// Update connector between this loop and the next
|
|
1431
|
+
if (i > 0) {
|
|
1432
|
+
const connector = dom.content.querySelector(`.builder-minimap-connector[data-connector-idx="${i}"]`);
|
|
1433
|
+
if (connector) {
|
|
1434
|
+
const prevOut = (tbs.loops[i - 1].outputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1435
|
+
const curIn = inFiles;
|
|
1436
|
+
const shared = prevOut.filter(f => curIn.includes(f));
|
|
1437
|
+
let fileEl = connector.querySelector('.builder-minimap-connector-file');
|
|
1438
|
+
if (shared.length > 0) {
|
|
1439
|
+
const label = esc(shared[0]) + (shared.length > 1 ? '+' + (shared.length - 1) : '');
|
|
1440
|
+
if (fileEl) {
|
|
1441
|
+
fileEl.textContent = label;
|
|
1442
|
+
fileEl.title = shared.join(', ');
|
|
1443
|
+
} else {
|
|
1444
|
+
fileEl = document.createElement('span');
|
|
1445
|
+
fileEl.className = 'builder-minimap-connector-file';
|
|
1446
|
+
fileEl.textContent = label;
|
|
1447
|
+
fileEl.title = shared.join(', ');
|
|
1448
|
+
connector.insertBefore(fileEl, connector.firstChild);
|
|
1449
|
+
}
|
|
1450
|
+
} else if (fileEl) {
|
|
1451
|
+
fileEl.remove();
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
// Also update the connector after this loop (for the next loop)
|
|
1456
|
+
if (i < tbs.loops.length - 1) {
|
|
1457
|
+
const nextConnector = dom.content.querySelector(`.builder-minimap-connector[data-connector-idx="${i + 1}"]`);
|
|
1458
|
+
if (nextConnector) {
|
|
1459
|
+
const nextIn = (tbs.loops[i + 1].inputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1460
|
+
const shared = outFiles.filter(f => nextIn.includes(f));
|
|
1461
|
+
let fileEl = nextConnector.querySelector('.builder-minimap-connector-file');
|
|
1462
|
+
if (shared.length > 0) {
|
|
1463
|
+
const label = esc(shared[0]) + (shared.length > 1 ? '+' + (shared.length - 1) : '');
|
|
1464
|
+
if (fileEl) {
|
|
1465
|
+
fileEl.textContent = label;
|
|
1466
|
+
fileEl.title = shared.join(', ');
|
|
1467
|
+
} else {
|
|
1468
|
+
fileEl = document.createElement('span');
|
|
1469
|
+
fileEl.className = 'builder-minimap-connector-file';
|
|
1470
|
+
fileEl.textContent = label;
|
|
1471
|
+
fileEl.title = shared.join(', ');
|
|
1472
|
+
nextConnector.insertBefore(fileEl, nextConnector.firstChild);
|
|
1473
|
+
}
|
|
1474
|
+
} else if (fileEl) {
|
|
1475
|
+
fileEl.remove();
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function generateYamlPreview(tbs) {
|
|
1483
|
+
let yaml = '';
|
|
1484
|
+
yaml += `name: ${tbs.name || 'my-template'}\n`;
|
|
1485
|
+
yaml += `description: "${tbs.description || ''}"\n`;
|
|
1486
|
+
yaml += `version: 1\n`;
|
|
1487
|
+
yaml += `dir: .ralph-flow\n`;
|
|
1488
|
+
yaml += `entities: {}\n`;
|
|
1489
|
+
yaml += `loops:\n`;
|
|
1490
|
+
|
|
1491
|
+
tbs.loops.forEach((loop, index) => {
|
|
1492
|
+
const baseName = (loop.name || `loop-${index + 1}`).toLowerCase().replace(/\s+/g, '-');
|
|
1493
|
+
const loopKey = baseName.endsWith('-loop') ? baseName : `${baseName}-loop`;
|
|
1494
|
+
const dirPrefix = String(index).padStart(2, '0');
|
|
1495
|
+
const loopDirName = `${dirPrefix}-${loopKey}`;
|
|
1496
|
+
|
|
1497
|
+
yaml += ` ${loopKey}:\n`;
|
|
1498
|
+
yaml += ` order: ${index}\n`;
|
|
1499
|
+
yaml += ` name: "${loop.name || `Loop ${index + 1}`}"\n`;
|
|
1500
|
+
yaml += ` prompt: ${loopDirName}/prompt.md\n`;
|
|
1501
|
+
yaml += ` tracker: ${loopDirName}/tracker.md\n`;
|
|
1502
|
+
yaml += ` stages: [${loop.stages.join(', ')}]\n`;
|
|
1503
|
+
yaml += ` completion: "${loop.completion || 'LOOP COMPLETE'}"\n`;
|
|
1504
|
+
|
|
1505
|
+
yaml += ` multi_agent: false\n`;
|
|
1506
|
+
yaml += ` model: ${loop.model || 'claude-sonnet-4-6'}\n`;
|
|
1507
|
+
yaml += ` cadence: 0\n`;
|
|
1508
|
+
|
|
1509
|
+
const claudeArgsList = (loop.claudeArgs || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1510
|
+
if (claudeArgsList.length > 0) {
|
|
1511
|
+
yaml += ` claude_args:\n`;
|
|
1512
|
+
claudeArgsList.forEach(a => { yaml += ` - ${a}\n`; });
|
|
1513
|
+
}
|
|
1514
|
+
if (loop.skipPermissions === false) {
|
|
1515
|
+
yaml += ` skip_permissions: false\n`;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const ioFedBy = (loop.inputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1519
|
+
const ioFeeds = (loop.outputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1520
|
+
if (ioFedBy.length > 0) {
|
|
1521
|
+
yaml += ` fed_by:\n`;
|
|
1522
|
+
ioFedBy.forEach(f => { yaml += ` - ${f}\n`; });
|
|
1523
|
+
}
|
|
1524
|
+
if (ioFeeds.length > 0) {
|
|
1525
|
+
yaml += ` feeds:\n`;
|
|
1526
|
+
ioFeeds.forEach(f => { yaml += ` - ${f}\n`; });
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
return yaml;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
async function saveTemplate() {
|
|
1534
|
+
captureBuilderInputs();
|
|
1535
|
+
const tbs = state.templateBuilderState;
|
|
1536
|
+
|
|
1537
|
+
if (!tbs.name || !tbs.name.trim()) {
|
|
1538
|
+
alert('Template name is required');
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
for (let i = 0; i < tbs.loops.length; i++) {
|
|
1543
|
+
const loop = tbs.loops[i];
|
|
1544
|
+
if (!loop.name || !loop.name.trim()) {
|
|
1545
|
+
alert(`Loop ${i + 1}: name is required`);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (loop.stages.length === 0) {
|
|
1549
|
+
alert(`Loop "${loop.name}": at least one stage is required`);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
if (!loop.completion || !loop.completion.trim()) {
|
|
1553
|
+
alert(`Loop "${loop.name}": completion string is required`);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const definition = {
|
|
1559
|
+
name: tbs.name.trim(),
|
|
1560
|
+
description: tbs.description.trim(),
|
|
1561
|
+
loops: tbs.loops.map(loop => {
|
|
1562
|
+
const loopDef = {
|
|
1563
|
+
name: loop.name.trim(),
|
|
1564
|
+
stages: loop.stages,
|
|
1565
|
+
completion: loop.completion.trim(),
|
|
1566
|
+
model: loop.model || undefined,
|
|
1567
|
+
};
|
|
1568
|
+
const fedByList = (loop.inputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1569
|
+
const feedsList = (loop.outputFiles || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1570
|
+
if (fedByList.length > 0) {
|
|
1571
|
+
loopDef.fed_by = fedByList;
|
|
1572
|
+
}
|
|
1573
|
+
if (feedsList.length > 0) {
|
|
1574
|
+
loopDef.feeds = feedsList;
|
|
1575
|
+
}
|
|
1576
|
+
const claudeArgsList = (loop.claudeArgs || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1577
|
+
if (claudeArgsList.length > 0) {
|
|
1578
|
+
loopDef.claude_args = claudeArgsList;
|
|
1579
|
+
}
|
|
1580
|
+
if (loop.skipPermissions === false) {
|
|
1581
|
+
loopDef.skip_permissions = false;
|
|
1582
|
+
}
|
|
1583
|
+
if (loop.prompt && loop.prompt.trim()) {
|
|
1584
|
+
loopDef.prompt = loop.prompt;
|
|
1585
|
+
}
|
|
1586
|
+
return loopDef;
|
|
1587
|
+
})
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
const saveBtnEl = document.getElementById('builderSaveBtn');
|
|
1591
|
+
const saveBtnLabel = state.editingTemplateName ? 'Update Template' : 'Save Template';
|
|
1592
|
+
if (saveBtnEl) {
|
|
1593
|
+
saveBtnEl.disabled = true;
|
|
1594
|
+
saveBtnEl.textContent = 'Saving...';
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
try {
|
|
1598
|
+
// In edit mode, delete the old template first
|
|
1599
|
+
if (state.editingTemplateName) {
|
|
1600
|
+
const delRes = await fetch('/api/templates/' + encodeURIComponent(state.editingTemplateName), { method: 'DELETE' });
|
|
1601
|
+
if (!delRes.ok) {
|
|
1602
|
+
const delData = await delRes.json();
|
|
1603
|
+
alert(delData.error || 'Failed to update template');
|
|
1604
|
+
if (saveBtnEl) { saveBtnEl.disabled = false; saveBtnEl.textContent = saveBtnLabel; }
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const res = await fetch('/api/templates', {
|
|
1610
|
+
method: 'POST',
|
|
1611
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1612
|
+
body: JSON.stringify(definition)
|
|
1613
|
+
});
|
|
1614
|
+
const data = await res.json();
|
|
1615
|
+
|
|
1616
|
+
if (!res.ok) {
|
|
1617
|
+
alert(data.error || 'Failed to save template');
|
|
1618
|
+
if (saveBtnEl) { saveBtnEl.disabled = false; saveBtnEl.textContent = saveBtnLabel; }
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
state.showTemplateBuilder = false;
|
|
1623
|
+
state.templateBuilderState = null;
|
|
1624
|
+
state.editingTemplateName = null;
|
|
1625
|
+
state.templatesList = [];
|
|
1626
|
+
renderTemplatesPage();
|
|
1627
|
+
} catch {
|
|
1628
|
+
alert('Network error — could not reach server');
|
|
1629
|
+
if (saveBtnEl) { saveBtnEl.disabled = false; saveBtnEl.textContent = saveBtnLabel; }
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
async function loadTemplateForEdit(templateName) {
|
|
1634
|
+
try {
|
|
1635
|
+
const configRes = await fetch('/api/templates/' + encodeURIComponent(templateName) + '/config');
|
|
1636
|
+
if (!configRes.ok) {
|
|
1637
|
+
alert('Failed to load template config');
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
const config = await configRes.json();
|
|
1641
|
+
|
|
1642
|
+
// Convert config loops to builder state
|
|
1643
|
+
const sortedLoops = Object.entries(config.loops)
|
|
1644
|
+
.sort(([, a], [, b]) => a.order - b.order);
|
|
1645
|
+
|
|
1646
|
+
const loops = [];
|
|
1647
|
+
for (const [loopKey, loop] of sortedLoops) {
|
|
1648
|
+
const loopState = {
|
|
1649
|
+
name: loop.name || '',
|
|
1650
|
+
model: loop.model || 'claude-sonnet-4-6',
|
|
1651
|
+
stages: loop.stages || [],
|
|
1652
|
+
completion: loop.completion || '',
|
|
1653
|
+
multi_agent: false,
|
|
1654
|
+
max_agents: 3,
|
|
1655
|
+
strategy: 'parallel',
|
|
1656
|
+
agent_placeholder: '{{AGENT_NAME}}',
|
|
1657
|
+
data_files: [],
|
|
1658
|
+
entities: [],
|
|
1659
|
+
showOptional: false,
|
|
1660
|
+
showPrompt: false,
|
|
1661
|
+
prompt: '',
|
|
1662
|
+
inputFiles: (loop.fed_by || []).join(', '),
|
|
1663
|
+
outputFiles: (loop.feeds || []).join(', '),
|
|
1664
|
+
claudeArgs: (loop.claude_args || []).join(', '),
|
|
1665
|
+
skipPermissions: loop.skip_permissions !== false,
|
|
1666
|
+
stageConfigs: [],
|
|
1667
|
+
showPromptForm: false,
|
|
1668
|
+
_outputAutoFilled: false,
|
|
1669
|
+
_inputAutoFilled: false
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1672
|
+
// Load prompt content
|
|
1673
|
+
try {
|
|
1674
|
+
const promptRes = await fetch('/api/templates/' + encodeURIComponent(templateName) + '/loops/' + encodeURIComponent(loopKey) + '/prompt');
|
|
1675
|
+
if (promptRes.ok) {
|
|
1676
|
+
const promptData = await promptRes.json();
|
|
1677
|
+
loopState.prompt = promptData.content || '';
|
|
1678
|
+
}
|
|
1679
|
+
} catch { /* prompt load is best-effort */ }
|
|
1680
|
+
|
|
1681
|
+
// Show form if no prompt content loaded
|
|
1682
|
+
loopState.showPromptForm = !loopState.prompt.trim();
|
|
1683
|
+
loops.push(loopState);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
state.editingTemplateName = templateName;
|
|
1687
|
+
state.showTemplateBuilder = true;
|
|
1688
|
+
state.selectedBuilderLoop = 0;
|
|
1689
|
+
state.templateBuilderState = {
|
|
1690
|
+
name: config.name || templateName,
|
|
1691
|
+
description: config.description || '',
|
|
1692
|
+
loops: loops.length > 0 ? loops : [createEmptyLoop()]
|
|
1693
|
+
};
|
|
1694
|
+
renderTemplatesPage();
|
|
1695
|
+
} catch {
|
|
1696
|
+
alert('Failed to load template for editing');
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function openDeleteTemplateModal(templateName) {
|
|
1701
|
+
const existing = document.querySelector('.modal-overlay');
|
|
1702
|
+
if (existing) existing.remove();
|
|
1703
|
+
|
|
1704
|
+
const overlay = document.createElement('div');
|
|
1705
|
+
overlay.className = 'modal-overlay';
|
|
1706
|
+
overlay.innerHTML = `
|
|
1707
|
+
<div class="modal">
|
|
1708
|
+
<div class="modal-header">
|
|
1709
|
+
<h3>Delete Template</h3>
|
|
1710
|
+
<button class="modal-close" data-action="close">×</button>
|
|
1711
|
+
</div>
|
|
1712
|
+
<div class="modal-body">
|
|
1713
|
+
<p style="margin-bottom:12px">Delete template <strong>${esc(templateName)}</strong>?</p>
|
|
1714
|
+
<p style="color:var(--red);font-size:13px">This will permanently remove the template. Apps already created from it are not affected.</p>
|
|
1715
|
+
<div id="deleteTemplateMessage"></div>
|
|
1716
|
+
</div>
|
|
1717
|
+
<div class="modal-footer">
|
|
1718
|
+
<button class="btn" data-action="close">Cancel</button>
|
|
1719
|
+
<button class="btn btn-danger" id="deleteTemplateBtn">Delete</button>
|
|
1720
|
+
</div>
|
|
1721
|
+
</div>
|
|
1722
|
+
`;
|
|
1723
|
+
|
|
1724
|
+
document.body.appendChild(overlay);
|
|
1725
|
+
|
|
1726
|
+
const escHandler = (e) => {
|
|
1727
|
+
if (e.key === 'Escape') {
|
|
1728
|
+
overlay.remove();
|
|
1729
|
+
document.removeEventListener('keydown', escHandler);
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
document.addEventListener('keydown', escHandler);
|
|
1733
|
+
|
|
1734
|
+
overlay.addEventListener('click', (e) => {
|
|
1735
|
+
if (e.target === overlay || e.target.dataset.action === 'close') {
|
|
1736
|
+
overlay.remove();
|
|
1737
|
+
document.removeEventListener('keydown', escHandler);
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
overlay.querySelector('#deleteTemplateBtn').addEventListener('click', async () => {
|
|
1742
|
+
const btn = overlay.querySelector('#deleteTemplateBtn');
|
|
1743
|
+
const msgEl = overlay.querySelector('#deleteTemplateMessage');
|
|
1744
|
+
btn.disabled = true;
|
|
1745
|
+
btn.textContent = 'Deleting...';
|
|
1746
|
+
|
|
1747
|
+
try {
|
|
1748
|
+
const res = await fetch('/api/templates/' + encodeURIComponent(templateName), { method: 'DELETE' });
|
|
1749
|
+
const data = await res.json();
|
|
1750
|
+
|
|
1751
|
+
if (!res.ok) {
|
|
1752
|
+
msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to delete')}</div>`;
|
|
1753
|
+
btn.disabled = false;
|
|
1754
|
+
btn.textContent = 'Delete';
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
overlay.remove();
|
|
1759
|
+
state.templatesList = [];
|
|
1760
|
+
renderTemplatesPage();
|
|
1761
|
+
} catch {
|
|
1762
|
+
msgEl.innerHTML = '<div class="form-error">Network error</div>';
|
|
1763
|
+
btn.disabled = false;
|
|
1764
|
+
btn.textContent = 'Delete';
|
|
1765
|
+
}
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function openCloneTemplateModal(templateName) {
|
|
1770
|
+
const existing = document.querySelector('.modal-overlay');
|
|
1771
|
+
if (existing) existing.remove();
|
|
1772
|
+
|
|
1773
|
+
const overlay = document.createElement('div');
|
|
1774
|
+
overlay.className = 'modal-overlay';
|
|
1775
|
+
overlay.innerHTML = `
|
|
1776
|
+
<div class="modal">
|
|
1777
|
+
<div class="modal-header">
|
|
1778
|
+
<h3>Clone Template</h3>
|
|
1779
|
+
<button class="modal-close" data-action="close">×</button>
|
|
1780
|
+
</div>
|
|
1781
|
+
<div class="modal-body">
|
|
1782
|
+
<p style="margin-bottom:12px">Clone <strong>${esc(templateName)}</strong> as a new custom template.</p>
|
|
1783
|
+
<div class="form-group">
|
|
1784
|
+
<label class="form-label">New Template Name</label>
|
|
1785
|
+
<input class="form-input" id="cloneTemplateName" type="text" placeholder="my-custom-pipeline" autocomplete="off">
|
|
1786
|
+
</div>
|
|
1787
|
+
<div id="cloneTemplateMessage"></div>
|
|
1788
|
+
</div>
|
|
1789
|
+
<div class="modal-footer">
|
|
1790
|
+
<button class="btn" data-action="close">Cancel</button>
|
|
1791
|
+
<button class="btn btn-primary" id="cloneTemplateBtn">Clone</button>
|
|
1792
|
+
</div>
|
|
1793
|
+
</div>
|
|
1794
|
+
`;
|
|
1795
|
+
|
|
1796
|
+
document.body.appendChild(overlay);
|
|
1797
|
+
|
|
1798
|
+
const nameInput = overlay.querySelector('#cloneTemplateName');
|
|
1799
|
+
nameInput.focus();
|
|
1800
|
+
|
|
1801
|
+
const escHandler = (e) => {
|
|
1802
|
+
if (e.key === 'Escape') {
|
|
1803
|
+
overlay.remove();
|
|
1804
|
+
document.removeEventListener('keydown', escHandler);
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
document.addEventListener('keydown', escHandler);
|
|
1808
|
+
|
|
1809
|
+
overlay.addEventListener('click', (e) => {
|
|
1810
|
+
if (e.target === overlay || e.target.dataset.action === 'close') {
|
|
1811
|
+
overlay.remove();
|
|
1812
|
+
document.removeEventListener('keydown', escHandler);
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
nameInput.addEventListener('keydown', (e) => {
|
|
1817
|
+
if (e.key === 'Enter') overlay.querySelector('#cloneTemplateBtn').click();
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
overlay.querySelector('#cloneTemplateBtn').addEventListener('click', async () => {
|
|
1821
|
+
const btn = overlay.querySelector('#cloneTemplateBtn');
|
|
1822
|
+
const msgEl = overlay.querySelector('#cloneTemplateMessage');
|
|
1823
|
+
const newName = nameInput.value.trim();
|
|
1824
|
+
|
|
1825
|
+
if (!newName) {
|
|
1826
|
+
msgEl.innerHTML = '<div class="form-error">Please enter a template name</div>';
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
btn.disabled = true;
|
|
1831
|
+
btn.textContent = 'Cloning...';
|
|
1832
|
+
|
|
1833
|
+
try {
|
|
1834
|
+
const res = await fetch('/api/templates/' + encodeURIComponent(templateName) + '/clone', {
|
|
1835
|
+
method: 'POST',
|
|
1836
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1837
|
+
body: JSON.stringify({ newName })
|
|
1838
|
+
});
|
|
1839
|
+
const data = await res.json();
|
|
1840
|
+
|
|
1841
|
+
if (!res.ok) {
|
|
1842
|
+
msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to clone')}</div>`;
|
|
1843
|
+
btn.disabled = false;
|
|
1844
|
+
btn.textContent = 'Clone';
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
overlay.remove();
|
|
1849
|
+
document.removeEventListener('keydown', escHandler);
|
|
1850
|
+
state.templatesList = [];
|
|
1851
|
+
renderTemplatesPage();
|
|
1852
|
+
} catch {
|
|
1853
|
+
msgEl.innerHTML = '<div class="form-error">Network error</div>';
|
|
1854
|
+
btn.disabled = false;
|
|
1855
|
+
btn.textContent = 'Clone';
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
}
|