| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440 |
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Planning Scheme Assistant — Tasmanian SPP & LPS Lookup</title>
- <meta name="description" content="Ask questions about Tasmanian planning zones, overlays, setbacks and acceptable solutions. Every answer cites the exact SPP or LPS clause.">
- <link rel="canonical" href="https://tasplanning.report/local_state-planning-scheme/">
- <meta name="robots" content="index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1">
-
- <meta property="og:type" content="website">
- <meta property="og:locale" content="en_AU">
- <meta property="og:site_name" content="Tasmanian Planning Scheme Assistant">
- <meta property="og:url" content="https://tasplanning.report/local_state-planning-scheme">
- <meta property="og:title" content="Planning Scheme Assistant — Tasmanian SPP & LPS Lookup">
- <meta property="og:description" content="Instant, clause-cited answers from the Tasmanian Planning Scheme.">
- <meta property="og:image" content="https://tasplanning.report/image/og-image.jpg">
-
- <meta name="twitter:card" content="summary_large_image">
- <meta name="theme-color" content="#0b0f0e">
-
- <link rel="icon" href="/favicon.ico">
- <link rel="apple-touch-icon" href="/image/apple-touch-icon.png">
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
- <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
- <link rel="stylesheet" href="/css/design-tokens.css">
- <style>
- /* ── Page-specific token overrides ───────────────────────────────── */
- :root { --user-bg: #1a2420; }
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- html, body { height: 100%; }
- body {
- font-family: var(--sans);
- background: var(--bg);
- color: var(--text-primary);
- font-size: 15px;
- line-height: 1.65;
- -webkit-font-smoothing: antialiased;
- display: flex; flex-direction: column;
- }
- ::selection { background: var(--accent); color: #0b0f0e; }
- /* ── Nav ─────────────────────────────────────────────────────────── */
- .site-nav {
- background: rgba(11,15,14,0.95);
- backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
- position: sticky; top: 0; z-index: 100;
- }
- .nav-inner {
- max-width: 1400px; margin: 0 auto;
- display: flex; align-items: center; justify-content: space-between;
- padding: 0 20px; height: 54px; gap: 12px;
- }
- .nav-brand {
- display: flex; align-items: center; gap: 9px;
- font-size: 0.85rem; font-weight: 500; color: var(--text-primary);
- text-decoration: none; white-space: nowrap; flex-shrink: 0;
- }
- .nav-links { display: flex; align-items: center; gap: 2px; }
- .nav-links a {
- font-size: 0.8rem; color: var(--text-secondary);
- padding: 5px 10px; border-radius: var(--radius-sm);
- text-decoration: none; transition: all var(--transition);
- }
- .nav-links a:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); }
- .nav-links a.active { color: var(--accent); }
- .nav-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
- .nav-status { display: flex; align-items: center; gap: 5px; font-size: 0.72rem; color: var(--text-muted); }
- .status-dot {
- width: 7px; height: 7px; border-radius: 50%;
- background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
- display: inline-block; flex-shrink: 0;
- animation: pulse 2.5s ease-in-out infinite;
- }
- @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
- /* ── App layout: sidebar + chat ──────────────────────────────────── */
- .app-wrap {
- display: grid;
- grid-template-columns: 300px 1fr;
- flex: 1; min-height: 0; max-width: 1400px;
- margin: 0 auto; width: 100%;
- }
- @media(max-width:900px) {
- .app-wrap { grid-template-columns: 1fr; }
- .sidebar { display: none; }
- }
- /* ── Sidebar ─────────────────────────────────────────────────────── */
- .sidebar {
- border-right: 1px solid var(--border);
- display: flex; flex-direction: column;
- overflow-y: auto; background: var(--bg-1);
- }
- .sidebar-section {
- padding: 16px; border-bottom: 1px solid var(--border);
- }
- .sidebar-label {
- font-size: 0.68rem; font-weight: 500; letter-spacing: 0.1em;
- text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px;
- display: block;
- }
- select, input[type=text], input[type=number] {
- width: 100%;
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius-sm); color: var(--text-primary);
- font-family: var(--sans); font-size: 0.82rem; outline: none;
- padding: 8px 10px;
- transition: border-color var(--transition);
- }
- select:focus, input:focus {
- border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim);
- }
- select option { background: var(--bg-2); }
- .toggle-row {
- display: flex; align-items: center; gap: 8px;
- cursor: pointer; margin-top: 10px;
- }
- .toggle-track {
- position: relative; width: 32px; height: 18px;
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: 999px; flex-shrink: 0;
- transition: background var(--transition), border-color var(--transition);
- }
- .toggle-track:has(input:checked) { background: var(--accent); border-color: var(--accent); }
- .toggle-track input {
- position: absolute; opacity: 0; width: 100%; height: 100%;
- cursor: pointer; margin: 0; border: none; background: none;
- }
- .toggle-knob {
- position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
- background: #fff; border-radius: 50%; pointer-events: none;
- transition: transform var(--transition);
- }
- .toggle-track:has(input:checked) .toggle-knob { transform: translateX(14px); }
- .toggle-label { font-size: 0.78rem; color: var(--text-secondary); }
- /* Quick-ask pills */
- .quick-pills { display: flex; flex-direction: column; gap: 5px; }
- .quick-pill {
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius-sm); padding: 7px 10px;
- font-size: 0.75rem; color: var(--text-secondary);
- cursor: pointer; transition: all var(--transition);
- text-align: left; font-family: var(--sans);
- }
- .quick-pill:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
- /* History list */
- .history-list { display: flex; flex-direction: column; gap: 4px; }
- .history-item {
- padding: 6px 10px; border-radius: var(--radius-sm);
- font-size: 0.76rem; color: var(--text-muted);
- cursor: pointer; transition: all var(--transition);
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
- }
- .history-item:hover { background: var(--bg-2); color: var(--text-secondary); }
- /* ── Chat area ───────────────────────────────────────────────────── */
- .chat-wrap {
- display: flex; flex-direction: column; min-height: 0; overflow: hidden;
- }
- .chat-thread {
- flex: 1; overflow-y: auto; padding: 20px 24px;
- display: flex; flex-direction: column; gap: 0;
- }
- /* Messages */
- .msg {
- display: flex; flex-direction: column;
- padding: 16px 0; border-bottom: 1px solid var(--border);
- }
- .msg:last-child { border-bottom: none; }
- .msg-role {
- font-size: 0.68rem; font-weight: 500; letter-spacing: 0.08em;
- text-transform: uppercase; margin-bottom: 8px; display: flex;
- align-items: center; gap: 7px;
- }
- .msg.user .msg-role { color: var(--text-muted); }
- .msg.assistant .msg-role { color: var(--accent); }
- .msg-role i { font-size: 0.85rem; }
- .msg.user .msg-content {
- background: var(--user-bg); border: 1px solid var(--border);
- border-radius: var(--radius); padding: 12px 16px;
- font-size: 0.9rem; color: var(--text-secondary);
- max-width: 680px;
- }
- .msg.assistant .msg-content {
- font-size: 0.9rem; color: var(--text-secondary); line-height: 1.75;
- max-width: 720px;
- }
- /* Markdown rendering in answers */
- .msg.assistant .msg-content h1,
- .msg.assistant .msg-content h2,
- .msg.assistant .msg-content h3 {
- font-family: var(--serif); font-weight: 400;
- color: var(--text-primary); margin: 1.2em 0 0.5em;
- }
- .msg.assistant .msg-content h1 { font-size: 1.25rem; }
- .msg.assistant .msg-content h2 { font-size: 1.05rem; }
- .msg.assistant .msg-content h3 { font-size: 0.95rem; font-family: var(--sans); font-weight: 500; }
- .msg.assistant .msg-content p { margin-bottom: 0.75em; }
- .msg.assistant .msg-content ul,
- .msg.assistant .msg-content ol { padding-left: 1.3em; margin-bottom: 0.75em; }
- .msg.assistant .msg-content li { margin-bottom: 0.3em; }
- .msg.assistant .msg-content strong { color: var(--text-primary); font-weight: 500; }
- .msg.assistant .msg-content code {
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: 3px; padding: 1px 5px;
- font-family: var(--mono); font-size: 0.8em;
- color: var(--accent);
- }
- .msg.assistant .msg-content table {
- width: 100%; border-collapse: collapse; font-size: 0.82rem;
- margin: 1em 0; border: 1px solid var(--border);
- }
- .msg.assistant .msg-content th {
- background: var(--bg-2); color: var(--text-secondary);
- padding: 7px 10px; text-align: left; font-weight: 500;
- border: 1px solid var(--border); font-size: 0.75rem;
- }
- .msg.assistant .msg-content td {
- padding: 7px 10px; border: 1px solid var(--border); color: var(--text-primary);
- vertical-align: top;
- }
- /* Scope badge */
- .scope-badge {
- display: inline-flex; align-items: center; gap: 5px;
- background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
- border-radius: 999px; padding: 2px 9px;
- font-size: 0.67rem; color: var(--accent); margin-bottom: 10px;
- }
- /* Sources */
- .msg-sources {
- margin-top: 12px; padding-top: 12px;
- border-top: 1px solid var(--border);
- }
- .sources-label {
- font-size: 0.67rem; font-weight: 500; letter-spacing: 0.1em;
- text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px;
- }
- .source-chips { display: flex; flex-wrap: wrap; gap: 6px; }
- .source-chip {
- display: inline-flex; align-items: center; gap: 5px;
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius-sm); padding: 3px 9px;
- font-size: 0.72rem; color: var(--text-secondary);
- cursor: pointer; transition: all var(--transition);
- font-family: var(--mono);
- }
- .source-chip:hover { border-color: var(--accent); color: var(--accent); }
- .source-score { color: var(--text-muted); font-size: 0.65rem; }
- /* Feedback */
- .msg-feedback {
- display: flex; align-items: center; gap: 6px; margin-top: 10px;
- }
- .fb-btn {
- background: none; border: 1px solid var(--border);
- border-radius: var(--radius-sm); padding: 3px 9px;
- color: var(--text-muted); font-size: 0.78rem; cursor: pointer;
- transition: all var(--transition); font-family: var(--sans);
- display: flex; align-items: center; gap: 5px;
- }
- .fb-btn:hover { border-color: var(--border-hover); color: var(--text-secondary); }
- .fb-btn.active-up { border-color: var(--accent); color: var(--accent); }
- .fb-btn.active-dn { border-color: var(--danger); color: var(--danger); }
- /* Streaming cursor */
- .streaming-cursor::after {
- content: '▍';
- display: inline-block;
- color: var(--accent);
- animation: blink 0.8s step-end infinite;
- margin-left: 1px;
- }
- @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
- /* Thinking indicator */
- .thinking {
- display: flex; align-items: center; gap: 10px;
- padding: 16px 0; color: var(--text-muted); font-size: 0.83rem;
- }
- .thinking-dots { display: flex; gap: 4px; }
- .thinking-dots span {
- width: 5px; height: 5px; border-radius: 50%;
- background: var(--text-muted); display: inline-block;
- animation: bounce 1.2s ease-in-out infinite;
- }
- .thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
- .thinking-dots span:nth-child(3) { animation-delay: 0.30s; }
- @keyframes bounce { 0%,60%,100%{transform:none} 30%{transform:translateY(-4px)} }
- /* Empty state */
- .chat-empty {
- flex: 1; display: flex; flex-direction: column;
- align-items: center; justify-content: center;
- gap: 12px; padding: 40px 20px; text-align: center;
- }
- .chat-empty h2 {
- font-family: var(--serif); font-size: 1.6rem; font-weight: 400;
- color: var(--text-primary); line-height: 1.2;
- }
- .chat-empty h2 em { font-style: italic; color: var(--accent); }
- .chat-empty p { font-size: 0.85rem; color: var(--text-muted); max-width: 400px; }
- .example-pills {
- display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
- margin-top: 8px; max-width: 560px;
- }
- .example-pill {
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: 999px; padding: 6px 14px;
- font-size: 0.78rem; color: var(--text-secondary);
- cursor: pointer; transition: all var(--transition);
- }
- .example-pill:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
- /* ── Input bar ───────────────────────────────────────────────────── */
- .input-bar {
- border-top: 1px solid var(--border);
- padding: 14px 20px; background: var(--bg-1);
- flex-shrink: 0;
- }
- .input-wrap {
- display: flex; gap: 10px; align-items: flex-end;
- max-width: 800px; position: relative;
- }
- .input-textarea {
- flex: 1; background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius); padding: 11px 14px;
- color: var(--text-primary); font-family: var(--sans); font-size: 0.88rem;
- resize: none; outline: none; line-height: 1.5;
- transition: border-color var(--transition), box-shadow var(--transition);
- min-height: 44px; max-height: 160px; overflow-y: auto;
- }
- .input-textarea::placeholder { color: var(--text-muted); }
- .input-textarea:focus {
- border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim);
- }
- .send-btn {
- background: var(--accent); color: #0b0f0e;
- border: none; border-radius: var(--radius);
- width: 44px; height: 44px; flex-shrink: 0;
- display: flex; align-items: center; justify-content: center;
- font-size: 1rem; cursor: pointer;
- transition: all var(--transition);
- }
- .send-btn:hover:not(:disabled) { background: #3bf59a; transform: translateY(-1px); }
- .send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
- .input-hint {
- 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-bar {
- background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
- border-radius: var(--radius-sm); padding: 6px 12px;
- font-size: 0.75rem; color: var(--text-secondary);
- margin-bottom: 8px; display: none; flex-wrap: wrap; gap: 6px;
- align-items: center; max-width: 800px;
- }
- .synonym-bar.show { display: flex; }
- .syn-pill {
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: 999px; padding: 2px 9px;
- font-size: 0.72rem; color: var(--accent);
- cursor: pointer; transition: all var(--transition);
- }
- .syn-pill:hover { background: var(--accent); color: #0b0f0e; }
- /* ── Buttons ─────────────────────────────────────────────────────── */
- .btn {
- display: inline-flex; align-items: center; gap: 6px;
- padding: 7px 14px; border-radius: var(--radius-sm);
- font-family: var(--sans); font-size: 0.78rem; font-weight: 500;
- cursor: pointer; transition: all var(--transition); border: none;
- }
- .btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
- .btn-ghost:hover { border-color: var(--border-hover); color: var(--text-secondary); }
- .btn-accent { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(45,220,138,0.2); }
- .btn-accent:hover { background: var(--accent); color: #0b0f0e; }
- /* ── TPS Viewer drawer ───────────────────────────────────────────── */
- .tps-drawer {
- position: fixed; inset: 0; z-index: 200;
- display: none;
- }
- .tps-drawer.open { display: flex; }
- .tps-overlay {
- position: absolute; inset: 0; background: rgba(0,0,0,0.6);
- backdrop-filter: blur(3px);
- }
- .tps-panel {
- position: absolute; right: 0; top: 0; bottom: 0;
- width: min(960px, 92vw); background: var(--bg-1);
- border-left: 1px solid var(--border);
- display: flex; flex-direction: column;
- animation: slideIn 0.22s ease;
- }
- @keyframes slideIn { from { transform: translateX(100%); } to { transform: none; } }
- .tps-header {
- padding: 16px 20px; border-bottom: 1px solid var(--border);
- display: flex; align-items: center; justify-content: space-between;
- flex-shrink: 0;
- }
- .tps-header h3 { font-size: 0.9rem; font-weight: 500; }
- .tps-controls {
- padding: 12px 20px; border-bottom: 1px solid var(--border);
- display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;
- flex-shrink: 0;
- }
- .tps-controls .field { display: flex; flex-direction: column; gap: 5px; min-width: 160px; flex: 1; }
- .tps-controls .field label { font-size: 0.68rem; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-muted); }
- .tps-frame { flex: 1; border: none; background: #fff; }
- /* ── Scrollbar ───────────────────────────────────────────────────── */
- ::-webkit-scrollbar { width: 5px; }
- ::-webkit-scrollbar-track { background: transparent; }
- ::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
- </style>
- <!-- Google tag (gtag.js) -->
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
- <script>
- window.dataLayer = window.dataLayer || [];
- function gtag(){dataLayer.push(arguments);}
- gtag('js', new Date());
- gtag('config', 'G-LWEHQVCWEZ');
- </script>
-
- <!-- Google Tag Manager -->
- <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
- new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
- j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
- 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
- })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
- <!-- End Google Tag Manager -->
- </head>
- <body>
- <!-- Google Tag Manager (noscript) -->
- <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
- height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
- <!-- End Google Tag Manager (noscript) -->
- <!-- ── Nav ──────────────────────────────────────────────────────────── -->
- <nav class="site-nav">
- <div class="nav-inner">
- <a class="nav-brand" href="/">
- <svg width="22" height="22" viewBox="0 0 28 28" fill="none">
- <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
- <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
- </svg>
- Tasmanian Planning Scheme
- </a>
- <div class="nav-links">
- <a href="/">Home</a>
- <a href="/local_state-planning-scheme.php" class="active">Assistant</a>
- <a href="/site-report.php">Property Lookup</a>
- <a href="/section-builder.php">Report Builder</a>
- <a href="/faq">FAQ</a>
- </div>
- <div class="nav-right">
- <button class="btn btn-ghost" id="btnTpsViewer" style="gap:5px;">
- <i class="bi bi-layout-sidebar-reverse"></i> TPS Viewer
- </button>
- <button class="btn btn-ghost" id="btnNewChat">
- <i class="bi bi-plus-square"></i> New chat
- </button>
- <a href="/byok-settings.php" class="btn btn-ghost" id="btnByok" title="Configure your own API key">
- <i class="bi bi-key"></i> <span id="byokLabel">Own key</span>
- </a>
- <div class="nav-status">
- <span class="status-dot"></span>
- <span class="nav-status-text">API live</span>
- </div>
- </div>
- </div>
- </nav>
- <!-- ── App ───────────────────────────────────────────────────────────── -->
- <div class="app-wrap">
- <!-- ── Sidebar ──────────────────────────────────────────────────── -->
- <aside class="sidebar">
- <!-- Scope controls -->
- <div class="sidebar-section">
- <span class="sidebar-label">Document scope</span>
- <select id="council">
- <option value="">All councils / SPP only</option>
- </select>
- <label class="toggle-row">
- <span class="toggle-track">
- <input type="checkbox" id="allowTPS" checked>
- <span class="toggle-knob"></span>
- </span>
- <span class="toggle-label">Include Tasmanian Planning Scheme (SPP)</span>
- </label>
- </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 -->
- <div class="sidebar-section">
- <span class="sidebar-label">Quick queries</span>
- <div class="quick-pills">
- <button class="quick-pill" data-q="What are the acceptable solutions for front setbacks in the Village Zone?">
- Village Zone setbacks
- </button>
- <button class="quick-pill" data-q="What is the car parking rate for a medical centre? Cite the clause.">
- Parking — medical centre
- </button>
- <button class="quick-pill" data-q="Summarise the acceptable solutions for the General Residential Zone.">
- General Residential Zone
- </button>
- <button class="quick-pill" data-q="What overlays should be checked for a coastal site in Tasmania?">
- Coastal overlays
- </button>
- <button class="quick-pill" data-q="What is the Use Class for a café under the Tasmanian Planning Scheme?">
- Use Class — café
- </button>
- <button class="quick-pill" data-q="When is a planning permit required for a shed or outbuilding?">
- Permit — shed/outbuilding
- </button>
- </div>
- </div>
- <!-- Chat history -->
- <div class="sidebar-section" style="flex:1;overflow:hidden;display:flex;flex-direction:column;">
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
- <span class="sidebar-label" style="margin:0;">Recent questions</span>
- <button class="btn btn-ghost" id="btnClearHistory" style="padding:2px 8px;font-size:0.68rem;">Clear</button>
- </div>
- <div class="history-list" id="historyList" style="overflow-y:auto;flex:1;"></div>
- </div>
- </aside>
- <!-- ── Chat ─────────────────────────────────────────────────────── -->
- <div class="chat-wrap">
- <!-- Thread -->
- <div class="chat-thread" id="chatThread">
- <!-- Empty state shown when no messages -->
- <div class="chat-empty" id="chatEmpty">
- <svg width="48" height="48" viewBox="0 0 28 28" fill="none" style="opacity:0.2;">
- <rect width="28" height="28" rx="6" fill="var(--accent)"/>
- <path d="M8 20 L14 8 L20 20" stroke="#0b0f0e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M10.5 16 L17.5 16" stroke="#0b0f0e" stroke-width="2" stroke-linecap="round"/>
- </svg>
- <h2>Ask about <em>Tasmanian planning</em></h2>
- <p>Get clause-cited answers from the SPPs and your council's LPS. Every response links back to the source.</p>
- <div class="example-pills">
- <span class="example-pill" data-q="Do I need a permit for a 20m² shed in the Rural Zone?">Permit for a shed?</span>
- <span class="example-pill" data-q="What are the acceptable solutions for setbacks in the Low Density Residential Zone?">Low Density Residential setbacks</span>
- <span class="example-pill" data-q="What codes apply to a new café in the General Business Zone?">Café in General Business Zone</span>
- <span class="example-pill" data-q="Explain the difference between Acceptable Solutions and Performance Criteria.">A vs P criteria explained</span>
- </div>
- </div>
- </div>
- <!-- Input -->
- <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="input-wrap">
- <textarea
- id="question"
- class="input-textarea"
- rows="1"
- placeholder="Ask a question about the Tasmanian Planning Scheme… (⏎ to send, Shift+⏎ for new line)"
- ></textarea>
- <button class="send-btn" id="askBtn" title="Send">
- <i class="bi bi-arrow-up"></i>
- </button>
- </div>
- <div class="input-hint">
- Tip: use Tasmanian planning terms — e.g. "Acceptable Solutions", "Use Class", "Performance Criteria", zone names.
- </div>
- </div>
- </div>
- </div>
- <!-- ── TPS Viewer Drawer ─────────────────────────────────────────────── -->
- <div class="tps-drawer" id="tpsDrawer">
- <div class="tps-overlay" id="tpsOverlay"></div>
- <div class="tps-panel">
- <div class="tps-header">
- <h3><i class="bi bi-layout-sidebar-reverse" style="color:var(--accent);margin-right:6px;"></i>TPS Viewer</h3>
- <button class="btn btn-ghost" id="btnCloseTps"><i class="bi bi-x-lg"></i></button>
- </div>
- <div class="tps-controls">
- <div class="field">
- <label>Document</label>
- <select id="tpsViewer">
- <option value="spps">SPPs — State Planning Provisions</option>
- <option value="lps">LPS — Council Local Provisions</option>
- <option value="custom">Custom URL…</option>
- </select>
- </div>
- <div class="field">
- <label>Section code (optional)</label>
- <input type="text" id="tpsSection" placeholder="e.g. C7.7.2 or 8.4">
- </div>
- <div style="display:flex;gap:8px;align-self:flex-end;flex-shrink:0;">
- <button class="btn btn-accent" id="btnOpenTps"><i class="bi bi-eye"></i> Open</button>
- <button class="btn btn-ghost" id="btnLastSource"><i class="bi bi-link-45deg"></i> Last source</button>
- </div>
- </div>
- <iframe id="tpsFrame" class="tps-frame" src="about:blank" referrerpolicy="no-referrer"></iframe>
- </div>
- </div>
- <script>
- 'use strict';
- /* ── Config ─────────────────────────────────────────────────────────── */
- const API = 'https://api.modulos.com.au';
- window.APP_API_BASE = API + '/ask';
- /* ── State ───────────────────────────────────────────────────────────── */
- let history = [];
- let sessionId = localStorage.getItem('tps_session_id') || crypto.randomUUID();
- let lastSources = [];
- let isAsking = false;
- localStorage.setItem('tps_session_id', sessionId);
- const byId = id => document.getElementById(id);
- const chatThread = byId('chatThread');
- const chatEmpty = byId('chatEmpty');
- const questionEl = byId('question');
- 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 ────────────────────────────────────────── */
- const SID_KEY = 'tpr_sid';
- const sid = localStorage.getItem(SID_KEY) || (() => {
- const v = crypto?.randomUUID?.() || String(Math.random()).slice(2) + Date.now();
- localStorage.setItem(SID_KEY, v);
- return v;
- })();
- /* ── Telemetry ───────────────────────────────────────────────────────── */
- function sendEvent(type, data = {}) {
- const payload = { type, ts: new Date().toISOString(), sid, ua: navigator.userAgent, data };
- const url = API + '/telemetry';
- const body = JSON.stringify(payload);
- fetch(url, {
- method: 'POST', mode: 'cors', credentials: 'omit',
- headers: { 'Content-Type': 'application/json' },
- body, keepalive: true
- }).catch(() => {});
- }
- window.TPRtelemetry = { sendEvent };
- /* ── Councils ────────────────────────────────────────────────────────── */
- async function loadCouncils() {
- try {
- const res = await fetch(`${API}/councils`, { cache: 'no-store' });
- const items = await res.json();
- const sel = byId('council');
- sel.innerHTML = '<option value="">All councils / SPP only</option>' +
- items.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
- } catch(e) { console.warn('[UI] councils failed', e); }
- }
- /* ── Scope ───────────────────────────────────────────────────────────── */
- function computeScope() {
- const allowTps = byId('allowTPS').checked;
- const council = (byId('council')?.value || '').trim();
- const hasCouncil = !!council;
- if (allowTps && hasCouncil) return 'state_plus_local';
- if (allowTps && !hasCouncil) return 'state_only';
- if (!allowTps && hasCouncil) return 'local_only';
- return 'any';
- }
- /* ── BYOK helpers ────────────────────────────────────────────────────── */
- const ACTIVE_KEY = 'tpr_byok_active';
- const KEY_PREFIX = 'tpr_byok_key_';
- const MODEL_PREFIX = 'tpr_byok_model_';
- function byokActive() { return localStorage.getItem(ACTIVE_KEY) || 'internal'; }
- function byokKey(id) { return localStorage.getItem(KEY_PREFIX + id) || ''; }
- function byokModel(id, fallback) { return localStorage.getItem(MODEL_PREFIX + id) || fallback || ''; }
- const BYOK_DEFAULTS = {
- anthropic: 'claude-sonnet-4-5',
- openai: 'gpt-4o-mini',
- grok: 'grok-3-mini',
- ollama: 'llama3.1:8b',
- };
- function updateByokButton() {
- const active = byokActive();
- const label = byId('byokLabel');
- if (!label) return;
- const names = { internal:'Own key', anthropic:'Claude', openai:'GPT-4o', grok:'Grok', ollama:'Ollama' };
- label.textContent = names[active] || 'Own key';
- const btn = byId('btnByok');
- if (btn) btn.style.color = active !== 'internal' ? 'var(--accent)' : '';
- }
- /* Call the active external LLM with the context returned from /ask?context_only=true */
- async function callExternalLLM(prompt, provider) {
- const key = byokKey(provider);
- const model = byokModel(provider, BYOK_DEFAULTS[provider]);
- if (provider === 'anthropic') {
- const res = await fetch('https://api.anthropic.com/v1/messages', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-api-key': key,
- 'anthropic-version': '2023-06-01',
- 'anthropic-dangerous-direct-browser-access': 'true',
- },
- body: JSON.stringify({
- model,
- max_tokens: 1024,
- messages: [{ role: 'user', content: prompt }]
- })
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(err?.error?.message || `Anthropic HTTP ${res.status}`);
- }
- const data = await res.json();
- return data?.content?.[0]?.text || '';
- }
- if (provider === 'openai' || provider === 'grok') {
- const baseUrl = provider === 'grok'
- ? 'https://api.x.ai/v1'
- : 'https://api.openai.com/v1';
- const res = await fetch(`${baseUrl}/chat/completions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
- body: JSON.stringify({
- model,
- max_tokens: 1024,
- messages: [{ role: 'user', content: prompt }]
- })
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(err?.error?.message || `${provider} HTTP ${res.status}`);
- }
- const data = await res.json();
- return data?.choices?.[0]?.message?.content || '';
- }
- if (provider === 'ollama') {
- const base = key.replace(/\/$/, '') || 'http://localhost:11434';
- const res = await fetch(`${base}/api/generate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ model, prompt, stream: false })
- });
- if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
- const data = await res.json();
- return data?.response || '';
- }
- throw new Error(`Unknown provider: ${provider}`);
- }
- /* ── Ask ─────────────────────────────────────────────────────────────── */
- async function ask(queryOverride) {
- 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 scope = computeScope();
- const provider = byokActive();
- const useBYOK = provider !== 'internal';
- const startedAt = performance.now();
- isAsking = true;
- askBtn.disabled = true;
- questionEl.value = '';
- autoResize(questionEl);
- hideSynonymBar();
- appendUserMsg(rawQuery);
- const thinkEl = appendThinking();
- sendEvent('search_performed', { query: rawQuery, scope, source: 'assistant', byok: useBYOK ? provider : null });
- try {
- if (useBYOK) {
- // ── BYOK path ──────────────────────────────────────────────────
- // Step 1: get RAG context from our backend (no Ollama call)
- const ragRes = await fetch(`${API}/ask`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-TPR-SID': sessionId },
- body: JSON.stringify({ query, council: council || null, top_k: 8, scope, context_only: true })
- });
- const ragRaw = await ragRes.text();
- if (!ragRes.ok) throw new Error(`RAG HTTP ${ragRes.status} — ${ragRaw.slice(0,200)}`);
- const ragData = JSON.parse(ragRaw);
- lastSources = Array.isArray(ragData.sources) ? ragData.sources : [];
- // Step 2: call external LLM with the pre-built prompt from the backend
- const answer = await callExternalLLM(ragData.prompt, provider);
- thinkEl.remove();
- const latencyMs = Math.round(performance.now() - startedAt);
- sendEvent('search_result', {
- latency_ms: latencyMs, ok: true, byok: provider,
- topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
- });
- appendAssistantMsg(answer || 'No answer returned.', scope, lastSources, rawQuery, provider);
- addToHistory(rawQuery);
- } else {
- // ── Internal Ollama path — streaming ──────────────────────────
- const res = await fetch(`${API}/ask/stream`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-TPR-SID': sessionId },
- body: JSON.stringify({ query, council: council || null, top_k: 8, scope })
- });
- if (!res.ok) {
- const raw = await res.text();
- throw new Error(`HTTP ${res.status} — ${raw.slice(0,200)}`);
- }
- thinkEl.remove();
- const msgEl = appendStreamingMsg(rawQuery, scope);
- const streamText = msgEl.querySelector('.stream-text');
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buf = '', fullAnswer = '';
- lastSources = [];
- outer: while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += decoder.decode(value, { stream: true });
- const lines = buf.split('\n');
- buf = lines.pop(); // keep incomplete line
- for (const line of lines) {
- if (!line.startsWith('data: ')) continue;
- let evt;
- try { evt = JSON.parse(line.slice(6)); } catch { continue; }
- if (evt.type === 'sources') {
- lastSources = evt.sources || [];
- } else if (evt.type === 'token') {
- fullAnswer += evt.text;
- streamText.textContent = fullAnswer; // raw text while streaming
- scrollBottom();
- } else if (evt.type === 'done') {
- break outer;
- } else if (evt.type === 'error') {
- throw new Error(evt.detail || 'Stream error');
- }
- }
- }
- finalizeStreamingMsg(msgEl, fullAnswer || 'No answer returned.', lastSources);
- const latencyMs = Math.round(performance.now() - startedAt);
- sendEvent('search_result', {
- latency_ms: latencyMs,
- topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
- model: 'stream', ok: true,
- });
- addToHistory(rawQuery);
- }
- } catch(e) {
- thinkEl.remove();
- appendErrorMsg(e.message);
- console.error('[ask]', e);
- } finally {
- isAsking = false;
- askBtn.disabled = false;
- questionEl.focus();
- }
- }
- /* ── Message rendering ───────────────────────────────────────────────── */
- function hideEmpty() {
- if (chatEmpty) chatEmpty.style.display = 'none';
- }
- function appendUserMsg(text) {
- hideEmpty();
- const div = document.createElement('div');
- div.className = 'msg user';
- div.innerHTML = `
- <div class="msg-role"><i class="bi bi-person"></i> You</div>
- <div class="msg-content">${esc(text)}</div>
- `;
- chatThread.appendChild(div);
- scrollBottom();
- }
- function appendThinking() {
- hideEmpty();
- const div = document.createElement('div');
- div.className = 'thinking';
- div.innerHTML = `
- <div class="thinking-dots"><span></span><span></span><span></span></div>
- Thinking…
- `;
- chatThread.appendChild(div);
- scrollBottom();
- return div;
- }
- function appendAssistantMsg(answer, scope, sources, query, provider = 'internal') {
- const msgId = `msg-${Date.now()}`;
- const div = document.createElement('div');
- div.className = 'msg assistant';
- div.id = msgId;
- div.dataset.query = query || '';
- div.dataset.scope = scope || '';
- div.dataset.provider = provider || 'internal';
- div.dataset.answer = answer.replace(/<[^>]*>/g, '').substring(0, 4000);
- div.innerHTML = `
- <div class="msg-role"><i class="bi bi-stars"></i> Assistant</div>
- ${_scopeHtml(scope, provider)}
- <div class="msg-content">${md2html(answer)}</div>
- ${_sourceChipsHtml(sources)}
- <div class="msg-feedback">
- <button class="fb-btn" onclick="feedback('${msgId}','up',this)"><i class="bi bi-hand-thumbs-up"></i> Helpful</button>
- <button class="fb-btn" onclick="feedback('${msgId}','down',this)"><i class="bi bi-hand-thumbs-down"></i> Not helpful</button>
- </div>
- `;
- chatThread.appendChild(div);
- scrollBottom();
- }
- /* ── Streaming message helpers ───────────────────────────────────────── */
- function _scopeHtml(scope, provider) {
- const providerNames = { internal:'Ollama', anthropic:'Claude', openai:'GPT-4o', grok:'Grok', ollama:'Local Ollama' };
- const providerName = providerNames[provider] || provider;
- const providerIcon = provider === 'internal' ? 'cpu' : 'key';
- return `<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;">
- <div class="scope-badge"><i class="bi bi-filter"></i> ${esc(scope)}</div>
- <div class="scope-badge" style="background:${provider !== 'internal' ? 'rgba(192,132,252,0.1)' : 'var(--accent-dim)'};border-color:${provider !== 'internal' ? 'rgba(192,132,252,0.25)' : 'rgba(45,220,138,0.2)'};color:${provider !== 'internal' ? '#c084fc' : 'var(--accent)'};">
- <i class="bi bi-${providerIcon}"></i> ${esc(providerName)}
- </div>
- </div>`;
- }
- function _sourceChipsHtml(sources) {
- if (!sources || !sources.length) return '';
- const chips = sources.map((s, i) => {
- const label = `${s.source_file} p.${s.page}`;
- const score = typeof s.score === 'number' ? `<span class="source-score">${s.score.toFixed(2)}</span>` : '';
- return `<span class="source-chip" data-cite="${esc(`${s.source_file}#p${s.page}`)}" data-index="${i}" onclick="openSourceInViewer(${i})">
- <i class="bi bi-file-earmark-text"></i>${esc(label)}${score}
- </span>`;
- }).join('');
- return `<div class="msg-sources"><div class="sources-label">Sources</div><div class="source-chips">${chips}</div></div>`;
- }
- // Create a message container for a streaming response.
- // Returns the div so the caller can access .querySelector('.stream-text') to append tokens.
- function appendStreamingMsg(rawQuery, scope) {
- hideEmpty();
- const msgId = `msg-${Date.now()}`;
- const div = document.createElement('div');
- div.className = 'msg assistant';
- div.id = msgId;
- div.dataset.query = rawQuery;
- div.dataset.scope = scope;
- div.dataset.provider = 'internal';
- div.dataset.answer = '';
- div.innerHTML = `
- <div class="msg-role"><i class="bi bi-stars"></i> Assistant</div>
- ${_scopeHtml(scope, 'internal')}
- <div class="msg-content"><span class="stream-text streaming-cursor"></span></div>
- `;
- chatThread.appendChild(div);
- scrollBottom();
- return div;
- }
- // Called when the stream is complete: renders markdown, appends sources + feedback.
- function finalizeStreamingMsg(msgEl, fullAnswer, sources) {
- const contentEl = msgEl.querySelector('.msg-content');
- contentEl.innerHTML = md2html(fullAnswer);
- msgEl.dataset.answer = fullAnswer.replace(/<[^>]*>/g, '').substring(0, 4000);
- const msgId = msgEl.id;
- const trailing = _sourceChipsHtml(sources) + `
- <div class="msg-feedback">
- <button class="fb-btn" onclick="feedback('${msgId}','up',this)"><i class="bi bi-hand-thumbs-up"></i> Helpful</button>
- <button class="fb-btn" onclick="feedback('${msgId}','down',this)"><i class="bi bi-hand-thumbs-down"></i> Not helpful</button>
- </div>`;
- msgEl.insertAdjacentHTML('beforeend', trailing);
- scrollBottom();
- }
- function appendErrorMsg(msg) {
- const div = document.createElement('div');
- div.className = 'msg assistant';
- div.innerHTML = `
- <div class="msg-role"><i class="bi bi-exclamation-circle" style="color:var(--danger)"></i> Error</div>
- <div class="msg-content" style="color:var(--danger);">${esc(msg)}</div>
- `;
- chatThread.appendChild(div);
- scrollBottom();
- }
- function scrollBottom() {
- chatThread.scrollTop = chatThread.scrollHeight;
- }
- /* ── Feedback ────────────────────────────────────────────────────────── */
- window.feedback = function(msgId, verdict, btn) {
- // Update button state immediately
- const row = btn.closest('.msg-feedback');
- row.querySelectorAll('.fb-btn').forEach(b => b.classList.remove('active-up','active-dn'));
- btn.classList.add(verdict === 'up' ? 'active-up' : 'active-dn');
- // Read context from data attributes stored on the message div at render time.
- // This avoids closure/scope issues — no reliance on outer variables.
- const msgEl = document.getElementById(msgId);
- const query = msgEl?.dataset.query || '';
- const answer = msgEl?.dataset.answer || '';
- const scope = msgEl?.dataset.scope || '';
- const provider= msgEl?.dataset.provider || 'internal';
- const storedSid = localStorage.getItem('tpr_sid') || '';
- // Collect note for thumbs-down (do this before the async fetch)
- let note = null;
- if (verdict === 'down') {
- note = window.prompt('What missed the mark? (optional — helps us improve)') || null;
- }
- // Post to the dedicated /feedback endpoint (stores full query + answer in DB)
- fetch(`${API}/feedback`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'omit',
- body: JSON.stringify({
- verdict,
- query,
- answer,
- note,
- sid: storedSid,
- scope,
- model: provider,
- sources: lastSources.slice(0, 10).map(s => ({
- source_file: s.source_file,
- page: s.page,
- score: s.score
- }))
- })
- }).catch(() => {}); // swallow silently — feedback must never break UX
- // Also fire the existing telemetry event for the events table
- sendEvent('feedback', { verdict, note, msg_id: msgId, scope, provider });
- };
- /* ── Source viewer ───────────────────────────────────────────────────── */
- window.openSourceInViewer = function(index) {
- openTpsDrawer();
- // Best effort: open SPPs base — we don't have direct clause URLs from /ask
- byId('tpsFrame').src = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
- sendEvent('interaction', { action: 'clicked_citation', cite_index: index });
- };
- /* ── History ─────────────────────────────────────────────────────────── */
- function addToHistory(query) {
- const h = JSON.parse(localStorage.getItem('tps_query_history') || '[]');
- const next = [query, ...h.filter(q => q !== query)].slice(0, 20);
- localStorage.setItem('tps_query_history', JSON.stringify(next));
- renderHistory();
- }
- function renderHistory() {
- const list = byId('historyList');
- if (!list) return;
- const h = JSON.parse(localStorage.getItem('tps_query_history') || '[]');
- list.innerHTML = h.length
- ? h.map(q => `<div class="history-item" onclick="ask(${JSON.stringify(q)})">${esc(q)}</div>`).join('')
- : `<div style="font-size:0.75rem;color:var(--text-muted);padding:4px 0;">No history yet.</div>`;
- }
- byId('btnClearHistory').addEventListener('click', () => {
- localStorage.removeItem('tps_query_history');
- renderHistory();
- });
- /* ── New chat ────────────────────────────────────────────────────────── */
- byId('btnNewChat').addEventListener('click', () => {
- history = [];
- sessionId = crypto.randomUUID();
- localStorage.setItem('tps_session_id', sessionId);
- // Clear messages except empty state
- [...chatThread.children].forEach(el => {
- if (el !== chatEmpty) el.remove();
- });
- if (chatEmpty) chatEmpty.style.display = '';
- lastSources = [];
- });
- /* ── Input handling ──────────────────────────────────────────────────── */
- questionEl.addEventListener('keydown', e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- ask();
- }
- });
- questionEl.addEventListener('input', () => {
- autoResize(questionEl);
- checkSynonyms(questionEl.value);
- checkAddressInQuery(questionEl.value);
- });
- askBtn.addEventListener('click', () => ask());
- function autoResize(el) {
- el.style.height = 'auto';
- el.style.height = Math.min(el.scrollHeight, 160) + 'px';
- }
- /* ── Quick asks / examples ───────────────────────────────────────────── */
- document.querySelectorAll('[data-q]').forEach(el => {
- el.addEventListener('click', () => ask(el.dataset.q));
- });
- /* ── TPS Viewer ──────────────────────────────────────────────────────── */
- function openTpsDrawer() {
- byId('tpsDrawer').classList.add('open');
- }
- function closeTpsDrawer() {
- byId('tpsDrawer').classList.remove('open');
- }
- byId('btnTpsViewer').addEventListener('click', openTpsDrawer);
- byId('btnCloseTps').addEventListener('click', closeTpsDrawer);
- byId('tpsOverlay').addEventListener('click', closeTpsDrawer);
- document.addEventListener('keydown', e => { if (e.key === 'Escape') closeTpsDrawer(); });
- byId('btnOpenTps').addEventListener('click', () => {
- const viewer = byId('tpsViewer').value;
- const section = (byId('tpsSection').value || '').trim();
- const frame = byId('tpsFrame');
- const council = (byId('council')?.value || '').trim();
- if (viewer === 'spps') {
- let url = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
- if (section) url += `/section/${encodeURIComponent(section)}`;
- frame.src = url;
- } else if (viewer === 'lps') {
- frame.src = 'https://planning.tas.gov.au/planning-schemes/tasmanian-planning-scheme';
- } else {
- const custom = prompt('Paste a TPS/TPSO URL:');
- if (custom) frame.src = custom;
- }
- });
- byId('btnLastSource').addEventListener('click', () => {
- if (!lastSources.length) { alert('No sources from the last answer yet.'); return; }
- byId('tpsFrame').src = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
- openTpsDrawer();
- });
- /* ── Synonym suggestions ─────────────────────────────────────────────── */
- const SYNONYMS = {
- "house":["single dwelling","Residential","Class 1a"],
- "home":["single dwelling","Residential","Class 1a"],
- "granny flat":["secondary residence","Residential"],
- "duplex":["multiple dwellings","Residential","Class 1a attached"],
- "townhouse":["multiple dwellings","Residential"],
- "apartment":["multiple dwellings","Residential","Class 2"],
- "flat":["multiple dwellings","Residential","Class 2"],
- "aged care":["residential care facility","Residential","Class 9c"],
- "airbnb":["Visitor Accommodation","short-stay"],
- "holiday let":["Visitor Accommodation"],
- "motel":["Visitor Accommodation","Class 3"],
- "hotel":["Hotel Industry"],
- "pub":["Hotel Industry"],
- "bar":["Hotel Industry"],
- "shop":["General Retail and Hire","Class 6"],
- "supermarket":["General Retail and Hire","Class 6"],
- "restaurant":["Food Services","Class 6"],
- "cafe":["Food Services","Class 6"],
- "takeaway":["Food Services","Class 6"],
- "office":["Business and Professional Services","Class 5"],
- "medical centre":["Business and Professional Services","Class 9a"],
- "clinic":["Business and Professional Services","Class 9a"],
- "hospital":["Hospital Services","Class 9a"],
- "childcare":["Educational and Occasional Care","Class 9b"],
- "school":["Educational and Occasional Care","Class 9b"],
- "university":["Educational and Occasional Care","Class 9b"],
- "church":["Community Meeting and Entertainment","Class 9b"],
- "gym":["Sports and Recreation","Class 9b"],
- "factory":["Manufacturing and Processing","Class 8"],
- "workshop":["Service Industry","Class 8"],
- "warehouse":["Storage","Class 7"],
- "shed":["ancillary structure","Class 10a"],
- "garage":["ancillary structure","Class 10a"],
- "carport":["ancillary structure","Class 10a"],
- "deck":["verandah","ancillary structure","Class 10a"],
- "pergola":["open structure","Class 10a"],
- "fence":["ancillary structure","Class 10b"],
- "pool":["swimming pool","Class 10b"],
- "service station":["Vehicle Fuel Sales and Service"],
- "servo":["Vehicle Fuel Sales and Service"],
- "farm":["Resource Development","agricultural use"],
- "cemetery":["Crematoria and Cemeteries"],
- };
- function checkSynonyms(val) {
- const bar = byId('synonymBar');
- const words = val.toLowerCase().trim().split(/\s+/);
- const found = [];
- // Check last 1–3 words as phrases
- for (let len = 3; len >= 1; len--) {
- const phrase = words.slice(-len).join(' ');
- if (SYNONYMS[phrase]) {
- SYNONYMS[phrase].forEach(s => {
- if (!found.includes(s)) found.push(s);
- });
- break;
- }
- }
- if (found.length) {
- bar.innerHTML = '<i class="bi bi-lightbulb" style="color:var(--accent);flex-shrink:0;"></i> TPS terms: ' +
- found.map(s => `<span class="syn-pill" onclick="appendSynonym('${esc(s)}')">${esc(s)}</span>`).join('');
- bar.classList.add('show');
- } else {
- hideSynonymBar();
- }
- }
- function hideSynonymBar() {
- const bar = byId('synonymBar');
- bar.classList.remove('show');
- 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) {
- questionEl.value = (questionEl.value + ' ' + term).trim();
- questionEl.focus();
- hideSynonymBar();
- };
- /* ── Utilities ───────────────────────────────────────────────────────── */
- function esc(s) {
- return String(s || '').replace(/[&<>"']/g, c =>
- ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])
- );
- }
- function md2html(s) {
- return String(s || '')
- .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
- .replace(/^#### (.+)$/gm,'<h3>$1</h3>')
- .replace(/^### (.+)$/gm,'<h3>$1</h3>')
- .replace(/^## (.+)$/gm,'<h2>$1</h2>')
- .replace(/^# (.+)$/gm,'<h2>$1</h2>')
- .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
- .replace(/\*(.+?)\*/g,'<em>$1</em>')
- .replace(/`([^`]+)`/g,'<code>$1</code>')
- // Tables
- .replace(/^\|(.+)\|$/gm, (row) => {
- const cells = row.split('|').slice(1,-1).map(c => c.trim());
- return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
- })
- .replace(/(<tr>.*<\/tr>)/gs, m => {
- const rows = m.match(/<tr>.*?<\/tr>/gs) || [];
- if (!rows.length) return m;
- // First row becomes thead
- const head = rows[0].replace(/<td>/g,'<th>').replace(/<\/td>/g,'</th>');
- const body = rows.slice(2).join(''); // skip separator row
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
- })
- .replace(/^[-*] (.+)$/gm,'<li>$1</li>')
- .replace(/(<li>.*<\/li>)/gs,'<ul>$1</ul>')
- .replace(/<\/ul>\s*<ul>/g,'')
- .replace(/^---+$/gm,'<hr>')
- .replace(/\n{2,}/g,'</p><p>')
- .replace(/\n/g,'<br>')
- .replace(/^(?!<[hupbtir])(.+)$/gm, s => s ? `<p>${s}</p>` : s);
- }
- /* ── Boot ────────────────────────────────────────────────────────────── */
- document.addEventListener('DOMContentLoaded', () => {
- loadCouncils();
- renderHistory();
- updateByokButton();
- loadPropCtx();
- questionEl.focus();
- byId('propCtxDismiss')?.addEventListener('click', clearPropCtx);
- });
- // Update button if user navigates back from settings with a new key
- window.addEventListener('focus', updateByokButton);
- window.addEventListener('storage', e => {
- if (e.key === ACTIVE_KEY || e.key?.startsWith(KEY_PREFIX)) updateByokButton();
- });
- </script>
- <!-- API status indicator -->
- <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
- </body>
- </html>
|