local_state-planning-scheme.php 54 KB

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