local_state-planning-scheme.php 61 KB

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