Add manual 'Save result' to all tools — replaces auto-save
All tool results can now be saved to My Case manually. Users click 'Save result', type a description, and confirm. This replaces the previous silent auto-save on barnevernet/timeline/etc., giving users control over what stays and what it's called (supports multiple runs of the same tool with different titles). - CaseResults: extend ELIGIBLE_TOOLS to include summarize, ask, redact, transcribe; add toolLabel/toolIcon entries; support explicit title via meta['title'] in save() - api/case/save-result.php: new client-initiated save endpoint; accepts tool + title + input_payload + output_payload + meta - Remove CaseResults::save() auto-save from barnevernet, deep-research, discrepancy, korrespond, timeline API endpoints - tools.js: add showSaveResultButton() (exposed as window.dbnShowSaveResultButton); wire for ask, redact, timeline, transcribe (both file-upload and stored-audio paths) - barnevernet.js: wire save button after final result render - summarize.js: wire save button after renderFinal(); passes sumResults container so widget appears in the correct #sumResults div - case-result.php: rich tool-specific rendering for summarize, ask, redact, transcribe, timeline; update re-run link map to include all new tools - tools.css: styles for .save-result-widget and its states (idle, prompt, done, error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9173,3 +9173,97 @@ body.lt-landing {
|
||||
.account-survey-cta span { font-size: 0.8rem; opacity: 0.85; }
|
||||
.account-survey-cta:hover { background: #fde68a; }
|
||||
|
||||
|
||||
/* ── Save result widget ────────────────────────────────────────────────────── */
|
||||
.save-result-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #c7d3f5;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.save-result-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--dbn-blue, #00205b);
|
||||
color: var(--dbn-blue, #00205b);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-result-btn:hover {
|
||||
background: var(--dbn-blue, #00205b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.save-result-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.save-result-input {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line, #d8dde7);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--dbn-ink, #16130f);
|
||||
background: #fff;
|
||||
}
|
||||
.save-result-input:focus {
|
||||
outline: 2px solid var(--dbn-blue, #00205b);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.save-result-confirm {
|
||||
background: var(--dbn-blue, #00205b);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-result-confirm:disabled { opacity: 0.55; cursor: default; }
|
||||
|
||||
.save-result-cancel {
|
||||
background: none;
|
||||
border: 1px solid var(--line, #d8dde7);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted, #667085);
|
||||
}
|
||||
.save-result-cancel:hover { border-color: #aaa; }
|
||||
|
||||
.save-result-done {
|
||||
width: 100%;
|
||||
}
|
||||
.save-result-link {
|
||||
color: var(--dbn-teal, #0f766e);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.save-result-link:hover { text-decoration: underline; }
|
||||
|
||||
.save-result-error {
|
||||
color: var(--coral, #c2410c);
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -570,6 +570,14 @@
|
||||
els.runButton.disabled = false;
|
||||
renderTrace(finalResult.trace || []);
|
||||
renderFinalResults(finalResult);
|
||||
if (typeof window.dbnShowSaveResultButton === 'function') {
|
||||
window.dbnShowSaveResultButton(
|
||||
finalResult.tool || 'barnevernet',
|
||||
payload,
|
||||
finalResult,
|
||||
{ model: (finalResult.trace_metadata || {}).deployment || null, latency_ms: finalResult.latency_ms || 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progressive rendering (renders as stream events arrive) ────────────────
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
var _extractedFiles = []; // [{ name, text, chars }]
|
||||
var _currentLang = 'en';
|
||||
var _lastPayload = null;
|
||||
|
||||
// ── Lang switcher ─────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.sum-lang-btn').forEach(function (btn) {
|
||||
@@ -177,6 +178,7 @@
|
||||
slices: slices,
|
||||
};
|
||||
if (docIds.length) payload.doc_ids = docIds;
|
||||
_lastPayload = payload;
|
||||
|
||||
setBusy(true);
|
||||
setStatus('Running…');
|
||||
@@ -218,6 +220,15 @@
|
||||
setStatus(data.detail || '');
|
||||
} else if (data.event === 'final') {
|
||||
renderFinal(data);
|
||||
if (typeof window.dbnShowSaveResultButton === 'function') {
|
||||
window.dbnShowSaveResultButton(
|
||||
'summarize',
|
||||
_lastPayload || {},
|
||||
data,
|
||||
{ model: data.engine || null, latency_ms: data.latency_ms || 0 },
|
||||
resultsEl
|
||||
);
|
||||
}
|
||||
if (data.balance != null) {
|
||||
var credEl = document.getElementById('creditsRemaining');
|
||||
if (credEl) credEl.textContent = data.balance;
|
||||
|
||||
@@ -401,6 +401,7 @@ let lastRedactedText = null;
|
||||
let lastOriginalText = '';
|
||||
let lastRedactPayload = null;
|
||||
let lastRunEngine = null;
|
||||
let lastToolPayload = null;
|
||||
|
||||
const VOCAB_PRESETS = {
|
||||
barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører',
|
||||
@@ -1110,6 +1111,8 @@ async function runTool(event) {
|
||||
payload.use_my_case = (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false;
|
||||
}
|
||||
|
||||
lastToolPayload = { ...payload };
|
||||
|
||||
setBusy(true);
|
||||
renderTrace([
|
||||
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
|
||||
@@ -1123,6 +1126,12 @@ async function runTool(event) {
|
||||
renderResults(data);
|
||||
renderTrace(data.trace || []);
|
||||
els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
|
||||
if (['ask', 'redact', 'timeline'].includes(state.activeTool)) {
|
||||
showSaveResultButton(state.activeTool, lastToolPayload, data, {
|
||||
model: data.trace_metadata?.deployment || null,
|
||||
latency_ms: data.latency_ms || 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
els.status.textContent = error.message;
|
||||
renderTrace([
|
||||
@@ -1846,6 +1855,10 @@ async function runTranscribe() {
|
||||
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Transcription failed.');
|
||||
if (typeof renderTranscribeResult === 'function') renderTranscribeResult(data);
|
||||
else renderResults(data);
|
||||
showSaveResultButton('transcribe', { audio_doc_id: storedAudioDocId }, data, {
|
||||
model: data.model || null,
|
||||
latency_ms: data.latency_ms || 0,
|
||||
});
|
||||
els.status.textContent = 'Done.';
|
||||
} catch (err) {
|
||||
els.status.textContent = err.message;
|
||||
@@ -1976,6 +1989,10 @@ async function runTranscribe() {
|
||||
|
||||
lastTranscriptData = merged;
|
||||
renderTranscriptResults(merged);
|
||||
showSaveResultButton('transcribe', lastToolPayload || {}, merged, {
|
||||
model: merged.model || null,
|
||||
latency_ms: merged.latency_ms || 0,
|
||||
});
|
||||
|
||||
const totalSec = Math.round(cumulativeOffset);
|
||||
const totalMin = Math.floor(totalSec / 60);
|
||||
@@ -2417,6 +2434,124 @@ function escapeHtml(value) {
|
||||
|
||||
// ── Free-tier credit badge ────────────────────────────────────────────────
|
||||
|
||||
// ── Save result widget ─────────────────────────────────────────────────────
|
||||
|
||||
const _SAVEABLE_TOOL_LABELS = {
|
||||
ask: 'Spørsmål & svar',
|
||||
redact: 'Anonymisering',
|
||||
timeline: 'Tidslinje',
|
||||
transcribe: 'Transkripsjon',
|
||||
summarize: 'Sammendrag',
|
||||
barnevernet: 'BVJ-analyse',
|
||||
'deep-research': 'Dyp analyse',
|
||||
advocate: 'Advokatutkast',
|
||||
discrepancy: 'Motstrid',
|
||||
korrespond: 'Korrespondanse',
|
||||
};
|
||||
|
||||
function _isSaveEligibleUser() {
|
||||
const tier = window.DBN_USER_TIER;
|
||||
return typeof tier === 'string' && !['free', 'caveau'].includes(tier);
|
||||
}
|
||||
|
||||
function showSaveResultButton(tool, inputPayload, outputPayload, meta, containerEl) {
|
||||
if (!_isSaveEligibleUser()) return;
|
||||
if (!Object.prototype.hasOwnProperty.call(_SAVEABLE_TOOL_LABELS, tool)) return;
|
||||
|
||||
document.getElementById('saveResultWidget')?.remove();
|
||||
|
||||
const label = _SAVEABLE_TOOL_LABELS[tool] || tool;
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString('no-NO', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
const timeStr = now.toLocaleTimeString('no-NO', { hour: '2-digit', minute: '2-digit' });
|
||||
const dfTitle = `${label} — ${dateStr} ${timeStr}`;
|
||||
|
||||
const widget = document.createElement('div');
|
||||
widget.id = 'saveResultWidget';
|
||||
widget.className = 'save-result-widget';
|
||||
widget.innerHTML = `
|
||||
<div class="save-result-idle">
|
||||
<button type="button" class="save-result-btn" id="saveResultTrigger">💾 Save result</button>
|
||||
</div>
|
||||
<div class="save-result-prompt is-hidden">
|
||||
<input type="text" class="save-result-input" id="saveResultTitle" maxlength="200" aria-label="Result title">
|
||||
<button type="button" class="save-result-confirm" id="saveResultConfirm">Save</button>
|
||||
<button type="button" class="save-result-cancel" id="saveResultCancel">Cancel</button>
|
||||
</div>
|
||||
<div class="save-result-done is-hidden">
|
||||
<a class="save-result-link" href="min-sak.php">✓ Saved — View in My Case ↗</a>
|
||||
</div>
|
||||
<p class="save-result-error is-hidden"></p>`;
|
||||
|
||||
const resultsEl = containerEl || document.getElementById('results');
|
||||
if (!resultsEl) return;
|
||||
resultsEl.insertBefore(widget, resultsEl.firstChild);
|
||||
|
||||
// Set value after inserting so escaping is handled by the DOM
|
||||
widget.querySelector('#saveResultTitle').value = dfTitle;
|
||||
|
||||
const triggerBtn = widget.querySelector('#saveResultTrigger');
|
||||
const promptDiv = widget.querySelector('.save-result-prompt');
|
||||
const idleDiv = widget.querySelector('.save-result-idle');
|
||||
const doneDiv = widget.querySelector('.save-result-done');
|
||||
const errorP = widget.querySelector('.save-result-error');
|
||||
const titleInput = widget.querySelector('#saveResultTitle');
|
||||
const confirmBtn = widget.querySelector('#saveResultConfirm');
|
||||
const cancelBtn = widget.querySelector('#saveResultCancel');
|
||||
|
||||
triggerBtn.addEventListener('click', () => {
|
||||
idleDiv.classList.add('is-hidden');
|
||||
promptDiv.classList.remove('is-hidden');
|
||||
titleInput.focus();
|
||||
titleInput.select();
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
promptDiv.classList.add('is-hidden');
|
||||
idleDiv.classList.remove('is-hidden');
|
||||
});
|
||||
|
||||
titleInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') confirmBtn.click();
|
||||
if (e.key === 'Escape') cancelBtn.click();
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener('click', async () => {
|
||||
const title = titleInput.value.trim();
|
||||
if (!title) { titleInput.focus(); return; }
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Saving…';
|
||||
errorP.classList.add('is-hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch('api/case/save-result.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tool,
|
||||
title,
|
||||
input_payload: inputPayload || {},
|
||||
output_payload: outputPayload || {},
|
||||
meta: meta || {},
|
||||
}),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed.');
|
||||
promptDiv.classList.add('is-hidden');
|
||||
doneDiv.classList.remove('is-hidden');
|
||||
} catch (err) {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Save';
|
||||
errorP.textContent = err.message;
|
||||
errorP.classList.remove('is-hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.dbnShowSaveResultButton = showSaveResultButton;
|
||||
|
||||
let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
|
||||
|
||||
function dbnUpdateCredits(balance) {
|
||||
|
||||
Reference in New Issue
Block a user