408 lines
16 KiB
HTML
408 lines
16 KiB
HTML
{% 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 %}
|