408 lines
16 KiB
HTML
Raw Permalink Normal View History

2026-03-13 00:18:14 +00:00
{% extends "base.html" %}
{% block title %}Compose — Ontoref{% endblock title %}
{% block content %}
<div class="mb-5 flex items-center justify-between">
<div>
<h1 class="text-xl font-bold">Agent Task Composer</h1>
<p class="text-sm text-base-content/50 mt-0.5">Select a form template, fill fields, send to an AI provider or export.</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Left: template selector + form fields -->
<div class="space-y-4">
<!-- Template selector -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5">
<h2 class="text-sm font-semibold mb-2">Template</h2>
{% if forms %}
<select id="form-select" class="select select-bordered select-sm w-full">
<option value="">— choose a form template —</option>
{% for f in forms %}
<option value="{{ f.id }}">{{ f.label }}</option>
{% endfor %}
</select>
{% else %}
<p class="text-xs text-base-content/40">No forms found in <code>reflection/forms/</code>.</p>
{% endif %}
</div>
</div>
<!-- Dynamic form fields -->
<div id="form-fields" class="card bg-base-200 border border-base-content/10 hidden">
<div class="card-body py-4 px-5">
<h2 class="text-sm font-semibold mb-1" id="form-name">Fields</h2>
<p class="text-xs text-base-content/40 mb-3" id="form-description"></p>
<div id="fields-container" class="space-y-3"></div>
</div>
</div>
<!-- System prompt override -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5">
<h2 class="text-sm font-semibold mb-2">System prompt <span class="text-xs font-normal text-base-content/40">(optional)</span></h2>
<textarea id="system-prompt" rows="3"
placeholder="Override default system instructions for the agent..."
class="textarea textarea-bordered textarea-sm w-full font-mono text-xs"></textarea>
</div>
</div>
</div>
<!-- Right: prompt preview + send -->
<div class="space-y-4">
<!-- Assembled prompt preview -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5">
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold">Assembled prompt</h2>
<button id="copy-btn" class="btn btn-xs btn-ghost gap-1" onclick="copyPrompt()">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
Copy
</button>
</div>
<textarea id="prompt-preview" rows="12" readonly
placeholder="Fill form fields to see assembled prompt..."
class="textarea textarea-bordered textarea-sm w-full font-mono text-xs bg-base-300 resize-none"></textarea>
</div>
</div>
<!-- Provider + actions -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5 space-y-3">
<h2 class="text-sm font-semibold">Send / Export</h2>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Provider</span></label>
<select id="provider-select" class="select select-bordered select-sm">
{% for p in providers %}
<option value="{{ p.id }}">{{ p.label }}</option>
{% endfor %}
</select>
</div>
<div class="flex gap-2 flex-wrap">
<button onclick="sendToAPI()" class="btn btn-sm btn-primary gap-1.5 flex-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Send to API
</button>
<button onclick="downloadPlan()" class="btn btn-sm btn-outline gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Save as plan
</button>
</div>
<!-- API response -->
<div id="api-response" class="hidden">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-base-content/60">Response</span>
<button onclick="copyResponse()" class="btn btn-xs btn-ghost">Copy</button>
</div>
<div id="response-text"
class="bg-base-300 rounded p-3 text-xs font-mono whitespace-pre-wrap max-h-64 overflow-auto"></div>
</div>
<div id="api-error" class="hidden alert alert-error text-xs py-2 px-3"></div>
<div id="api-loading" class="hidden flex items-center gap-2 text-xs text-base-content/40">
<span class="loading loading-spinner loading-xs"></span> Sending...
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<script>
const BASE_URL = "{{ base_url }}";
let formSchema = null;
// ── Template selection ────────────────────────────────────────────────────────
document.getElementById('form-select')?.addEventListener('change', async function() {
const id = this.value;
if (!id) {
document.getElementById('form-fields').classList.add('hidden');
formSchema = null;
updatePreview();
return;
}
try {
const resp = await fetch(`${BASE_URL}/compose/form/${encodeURIComponent(id)}`);
if (!resp.ok) throw new Error(await resp.text());
formSchema = await resp.json();
renderFields(formSchema);
updatePreview();
} catch(e) {
console.error('failed to load form schema', e);
}
});
// ── Dynamic field rendering ───────────────────────────────────────────────────
function renderFields(schema) {
document.getElementById('form-name').textContent = schema.name || '';
document.getElementById('form-description').textContent = schema.description || '';
const container = document.getElementById('fields-container');
container.innerHTML = '';
const elements = Array.isArray(schema.elements) ? schema.elements : [];
for (const el of elements) {
if (['section_header', 'section', 'confirm'].includes(el.type)) continue;
const wrap = document.createElement('div');
wrap.className = 'form-control';
const label = document.createElement('label');
label.className = 'label py-0.5';
label.innerHTML = `<span class="label-text text-xs font-medium">${el.prompt || el.name}${el.required ? ' <span class="text-error">*</span>' : ''}</span>`;
wrap.appendChild(label);
if (el.help) {
const help = document.createElement('p');
help.className = 'text-xs text-base-content/40 mb-1';
help.textContent = el.help;
wrap.appendChild(help);
}
let input;
if (el.type === 'select') {
input = document.createElement('select');
input.className = 'select select-bordered select-sm';
for (const opt of (el.options || [])) {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label || opt.value;
if (opt.value === el.default) o.selected = true;
input.appendChild(o);
}
} else if (el.type === 'multiselect') {
input = document.createElement('div');
input.className = 'flex flex-wrap gap-2';
for (const opt of (el.options || [])) {
const lbl = document.createElement('label');
lbl.className = 'flex items-center gap-1 text-xs cursor-pointer';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'checkbox checkbox-xs';
cb.value = opt.value;
cb.dataset.name = el.name;
cb.addEventListener('change', updatePreview);
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(opt.label || opt.value));
input.appendChild(lbl);
}
} else if (el.type === 'editor') {
input = document.createElement('textarea');
input.className = 'textarea textarea-bordered textarea-sm w-full font-mono text-xs';
input.rows = 4;
input.placeholder = el.prefix_text ? el.prefix_text.replace(/^#.*\n/gm, '').trim() : '';
} else {
input = document.createElement('input');
input.type = 'text';
input.className = 'input input-bordered input-sm w-full';
input.placeholder = el.placeholder || '';
if (el.default) input.value = el.default;
}
if (input.tagName !== 'DIV') {
input.dataset.name = el.name;
input.addEventListener('input', updatePreview);
input.addEventListener('change', updatePreview);
}
wrap.appendChild(input);
container.appendChild(wrap);
}
document.getElementById('form-fields').classList.remove('hidden');
}
// ── Prompt assembly ───────────────────────────────────────────────────────────
function collectFields() {
const fields = {};
const container = document.getElementById('fields-container');
container.querySelectorAll('[data-name]').forEach(el => {
const name = el.dataset.name;
if (el.type === 'checkbox') {
if (!fields[name]) fields[name] = [];
if (el.checked) fields[name].push(el.value);
} else {
fields[name] = el.value;
}
});
return fields;
}
function updatePreview() {
if (!formSchema) {
document.getElementById('prompt-preview').value = '';
return;
}
const fields = collectFields();
const lines = [];
lines.push(`# Task: ${formSchema.name || 'Agent Task'}`);
if (formSchema.description) lines.push(`\n${formSchema.description}`);
lines.push('\n## Parameters\n');
const elements = Array.isArray(formSchema.elements) ? formSchema.elements : [];
for (const el of elements) {
if (['section_header', 'section', 'confirm'].includes(el.type)) continue;
const val = fields[el.name];
if (!val || (Array.isArray(val) && val.length === 0)) continue;
const display = Array.isArray(val) ? val.join(', ') : val;
lines.push(`**${el.prompt || el.name}**: ${display}\n`);
}
lines.push('\n## Instructions');
lines.push('Execute this task according to the above parameters. Return results in structured format.');
document.getElementById('prompt-preview').value = lines.join('\n');
}
// ── Send to API ───────────────────────────────────────────────────────────────
async function sendToAPI() {
const prompt = document.getElementById('prompt-preview').value.trim();
if (!prompt) { alert('Fill in form fields first.'); return; }
const providerId = document.getElementById('provider-select').value;
const system = document.getElementById('system-prompt').value.trim();
document.getElementById('api-loading').classList.remove('hidden');
document.getElementById('api-response').classList.add('hidden');
document.getElementById('api-error').classList.add('hidden');
try {
const resp = await fetch(`${BASE_URL}/compose/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: providerId, prompt, system }),
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || JSON.stringify(data));
}
// Extract text from Anthropic or OpenAI response shape
let text = '';
if (data.content && Array.isArray(data.content)) {
text = data.content.map(c => c.text || '').join('\n');
} else if (data.choices && Array.isArray(data.choices)) {
text = data.choices.map(c => c.message?.content || '').join('\n');
} else {
text = JSON.stringify(data, null, 2);
}
document.getElementById('response-text').textContent = text;
document.getElementById('api-response').classList.remove('hidden');
} catch(e) {
document.getElementById('api-error').textContent = e.message || String(e);
document.getElementById('api-error').classList.remove('hidden');
} finally {
document.getElementById('api-loading').classList.add('hidden');
}
}
// ── Download as plan file ─────────────────────────────────────────────────────
function buildNclMeta(formId, fields, provider) {
const date = new Date().toISOString().slice(0, 10);
const fieldLines = Object.entries(fields)
.filter(([, v]) => v !== '' && !(Array.isArray(v) && v.length === 0))
.map(([k, v]) => {
const val = Array.isArray(v) ? `[${v.map(s => `"${s}"`).join(', ')}]` : `"${v.replace(/"/g, '\\"')}"`;
return ` ${k} = ${val},`;
}).join('\n');
return `let S = import "reflection/schemas/plan.ncl" in S.Plan & {
template = "${formId}",
date = "${date}",
provider = "${provider}",
status = 'Draft,
linked_backlog = [],
linked_adrs = [],
fields = {
${fieldLines}
},
dag = [],
}
`;
}
function downloadPlan() {
const prompt = document.getElementById('prompt-preview').value.trim();
if (!prompt) { alert('Fill in form fields first.'); return; }
const formId = document.getElementById('form-select').value || 'task';
const provider = document.getElementById('provider-select').value || '';
const date = new Date().toISOString().slice(0, 10);
const base = `${date}-${formId}`;
// Download .plan.md
const mdBlob = new Blob([prompt], { type: 'text/markdown' });
triggerDownload(mdBlob, `${base}.plan.md`);
// Download .plan.ncl companion
const fields = collectFields();
const nclText = buildNclMeta(formId, fields, provider);
const nclBlob = new Blob([nclText], { type: 'text/plain' });
// Small delay so browsers don't block the second download
setTimeout(() => triggerDownload(nclBlob, `${base}.plan.ncl`), 100);
}
function triggerDownload(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
// ── Copy helpers ──────────────────────────────────────────────────────────────
function copyPrompt() {
const text = document.getElementById('prompt-preview').value;
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn');
const orig = btn.innerHTML;
btn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Copied';
setTimeout(() => { btn.innerHTML = orig; }, 1500);
});
}
function copyResponse() {
const text = document.getElementById('response-text').textContent;
navigator.clipboard.writeText(text);
}
// Pre-fill from URL params (?form=...) — useful when launched from backlog "Send to agent"
(function() {
const p = new URLSearchParams(location.search);
const f = p.get('form');
const sel = document.getElementById('form-select');
if (f && sel) {
sel.value = f;
sel.dispatchEvent(new Event('change'));
}
})();
</script>
{% endblock scripts %}