local_state-planning-scheme.php 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245
  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. /* Synonym suggestion */
  334. .synonym-bar {
  335. background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
  336. border-radius: var(--radius-sm); padding: 6px 12px;
  337. font-size: 0.75rem; color: var(--text-secondary);
  338. margin-bottom: 8px; display: none; flex-wrap: wrap; gap: 6px;
  339. align-items: center; max-width: 800px;
  340. }
  341. .synonym-bar.show { display: flex; }
  342. .syn-pill {
  343. background: var(--bg-2); border: 1px solid var(--border);
  344. border-radius: 999px; padding: 2px 9px;
  345. font-size: 0.72rem; color: var(--accent);
  346. cursor: pointer; transition: all var(--transition);
  347. }
  348. .syn-pill:hover { background: var(--accent); color: #0b0f0e; }
  349. /* ── Buttons ─────────────────────────────────────────────────────── */
  350. .btn {
  351. display: inline-flex; align-items: center; gap: 6px;
  352. padding: 7px 14px; border-radius: var(--radius-sm);
  353. font-family: var(--sans); font-size: 0.78rem; font-weight: 500;
  354. cursor: pointer; transition: all var(--transition); border: none;
  355. }
  356. .btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
  357. .btn-ghost:hover { border-color: var(--border-hover); color: var(--text-secondary); }
  358. .btn-accent { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(45,220,138,0.2); }
  359. .btn-accent:hover { background: var(--accent); color: #0b0f0e; }
  360. /* ── TPS Viewer drawer ───────────────────────────────────────────── */
  361. .tps-drawer {
  362. position: fixed; inset: 0; z-index: 200;
  363. display: none;
  364. }
  365. .tps-drawer.open { display: flex; }
  366. .tps-overlay {
  367. position: absolute; inset: 0; background: rgba(0,0,0,0.6);
  368. backdrop-filter: blur(3px);
  369. }
  370. .tps-panel {
  371. position: absolute; right: 0; top: 0; bottom: 0;
  372. width: min(960px, 92vw); background: var(--bg-1);
  373. border-left: 1px solid var(--border);
  374. display: flex; flex-direction: column;
  375. animation: slideIn 0.22s ease;
  376. }
  377. @keyframes slideIn { from { transform: translateX(100%); } to { transform: none; } }
  378. .tps-header {
  379. padding: 16px 20px; border-bottom: 1px solid var(--border);
  380. display: flex; align-items: center; justify-content: space-between;
  381. flex-shrink: 0;
  382. }
  383. .tps-header h3 { font-size: 0.9rem; font-weight: 500; }
  384. .tps-controls {
  385. padding: 12px 20px; border-bottom: 1px solid var(--border);
  386. display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;
  387. flex-shrink: 0;
  388. }
  389. .tps-controls .field { display: flex; flex-direction: column; gap: 5px; min-width: 160px; flex: 1; }
  390. .tps-controls .field label { font-size: 0.68rem; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-muted); }
  391. .tps-frame { flex: 1; border: none; background: #fff; }
  392. /* ── Scrollbar ───────────────────────────────────────────────────── */
  393. ::-webkit-scrollbar { width: 5px; }
  394. ::-webkit-scrollbar-track { background: transparent; }
  395. ::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
  396. </style>
  397. <!-- Google tag (gtag.js) -->
  398. <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
  399. <script>
  400. window.dataLayer = window.dataLayer || [];
  401. function gtag(){dataLayer.push(arguments);}
  402. gtag('js', new Date());
  403. gtag('config', 'G-LWEHQVCWEZ');
  404. </script>
  405. <!-- Google Tag Manager -->
  406. <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  407. new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  408. j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  409. 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  410. })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
  411. <!-- End Google Tag Manager -->
  412. </head>
  413. <body>
  414. <!-- Google Tag Manager (noscript) -->
  415. <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
  416. height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  417. <!-- End Google Tag Manager (noscript) -->
  418. <!-- ── Nav ──────────────────────────────────────────────────────────── -->
  419. <nav class="site-nav">
  420. <div class="nav-inner">
  421. <a class="nav-brand" href="/">
  422. <svg width="22" height="22" viewBox="0 0 28 28" fill="none">
  423. <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
  424. <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  425. <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
  426. </svg>
  427. Tasmanian Planning Scheme
  428. </a>
  429. <div class="nav-links">
  430. <a href="/">Home</a>
  431. <a href="/local_state-planning-scheme.php" class="active">Assistant</a>
  432. <a href="/site-report.php">Property Lookup</a>
  433. <a href="/section-builder.php">Report Builder</a>
  434. <a href="/faq">FAQ</a>
  435. </div>
  436. <div class="nav-right">
  437. <button class="btn btn-ghost" id="btnTpsViewer" style="gap:5px;">
  438. <i class="bi bi-layout-sidebar-reverse"></i> TPS Viewer
  439. </button>
  440. <button class="btn btn-ghost" id="btnNewChat">
  441. <i class="bi bi-plus-square"></i> New chat
  442. </button>
  443. <a href="/byok-settings.php" class="btn btn-ghost" id="btnByok" title="Configure your own API key">
  444. <i class="bi bi-key"></i> <span id="byokLabel">Own key</span>
  445. </a>
  446. <div class="nav-status">
  447. <span class="status-dot"></span>
  448. <span class="nav-status-text">API live</span>
  449. </div>
  450. </div>
  451. </div>
  452. </nav>
  453. <!-- ── App ───────────────────────────────────────────────────────────── -->
  454. <div class="app-wrap">
  455. <!-- ── Sidebar ──────────────────────────────────────────────────── -->
  456. <aside class="sidebar">
  457. <!-- Scope controls -->
  458. <div class="sidebar-section">
  459. <span class="sidebar-label">Document scope</span>
  460. <select id="council">
  461. <option value="">All councils / SPP only</option>
  462. </select>
  463. <label class="toggle-row">
  464. <span class="toggle-track">
  465. <input type="checkbox" id="allowTPS" checked>
  466. <span class="toggle-knob"></span>
  467. </span>
  468. <span class="toggle-label">Include Tasmanian Planning Scheme (SPP)</span>
  469. </label>
  470. </div>
  471. <!-- Quick asks -->
  472. <div class="sidebar-section">
  473. <span class="sidebar-label">Quick queries</span>
  474. <div class="quick-pills">
  475. <button class="quick-pill" data-q="What are the acceptable solutions for front setbacks in the Village Zone?">
  476. Village Zone setbacks
  477. </button>
  478. <button class="quick-pill" data-q="What is the car parking rate for a medical centre? Cite the clause.">
  479. Parking — medical centre
  480. </button>
  481. <button class="quick-pill" data-q="Summarise the acceptable solutions for the General Residential Zone.">
  482. General Residential Zone
  483. </button>
  484. <button class="quick-pill" data-q="What overlays should be checked for a coastal site in Tasmania?">
  485. Coastal overlays
  486. </button>
  487. <button class="quick-pill" data-q="What is the Use Class for a café under the Tasmanian Planning Scheme?">
  488. Use Class — café
  489. </button>
  490. <button class="quick-pill" data-q="When is a planning permit required for a shed or outbuilding?">
  491. Permit — shed/outbuilding
  492. </button>
  493. </div>
  494. </div>
  495. <!-- Chat history -->
  496. <div class="sidebar-section" style="flex:1;overflow:hidden;display:flex;flex-direction:column;">
  497. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
  498. <span class="sidebar-label" style="margin:0;">Recent questions</span>
  499. <button class="btn btn-ghost" id="btnClearHistory" style="padding:2px 8px;font-size:0.68rem;">Clear</button>
  500. </div>
  501. <div class="history-list" id="historyList" style="overflow-y:auto;flex:1;"></div>
  502. </div>
  503. </aside>
  504. <!-- ── Chat ─────────────────────────────────────────────────────── -->
  505. <div class="chat-wrap">
  506. <!-- Thread -->
  507. <div class="chat-thread" id="chatThread">
  508. <!-- Empty state shown when no messages -->
  509. <div class="chat-empty" id="chatEmpty">
  510. <svg width="48" height="48" viewBox="0 0 28 28" fill="none" style="opacity:0.2;">
  511. <rect width="28" height="28" rx="6" fill="var(--accent)"/>
  512. <path d="M8 20 L14 8 L20 20" stroke="#0b0f0e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  513. <path d="M10.5 16 L17.5 16" stroke="#0b0f0e" stroke-width="2" stroke-linecap="round"/>
  514. </svg>
  515. <h2>Ask about <em>Tasmanian planning</em></h2>
  516. <p>Get clause-cited answers from the SPPs and your council's LPS. Every response links back to the source.</p>
  517. <div class="example-pills">
  518. <span class="example-pill" data-q="Do I need a permit for a 20m² shed in the Rural Zone?">Permit for a shed?</span>
  519. <span class="example-pill" data-q="What are the acceptable solutions for setbacks in the Low Density Residential Zone?">Low Density Residential setbacks</span>
  520. <span class="example-pill" data-q="What codes apply to a new café in the General Business Zone?">Café in General Business Zone</span>
  521. <span class="example-pill" data-q="Explain the difference between Acceptable Solutions and Performance Criteria.">A vs P criteria explained</span>
  522. </div>
  523. </div>
  524. </div>
  525. <!-- Input -->
  526. <div class="input-bar">
  527. <div class="synonym-bar" id="synonymBar"></div>
  528. <div class="input-wrap">
  529. <textarea
  530. id="question"
  531. class="input-textarea"
  532. rows="1"
  533. placeholder="Ask a question about the Tasmanian Planning Scheme… (⏎ to send, Shift+⏎ for new line)"
  534. ></textarea>
  535. <button class="send-btn" id="askBtn" title="Send">
  536. <i class="bi bi-arrow-up"></i>
  537. </button>
  538. </div>
  539. <div class="input-hint">
  540. Tip: use Tasmanian planning terms — e.g. "Acceptable Solutions", "Use Class", "Performance Criteria", zone names.
  541. </div>
  542. </div>
  543. </div>
  544. </div>
  545. <!-- ── TPS Viewer Drawer ─────────────────────────────────────────────── -->
  546. <div class="tps-drawer" id="tpsDrawer">
  547. <div class="tps-overlay" id="tpsOverlay"></div>
  548. <div class="tps-panel">
  549. <div class="tps-header">
  550. <h3><i class="bi bi-layout-sidebar-reverse" style="color:var(--accent);margin-right:6px;"></i>TPS Viewer</h3>
  551. <button class="btn btn-ghost" id="btnCloseTps"><i class="bi bi-x-lg"></i></button>
  552. </div>
  553. <div class="tps-controls">
  554. <div class="field">
  555. <label>Document</label>
  556. <select id="tpsViewer">
  557. <option value="spps">SPPs — State Planning Provisions</option>
  558. <option value="lps">LPS — Council Local Provisions</option>
  559. <option value="custom">Custom URL…</option>
  560. </select>
  561. </div>
  562. <div class="field">
  563. <label>Section code (optional)</label>
  564. <input type="text" id="tpsSection" placeholder="e.g. C7.7.2 or 8.4">
  565. </div>
  566. <div style="display:flex;gap:8px;align-self:flex-end;flex-shrink:0;">
  567. <button class="btn btn-accent" id="btnOpenTps"><i class="bi bi-eye"></i> Open</button>
  568. <button class="btn btn-ghost" id="btnLastSource"><i class="bi bi-link-45deg"></i> Last source</button>
  569. </div>
  570. </div>
  571. <iframe id="tpsFrame" class="tps-frame" src="about:blank" referrerpolicy="no-referrer"></iframe>
  572. </div>
  573. </div>
  574. <script>
  575. 'use strict';
  576. /* ── Config ─────────────────────────────────────────────────────────── */
  577. const API = 'https://api.modulos.com.au';
  578. window.APP_API_BASE = API + '/ask';
  579. /* ── State ───────────────────────────────────────────────────────────── */
  580. let history = [];
  581. let sessionId = localStorage.getItem('tps_session_id') || crypto.randomUUID();
  582. let lastSources = [];
  583. let isAsking = false;
  584. localStorage.setItem('tps_session_id', sessionId);
  585. const byId = id => document.getElementById(id);
  586. const chatThread = byId('chatThread');
  587. const chatEmpty = byId('chatEmpty');
  588. const questionEl = byId('question');
  589. const askBtn = byId('askBtn');
  590. /* ── Session ID for telemetry ────────────────────────────────────────── */
  591. const SID_KEY = 'tpr_sid';
  592. const sid = localStorage.getItem(SID_KEY) || (() => {
  593. const v = crypto?.randomUUID?.() || String(Math.random()).slice(2) + Date.now();
  594. localStorage.setItem(SID_KEY, v);
  595. return v;
  596. })();
  597. /* ── Telemetry ───────────────────────────────────────────────────────── */
  598. function sendEvent(type, data = {}) {
  599. const payload = { type, ts: new Date().toISOString(), sid, ua: navigator.userAgent, data };
  600. const url = API + '/telemetry';
  601. const body = JSON.stringify(payload);
  602. fetch(url, {
  603. method: 'POST', mode: 'cors', credentials: 'omit',
  604. headers: { 'Content-Type': 'application/json' },
  605. body, keepalive: true
  606. }).catch(() => {});
  607. }
  608. window.TPRtelemetry = { sendEvent };
  609. /* ── Councils ────────────────────────────────────────────────────────── */
  610. async function loadCouncils() {
  611. try {
  612. const res = await fetch(`${API}/councils`, { cache: 'no-store' });
  613. const items = await res.json();
  614. const sel = byId('council');
  615. sel.innerHTML = '<option value="">All councils / SPP only</option>' +
  616. items.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
  617. } catch(e) { console.warn('[UI] councils failed', e); }
  618. }
  619. /* ── Scope ───────────────────────────────────────────────────────────── */
  620. function computeScope() {
  621. const allowTps = byId('allowTPS').checked;
  622. const council = (byId('council')?.value || '').trim();
  623. const hasCouncil = !!council;
  624. if (allowTps && hasCouncil) return 'state_plus_local';
  625. if (allowTps && !hasCouncil) return 'state_only';
  626. if (!allowTps && hasCouncil) return 'local_only';
  627. return 'any';
  628. }
  629. /* ── BYOK helpers ────────────────────────────────────────────────────── */
  630. const ACTIVE_KEY = 'tpr_byok_active';
  631. const KEY_PREFIX = 'tpr_byok_key_';
  632. const MODEL_PREFIX = 'tpr_byok_model_';
  633. function byokActive() { return localStorage.getItem(ACTIVE_KEY) || 'internal'; }
  634. function byokKey(id) { return localStorage.getItem(KEY_PREFIX + id) || ''; }
  635. function byokModel(id, fallback) { return localStorage.getItem(MODEL_PREFIX + id) || fallback || ''; }
  636. const BYOK_DEFAULTS = {
  637. anthropic: 'claude-sonnet-4-5',
  638. openai: 'gpt-4o-mini',
  639. grok: 'grok-3-mini',
  640. ollama: 'llama3.1:8b',
  641. };
  642. function updateByokButton() {
  643. const active = byokActive();
  644. const label = byId('byokLabel');
  645. if (!label) return;
  646. const names = { internal:'Own key', anthropic:'Claude', openai:'GPT-4o', grok:'Grok', ollama:'Ollama' };
  647. label.textContent = names[active] || 'Own key';
  648. const btn = byId('btnByok');
  649. if (btn) btn.style.color = active !== 'internal' ? 'var(--accent)' : '';
  650. }
  651. /* Call the active external LLM with the context returned from /ask?context_only=true */
  652. async function callExternalLLM(prompt, provider) {
  653. const key = byokKey(provider);
  654. const model = byokModel(provider, BYOK_DEFAULTS[provider]);
  655. if (provider === 'anthropic') {
  656. const res = await fetch('https://api.anthropic.com/v1/messages', {
  657. method: 'POST',
  658. headers: {
  659. 'Content-Type': 'application/json',
  660. 'x-api-key': key,
  661. 'anthropic-version': '2023-06-01',
  662. 'anthropic-dangerous-direct-browser-access': 'true',
  663. },
  664. body: JSON.stringify({
  665. model,
  666. max_tokens: 1024,
  667. messages: [{ role: 'user', content: prompt }]
  668. })
  669. });
  670. if (!res.ok) {
  671. const err = await res.json().catch(() => ({}));
  672. throw new Error(err?.error?.message || `Anthropic HTTP ${res.status}`);
  673. }
  674. const data = await res.json();
  675. return data?.content?.[0]?.text || '';
  676. }
  677. if (provider === 'openai' || provider === 'grok') {
  678. const baseUrl = provider === 'grok'
  679. ? 'https://api.x.ai/v1'
  680. : 'https://api.openai.com/v1';
  681. const res = await fetch(`${baseUrl}/chat/completions`, {
  682. method: 'POST',
  683. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
  684. body: JSON.stringify({
  685. model,
  686. max_tokens: 1024,
  687. messages: [{ role: 'user', content: prompt }]
  688. })
  689. });
  690. if (!res.ok) {
  691. const err = await res.json().catch(() => ({}));
  692. throw new Error(err?.error?.message || `${provider} HTTP ${res.status}`);
  693. }
  694. const data = await res.json();
  695. return data?.choices?.[0]?.message?.content || '';
  696. }
  697. if (provider === 'ollama') {
  698. const base = key.replace(/\/$/, '') || 'http://localhost:11434';
  699. const res = await fetch(`${base}/api/generate`, {
  700. method: 'POST',
  701. headers: { 'Content-Type': 'application/json' },
  702. body: JSON.stringify({ model, prompt, stream: false })
  703. });
  704. if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
  705. const data = await res.json();
  706. return data?.response || '';
  707. }
  708. throw new Error(`Unknown provider: ${provider}`);
  709. }
  710. /* ── Ask ─────────────────────────────────────────────────────────────── */
  711. async function ask(queryOverride) {
  712. const query = (queryOverride || questionEl.value || '').trim();
  713. if (!query || isAsking) return;
  714. const council = (byId('council')?.value || '').trim();
  715. const scope = computeScope();
  716. const provider = byokActive();
  717. const useBYOK = provider !== 'internal';
  718. const startedAt = performance.now();
  719. isAsking = true;
  720. askBtn.disabled = true;
  721. questionEl.value = '';
  722. autoResize(questionEl);
  723. hideSynonymBar();
  724. appendUserMsg(query);
  725. const thinkEl = appendThinking();
  726. sendEvent('search_performed', { query, scope, source: 'assistant', byok: useBYOK ? provider : null });
  727. try {
  728. if (useBYOK) {
  729. // ── BYOK path ──────────────────────────────────────────────────
  730. // Step 1: get RAG context from our backend (no Ollama call)
  731. const ragRes = await fetch(`${API}/ask`, {
  732. method: 'POST',
  733. headers: { 'Content-Type': 'application/json', 'X-TPR-SID': sessionId },
  734. body: JSON.stringify({ query, council: council || null, top_k: 8, scope, context_only: true })
  735. });
  736. const ragRaw = await ragRes.text();
  737. if (!ragRes.ok) throw new Error(`RAG HTTP ${ragRes.status} — ${ragRaw.slice(0,200)}`);
  738. const ragData = JSON.parse(ragRaw);
  739. lastSources = Array.isArray(ragData.sources) ? ragData.sources : [];
  740. // Step 2: call external LLM with the pre-built prompt from the backend
  741. const answer = await callExternalLLM(ragData.prompt, provider);
  742. thinkEl.remove();
  743. const latencyMs = Math.round(performance.now() - startedAt);
  744. sendEvent('search_result', {
  745. latency_ms: latencyMs, ok: true, byok: provider,
  746. topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
  747. });
  748. appendAssistantMsg(answer || 'No answer returned.', scope, lastSources, query, provider);
  749. addToHistory(query);
  750. } else {
  751. // ── Internal Ollama path (unchanged) ───────────────────────────
  752. const res = await fetch(`${API}/ask`, {
  753. method: 'POST',
  754. headers: { 'Content-Type': 'application/json', 'X-TPR-SID': sessionId },
  755. body: JSON.stringify({ query, council: council || null, top_k: 8, scope })
  756. });
  757. const raw = await res.text();
  758. if (!res.ok) throw new Error(`HTTP ${res.status} — ${raw.slice(0,200)}`);
  759. const data = JSON.parse(raw);
  760. thinkEl.remove();
  761. lastSources = Array.isArray(data.sources) ? data.sources : [];
  762. const latencyMs = Math.round(performance.now() - startedAt);
  763. sendEvent('search_result', {
  764. latency_ms: latencyMs,
  765. topk: lastSources.slice(0,10).map(s => ({ id:`${s.source_file}#p${s.page}`, score:s.score })),
  766. model: data.model || 'unknown', ok: true,
  767. });
  768. appendAssistantMsg(data.answer || 'No answer returned.', scope, lastSources, query, 'internal');
  769. addToHistory(query);
  770. }
  771. } catch(e) {
  772. thinkEl.remove();
  773. appendErrorMsg(e.message);
  774. console.error('[ask]', e);
  775. } finally {
  776. isAsking = false;
  777. askBtn.disabled = false;
  778. questionEl.focus();
  779. }
  780. }
  781. /* ── Message rendering ───────────────────────────────────────────────── */
  782. function hideEmpty() {
  783. if (chatEmpty) chatEmpty.style.display = 'none';
  784. }
  785. function appendUserMsg(text) {
  786. hideEmpty();
  787. const div = document.createElement('div');
  788. div.className = 'msg user';
  789. div.innerHTML = `
  790. <div class="msg-role"><i class="bi bi-person"></i> You</div>
  791. <div class="msg-content">${esc(text)}</div>
  792. `;
  793. chatThread.appendChild(div);
  794. scrollBottom();
  795. }
  796. function appendThinking() {
  797. hideEmpty();
  798. const div = document.createElement('div');
  799. div.className = 'thinking';
  800. div.innerHTML = `
  801. <div class="thinking-dots"><span></span><span></span><span></span></div>
  802. Thinking…
  803. `;
  804. chatThread.appendChild(div);
  805. scrollBottom();
  806. return div;
  807. }
  808. function appendAssistantMsg(answer, scope, sources, query, provider = 'internal') {
  809. const div = document.createElement('div');
  810. div.className = 'msg assistant';
  811. const providerNames = { internal:'Ollama', anthropic:'Claude', openai:'GPT-4o', grok:'Grok', ollama:'Local Ollama' };
  812. const providerName = providerNames[provider] || provider;
  813. const providerIcon = provider === 'internal' ? 'cpu' : 'key';
  814. const scopeHtml = `
  815. <div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;">
  816. <div class="scope-badge"><i class="bi bi-filter"></i> ${esc(scope)}</div>
  817. <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)'};">
  818. <i class="bi bi-${providerIcon}"></i> ${esc(providerName)}
  819. </div>
  820. </div>`;
  821. const answerHtml = md2html(answer);
  822. let sourcesHtml = '';
  823. if (sources && sources.length) {
  824. const chips = sources.map((s, i) => {
  825. const label = `${s.source_file} p.${s.page}`;
  826. const score = typeof s.score === 'number' ? `<span class="source-score">${s.score.toFixed(2)}</span>` : '';
  827. return `<span class="source-chip" data-cite="${esc(`${s.source_file}#p${s.page}`)}" data-index="${i}"
  828. onclick="openSourceInViewer(${i})">
  829. <i class="bi bi-file-earmark-text"></i>${esc(label)}${score}
  830. </span>`;
  831. }).join('');
  832. sourcesHtml = `
  833. <div class="msg-sources">
  834. <div class="sources-label">Sources</div>
  835. <div class="source-chips">${chips}</div>
  836. </div>`;
  837. }
  838. const msgId = `msg-${Date.now()}`;
  839. div.id = msgId;
  840. // Store context on the element so feedback() can read it without closure issues
  841. div.dataset.query = query || '';
  842. div.dataset.scope = scope || '';
  843. div.dataset.provider = provider || 'internal';
  844. // Store answer as plain text (strip HTML tags) for the feedback payload
  845. div.dataset.answer = answer.replace(/<[^>]*>/g, '').substring(0, 4000);
  846. div.innerHTML = `
  847. <div class="msg-role"><i class="bi bi-stars"></i> Assistant</div>
  848. ${scopeHtml}
  849. <div class="msg-content">${answerHtml}</div>
  850. ${sourcesHtml}
  851. <div class="msg-feedback">
  852. <button class="fb-btn" onclick="feedback('${msgId}','up',this)"><i class="bi bi-hand-thumbs-up"></i> Helpful</button>
  853. <button class="fb-btn" onclick="feedback('${msgId}','down',this)"><i class="bi bi-hand-thumbs-down"></i> Not helpful</button>
  854. </div>
  855. `;
  856. chatThread.appendChild(div);
  857. scrollBottom();
  858. }
  859. function appendErrorMsg(msg) {
  860. const div = document.createElement('div');
  861. div.className = 'msg assistant';
  862. div.innerHTML = `
  863. <div class="msg-role"><i class="bi bi-exclamation-circle" style="color:var(--danger)"></i> Error</div>
  864. <div class="msg-content" style="color:var(--danger);">${esc(msg)}</div>
  865. `;
  866. chatThread.appendChild(div);
  867. scrollBottom();
  868. }
  869. function scrollBottom() {
  870. chatThread.scrollTop = chatThread.scrollHeight;
  871. }
  872. /* ── Feedback ────────────────────────────────────────────────────────── */
  873. window.feedback = function(msgId, verdict, btn) {
  874. // Update button state immediately
  875. const row = btn.closest('.msg-feedback');
  876. row.querySelectorAll('.fb-btn').forEach(b => b.classList.remove('active-up','active-dn'));
  877. btn.classList.add(verdict === 'up' ? 'active-up' : 'active-dn');
  878. // Read context from data attributes stored on the message div at render time.
  879. // This avoids closure/scope issues — no reliance on outer variables.
  880. const msgEl = document.getElementById(msgId);
  881. const query = msgEl?.dataset.query || '';
  882. const answer = msgEl?.dataset.answer || '';
  883. const scope = msgEl?.dataset.scope || '';
  884. const provider= msgEl?.dataset.provider || 'internal';
  885. const storedSid = localStorage.getItem('tpr_sid') || '';
  886. // Collect note for thumbs-down (do this before the async fetch)
  887. let note = null;
  888. if (verdict === 'down') {
  889. note = window.prompt('What missed the mark? (optional — helps us improve)') || null;
  890. }
  891. // Post to the dedicated /feedback endpoint (stores full query + answer in DB)
  892. fetch(`${API}/feedback`, {
  893. method: 'POST',
  894. headers: { 'Content-Type': 'application/json' },
  895. credentials: 'omit',
  896. body: JSON.stringify({
  897. verdict,
  898. query,
  899. answer,
  900. note,
  901. sid: storedSid,
  902. scope,
  903. model: provider,
  904. sources: lastSources.slice(0, 10).map(s => ({
  905. source_file: s.source_file,
  906. page: s.page,
  907. score: s.score
  908. }))
  909. })
  910. }).catch(() => {}); // swallow silently — feedback must never break UX
  911. // Also fire the existing telemetry event for the events table
  912. sendEvent('feedback', { verdict, note, msg_id: msgId, scope, provider });
  913. };
  914. /* ── Source viewer ───────────────────────────────────────────────────── */
  915. window.openSourceInViewer = function(index) {
  916. openTpsDrawer();
  917. // Best effort: open SPPs base — we don't have direct clause URLs from /ask
  918. byId('tpsFrame').src = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
  919. sendEvent('interaction', { action: 'clicked_citation', cite_index: index });
  920. };
  921. /* ── History ─────────────────────────────────────────────────────────── */
  922. function addToHistory(query) {
  923. const h = JSON.parse(localStorage.getItem('tps_query_history') || '[]');
  924. const next = [query, ...h.filter(q => q !== query)].slice(0, 20);
  925. localStorage.setItem('tps_query_history', JSON.stringify(next));
  926. renderHistory();
  927. }
  928. function renderHistory() {
  929. const list = byId('historyList');
  930. if (!list) return;
  931. const h = JSON.parse(localStorage.getItem('tps_query_history') || '[]');
  932. list.innerHTML = h.length
  933. ? h.map(q => `<div class="history-item" onclick="ask(${JSON.stringify(q)})">${esc(q)}</div>`).join('')
  934. : `<div style="font-size:0.75rem;color:var(--text-muted);padding:4px 0;">No history yet.</div>`;
  935. }
  936. byId('btnClearHistory').addEventListener('click', () => {
  937. localStorage.removeItem('tps_query_history');
  938. renderHistory();
  939. });
  940. /* ── New chat ────────────────────────────────────────────────────────── */
  941. byId('btnNewChat').addEventListener('click', () => {
  942. history = [];
  943. sessionId = crypto.randomUUID();
  944. localStorage.setItem('tps_session_id', sessionId);
  945. // Clear messages except empty state
  946. [...chatThread.children].forEach(el => {
  947. if (el !== chatEmpty) el.remove();
  948. });
  949. if (chatEmpty) chatEmpty.style.display = '';
  950. lastSources = [];
  951. });
  952. /* ── Input handling ──────────────────────────────────────────────────── */
  953. questionEl.addEventListener('keydown', e => {
  954. if (e.key === 'Enter' && !e.shiftKey) {
  955. e.preventDefault();
  956. ask();
  957. }
  958. });
  959. questionEl.addEventListener('input', () => {
  960. autoResize(questionEl);
  961. checkSynonyms(questionEl.value);
  962. });
  963. askBtn.addEventListener('click', () => ask());
  964. function autoResize(el) {
  965. el.style.height = 'auto';
  966. el.style.height = Math.min(el.scrollHeight, 160) + 'px';
  967. }
  968. /* ── Quick asks / examples ───────────────────────────────────────────── */
  969. document.querySelectorAll('[data-q]').forEach(el => {
  970. el.addEventListener('click', () => ask(el.dataset.q));
  971. });
  972. /* ── TPS Viewer ──────────────────────────────────────────────────────── */
  973. function openTpsDrawer() {
  974. byId('tpsDrawer').classList.add('open');
  975. }
  976. function closeTpsDrawer() {
  977. byId('tpsDrawer').classList.remove('open');
  978. }
  979. byId('btnTpsViewer').addEventListener('click', openTpsDrawer);
  980. byId('btnCloseTps').addEventListener('click', closeTpsDrawer);
  981. byId('tpsOverlay').addEventListener('click', closeTpsDrawer);
  982. document.addEventListener('keydown', e => { if (e.key === 'Escape') closeTpsDrawer(); });
  983. byId('btnOpenTps').addEventListener('click', () => {
  984. const viewer = byId('tpsViewer').value;
  985. const section = (byId('tpsSection').value || '').trim();
  986. const frame = byId('tpsFrame');
  987. const council = (byId('council')?.value || '').trim();
  988. if (viewer === 'spps') {
  989. let url = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
  990. if (section) url += `/section/${encodeURIComponent(section)}`;
  991. frame.src = url;
  992. } else if (viewer === 'lps') {
  993. frame.src = 'https://planning.tas.gov.au/planning-schemes/tasmanian-planning-scheme';
  994. } else {
  995. const custom = prompt('Paste a TPS/TPSO URL:');
  996. if (custom) frame.src = custom;
  997. }
  998. });
  999. byId('btnLastSource').addEventListener('click', () => {
  1000. if (!lastSources.length) { alert('No sources from the last answer yet.'); return; }
  1001. byId('tpsFrame').src = 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30';
  1002. openTpsDrawer();
  1003. });
  1004. /* ── Synonym suggestions ─────────────────────────────────────────────── */
  1005. const SYNONYMS = {
  1006. "house":["single dwelling","Residential","Class 1a"],
  1007. "home":["single dwelling","Residential","Class 1a"],
  1008. "granny flat":["secondary residence","Residential"],
  1009. "duplex":["multiple dwellings","Residential","Class 1a attached"],
  1010. "townhouse":["multiple dwellings","Residential"],
  1011. "apartment":["multiple dwellings","Residential","Class 2"],
  1012. "flat":["multiple dwellings","Residential","Class 2"],
  1013. "aged care":["residential care facility","Residential","Class 9c"],
  1014. "airbnb":["Visitor Accommodation","short-stay"],
  1015. "holiday let":["Visitor Accommodation"],
  1016. "motel":["Visitor Accommodation","Class 3"],
  1017. "hotel":["Hotel Industry"],
  1018. "pub":["Hotel Industry"],
  1019. "bar":["Hotel Industry"],
  1020. "shop":["General Retail and Hire","Class 6"],
  1021. "supermarket":["General Retail and Hire","Class 6"],
  1022. "restaurant":["Food Services","Class 6"],
  1023. "cafe":["Food Services","Class 6"],
  1024. "takeaway":["Food Services","Class 6"],
  1025. "office":["Business and Professional Services","Class 5"],
  1026. "medical centre":["Business and Professional Services","Class 9a"],
  1027. "clinic":["Business and Professional Services","Class 9a"],
  1028. "hospital":["Hospital Services","Class 9a"],
  1029. "childcare":["Educational and Occasional Care","Class 9b"],
  1030. "school":["Educational and Occasional Care","Class 9b"],
  1031. "university":["Educational and Occasional Care","Class 9b"],
  1032. "church":["Community Meeting and Entertainment","Class 9b"],
  1033. "gym":["Sports and Recreation","Class 9b"],
  1034. "factory":["Manufacturing and Processing","Class 8"],
  1035. "workshop":["Service Industry","Class 8"],
  1036. "warehouse":["Storage","Class 7"],
  1037. "shed":["ancillary structure","Class 10a"],
  1038. "garage":["ancillary structure","Class 10a"],
  1039. "carport":["ancillary structure","Class 10a"],
  1040. "deck":["verandah","ancillary structure","Class 10a"],
  1041. "pergola":["open structure","Class 10a"],
  1042. "fence":["ancillary structure","Class 10b"],
  1043. "pool":["swimming pool","Class 10b"],
  1044. "service station":["Vehicle Fuel Sales and Service"],
  1045. "servo":["Vehicle Fuel Sales and Service"],
  1046. "farm":["Resource Development","agricultural use"],
  1047. "cemetery":["Crematoria and Cemeteries"],
  1048. };
  1049. function checkSynonyms(val) {
  1050. const bar = byId('synonymBar');
  1051. const words = val.toLowerCase().trim().split(/\s+/);
  1052. const found = [];
  1053. // Check last 1–3 words as phrases
  1054. for (let len = 3; len >= 1; len--) {
  1055. const phrase = words.slice(-len).join(' ');
  1056. if (SYNONYMS[phrase]) {
  1057. SYNONYMS[phrase].forEach(s => {
  1058. if (!found.includes(s)) found.push(s);
  1059. });
  1060. break;
  1061. }
  1062. }
  1063. if (found.length) {
  1064. bar.innerHTML = '<i class="bi bi-lightbulb" style="color:var(--accent);flex-shrink:0;"></i> TPS terms: ' +
  1065. found.map(s => `<span class="syn-pill" onclick="appendSynonym('${esc(s)}')">${esc(s)}</span>`).join('');
  1066. bar.classList.add('show');
  1067. } else {
  1068. hideSynonymBar();
  1069. }
  1070. }
  1071. function hideSynonymBar() {
  1072. const bar = byId('synonymBar');
  1073. bar.classList.remove('show');
  1074. bar.innerHTML = '';
  1075. }
  1076. window.appendSynonym = function(term) {
  1077. questionEl.value = (questionEl.value + ' ' + term).trim();
  1078. questionEl.focus();
  1079. hideSynonymBar();
  1080. };
  1081. /* ── Utilities ───────────────────────────────────────────────────────── */
  1082. function esc(s) {
  1083. return String(s || '').replace(/[&<>"']/g, c =>
  1084. ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;' }[c])
  1085. );
  1086. }
  1087. function md2html(s) {
  1088. return String(s || '')
  1089. .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
  1090. .replace(/^#### (.+)$/gm,'<h3>$1</h3>')
  1091. .replace(/^### (.+)$/gm,'<h3>$1</h3>')
  1092. .replace(/^## (.+)$/gm,'<h2>$1</h2>')
  1093. .replace(/^# (.+)$/gm,'<h2>$1</h2>')
  1094. .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
  1095. .replace(/\*(.+?)\*/g,'<em>$1</em>')
  1096. .replace(/`([^`]+)`/g,'<code>$1</code>')
  1097. // Tables
  1098. .replace(/^\|(.+)\|$/gm, (row) => {
  1099. const cells = row.split('|').slice(1,-1).map(c => c.trim());
  1100. return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
  1101. })
  1102. .replace(/(<tr>.*<\/tr>)/gs, m => {
  1103. const rows = m.match(/<tr>.*?<\/tr>/gs) || [];
  1104. if (!rows.length) return m;
  1105. // First row becomes thead
  1106. const head = rows[0].replace(/<td>/g,'<th>').replace(/<\/td>/g,'</th>');
  1107. const body = rows.slice(2).join(''); // skip separator row
  1108. return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
  1109. })
  1110. .replace(/^[-*] (.+)$/gm,'<li>$1</li>')
  1111. .replace(/(<li>.*<\/li>)/gs,'<ul>$1</ul>')
  1112. .replace(/<\/ul>\s*<ul>/g,'')
  1113. .replace(/^---+$/gm,'<hr>')
  1114. .replace(/\n{2,}/g,'</p><p>')
  1115. .replace(/\n/g,'<br>')
  1116. .replace(/^(?!<[hupbtir])(.+)$/gm, s => s ? `<p>${s}</p>` : s);
  1117. }
  1118. /* ── Boot ────────────────────────────────────────────────────────────── */
  1119. document.addEventListener('DOMContentLoaded', () => {
  1120. loadCouncils();
  1121. renderHistory();
  1122. updateByokButton();
  1123. questionEl.focus();
  1124. });
  1125. // Update button if user navigates back from settings with a new key
  1126. window.addEventListener('focus', updateByokButton);
  1127. window.addEventListener('storage', e => {
  1128. if (e.key === ACTIVE_KEY || e.key?.startsWith(KEY_PREFIX)) updateByokButton();
  1129. });
  1130. </script>
  1131. <!-- API status indicator -->
  1132. <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
  1133. </body>
  1134. </html>