|
@@ -354,6 +354,30 @@
|
|
|
font-size: 0.7rem; color: var(--text-muted); margin-top: 7px;
|
|
font-size: 0.7rem; color: var(--text-muted); margin-top: 7px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /* Property context panel */
|
|
|
|
|
+ .prop-ctx-panel {
|
|
|
|
|
+ background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
|
|
|
|
|
+ border-radius: var(--radius-sm); padding: 10px 12px;
|
|
|
|
|
+ font-size: 0.78rem;
|
|
|
|
|
+ }
|
|
|
|
|
+ .prop-ctx-addr {
|
|
|
|
|
+ color: var(--text-primary); font-weight: 500; margin-bottom: 3px;
|
|
|
|
|
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
|
|
|
+ }
|
|
|
|
|
+ .prop-ctx-meta { color: var(--text-muted); font-size: 0.72rem; line-height: 1.6; }
|
|
|
|
|
+ .prop-ctx-meta span { display: block; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Address suggestion bar */
|
|
|
|
|
+ .address-suggest-bar {
|
|
|
|
|
+ background: rgba(45,220,138,0.06); border: 1px solid rgba(45,220,138,0.18);
|
|
|
|
|
+ border-radius: var(--radius-sm); padding: 6px 12px;
|
|
|
|
|
+ font-size: 0.75rem; color: var(--text-secondary);
|
|
|
|
|
+ margin-bottom: 8px; display: none; align-items: center;
|
|
|
|
|
+ gap: 8px; max-width: 800px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .address-suggest-bar.show { display: flex; }
|
|
|
|
|
+ .address-suggest-bar a { color: var(--accent); text-decoration: underline; }
|
|
|
|
|
+
|
|
|
/* Synonym suggestion */
|
|
/* Synonym suggestion */
|
|
|
.synonym-bar {
|
|
.synonym-bar {
|
|
|
background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
|
|
background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
|
|
@@ -501,6 +525,17 @@
|
|
|
</label>
|
|
</label>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <!-- Active property context (populated when arriving from site-report.php) -->
|
|
|
|
|
+ <div class="sidebar-section" id="propCtxSection" style="display:none;">
|
|
|
|
|
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
|
|
|
|
+ <span class="sidebar-label" style="margin:0;">Active property</span>
|
|
|
|
|
+ <button id="propCtxDismiss" class="btn btn-ghost" style="padding:2px 8px;font-size:0.68rem;">
|
|
|
|
|
+ <i class="bi bi-x"></i> Clear
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="prop-ctx-panel" id="propCtxPanel"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<!-- Quick asks -->
|
|
<!-- Quick asks -->
|
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section">
|
|
|
<span class="sidebar-label">Quick queries</span>
|
|
<span class="sidebar-label">Quick queries</span>
|
|
@@ -562,6 +597,10 @@
|
|
|
|
|
|
|
|
<!-- Input -->
|
|
<!-- Input -->
|
|
|
<div class="input-bar">
|
|
<div class="input-bar">
|
|
|
|
|
+ <div class="address-suggest-bar" id="addressSuggestBar">
|
|
|
|
|
+ <i class="bi bi-geo-alt" style="color:var(--accent);flex-shrink:0;"></i>
|
|
|
|
|
+ <span id="addressSuggestText">Looks like an address — <a id="addressSuggestLink" href="/site-report.php">look up property data</a> for zone & overlay context, then return here.</span>
|
|
|
|
|
+ </div>
|
|
|
<div class="synonym-bar" id="synonymBar"></div>
|
|
<div class="synonym-bar" id="synonymBar"></div>
|
|
|
<div class="input-wrap">
|
|
<div class="input-wrap">
|
|
|
<textarea
|
|
<textarea
|
|
@@ -632,6 +671,55 @@ const chatEmpty = byId('chatEmpty');
|
|
|
const questionEl = byId('question');
|
|
const questionEl = byId('question');
|
|
|
const askBtn = byId('askBtn');
|
|
const askBtn = byId('askBtn');
|
|
|
|
|
|
|
|
|
|
+/* ── Property context (arrives via localStorage from site-report.php) ── */
|
|
|
|
|
+let propCtx = null;
|
|
|
|
|
+
|
|
|
|
|
+function loadPropCtx() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const raw = localStorage.getItem('tpr_builder_ctx');
|
|
|
|
|
+ if (!raw) return;
|
|
|
|
|
+ const { ctx, written_at } = JSON.parse(raw);
|
|
|
|
|
+ if (!ctx || (Date.now() - written_at > 30 * 60 * 1000)) return; // 30-min TTL
|
|
|
|
|
+ propCtx = ctx;
|
|
|
|
|
+ renderPropCtx();
|
|
|
|
|
+ // Auto-set council dropdown once options are loaded
|
|
|
|
|
+ if (ctx.council) {
|
|
|
|
|
+ const trySet = setInterval(() => {
|
|
|
|
|
+ const sel = byId('council');
|
|
|
|
|
+ const opt = sel && [...sel.options].find(o =>
|
|
|
|
|
+ o.value.toLowerCase() === ctx.council.toLowerCase()
|
|
|
|
|
+ );
|
|
|
|
|
+ if (opt) { opt.selected = true; clearInterval(trySet); }
|
|
|
|
|
+ }, 100);
|
|
|
|
|
+ setTimeout(() => clearInterval(trySet), 5000);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch(e) { console.warn('[propCtx] load failed', e); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderPropCtx() {
|
|
|
|
|
+ const section = byId('propCtxSection');
|
|
|
|
|
+ const panel = byId('propCtxPanel');
|
|
|
|
|
+ if (!section || !panel || !propCtx) return;
|
|
|
|
|
+ const zones = Array.isArray(propCtx.planning_zones) ? propCtx.planning_zones.join(', ') : (propCtx.planning_zones || '');
|
|
|
|
|
+ const codes = Array.isArray(propCtx.planning_codes) ? propCtx.planning_codes.join(', ') : (propCtx.planning_codes || '');
|
|
|
|
|
+ panel.innerHTML = `
|
|
|
|
|
+ <div class="prop-ctx-addr"><i class="bi bi-geo-alt" style="color:var(--accent);margin-right:4px;"></i>${esc(propCtx.address || '—')}</div>
|
|
|
|
|
+ <div class="prop-ctx-meta">
|
|
|
|
|
+ ${propCtx.council ? `<span>Council: ${esc(propCtx.council)}</span>` : ''}
|
|
|
|
|
+ ${zones ? `<span>Zone: ${esc(zones)}</span>` : ''}
|
|
|
|
|
+ ${codes ? `<span>Codes: ${esc(codes)}</span>` : ''}
|
|
|
|
|
+ ${propCtx.area_sqm ? `<span>Area: ${esc(String(propCtx.area_sqm))} m²</span>` : ''}
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ section.style.display = '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearPropCtx() {
|
|
|
|
|
+ propCtx = null;
|
|
|
|
|
+ localStorage.removeItem('tpr_builder_ctx');
|
|
|
|
|
+ const section = byId('propCtxSection');
|
|
|
|
|
+ if (section) section.style.display = 'none';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/* ── Session ID for telemetry ────────────────────────────────────────── */
|
|
/* ── Session ID for telemetry ────────────────────────────────────────── */
|
|
|
const SID_KEY = 'tpr_sid';
|
|
const SID_KEY = 'tpr_sid';
|
|
|
const sid = localStorage.getItem(SID_KEY) || (() => {
|
|
const sid = localStorage.getItem(SID_KEY) || (() => {
|
|
@@ -767,8 +855,23 @@ async function callExternalLLM(prompt, provider) {
|
|
|
|
|
|
|
|
/* ── Ask ─────────────────────────────────────────────────────────────── */
|
|
/* ── Ask ─────────────────────────────────────────────────────────────── */
|
|
|
async function ask(queryOverride) {
|
|
async function ask(queryOverride) {
|
|
|
- const query = (queryOverride || questionEl.value || '').trim();
|
|
|
|
|
- if (!query || isAsking) return;
|
|
|
|
|
|
|
+ const rawQuery = (queryOverride || questionEl.value || '').trim();
|
|
|
|
|
+ if (!rawQuery || isAsking) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Prepend site context if a property has been looked up
|
|
|
|
|
+ let query = rawQuery;
|
|
|
|
|
+ if (propCtx) {
|
|
|
|
|
+ const zones = Array.isArray(propCtx.planning_zones) ? propCtx.planning_zones.join(', ') : (propCtx.planning_zones || '');
|
|
|
|
|
+ const codes = Array.isArray(propCtx.planning_codes) ? propCtx.planning_codes.join(', ') : (propCtx.planning_codes || '');
|
|
|
|
|
+ const parts = [
|
|
|
|
|
+ propCtx.address ? `Address: ${propCtx.address}` : '',
|
|
|
|
|
+ propCtx.council ? `Council: ${propCtx.council}` : '',
|
|
|
|
|
+ zones ? `Zone(s): ${zones}` : '',
|
|
|
|
|
+ codes ? `Codes/overlays: ${codes}` : '',
|
|
|
|
|
+ propCtx.area_sqm ? `Site area: ${propCtx.area_sqm} m²` : '',
|
|
|
|
|
+ ].filter(Boolean).join('; ');
|
|
|
|
|
+ query = `[Site context — ${parts}]\n\n${rawQuery}`;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
const council = (byId('council')?.value || '').trim();
|
|
const council = (byId('council')?.value || '').trim();
|
|
|
const scope = computeScope();
|
|
const scope = computeScope();
|
|
@@ -782,9 +885,9 @@ async function ask(queryOverride) {
|
|
|
autoResize(questionEl);
|
|
autoResize(questionEl);
|
|
|
hideSynonymBar();
|
|
hideSynonymBar();
|
|
|
|
|
|
|
|
- appendUserMsg(query);
|
|
|
|
|
|
|
+ appendUserMsg(rawQuery);
|
|
|
const thinkEl = appendThinking();
|
|
const thinkEl = appendThinking();
|
|
|
- sendEvent('search_performed', { query, scope, source: 'assistant', byok: useBYOK ? provider : null });
|
|
|
|
|
|
|
+ sendEvent('search_performed', { query: rawQuery, scope, source: 'assistant', byok: useBYOK ? provider : null });
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
if (useBYOK) {
|
|
if (useBYOK) {
|
|
@@ -811,8 +914,8 @@ async function ask(queryOverride) {
|
|
|
topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
|
|
topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- appendAssistantMsg(answer || 'No answer returned.', scope, lastSources, query, provider);
|
|
|
|
|
- addToHistory(query);
|
|
|
|
|
|
|
+ appendAssistantMsg(answer || 'No answer returned.', scope, lastSources, rawQuery, provider);
|
|
|
|
|
+ addToHistory(rawQuery);
|
|
|
|
|
|
|
|
} else {
|
|
} else {
|
|
|
// ── Internal Ollama path (unchanged) ───────────────────────────
|
|
// ── Internal Ollama path (unchanged) ───────────────────────────
|
|
@@ -835,8 +938,8 @@ async function ask(queryOverride) {
|
|
|
model: data.model || 'unknown', ok: true,
|
|
model: data.model || 'unknown', ok: true,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- appendAssistantMsg(data.answer || 'No answer returned.', scope, lastSources, query, 'internal');
|
|
|
|
|
- addToHistory(query);
|
|
|
|
|
|
|
+ appendAssistantMsg(data.answer || 'No answer returned.', scope, lastSources, rawQuery, 'internal');
|
|
|
|
|
+ addToHistory(rawQuery);
|
|
|
}
|
|
}
|
|
|
} catch(e) {
|
|
} catch(e) {
|
|
|
thinkEl.remove();
|
|
thinkEl.remove();
|
|
@@ -1050,6 +1153,7 @@ questionEl.addEventListener('keydown', e => {
|
|
|
questionEl.addEventListener('input', () => {
|
|
questionEl.addEventListener('input', () => {
|
|
|
autoResize(questionEl);
|
|
autoResize(questionEl);
|
|
|
checkSynonyms(questionEl.value);
|
|
checkSynonyms(questionEl.value);
|
|
|
|
|
+ checkAddressInQuery(questionEl.value);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
askBtn.addEventListener('click', () => ask());
|
|
askBtn.addEventListener('click', () => ask());
|
|
@@ -1178,6 +1282,22 @@ function hideSynonymBar() {
|
|
|
bar.innerHTML = '';
|
|
bar.innerHTML = '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/* ── Address detection ───────────────────────────────────────────────── */
|
|
|
|
|
+// Matches patterns like "12 Smith Street" or "12a High Road Hobart"
|
|
|
|
|
+const ADDRESS_RE = /\b\d+[a-z]?\s+[a-z][a-z\s'-]{2,35}\b(?:\s+(?:street|st|road|rd|avenue|ave|drive|dr|court|ct|place|pl|crescent|cr|lane|ln|way|close|cl|circuit|grove|gr|terrace|tce|boulevard|blvd|highway|hwy))?/i;
|
|
|
|
|
+
|
|
|
|
|
+function checkAddressInQuery(val) {
|
|
|
|
|
+ const bar = byId('addressSuggestBar');
|
|
|
|
|
+ if (!bar) return;
|
|
|
|
|
+ // Don't show if property context is already loaded
|
|
|
|
|
+ if (propCtx) { bar.classList.remove('show'); return; }
|
|
|
|
|
+ if (val.length > 8 && ADDRESS_RE.test(val)) {
|
|
|
|
|
+ bar.classList.add('show');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ bar.classList.remove('show');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
window.appendSynonym = function(term) {
|
|
window.appendSynonym = function(term) {
|
|
|
questionEl.value = (questionEl.value + ' ' + term).trim();
|
|
questionEl.value = (questionEl.value + ' ' + term).trim();
|
|
|
questionEl.focus();
|
|
questionEl.focus();
|
|
@@ -1228,7 +1348,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
loadCouncils();
|
|
loadCouncils();
|
|
|
renderHistory();
|
|
renderHistory();
|
|
|
updateByokButton();
|
|
updateByokButton();
|
|
|
|
|
+ loadPropCtx();
|
|
|
questionEl.focus();
|
|
questionEl.focus();
|
|
|
|
|
+
|
|
|
|
|
+ byId('propCtxDismiss')?.addEventListener('click', clearPropCtx);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Update button if user navigates back from settings with a new key
|
|
// Update button if user navigates back from settings with a new key
|