local_state-planning-scheme.php 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Planning Scheme Assistant — Tasmanian SPP & LPS Lookup</title>
  7. <meta name="description" content="Ask questions about Tasmanian planning zones, overlays, setbacks and acceptable solutions. Every answer cites the exact SPP or LPS clause.">
  8. <link rel="canonical" href="https://tasplanning.report/local_state-planning-scheme/">
  9. <meta name="robots" content="index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1">
  10. <meta property="og:type" content="website">
  11. <meta property="og:locale" content="en_AU">
  12. <meta property="og:site_name" content="Tasmanian Planning Scheme Assistant">
  13. <meta property="og:url" content="https://tasplanning.report/local_state-planning-scheme">
  14. <meta property="og:title" content="Planning Scheme Assistant — Tasmanian SPP & LPS Lookup">
  15. <meta property="og:description" content="Instant, clause-cited answers from the Tasmanian Planning Scheme.">
  16. <meta property="og:image" content="https://tasplanning.report/image/og-image.jpg">
  17. <meta name="twitter:card" content="summary_large_image">
  18. <meta name="theme-color" content="#0b0f0e">
  19. <link rel="icon" href="/favicon.ico">
  20. <link rel="apple-touch-icon" href="/image/apple-touch-icon.png">
  21. <link rel="preconnect" href="https://fonts.googleapis.com">
  22. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  23. <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">
  24. <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
  25. <link rel="stylesheet" href="/css/design-tokens.css">
  26. <style>
  27. /* ── Page-specific token overrides ───────────────────────────────── */
  28. :root { --user-bg: #1a2420; }
  29. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  30. html, body { height: 100%; }
  31. body {
  32. font-family: var(--sans);
  33. background: var(--bg);
  34. color: var(--text-primary);
  35. font-size: 15px;
  36. line-height: 1.65;
  37. -webkit-font-smoothing: antialiased;
  38. display: flex; flex-direction: column;
  39. }
  40. ::selection { background: var(--accent); color: #0b0f0e; }
  41. /* ── Nav ─────────────────────────────────────────────────────────── */
  42. .site-nav {
  43. background: rgba(11,15,14,0.95);
  44. backdrop-filter: blur(12px);
  45. border-bottom: 1px solid var(--border);
  46. flex-shrink: 0;
  47. position: sticky; top: 0; z-index: 100;
  48. }
  49. .nav-inner {
  50. max-width: 1400px; margin: 0 auto;
  51. display: flex; align-items: center; justify-content: space-between;
  52. padding: 0 20px; height: 54px; gap: 12px;
  53. }
  54. .nav-brand {
  55. display: flex; align-items: center; gap: 9px;
  56. font-size: 0.85rem; font-weight: 500; color: var(--text-primary);
  57. text-decoration: none; white-space: nowrap; flex-shrink: 0;
  58. }
  59. .nav-links { display: flex; align-items: center; gap: 2px; }
  60. .nav-links a {
  61. font-size: 0.8rem; color: var(--text-secondary);
  62. padding: 5px 10px; border-radius: var(--radius-sm);
  63. text-decoration: none; transition: all var(--transition);
  64. }
  65. .nav-links a:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); }
  66. .nav-links a.active { color: var(--accent); }
  67. .nav-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
  68. .nav-status { display: flex; align-items: center; gap: 5px; font-size: 0.72rem; color: var(--text-muted); }
  69. .status-dot {
  70. width: 7px; height: 7px; border-radius: 50%;
  71. background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
  72. display: inline-block; flex-shrink: 0;
  73. animation: pulse 2.5s ease-in-out infinite;
  74. }
  75. @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
  76. /* ── App layout: sidebar + chat ──────────────────────────────────── */
  77. .app-wrap {
  78. display: grid;
  79. grid-template-columns: 300px 1fr;
  80. flex: 1; min-height: 0; max-width: 1400px;
  81. margin: 0 auto; width: 100%;
  82. }
  83. @media(max-width:900px) {
  84. .app-wrap { grid-template-columns: 1fr; }
  85. .sidebar { display: none; }
  86. }
  87. /* ── Sidebar ─────────────────────────────────────────────────────── */
  88. .sidebar {
  89. border-right: 1px solid var(--border);
  90. display: flex; flex-direction: column;
  91. overflow-y: auto; background: var(--bg-1);
  92. }
  93. .sidebar-section {
  94. padding: 16px; border-bottom: 1px solid var(--border);
  95. }
  96. .sidebar-label {
  97. font-size: 0.68rem; font-weight: 500; letter-spacing: 0.1em;
  98. text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px;
  99. display: block;
  100. }
  101. select, input[type=text], input[type=number] {
  102. width: 100%;
  103. background: var(--bg-2); border: 1px solid var(--border);
  104. border-radius: var(--radius-sm); color: var(--text-primary);
  105. font-family: var(--sans); font-size: 0.82rem; outline: none;
  106. padding: 8px 10px;
  107. transition: border-color var(--transition);
  108. }
  109. select:focus, input:focus {
  110. border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim);
  111. }
  112. select option { background: var(--bg-2); }
  113. .toggle-row {
  114. display: flex; align-items: center; gap: 8px;
  115. cursor: pointer; margin-top: 10px;
  116. }
  117. .toggle-track {
  118. position: relative; width: 32px; height: 18px;
  119. background: var(--bg-2); border: 1px solid var(--border);
  120. border-radius: 999px; flex-shrink: 0;
  121. transition: background var(--transition), border-color var(--transition);
  122. }
  123. .toggle-track:has(input:checked) { background: var(--accent); border-color: var(--accent); }
  124. .toggle-track input {
  125. position: absolute; opacity: 0; width: 100%; height: 100%;
  126. cursor: pointer; margin: 0; border: none; background: none;
  127. }
  128. .toggle-knob {
  129. position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
  130. background: #fff; border-radius: 50%; pointer-events: none;
  131. transition: transform var(--transition);
  132. }
  133. .toggle-track:has(input:checked) .toggle-knob { transform: translateX(14px); }
  134. .toggle-label { font-size: 0.78rem; color: var(--text-secondary); }
  135. /* Quick-ask pills */
  136. .quick-pills { display: flex; flex-direction: column; gap: 5px; }
  137. .quick-pill {
  138. background: var(--bg-2); border: 1px solid var(--border);
  139. border-radius: var(--radius-sm); padding: 7px 10px;
  140. font-size: 0.75rem; color: var(--text-secondary);
  141. cursor: pointer; transition: all var(--transition);
  142. text-align: left; font-family: var(--sans);
  143. }
  144. .quick-pill:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
  145. /* History list */
  146. .history-list { display: flex; flex-direction: column; gap: 4px; }
  147. .history-item {
  148. padding: 6px 10px; border-radius: var(--radius-sm);
  149. font-size: 0.76rem; color: var(--text-muted);
  150. cursor: pointer; transition: all var(--transition);
  151. white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  152. }
  153. .history-item:hover { background: var(--bg-2); color: var(--text-secondary); }
  154. /* ── Chat area ───────────────────────────────────────────────────── */
  155. .chat-wrap {
  156. display: flex; flex-direction: column; min-height: 0; overflow: hidden;
  157. }
  158. .chat-thread {
  159. flex: 1; overflow-y: auto; padding: 20px 24px;
  160. display: flex; flex-direction: column; gap: 0;
  161. }
  162. /* Messages */
  163. .msg {
  164. display: flex; flex-direction: column;
  165. padding: 16px 0; border-bottom: 1px solid var(--border);
  166. }
  167. .msg:last-child { border-bottom: none; }
  168. .msg-role {
  169. font-size: 0.68rem; font-weight: 500; letter-spacing: 0.08em;
  170. text-transform: uppercase; margin-bottom: 8px; display: flex;
  171. align-items: center; gap: 7px;
  172. }
  173. .msg.user .msg-role { color: var(--text-muted); }
  174. .msg.assistant .msg-role { color: var(--accent); }
  175. .msg-role i { font-size: 0.85rem; }
  176. .msg.user .msg-content {
  177. background: var(--user-bg); border: 1px solid var(--border);
  178. border-radius: var(--radius); padding: 12px 16px;
  179. font-size: 0.9rem; color: var(--text-secondary);
  180. max-width: 680px;
  181. }
  182. .msg.assistant .msg-content {
  183. font-size: 0.9rem; color: var(--text-secondary); line-height: 1.75;
  184. max-width: 720px;
  185. }
  186. /* Markdown rendering in answers */
  187. .msg.assistant .msg-content h1,
  188. .msg.assistant .msg-content h2,
  189. .msg.assistant .msg-content h3 {
  190. font-family: var(--serif); font-weight: 400;
  191. color: var(--text-primary); margin: 1.2em 0 0.5em;
  192. }
  193. .msg.assistant .msg-content h1 { font-size: 1.25rem; }
  194. .msg.assistant .msg-content h2 { font-size: 1.05rem; }
  195. .msg.assistant .msg-content h3 { font-size: 0.95rem; font-family: var(--sans); font-weight: 500; }
  196. .msg.assistant .msg-content p { margin-bottom: 0.75em; }
  197. .msg.assistant .msg-content ul,
  198. .msg.assistant .msg-content ol { padding-left: 1.3em; margin-bottom: 0.75em; }
  199. .msg.assistant .msg-content li { margin-bottom: 0.3em; }
  200. .msg.assistant .msg-content strong { color: var(--text-primary); font-weight: 500; }
  201. .msg.assistant .msg-content code {
  202. background: var(--bg-2); border: 1px solid var(--border);
  203. border-radius: 3px; padding: 1px 5px;
  204. font-family: var(--mono); font-size: 0.8em;
  205. color: var(--accent);
  206. }
  207. .msg.assistant .msg-content table {
  208. width: 100%; border-collapse: collapse; font-size: 0.82rem;
  209. margin: 1em 0; border: 1px solid var(--border);
  210. }
  211. .msg.assistant .msg-content th {
  212. background: var(--bg-2); color: var(--text-secondary);
  213. padding: 7px 10px; text-align: left; font-weight: 500;
  214. border: 1px solid var(--border); font-size: 0.75rem;
  215. }
  216. .msg.assistant .msg-content td {
  217. padding: 7px 10px; border: 1px solid var(--border); color: var(--text-primary);
  218. vertical-align: top;
  219. }
  220. /* Scope badge */
  221. .scope-badge {
  222. display: inline-flex; align-items: center; gap: 5px;
  223. background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
  224. border-radius: 999px; padding: 2px 9px;
  225. font-size: 0.67rem; color: var(--accent); margin-bottom: 10px;
  226. }
  227. /* Sources */
  228. .msg-sources {
  229. margin-top: 12px; padding-top: 12px;
  230. border-top: 1px solid var(--border);
  231. }
  232. .sources-label {
  233. font-size: 0.67rem; font-weight: 500; letter-spacing: 0.1em;
  234. text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px;
  235. }
  236. .source-chips { display: flex; flex-wrap: wrap; gap: 6px; }
  237. .source-chip {
  238. display: inline-flex; align-items: center; gap: 5px;
  239. background: var(--bg-2); border: 1px solid var(--border);
  240. border-radius: var(--radius-sm); padding: 3px 9px;
  241. font-size: 0.72rem; color: var(--text-secondary);
  242. cursor: pointer; transition: all var(--transition);
  243. font-family: var(--mono);
  244. }
  245. .source-chip:hover { border-color: var(--accent); color: var(--accent); }
  246. .source-score { color: var(--text-muted); font-size: 0.65rem; }
  247. /* Feedback */
  248. .msg-feedback {
  249. display: flex; align-items: center; gap: 6px; margin-top: 10px;
  250. }
  251. .fb-btn {
  252. background: none; border: 1px solid var(--border);
  253. border-radius: var(--radius-sm); padding: 3px 9px;
  254. color: var(--text-muted); font-size: 0.78rem; cursor: pointer;
  255. transition: all var(--transition); font-family: var(--sans);
  256. display: flex; align-items: center; gap: 5px;
  257. }
  258. .fb-btn:hover { border-color: var(--border-hover); color: var(--text-secondary); }
  259. .fb-btn.active-up { border-color: var(--accent); color: var(--accent); }
  260. .fb-btn.active-dn { border-color: var(--danger); color: var(--danger); }
  261. /* Thinking indicator */
  262. .thinking {
  263. display: flex; align-items: center; gap: 10px;
  264. padding: 16px 0; color: var(--text-muted); font-size: 0.83rem;
  265. }
  266. .thinking-dots { display: flex; gap: 4px; }
  267. .thinking-dots span {
  268. width: 5px; height: 5px; border-radius: 50%;
  269. background: var(--text-muted); display: inline-block;
  270. animation: bounce 1.2s ease-in-out infinite;
  271. }
  272. .thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
  273. .thinking-dots span:nth-child(3) { animation-delay: 0.30s; }
  274. @keyframes bounce { 0%,60%,100%{transform:none} 30%{transform:translateY(-4px)} }
  275. /* Empty state */
  276. .chat-empty {
  277. flex: 1; display: flex; flex-direction: column;
  278. align-items: center; justify-content: center;
  279. gap: 12px; padding: 40px 20px; text-align: center;
  280. }
  281. .chat-empty h2 {
  282. font-family: var(--serif); font-size: 1.6rem; font-weight: 400;
  283. color: var(--text-primary); line-height: 1.2;
  284. }
  285. .chat-empty h2 em { font-style: italic; color: var(--accent); }
  286. .chat-empty p { font-size: 0.85rem; color: var(--text-muted); max-width: 400px; }
  287. .example-pills {
  288. display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
  289. margin-top: 8px; max-width: 560px;
  290. }
  291. .example-pill {
  292. background: var(--bg-card); border: 1px solid var(--border);
  293. border-radius: 999px; padding: 6px 14px;
  294. font-size: 0.78rem; color: var(--text-secondary);
  295. cursor: pointer; transition: all var(--transition);
  296. }
  297. .example-pill:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
  298. /* ── Input bar ───────────────────────────────────────────────────── */
  299. .input-bar {
  300. border-top: 1px solid var(--border);
  301. padding: 14px 20px; background: var(--bg-1);
  302. flex-shrink: 0;
  303. }
  304. .input-wrap {
  305. display: flex; gap: 10px; align-items: flex-end;
  306. max-width: 800px; position: relative;
  307. }
  308. .input-textarea {
  309. flex: 1; background: var(--bg-2); border: 1px solid var(--border);
  310. border-radius: var(--radius); padding: 11px 14px;
  311. color: var(--text-primary); font-family: var(--sans); font-size: 0.88rem;
  312. resize: none; outline: none; line-height: 1.5;
  313. transition: border-color var(--transition), box-shadow var(--transition);
  314. min-height: 44px; max-height: 160px; overflow-y: auto;
  315. }
  316. .input-textarea::placeholder { color: var(--text-muted); }
  317. .input-textarea:focus {
  318. border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim);
  319. }
  320. .send-btn {
  321. background: var(--accent); color: #0b0f0e;
  322. border: none; border-radius: var(--radius);
  323. width: 44px; height: 44px; flex-shrink: 0;
  324. display: flex; align-items: center; justify-content: center;
  325. font-size: 1rem; cursor: pointer;
  326. transition: all var(--transition);
  327. }
  328. .send-btn:hover:not(:disabled) { background: #3bf59a; transform: translateY(-1px); }
  329. .send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
  330. .input-hint {
  331. font-size: 0.7rem; color: var(--text-muted); margin-top: 7px;
  332. }
  333. /* Property context panel */
  334. .prop-ctx-panel {
  335. background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
  336. border-radius: var(--radius-sm); padding: 10px 12px;
  337. font-size: 0.78rem;
  338. }
  339. .prop-ctx-addr {
  340. color: var(--text-primary); font-weight: 500; margin-bottom: 3px;
  341. white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  342. }
  343. .prop-ctx-meta { color: var(--text-muted); font-size: 0.72rem; line-height: 1.6; }
  344. .prop-ctx-meta span { display: block; }
  345. /* Address suggestion bar */
  346. .address-suggest-bar {
  347. background: rgba(45,220,138,0.06); border: 1px solid rgba(45,220,138,0.18);
  348. border-radius: var(--radius-sm); padding: 6px 12px;
  349. font-size: 0.75rem; color: var(--text-secondary);
  350. margin-bottom: 8px; display: none; align-items: center;
  351. gap: 8px; max-width: 800px;
  352. }
  353. .address-suggest-bar.show { display: flex; }
  354. .address-suggest-bar a { color: var(--accent); text-decoration: underline; }
  355. /* Synonym suggestion */
  356. .synonym-bar {
  357. background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
  358. border-radius: var(--radius-sm); padding: 6px 12px;
  359. font-size: 0.75rem; color: var(--text-secondary);
  360. margin-bottom: 8px; display: none; flex-wrap: wrap; gap: 6px;
  361. align-items: center; max-width: 800px;
  362. }
  363. .synonym-bar.show { display: flex; }
  364. .syn-pill {
  365. background: var(--bg-2); border: 1px solid var(--border);
  366. border-radius: 999px; padding: 2px 9px;
  367. font-size: 0.72rem; color: var(--accent);
  368. cursor: pointer; transition: all var(--transition);
  369. }
  370. .syn-pill:hover { background: var(--accent); color: #0b0f0e; }
  371. /* ── Buttons ─────────────────────────────────────────────────────── */
  372. .btn {
  373. display: inline-flex; align-items: center; gap: 6px;
  374. padding: 7px 14px; border-radius: var(--radius-sm);
  375. font-family: var(--sans); font-size: 0.78rem; font-weight: 500;
  376. cursor: pointer; transition: all var(--transition); border: none;
  377. }
  378. .btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
  379. .btn-ghost:hover { border-color: var(--border-hover); color: var(--text-secondary); }
  380. .btn-accent { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(45,220,138,0.2); }
  381. .btn-accent:hover { background: var(--accent); color: #0b0f0e; }
  382. /* ── TPS Viewer drawer ───────────────────────────────────────────── */
  383. .tps-drawer {
  384. position: fixed; inset: 0; z-index: 200;
  385. display: none;
  386. }
  387. .tps-drawer.open { display: flex; }
  388. .tps-overlay {
  389. position: absolute; inset: 0; background: rgba(0,0,0,0.6);
  390. backdrop-filter: blur(3px);
  391. }
  392. .tps-panel {
  393. position: absolute; right: 0; top: 0; bottom: 0;
  394. width: min(960px, 92vw); background: var(--bg-1);
  395. border-left: 1px solid var(--border);
  396. display: flex; flex-direction: column;
  397. animation: slideIn 0.22s ease;
  398. }
  399. @keyframes slideIn { from { transform: translateX(100%); } to { transform: none; } }
  400. .tps-header {
  401. padding: 16px 20px; border-bottom: 1px solid var(--border);
  402. display: flex; align-items: center; justify-content: space-between;
  403. flex-shrink: 0;
  404. }
  405. .tps-header h3 { font-size: 0.9rem; font-weight: 500; }
  406. .tps-controls {
  407. padding: 12px 20px; border-bottom: 1px solid var(--border);
  408. display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;
  409. flex-shrink: 0;
  410. }
  411. .tps-controls .field { display: flex; flex-direction: column; gap: 5px; min-width: 160px; flex: 1; }
  412. .tps-controls .field label { font-size: 0.68rem; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-muted); }
  413. .tps-frame { flex: 1; border: none; background: #fff; }
  414. /* ── Scrollbar ───────────────────────────────────────────────────── */
  415. ::-webkit-scrollbar { width: 5px; }
  416. ::-webkit-scrollbar-track { background: transparent; }
  417. ::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
  418. </style>
  419. <!-- Google tag (gtag.js) -->
  420. <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
  421. <script>
  422. window.dataLayer = window.dataLayer || [];
  423. function gtag(){dataLayer.push(arguments);}
  424. gtag('js', new Date());
  425. gtag('config', 'G-LWEHQVCWEZ');
  426. </script>
  427. <!-- Google Tag Manager -->
  428. <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  429. new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  430. j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  431. 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  432. })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
  433. <!-- End Google Tag Manager -->
  434. </head>
  435. <body>
  436. <!-- Google Tag Manager (noscript) -->
  437. <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
  438. height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  439. <!-- End Google Tag Manager (noscript) -->
  440. <!-- ── Nav ──────────────────────────────────────────────────────────── -->
  441. <nav class="site-nav">
  442. <div class="nav-inner">
  443. <a class="nav-brand" href="/">
  444. <svg width="22" height="22" viewBox="0 0 28 28" fill="none">
  445. <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
  446. <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  447. <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
  448. </svg>
  449. Tasmanian Planning Scheme
  450. </a>
  451. <div class="nav-links">
  452. <a href="/">Home</a>
  453. <a href="/local_state-planning-scheme.php" class="active">Assistant</a>
  454. <a href="/site-report.php">Property Lookup</a>
  455. <a href="/section-builder.php">Report Builder</a>
  456. <a href="/faq">FAQ</a>
  457. </div>
  458. <div class="nav-right">
  459. <button class="btn btn-ghost" id="btnTpsViewer" style="gap:5px;">
  460. <i class="bi bi-layout-sidebar-reverse"></i> TPS Viewer
  461. </button>
  462. <button class="btn btn-ghost" id="btnNewChat">
  463. <i class="bi bi-plus-square"></i> New chat
  464. </button>
  465. <a href="/byok-settings.php" class="btn btn-ghost" id="btnByok" title="Configure your own API key">
  466. <i class="bi bi-key"></i> <span id="byokLabel">Own key</span>
  467. </a>
  468. <div class="nav-status">
  469. <span class="status-dot"></span>
  470. <span class="nav-status-text">API live</span>
  471. </div>
  472. </div>
  473. </div>
  474. </nav>
  475. <!-- ── App ───────────────────────────────────────────────────────────── -->
  476. <div class="app-wrap">
  477. <!-- ── Sidebar ──────────────────────────────────────────────────── -->
  478. <aside class="sidebar">
  479. <!-- Scope controls -->
  480. <div class="sidebar-section">
  481. <span class="sidebar-label">Document scope</span>
  482. <select id="council">
  483. <option value="">All councils / SPP only</option>
  484. </select>
  485. <label class="toggle-row">
  486. <span class="toggle-track">
  487. <input type="checkbox" id="allowTPS" checked>
  488. <span class="toggle-knob"></span>
  489. </span>
  490. <span class="toggle-label">Include Tasmanian Planning Scheme (SPP)</span>
  491. </label>
  492. </div>
  493. <!-- Active property context (populated when arriving from site-report.php) -->
  494. <div class="sidebar-section" id="propCtxSection" style="display:none;">
  495. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
  496. <span class="sidebar-label" style="margin:0;">Active property</span>
  497. <button id="propCtxDismiss" class="btn btn-ghost" style="padding:2px 8px;font-size:0.68rem;">
  498. <i class="bi bi-x"></i> Clear
  499. </button>
  500. </div>
  501. <div class="prop-ctx-panel" id="propCtxPanel"></div>
  502. </div>
  503. <!-- Quick asks -->
  504. <div class="sidebar-section">
  505. <span class="sidebar-label">Quick queries</span>
  506. <div class="quick-pills">
  507. <button class="quick-pill" data-q="What are the acceptable solutions for front setbacks in the Village Zone?">
  508. Village Zone setbacks
  509. </button>
  510. <button class="quick-pill" data-q="What is the car parking rate for a medical centre? Cite the clause.">
  511. Parking — medical centre
  512. </button>
  513. <button class="quick-pill" data-q="Summarise the acceptable solutions for the General Residential Zone.">
  514. General Residential Zone
  515. </button>
  516. <button class="quick-pill" data-q="What overlays should be checked for a coastal site in Tasmania?">
  517. Coastal overlays
  518. </button>
  519. <button class="quick-pill" data-q="What is the Use Class for a café under the Tasmanian Planning Scheme?">
  520. Use Class — café
  521. </button>
  522. <button class="quick-pill" data-q="When is a planning permit required for a shed or outbuilding?">
  523. Permit — shed/outbuilding
  524. </button>
  525. </div>
  526. </div>
  527. <!-- Chat history -->
  528. <div class="sidebar-section" style="flex:1;overflow:hidden;display:flex;flex-direction:column;">
  529. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
  530. <span class="sidebar-label" style="margin:0;">Recent questions</span>
  531. <button class="btn btn-ghost" id="btnClearHistory" style="padding:2px 8px;font-size:0.68rem;">Clear</button>
  532. </div>
  533. <div class="history-list" id="historyList" style="overflow-y:auto;flex:1;"></div>
  534. </div>
  535. </aside>
  536. <!-- ── Chat ─────────────────────────────────────────────────────── -->
  537. <div class="chat-wrap">
  538. <!-- Thread -->
  539. <div class="chat-thread" id="chatThread">
  540. <!-- Empty state shown when no messages -->
  541. <div class="chat-empty" id="chatEmpty">
  542. <svg width="48" height="48" viewBox="0 0 28 28" fill="none" style="opacity:0.2;">
  543. <rect width="28" height="28" rx="6" fill="var(--accent)"/>
  544. <path d="M8 20 L14 8 L20 20" stroke="#0b0f0e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  545. <path d="M10.5 16 L17.5 16" stroke="#0b0f0e" stroke-width="2" stroke-linecap="round"/>
  546. </svg>
  547. <h2>Ask about <em>Tasmanian planning</em></h2>
  548. <p>Get clause-cited answers from the SPPs and your council's LPS. Every response links back to the source.</p>
  549. <div class="example-pills">
  550. <span class="example-pill" data-q="Do I need a permit for a 20m² shed in the Rural Zone?">Permit for a shed?</span>
  551. <span class="example-pill" data-q="What are the acceptable solutions for setbacks in the Low Density Residential Zone?">Low Density Residential setbacks</span>
  552. <span class="example-pill" data-q="What codes apply to a new café in the General Business Zone?">Café in General Business Zone</span>
  553. <span class="example-pill" data-q="Explain the difference between Acceptable Solutions and Performance Criteria.">A vs P criteria explained</span>
  554. </div>
  555. </div>
  556. </div>
  557. <!-- Input -->
  558. <div class="input-bar">
  559. <div class="address-suggest-bar" id="addressSuggestBar">
  560. <i class="bi bi-geo-alt" style="color:var(--accent);flex-shrink:0;"></i>
  561. <span id="addressSuggestText">Looks like an address — <a id="addressSuggestLink" href="/site-report.php">look up property data</a> for zone &amp; overlay context, then return here.</span>
  562. </div>
  563. <div class="synonym-bar" id="synonymBar"></div>
  564. <div class="input-wrap">
  565. <textarea
  566. id="question"
  567. class="input-textarea"
  568. rows="1"
  569. placeholder="Ask a question about the Tasmanian Planning Scheme… (⏎ to send, Shift+⏎ for new line)"
  570. ></textarea>
  571. <button class="send-btn" id="askBtn" title="Send">
  572. <i class="bi bi-arrow-up"></i>
  573. </button>
  574. </div>
  575. <div class="input-hint">
  576. Tip: use Tasmanian planning terms — e.g. "Acceptable Solutions", "Use Class", "Performance Criteria", zone names.
  577. </div>
  578. </div>
  579. </div>
  580. </div>
  581. <!-- ── TPS Viewer Drawer ─────────────────────────────────────────────── -->
  582. <div class="tps-drawer" id="tpsDrawer">
  583. <div class="tps-overlay" id="tpsOverlay"></div>
  584. <div class="tps-panel">
  585. <div class="tps-header">
  586. <h3><i class="bi bi-layout-sidebar-reverse" style="color:var(--accent);margin-right:6px;"></i>TPS Viewer</h3>
  587. <button class="btn btn-ghost" id="btnCloseTps"><i class="bi bi-x-lg"></i></button>
  588. </div>
  589. <div class="tps-controls">
  590. <div class="field">
  591. <label>Document</label>
  592. <select id="tpsViewer">
  593. <option value="spps">SPPs — State Planning Provisions</option>
  594. <option value="lps">LPS — Council Local Provisions</option>
  595. <option value="custom">Custom URL…</option>
  596. </select>
  597. </div>
  598. <div class="field">
  599. <label>Section code (optional)</label>
  600. <input type="text" id="tpsSection" placeholder="e.g. C7.7.2 or 8.4">
  601. </div>
  602. <div style="display:flex;gap:8px;align-self:flex-end;flex-shrink:0;">
  603. <button class="btn btn-accent" id="btnOpenTps"><i class="bi bi-eye"></i> Open</button>
  604. <button class="btn btn-ghost" id="btnLastSource"><i class="bi bi-link-45deg"></i> Last source</button>
  605. </div>
  606. </div>
  607. <iframe id="tpsFrame" class="tps-frame" src="about:blank" referrerpolicy="no-referrer"></iframe>
  608. </div>
  609. </div>
  610. <script>
  611. 'use strict';
  612. /* ── Config ─────────────────────────────────────────────────────────── */
  613. const API = 'https://api.modulos.com.au';
  614. window.APP_API_BASE = API + '/ask';
  615. /* ── State ───────────────────────────────────────────────────────────── */
  616. let history = [];
  617. let sessionId = localStorage.getItem('tps_session_id') || crypto.randomUUID();
  618. let lastSources = [];
  619. let isAsking = false;
  620. localStorage.setItem('tps_session_id', sessionId);
  621. const byId = id => document.getElementById(id);
  622. const chatThread = byId('chatThread');
  623. const chatEmpty = byId('chatEmpty');
  624. const questionEl = byId('question');
  625. const askBtn = byId('askBtn');
  626. /* ── Property context (arrives via localStorage from site-report.php) ── */
  627. let propCtx = null;
  628. function loadPropCtx() {
  629. try {
  630. const raw = localStorage.getItem('tpr_builder_ctx');
  631. if (!raw) return;
  632. const { ctx, written_at } = JSON.parse(raw);
  633. if (!ctx || (Date.now() - written_at > 30 * 60 * 1000)) return; // 30-min TTL
  634. propCtx = ctx;
  635. renderPropCtx();
  636. // Auto-set council dropdown once options are loaded
  637. if (ctx.council) {
  638. const trySet = setInterval(() => {
  639. const sel = byId('council');
  640. const opt = sel && [...sel.options].find(o =>
  641. o.value.toLowerCase() === ctx.council.toLowerCase()
  642. );
  643. if (opt) { opt.selected = true; clearInterval(trySet); }
  644. }, 100);
  645. setTimeout(() => clearInterval(trySet), 5000);
  646. }
  647. } catch(e) { console.warn('[propCtx] load failed', e); }
  648. }
  649. function renderPropCtx() {
  650. const section = byId('propCtxSection');
  651. const panel = byId('propCtxPanel');
  652. if (!section || !panel || !propCtx) return;
  653. const zones = Array.isArray(propCtx.planning_zones) ? propCtx.planning_zones.join(', ') : (propCtx.planning_zones || '');
  654. const codes = Array.isArray(propCtx.planning_codes) ? propCtx.planning_codes.join(', ') : (propCtx.planning_codes || '');
  655. panel.innerHTML = `
  656. <div class="prop-ctx-addr"><i class="bi bi-geo-alt" style="color:var(--accent);margin-right:4px;"></i>${esc(propCtx.address || '—')}</div>
  657. <div class="prop-ctx-meta">
  658. ${propCtx.council ? `<span>Council: ${esc(propCtx.council)}</span>` : ''}
  659. ${zones ? `<span>Zone: ${esc(zones)}</span>` : ''}
  660. ${codes ? `<span>Codes: ${esc(codes)}</span>` : ''}
  661. ${propCtx.area_sqm ? `<span>Area: ${esc(String(propCtx.area_sqm))} m²</span>` : ''}
  662. </div>`;
  663. section.style.display = '';
  664. }
  665. function clearPropCtx() {
  666. propCtx = null;
  667. localStorage.removeItem('tpr_builder_ctx');
  668. const section = byId('propCtxSection');
  669. if (section) section.style.display = 'none';
  670. }
  671. /* ── Session ID for telemetry ────────────────────────────────────────── */
  672. const SID_KEY = 'tpr_sid';
  673. const sid = localStorage.getItem(SID_KEY) || (() => {
  674. const v = crypto?.randomUUID?.() || String(Math.random()).slice(2) + Date.now();
  675. localStorage.setItem(SID_KEY, v);
  676. return v;
  677. })();
  678. /* ── Telemetry ───────────────────────────────────────────────────────── */
  679. function sendEvent(type, data = {}) {
  680. const payload = { type, ts: new Date().toISOString(), sid, ua: navigator.userAgent, data };
  681. const url = API + '/telemetry';
  682. const body = JSON.stringify(payload);
  683. fetch(url, {
  684. method: 'POST', mode: 'cors', credentials: 'omit',
  685. headers: { 'Content-Type': 'application/json' },
  686. body, keepalive: true
  687. }).catch(() => {});
  688. }
  689. window.TPRtelemetry = { sendEvent };
  690. /* ── Councils ────────────────────────────────────────────────────────── */
  691. async function loadCouncils() {
  692. try {
  693. const res = await fetch(`${API}/councils`, { cache: 'no-store' });
  694. const items = await res.json();
  695. const sel = byId('council');
  696. sel.innerHTML = '<option value="">All councils / SPP only</option>' +
  697. items.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
  698. } catch(e) { console.warn('[UI] councils failed', e); }
  699. }
  700. /* ── Scope ───────────────────────────────────────────────────────────── */
  701. function computeScope() {
  702. const allowTps = byId('allowTPS').checked;
  703. const council = (byId('council')?.value || '').trim();
  704. const hasCouncil = !!council;
  705. if (allowTps && hasCouncil) return 'state_plus_local';
  706. if (allowTps && !hasCouncil) return 'state_only';
  707. if (!allowTps && hasCouncil) return 'local_only';
  708. return 'any';
  709. }
  710. /* ── BYOK helpers ────────────────────────────────────────────────────── */
  711. const ACTIVE_KEY = 'tpr_byok_active';
  712. const KEY_PREFIX = 'tpr_byok_key_';
  713. const MODEL_PREFIX = 'tpr_byok_model_';
  714. function byokActive() { return localStorage.getItem(ACTIVE_KEY) || 'internal'; }
  715. function byokKey(id) { return localStorage.getItem(KEY_PREFIX + id) || ''; }
  716. function byokModel(id, fallback) { return localStorage.getItem(MODEL_PREFIX + id) || fallback || ''; }
  717. const BYOK_DEFAULTS = {
  718. anthropic: 'claude-sonnet-4-5',
  719. openai: 'gpt-4o-mini',
  720. grok: 'grok-3-mini',
  721. ollama: 'llama3.1:8b',
  722. };
  723. function updateByokButton() {
  724. const active = byokActive();
  725. const label = byId('byokLabel');
  726. if (!label) return;
  727. const names = { internal:'Own key', anthropic:'Claude', openai:'GPT-4o', grok:'Grok', ollama:'Ollama' };
  728. label.textContent = names[active] || 'Own key';
  729. const btn = byId('btnByok');
  730. if (btn) btn.style.color = active !== 'internal' ? 'var(--accent)' : '';
  731. }
  732. /* Call the active external LLM with the context returned from /ask?context_only=true */
  733. async function callExternalLLM(prompt, provider) {
  734. const key = byokKey(provider);
  735. const model = byokModel(provider, BYOK_DEFAULTS[provider]);
  736. if (provider === 'anthropic') {
  737. const res = await fetch('https://api.anthropic.com/v1/messages', {
  738. method: 'POST',
  739. headers: {
  740. 'Content-Type': 'application/json',
  741. 'x-api-key': key,
  742. 'anthropic-version': '2023-06-01',
  743. 'anthropic-dangerous-direct-browser-access': 'true',
  744. },
  745. body: JSON.stringify({
  746. model,
  747. max_tokens: 1024,
  748. messages: [{ role: 'user', content: prompt }]
  749. })
  750. });
  751. if (!res.ok) {
  752. const err = await res.json().catch(() => ({}));
  753. throw new Error(err?.error?.message || `Anthropic HTTP ${res.status}`);
  754. }
  755. const data = await res.json();
  756. return data?.content?.[0]?.text || '';
  757. }
  758. if (provider === 'openai' || provider === 'grok') {
  759. const baseUrl = provider === 'grok'
  760. ? 'https://api.x.ai/v1'
  761. : 'https://api.openai.com/v1';
  762. const res = await fetch(`${baseUrl}/chat/completions`, {
  763. method: 'POST',
  764. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
  765. body: JSON.stringify({
  766. model,
  767. max_tokens: 1024,
  768. messages: [{ role: 'user', content: prompt }]
  769. })
  770. });
  771. if (!res.ok) {
  772. const err = await res.json().catch(() => ({}));
  773. throw new Error(err?.error?.message || `${provider} HTTP ${res.status}`);
  774. }
  775. const data = await res.json();
  776. return data?.choices?.[0]?.message?.content || '';
  777. }
  778. if (provider === 'ollama') {
  779. const base = key.replace(/\/$/, '') || 'http://localhost:11434';
  780. const res = await fetch(`${base}/api/generate`, {
  781. method: 'POST',
  782. headers: { 'Content-Type': 'application/json' },
  783. body: JSON.stringify({ model, prompt, stream: false })
  784. });
  785. if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
  786. const data = await res.json();
  787. return data?.response || '';
  788. }
  789. throw new Error(`Unknown provider: ${provider}`);
  790. }
  791. /* ── Ask ─────────────────────────────────────────────────────────────── */
  792. async function ask(queryOverride) {
  793. const rawQuery = (queryOverride || questionEl.value || '').trim();
  794. if (!rawQuery || isAsking) return;
  795. // Prepend site context if a property has been looked up
  796. let query = rawQuery;
  797. if (propCtx) {
  798. const zones = Array.isArray(propCtx.planning_zones) ? propCtx.planning_zones.join(', ') : (propCtx.planning_zones || '');
  799. const codes = Array.isArray(propCtx.planning_codes) ? propCtx.planning_codes.join(', ') : (propCtx.planning_codes || '');
  800. const parts = [
  801. propCtx.address ? `Address: ${propCtx.address}` : '',
  802. propCtx.council ? `Council: ${propCtx.council}` : '',
  803. zones ? `Zone(s): ${zones}` : '',
  804. codes ? `Codes/overlays: ${codes}` : '',
  805. propCtx.area_sqm ? `Site area: ${propCtx.area_sqm} m²` : '',
  806. ].filter(Boolean).join('; ');
  807. query = `[Site context — ${parts}]\n\n${rawQuery}`;
  808. }
  809. const council = (byId('council')?.value || '').trim();
  810. const scope = computeScope();
  811. const provider = byokActive();
  812. const useBYOK = provider !== 'internal';
  813. const startedAt = performance.now();
  814. isAsking = true;
  815. askBtn.disabled = true;
  816. questionEl.value = '';
  817. autoResize(questionEl);
  818. hideSynonymBar();
  819. appendUserMsg(rawQuery);
  820. const thinkEl = appendThinking();
  821. sendEvent('search_performed', { query: rawQuery, scope, source: 'assistant', byok: useBYOK ? provider : null });
  822. try {
  823. if (useBYOK) {
  824. // ── BYOK path ──────────────────────────────────────────────────
  825. // Step 1: get RAG context from our backend (no Ollama call)
  826. const ragRes = await fetch(`${API}/ask`, {
  827. method: 'POST',
  828. headers: { 'Content-Type': 'application/json', 'X-TPR-SID': sessionId },
  829. body: JSON.stringify({ query, council: council || null, top_k: 8, scope, context_only: true })
  830. });
  831. const ragRaw = await ragRes.text();
  832. if (!ragRes.ok) throw new Error(`RAG HTTP ${ragRes.status} — ${ragRaw.slice(0,200)}`);
  833. const ragData = JSON.parse(ragRaw);
  834. lastSources = Array.isArray(ragData.sources) ? ragData.sources : [];
  835. // Step 2: call external LLM with the pre-built prompt from the backend
  836. const answer = await callExternalLLM(ragData.prompt, provider);
  837. thinkEl.remove();
  838. const latencyMs = Math.round(performance.now() - startedAt);
  839. sendEvent('search_result', {
  840. latency_ms: latencyMs, ok: true, byok: provider,
  841. topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
  842. });
  843. appendAssistantMsg(answer || 'No answer returned.', scope, lastSources, rawQuery, provider);
  844. addToHistory(rawQuery);
  845. } else {
  846. // ── Internal Ollama path (unchanged) ───────────────────────────
  847. const res = await fetch(`${API}/ask`, {
  848. method: 'POST',
  849. headers: { 'Content-Type': 'application/json', 'X-TPR-SID': sessionId },
  850. body: JSON.stringify({ query, council: council || null, top_k: 8, scope })
  851. });
  852. const raw = await res.text();
  853. if (!res.ok) throw new Error(`HTTP ${res.status} — ${raw.slice(0,200)}`);
  854. const data = JSON.parse(raw);
  855. thinkEl.remove();
  856. lastSources = Array.isArray(data.sources) ? data.sources : [];
  857. const latencyMs = Math.round(performance.now() - startedAt);
  858. sendEvent('search_result', {
  859. latency_ms: latencyMs,
  860. topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
  861. model: data.model || 'unknown', ok: true,
  862. });
  863. appendAssistantMsg(data.answer || 'No answer returned.', scope, lastSources, rawQuery, 'internal');
  864. addToHistory(rawQuery);
  865. }
  866. } catch(e) {
  867. thinkEl.remove();
  868. appendErrorMsg(e.message);
  869. console.error('[ask]', e);
  870. } finally {
  871. isAsking = false;
  872. askBtn.disabled = false;
  873. questionEl.focus();
  874. }
  875. }
  876. /* ── Message rendering ───────────────────────────────────────────────── */
  877. function hideEmpty() {
  878. if (chatEmpty) chatEmpty.style.display = 'none';
  879. }
  880. function appendUserMsg(text) {
  881. hideEmpty();
  882. const div = document.createElement('div');
  883. div.className = 'msg user';
  884. div.innerHTML = `
  885. <div class="msg-role"><i class="bi bi-person"></i> You</div>
  886. <div class="msg-content">${esc(text)}</div>
  887. `;
  888. chatThread.appendChild(div);
  889. scrollBottom();
  890. }
  891. function appendThinking() {
  892. hideEmpty();
  893. const div = document.createElement('div');
  894. div.className = 'thinking';
  895. div.innerHTML = `
  896. <div class="thinking-dots"><span></span><span></span><span></span></div>
  897. Thinking…
  898. `;
  899. chatThread.appendChild(div);
  900. scrollBottom();
  901. return div;
  902. }
  903. function appendAssistantMsg(answer, scope, sources, query, provider = 'internal') {
  904. const div = document.createElement('div');
  905. div.className = 'msg assistant';
  906. const providerNames = { internal:'Ollama', anthropic:'Claude', openai:'GPT-4o', grok:'Grok', ollama:'Local Ollama' };
  907. const providerName = providerNames[provider] || provider;
  908. const providerIcon = provider === 'internal' ? 'cpu' : 'key';
  909. const scopeHtml = `
  910. <div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;">
  911. <div class="scope-badge"><i class="bi bi-filter"></i> ${esc(scope)}</div>
  912. <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)'};">
  913. <i class="bi bi-${providerIcon}"></i> ${esc(providerName)}
  914. </div>
  915. </div>`;
  916. const answerHtml = md2html(answer);
  917. let sourcesHtml = '';
  918. if (sources && sources.length) {
  919. const chips = sources.map((s, i) => {
  920. const label = `${s.source_file} p.${s.page}`;
  921. const score = typeof s.score === 'number' ? `<span class="source-score">${s.score.toFixed(2)}</span>` : '';
  922. return `<span class="source-chip" data-cite="${esc(`${s.source_file}#p${s.page}`)}" data-index="${i}"
  923. onclick="openSourceInViewer(${i})">
  924. <i class="bi bi-file-earmark-text"></i>${esc(label)}${score}
  925. </span>`;
  926. }).join('');
  927. sourcesHtml = `
  928. <div class="msg-sources">
  929. <div class="sources-label">Sources</div>
  930. <div class="source-chips">${chips}</div>
  931. </div>`;
  932. }
  933. const msgId = `msg-${Date.now()}`;
  934. div.id = msgId;
  935. // Store context on the element so feedback() can read it without closure issues
  936. div.dataset.query = query || '';
  937. div.dataset.scope = scope || '';
  938. div.dataset.provider = provider || 'internal';
  939. // Store answer as plain text (strip HTML tags) for the feedback payload
  940. div.dataset.answer = answer.replace(/<[^>]*>/g, '').substring(0, 4000);
  941. div.innerHTML = `
  942. <div class="msg-role"><i class="bi bi-stars"></i> Assistant</div>
  943. ${scopeHtml}
  944. <div class="msg-content">${answerHtml}</div>
  945. ${sourcesHtml}
  946. <div class="msg-feedback">
  947. <button class="fb-btn" onclick="feedback('${msgId}','up',this)"><i class="bi bi-hand-thumbs-up"></i> Helpful</button>
  948. <button class="fb-btn" onclick="feedback('${msgId}','down',this)"><i class="bi bi-hand-thumbs-down"></i> Not helpful</button>
  949. </div>
  950. `;
  951. chatThread.appendChild(div);
  952. scrollBottom();
  953. }
  954. function appendErrorMsg(msg) {
  955. const div = document.createElement('div');
  956. div.className = 'msg assistant';
  957. div.innerHTML = `
  958. <div class="msg-role"><i class="bi bi-exclamation-circle" style="color:var(--danger)"></i> Error</div>
  959. <div class="msg-content" style="color:var(--danger);">${esc(msg)}</div>
  960. `;
  961. chatThread.appendChild(div);
  962. scrollBottom();
  963. }
  964. function scrollBottom() {
  965. chatThread.scrollTop = chatThread.scrollHeight;
  966. }
  967. /* ── Feedback ────────────────────────────────────────────────────────── */
  968. window.feedback = function(msgId, verdict, btn) {
  969. // Update button state immediately
  970. const row = btn.closest('.msg-feedback');
  971. row.querySelectorAll('.fb-btn').forEach(b => b.classList.remove('active-up','active-dn'));
  972. btn.classList.add(verdict === 'up' ? 'active-up' : 'active-dn');
  973. // Read context from data attributes stored on the message div at render time.
  974. // This avoids closure/scope issues — no reliance on outer variables.
  975. const msgEl = document.getElementById(msgId);
  976. const query = msgEl?.dataset.query || '';
  977. const answer = msgEl?.dataset.answer || '';
  978. const scope = msgEl?.dataset.scope || '';
  979. const provider= msgEl?.dataset.provider || 'internal';
  980. const storedSid = localStorage.getItem('tpr_sid') || '';
  981. // Collect note for thumbs-down (do this before the async fetch)
  982. let note = null;
  983. if (verdict === 'down') {
  984. note = window.prompt('What missed the mark? (optional — helps us improve)') || null;
  985. }
  986. // Post to the dedicated /feedback endpoint (stores full query + answer in DB)
  987. fetch(`${API}/feedback`, {
  988. method: 'POST',
  989. headers: { 'Content-Type': 'application/json' },
  990. credentials: 'omit',
  991. body: JSON.stringify({
  992. verdict,
  993. query,
  994. answer,
  995. note,
  996. sid: storedSid,
  997. scope,
  998. model: provider,
  999. sources: lastSources.slice(0, 10).map(s => ({
  1000. source_file: s.source_file,
  1001. page: s.page,
  1002. score: s.score
  1003. }))
  1004. })
  1005. }).catch(() => {}); // swallow silently — feedback must never break UX
  1006. // Also fire the existing telemetry event for the events table
  1007. sendEvent('feedback', { verdict, note, msg_id: msgId, scope, provider });
  1008. };
  1009. /* ── Source viewer ───────────────────────────────────────────────────── */
  1010. window.openSourceInViewer = function(index) {
  1011. openTpsDrawer();
  1012. // Best effort: open SPPs base — we don't have direct clause URLs from /ask
  1013. byId('tpsFrame').src = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
  1014. sendEvent('interaction', { action: 'clicked_citation', cite_index: index });
  1015. };
  1016. /* ── History ─────────────────────────────────────────────────────────── */
  1017. function addToHistory(query) {
  1018. const h = JSON.parse(localStorage.getItem('tps_query_history') || '[]');
  1019. const next = [query, ...h.filter(q => q !== query)].slice(0, 20);
  1020. localStorage.setItem('tps_query_history', JSON.stringify(next));
  1021. renderHistory();
  1022. }
  1023. function renderHistory() {
  1024. const list = byId('historyList');
  1025. if (!list) return;
  1026. const h = JSON.parse(localStorage.getItem('tps_query_history') || '[]');
  1027. list.innerHTML = h.length
  1028. ? h.map(q => `<div class="history-item" onclick="ask(${JSON.stringify(q)})">${esc(q)}</div>`).join('')
  1029. : `<div style="font-size:0.75rem;color:var(--text-muted);padding:4px 0;">No history yet.</div>`;
  1030. }
  1031. byId('btnClearHistory').addEventListener('click', () => {
  1032. localStorage.removeItem('tps_query_history');
  1033. renderHistory();
  1034. });
  1035. /* ── New chat ────────────────────────────────────────────────────────── */
  1036. byId('btnNewChat').addEventListener('click', () => {
  1037. history = [];
  1038. sessionId = crypto.randomUUID();
  1039. localStorage.setItem('tps_session_id', sessionId);
  1040. // Clear messages except empty state
  1041. [...chatThread.children].forEach(el => {
  1042. if (el !== chatEmpty) el.remove();
  1043. });
  1044. if (chatEmpty) chatEmpty.style.display = '';
  1045. lastSources = [];
  1046. });
  1047. /* ── Input handling ──────────────────────────────────────────────────── */
  1048. questionEl.addEventListener('keydown', e => {
  1049. if (e.key === 'Enter' && !e.shiftKey) {
  1050. e.preventDefault();
  1051. ask();
  1052. }
  1053. });
  1054. questionEl.addEventListener('input', () => {
  1055. autoResize(questionEl);
  1056. checkSynonyms(questionEl.value);
  1057. checkAddressInQuery(questionEl.value);
  1058. });
  1059. askBtn.addEventListener('click', () => ask());
  1060. function autoResize(el) {
  1061. el.style.height = 'auto';
  1062. el.style.height = Math.min(el.scrollHeight, 160) + 'px';
  1063. }
  1064. /* ── Quick asks / examples ───────────────────────────────────────────── */
  1065. document.querySelectorAll('[data-q]').forEach(el => {
  1066. el.addEventListener('click', () => ask(el.dataset.q));
  1067. });
  1068. /* ── TPS Viewer ──────────────────────────────────────────────────────── */
  1069. function openTpsDrawer() {
  1070. byId('tpsDrawer').classList.add('open');
  1071. }
  1072. function closeTpsDrawer() {
  1073. byId('tpsDrawer').classList.remove('open');
  1074. }
  1075. byId('btnTpsViewer').addEventListener('click', openTpsDrawer);
  1076. byId('btnCloseTps').addEventListener('click', closeTpsDrawer);
  1077. byId('tpsOverlay').addEventListener('click', closeTpsDrawer);
  1078. document.addEventListener('keydown', e => { if (e.key === 'Escape') closeTpsDrawer(); });
  1079. byId('btnOpenTps').addEventListener('click', () => {
  1080. const viewer = byId('tpsViewer').value;
  1081. const section = (byId('tpsSection').value || '').trim();
  1082. const frame = byId('tpsFrame');
  1083. const council = (byId('council')?.value || '').trim();
  1084. if (viewer === 'spps') {
  1085. let url = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
  1086. if (section) url += `/section/${encodeURIComponent(section)}`;
  1087. frame.src = url;
  1088. } else if (viewer === 'lps') {
  1089. frame.src = 'https://planning.tas.gov.au/planning-schemes/tasmanian-planning-scheme';
  1090. } else {
  1091. const custom = prompt('Paste a TPS/TPSO URL:');
  1092. if (custom) frame.src = custom;
  1093. }
  1094. });
  1095. byId('btnLastSource').addEventListener('click', () => {
  1096. if (!lastSources.length) { alert('No sources from the last answer yet.'); return; }
  1097. byId('tpsFrame').src = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
  1098. openTpsDrawer();
  1099. });
  1100. /* ── Synonym suggestions ─────────────────────────────────────────────── */
  1101. const SYNONYMS = {
  1102. "house":["single dwelling","Residential","Class 1a"],
  1103. "home":["single dwelling","Residential","Class 1a"],
  1104. "granny flat":["secondary residence","Residential"],
  1105. "duplex":["multiple dwellings","Residential","Class 1a attached"],
  1106. "townhouse":["multiple dwellings","Residential"],
  1107. "apartment":["multiple dwellings","Residential","Class 2"],
  1108. "flat":["multiple dwellings","Residential","Class 2"],
  1109. "aged care":["residential care facility","Residential","Class 9c"],
  1110. "airbnb":["Visitor Accommodation","short-stay"],
  1111. "holiday let":["Visitor Accommodation"],
  1112. "motel":["Visitor Accommodation","Class 3"],
  1113. "hotel":["Hotel Industry"],
  1114. "pub":["Hotel Industry"],
  1115. "bar":["Hotel Industry"],
  1116. "shop":["General Retail and Hire","Class 6"],
  1117. "supermarket":["General Retail and Hire","Class 6"],
  1118. "restaurant":["Food Services","Class 6"],
  1119. "cafe":["Food Services","Class 6"],
  1120. "takeaway":["Food Services","Class 6"],
  1121. "office":["Business and Professional Services","Class 5"],
  1122. "medical centre":["Business and Professional Services","Class 9a"],
  1123. "clinic":["Business and Professional Services","Class 9a"],
  1124. "hospital":["Hospital Services","Class 9a"],
  1125. "childcare":["Educational and Occasional Care","Class 9b"],
  1126. "school":["Educational and Occasional Care","Class 9b"],
  1127. "university":["Educational and Occasional Care","Class 9b"],
  1128. "church":["Community Meeting and Entertainment","Class 9b"],
  1129. "gym":["Sports and Recreation","Class 9b"],
  1130. "factory":["Manufacturing and Processing","Class 8"],
  1131. "workshop":["Service Industry","Class 8"],
  1132. "warehouse":["Storage","Class 7"],
  1133. "shed":["ancillary structure","Class 10a"],
  1134. "garage":["ancillary structure","Class 10a"],
  1135. "carport":["ancillary structure","Class 10a"],
  1136. "deck":["verandah","ancillary structure","Class 10a"],
  1137. "pergola":["open structure","Class 10a"],
  1138. "fence":["ancillary structure","Class 10b"],
  1139. "pool":["swimming pool","Class 10b"],
  1140. "service station":["Vehicle Fuel Sales and Service"],
  1141. "servo":["Vehicle Fuel Sales and Service"],
  1142. "farm":["Resource Development","agricultural use"],
  1143. "cemetery":["Crematoria and Cemeteries"],
  1144. };
  1145. function checkSynonyms(val) {
  1146. const bar = byId('synonymBar');
  1147. const words = val.toLowerCase().trim().split(/\s+/);
  1148. const found = [];
  1149. // Check last 1–3 words as phrases
  1150. for (let len = 3; len >= 1; len--) {
  1151. const phrase = words.slice(-len).join(' ');
  1152. if (SYNONYMS[phrase]) {
  1153. SYNONYMS[phrase].forEach(s => {
  1154. if (!found.includes(s)) found.push(s);
  1155. });
  1156. break;
  1157. }
  1158. }
  1159. if (found.length) {
  1160. bar.innerHTML = '<i class="bi bi-lightbulb" style="color:var(--accent);flex-shrink:0;"></i> TPS terms: ' +
  1161. found.map(s => `<span class="syn-pill" onclick="appendSynonym('${esc(s)}')">${esc(s)}</span>`).join('');
  1162. bar.classList.add('show');
  1163. } else {
  1164. hideSynonymBar();
  1165. }
  1166. }
  1167. function hideSynonymBar() {
  1168. const bar = byId('synonymBar');
  1169. bar.classList.remove('show');
  1170. bar.innerHTML = '';
  1171. }
  1172. /* ── Address detection ───────────────────────────────────────────────── */
  1173. // Matches patterns like "12 Smith Street" or "12a High Road Hobart"
  1174. 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;
  1175. function checkAddressInQuery(val) {
  1176. const bar = byId('addressSuggestBar');
  1177. if (!bar) return;
  1178. // Don't show if property context is already loaded
  1179. if (propCtx) { bar.classList.remove('show'); return; }
  1180. if (val.length > 8 && ADDRESS_RE.test(val)) {
  1181. bar.classList.add('show');
  1182. } else {
  1183. bar.classList.remove('show');
  1184. }
  1185. }
  1186. window.appendSynonym = function(term) {
  1187. questionEl.value = (questionEl.value + ' ' + term).trim();
  1188. questionEl.focus();
  1189. hideSynonymBar();
  1190. };
  1191. /* ── Utilities ───────────────────────────────────────────────────────── */
  1192. function esc(s) {
  1193. return String(s || '').replace(/[&<>"']/g, c =>
  1194. ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;' }[c])
  1195. );
  1196. }
  1197. function md2html(s) {
  1198. return String(s || '')
  1199. .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
  1200. .replace(/^#### (.+)$/gm,'<h3>$1</h3>')
  1201. .replace(/^### (.+)$/gm,'<h3>$1</h3>')
  1202. .replace(/^## (.+)$/gm,'<h2>$1</h2>')
  1203. .replace(/^# (.+)$/gm,'<h2>$1</h2>')
  1204. .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
  1205. .replace(/\*(.+?)\*/g,'<em>$1</em>')
  1206. .replace(/`([^`]+)`/g,'<code>$1</code>')
  1207. // Tables
  1208. .replace(/^\|(.+)\|$/gm, (row) => {
  1209. const cells = row.split('|').slice(1,-1).map(c => c.trim());
  1210. return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
  1211. })
  1212. .replace(/(<tr>.*<\/tr>)/gs, m => {
  1213. const rows = m.match(/<tr>.*?<\/tr>/gs) || [];
  1214. if (!rows.length) return m;
  1215. // First row becomes thead
  1216. const head = rows[0].replace(/<td>/g,'<th>').replace(/<\/td>/g,'</th>');
  1217. const body = rows.slice(2).join(''); // skip separator row
  1218. return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
  1219. })
  1220. .replace(/^[-*] (.+)$/gm,'<li>$1</li>')
  1221. .replace(/(<li>.*<\/li>)/gs,'<ul>$1</ul>')
  1222. .replace(/<\/ul>\s*<ul>/g,'')
  1223. .replace(/^---+$/gm,'<hr>')
  1224. .replace(/\n{2,}/g,'</p><p>')
  1225. .replace(/\n/g,'<br>')
  1226. .replace(/^(?!<[hupbtir])(.+)$/gm, s => s ? `<p>${s}</p>` : s);
  1227. }
  1228. /* ── Boot ────────────────────────────────────────────────────────────── */
  1229. document.addEventListener('DOMContentLoaded', () => {
  1230. loadCouncils();
  1231. renderHistory();
  1232. updateByokButton();
  1233. loadPropCtx();
  1234. questionEl.focus();
  1235. byId('propCtxDismiss')?.addEventListener('click', clearPropCtx);
  1236. });
  1237. // Update button if user navigates back from settings with a new key
  1238. window.addEventListener('focus', updateByokButton);
  1239. window.addEventListener('storage', e => {
  1240. if (e.key === ACTIVE_KEY || e.key?.startsWith(KEY_PREFIX)) updateByokButton();
  1241. });
  1242. </script>
  1243. <!-- API status indicator -->
  1244. <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
  1245. </body>
  1246. </html>