tep 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,249 @@
1
+ // chat.js -- Phase C: sidebar + multi-conversation + SSE streaming.
2
+ // Vanilla JS, ~200 lines. Polls /api/conversations every 10s to
3
+ // pick up titles set by TitleJob (background worker, server-side).
4
+
5
+ (function () {
6
+ var chatEl = document.getElementById('chat');
7
+ var messagesEl = document.getElementById('messages');
8
+ var formEl = document.getElementById('composer');
9
+ var inputEl = document.getElementById('composer-input');
10
+ var btnEl = document.getElementById('send-btn');
11
+ var statusEl = document.getElementById('status');
12
+ var convListEl = document.getElementById('conv-list');
13
+ var newBtnEl = document.getElementById('new-conv-btn');
14
+
15
+ var currentConvId = parseInt(chatEl.dataset.convId || '0', 10);
16
+
17
+ // Boot from inline JSON the server embedded.
18
+ var bootMsgs, bootConvs;
19
+ try { bootMsgs = JSON.parse(document.getElementById('bootmsgs').textContent); }
20
+ catch (e) { bootMsgs = { messages: [] }; }
21
+ try { bootConvs = JSON.parse(document.getElementById('bootconvs').textContent); }
22
+ catch (e) { bootConvs = { conversations: [] }; }
23
+
24
+ bootMsgs.messages.forEach(appendMessage);
25
+ renderConvList(bootConvs.conversations);
26
+ scrollToEnd();
27
+
28
+ // ---------------- conversation sidebar ----------------
29
+
30
+ function renderConvList(convs) {
31
+ convListEl.innerHTML = '';
32
+ convs.forEach(function (c) {
33
+ var li = document.createElement('li');
34
+ var label = (c.title && c.title.length > 0) ? c.title : 'New chat';
35
+ li.textContent = label;
36
+ if (!c.title || c.title.length === 0) li.classList.add('untitled');
37
+ if (c.id === currentConvId) li.classList.add('active');
38
+ li.addEventListener('click', function () {
39
+ if (c.id !== currentConvId) {
40
+ window.location.href = '/c/' + c.id;
41
+ }
42
+ });
43
+ convListEl.appendChild(li);
44
+ });
45
+ }
46
+
47
+ function refreshConvList() {
48
+ fetch('/api/conversations')
49
+ .then(function (r) { return r.json(); })
50
+ .then(function (j) { renderConvList(j.conversations); })
51
+ .catch(function () { /* ignore transient errors */ });
52
+ }
53
+
54
+ newBtnEl.addEventListener('click', function () {
55
+ fetch('/api/conversations', { method: 'POST' })
56
+ .then(function (r) { return r.json(); })
57
+ .then(function (j) { window.location.href = '/c/' + j.id; })
58
+ .catch(function (err) { appendError('could not create: ' + err.message); });
59
+ });
60
+
61
+ // Sidebar refresh tick. TitleJob latency is ~5s (poll) + ~LLM
62
+ // round-trip; 10s is plenty.
63
+ setInterval(refreshConvList, 10000);
64
+
65
+ // ---------------- messages + streaming ----------------
66
+
67
+ function appendMessage(msg) {
68
+ var li = document.createElement('li');
69
+ li.className = 'msg ' + msg.role;
70
+ if (msg.role === 'assistant') {
71
+ li.dataset.raw = msg.content;
72
+ li.innerHTML = renderMarkdown(msg.content);
73
+ } else {
74
+ li.textContent = msg.content;
75
+ }
76
+ messagesEl.appendChild(li);
77
+ }
78
+
79
+ function appendError(text) {
80
+ var li = document.createElement('li');
81
+ li.className = 'msg error';
82
+ li.textContent = text;
83
+ messagesEl.appendChild(li);
84
+ }
85
+
86
+ function scrollToEnd() {
87
+ messagesEl.scrollTop = messagesEl.scrollHeight;
88
+ }
89
+
90
+ function setSending(on) {
91
+ btnEl.disabled = on;
92
+ inputEl.disabled = on;
93
+ statusEl.textContent = on ? 'Thinking…' : '';
94
+ }
95
+
96
+ // WebSocket streaming (Phase F). Single long-lived WS to
97
+ // /api/c/ws; each user turn sends one TEXT frame
98
+ // {"conv_id":N,"content":"..."}; server sends back SSE-shaped
99
+ // chunks (`data: {...}\n\n`) per LLM delta, the same wire
100
+ // shape the SSE route uses -- so the parsing loop below is
101
+ // shared. Falls back to SSE if WebSocket isn't available or
102
+ // the upgrade fails.
103
+ var chatWs = null;
104
+ var chatWsLi = null;
105
+ var chatWsFinalize = null;
106
+ function openChatWs() {
107
+ if (chatWs && chatWs.readyState === WebSocket.OPEN) return chatWs;
108
+ var proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
109
+ var ws = new WebSocket(proto + location.host + '/api/c/ws');
110
+ var buf = '';
111
+ ws.onmessage = function (evt) {
112
+ // Each frame is one SSE-shaped chunk: `data: {...}\n\n`.
113
+ buf += evt.data;
114
+ var sep;
115
+ while ((sep = buf.indexOf('\n\n')) >= 0) {
116
+ var ev = buf.slice(0, sep);
117
+ buf = buf.slice(sep + 2);
118
+ if (!ev.startsWith('data: ')) continue;
119
+ var data = ev.slice(6);
120
+ if (data === '[DONE]') {
121
+ if (chatWsFinalize) { chatWsFinalize(); chatWsFinalize = null; }
122
+ continue;
123
+ }
124
+ try {
125
+ var obj = JSON.parse(data);
126
+ if (obj.content && chatWsLi) {
127
+ chatWsLi.dataset.raw = (chatWsLi.dataset.raw || '') + obj.content;
128
+ chatWsLi.innerHTML = renderMarkdown(chatWsLi.dataset.raw);
129
+ scrollToEnd();
130
+ }
131
+ } catch (e) { /* ignore malformed */ }
132
+ }
133
+ };
134
+ ws.onclose = function () {
135
+ chatWs = null;
136
+ if (chatWsFinalize) { chatWsFinalize(); chatWsFinalize = null; }
137
+ };
138
+ ws.onerror = function () {
139
+ // Let onclose fire; the caller's finalize closes out the UI.
140
+ };
141
+ chatWs = ws;
142
+ return ws;
143
+ }
144
+
145
+ formEl.addEventListener('submit', function (ev) {
146
+ ev.preventDefault();
147
+ var content = inputEl.value.trim();
148
+ if (!content) return;
149
+
150
+ appendMessage({ role: 'user', content: content });
151
+ inputEl.value = '';
152
+ scrollToEnd();
153
+ setSending(true);
154
+
155
+ var assistantLi = document.createElement('li');
156
+ assistantLi.className = 'msg assistant';
157
+ assistantLi.dataset.raw = '';
158
+ messagesEl.appendChild(assistantLi);
159
+
160
+ chatWsLi = assistantLi;
161
+ chatWsFinalize = function () {
162
+ setSending(false);
163
+ inputEl.focus();
164
+ setTimeout(refreshConvList, 6000);
165
+ chatWsLi = null;
166
+ };
167
+
168
+ if ('WebSocket' in window) {
169
+ try {
170
+ var ws = openChatWs();
171
+ var sendIt = function () {
172
+ ws.send(JSON.stringify({ conv_id: currentConvId, content: content }));
173
+ };
174
+ if (ws.readyState === WebSocket.OPEN) {
175
+ sendIt();
176
+ } else {
177
+ ws.addEventListener('open', sendIt, { once: true });
178
+ }
179
+ return;
180
+ } catch (e) {
181
+ // fall through to SSE
182
+ }
183
+ }
184
+
185
+ // SSE fallback (also handles older browsers + when WS
186
+ // can't open).
187
+ var body = new URLSearchParams();
188
+ body.set('content', content);
189
+ fetch('/api/c/' + currentConvId + '/stream', {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
192
+ body: body.toString()
193
+ })
194
+ .then(function (resp) {
195
+ if (!resp.ok) {
196
+ appendError('error: HTTP ' + resp.status);
197
+ return null;
198
+ }
199
+ var reader = resp.body.getReader();
200
+ var decoder = new TextDecoder();
201
+ return readChunk(reader, decoder, '', assistantLi);
202
+ })
203
+ .catch(function (err) {
204
+ appendError('network error: ' + err.message);
205
+ })
206
+ .finally(function () {
207
+ setSending(false);
208
+ inputEl.focus();
209
+ setTimeout(refreshConvList, 6000);
210
+ });
211
+ });
212
+
213
+ function readChunk(reader, decoder, buf, liEl) {
214
+ return reader.read().then(function (out) {
215
+ if (out.done) return;
216
+ buf += decoder.decode(out.value, { stream: true });
217
+ var sep;
218
+ while ((sep = buf.indexOf('\n\n')) >= 0) {
219
+ var ev = buf.slice(0, sep);
220
+ buf = buf.slice(sep + 2);
221
+ if (!ev.startsWith('data: ')) continue;
222
+ var data = ev.slice(6);
223
+ if (data === '[DONE]') {
224
+ buf = '';
225
+ continue;
226
+ }
227
+ try {
228
+ var obj = JSON.parse(data);
229
+ if (obj.content) {
230
+ liEl.dataset.raw = (liEl.dataset.raw || '') + obj.content;
231
+ liEl.innerHTML = renderMarkdown(liEl.dataset.raw);
232
+ scrollToEnd();
233
+ }
234
+ } catch (e) {
235
+ // ignore malformed
236
+ }
237
+ }
238
+ return readChunk(reader, decoder, buf, liEl);
239
+ });
240
+ }
241
+
242
+ // Cmd/Ctrl+Enter to send.
243
+ inputEl.addEventListener('keydown', function (ev) {
244
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') {
245
+ ev.preventDefault();
246
+ formEl.requestSubmit();
247
+ }
248
+ });
249
+ })();
@@ -0,0 +1,93 @@
1
+ // compare.js -- Phase E. POST prompt to /api/compare, render N
2
+ // backend responses side-by-side. Vanilla JS, ~80 lines.
3
+
4
+ (function () {
5
+ var gridEl = document.getElementById('compare-grid');
6
+ var formEl = document.getElementById('prompt-form');
7
+ var inputEl = document.getElementById('prompt-input');
8
+ var btnEl = document.getElementById('prompt-btn');
9
+ var statusEl = document.getElementById('compare-status');
10
+
11
+ // Initial render: empty cards keyed by (backend, model).
12
+ var backends = [];
13
+ try {
14
+ backends = JSON.parse(document.getElementById('bootbackends').textContent);
15
+ } catch (e) { backends = []; }
16
+ renderEmptyCards(backends);
17
+
18
+ function renderEmptyCards(list) {
19
+ gridEl.innerHTML = '';
20
+ list.forEach(function (b, i) {
21
+ var card = document.createElement('article');
22
+ card.className = 'compare-card';
23
+ card.dataset.index = i;
24
+ card.innerHTML =
25
+ '<header class="card-head">' +
26
+ '<strong>' + escapeHtml(b.model) + '</strong>' +
27
+ '<span class="card-backend">' + escapeHtml(b.backend) + '</span>' +
28
+ '</header>' +
29
+ '<div class="card-meta"><span class="card-time"></span></div>' +
30
+ '<div class="card-body"><em>Waiting…</em></div>';
31
+ gridEl.appendChild(card);
32
+ });
33
+ }
34
+
35
+ function renderResults(out) {
36
+ statusEl.textContent =
37
+ 'fan-out done in ' + (out.total_ms / 1000).toFixed(1) + 's';
38
+ var cards = gridEl.querySelectorAll('.compare-card');
39
+ out.results.forEach(function (r, i) {
40
+ var card = cards[i];
41
+ if (!card) return;
42
+ card.querySelector('.card-time').textContent = r.took_s + 's';
43
+ var body = card.querySelector('.card-body');
44
+ if (r.content.length === 0) {
45
+ body.innerHTML = '<em class="card-empty">(empty reply — backend unreachable?)</em>';
46
+ } else {
47
+ body.innerHTML = renderMarkdown(r.content);
48
+ }
49
+ });
50
+ }
51
+
52
+ function setSending(on) {
53
+ btnEl.disabled = on;
54
+ inputEl.disabled = on;
55
+ statusEl.textContent = on ? 'fan-out running…' : '';
56
+ }
57
+
58
+ formEl.addEventListener('submit', function (ev) {
59
+ ev.preventDefault();
60
+ var prompt = inputEl.value.trim();
61
+ if (!prompt) return;
62
+ setSending(true);
63
+ renderEmptyCards(backends); // reset to "Waiting…"
64
+
65
+ var body = new URLSearchParams();
66
+ body.set('prompt', prompt);
67
+ fetch('/api/compare', {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
70
+ body: body.toString()
71
+ })
72
+ .then(function (r) { return r.json(); })
73
+ .then(renderResults)
74
+ .catch(function (err) {
75
+ statusEl.textContent = 'error: ' + err.message;
76
+ })
77
+ .finally(function () { setSending(false); });
78
+ });
79
+
80
+ // Cmd/Ctrl+Enter to send.
81
+ inputEl.addEventListener('keydown', function (ev) {
82
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') {
83
+ ev.preventDefault();
84
+ formEl.requestSubmit();
85
+ }
86
+ });
87
+
88
+ function escapeHtml(s) {
89
+ return String(s)
90
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
92
+ }
93
+ })();
@@ -0,0 +1,84 @@
1
+ // markdown.js -- ~60 lines, vanilla. Subset chosen to match what
2
+ // LLM assistants actually emit; not a full CommonMark implementation.
3
+ //
4
+ // Supported:
5
+ // - Fenced code blocks: ```lang\n...\n```
6
+ // - Inline code: `code`
7
+ // - Bold: **text**
8
+ // - Italic: *text*
9
+ // - Links: [label](url)
10
+ // - Paragraph breaks: double newline
11
+ // - Newlines preserved inside paragraphs
12
+ //
13
+ // Not supported (escape-and-pass-through):
14
+ // - Headings, lists, blockquotes, tables, images, HTML
15
+
16
+ (function (global) {
17
+ function escapeHtml(s) {
18
+ return s
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&#39;');
24
+ }
25
+
26
+ function renderInline(s) {
27
+ // Inline code first (so its contents are not further processed).
28
+ var parts = [];
29
+ var i = 0;
30
+ while (i < s.length) {
31
+ var tick = s.indexOf('`', i);
32
+ if (tick < 0) { parts.push(['text', s.slice(i)]); break; }
33
+ var close = s.indexOf('`', tick + 1);
34
+ if (close < 0) { parts.push(['text', s.slice(i)]); break; }
35
+ if (tick > i) parts.push(['text', s.slice(i, tick)]);
36
+ parts.push(['code', s.slice(tick + 1, close)]);
37
+ i = close + 1;
38
+ }
39
+ return parts.map(function (p) {
40
+ if (p[0] === 'code') return '<code>' + escapeHtml(p[1]) + '</code>';
41
+ var t = escapeHtml(p[1]);
42
+ // Links [label](url)
43
+ t = t.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g,
44
+ function (_, lbl, url) { return '<a href="' + url + '" rel="noopener">' + lbl + '</a>'; });
45
+ // Bold **text** (before italic so doubled asterisks bind tighter)
46
+ t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
47
+ // Italic *text*
48
+ t = t.replace(/\*([^*]+)\*/g, '<em>$1</em>');
49
+ return t;
50
+ }).join('');
51
+ }
52
+
53
+ function renderMarkdown(src) {
54
+ // Split off fenced code blocks first; they're not subject to
55
+ // inline processing.
56
+ var out = [];
57
+ var rest = src;
58
+ while (true) {
59
+ var open = rest.indexOf('```');
60
+ if (open < 0) { out.push(['md', rest]); break; }
61
+ if (open > 0) out.push(['md', rest.slice(0, open)]);
62
+ var afterOpen = open + 3;
63
+ var nlAfterOpen = rest.indexOf('\n', afterOpen);
64
+ if (nlAfterOpen < 0) { out.push(['md', rest.slice(open)]); break; }
65
+ var close = rest.indexOf('```', nlAfterOpen + 1);
66
+ if (close < 0) { out.push(['md', rest.slice(open)]); break; }
67
+ out.push(['code', rest.slice(nlAfterOpen + 1, close)]);
68
+ rest = rest.slice(close + 3);
69
+ }
70
+ return out.map(function (chunk) {
71
+ if (chunk[0] === 'code') {
72
+ return '<pre><code>' + escapeHtml(chunk[1]) + '</code></pre>';
73
+ }
74
+ // Split on double-newline paragraphs.
75
+ var paragraphs = chunk[1].split(/\n{2,}/);
76
+ return paragraphs
77
+ .filter(function (p) { return p.length > 0; })
78
+ .map(function (p) { return '<p>' + renderInline(p) + '</p>'; })
79
+ .join('');
80
+ }).join('');
81
+ }
82
+
83
+ global.renderMarkdown = renderMarkdown;
84
+ })(window);
@@ -0,0 +1,215 @@
1
+ /* tep chatbot — Phase C: sidebar + chat panel layout. ~200 lines,
2
+ * vanilla CSS, no framework. */
3
+
4
+ * { box-sizing: border-box; }
5
+ html, body {
6
+ margin: 0;
7
+ height: 100%;
8
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
9
+ background: #f7f7f8;
10
+ color: #111;
11
+ }
12
+
13
+ /* ---------------- auth pages ---------------- */
14
+ .auth-page {
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ }
19
+ .auth-card {
20
+ width: 360px;
21
+ background: #fff;
22
+ padding: 2rem;
23
+ border-radius: 12px;
24
+ box-shadow: 0 2px 16px rgba(0,0,0,0.08);
25
+ }
26
+ .auth-card h1 { margin: 0 0 1rem; font-size: 1.25rem; }
27
+ .auth-card .hint { font-size: 0.9rem; color: #666; margin: 0 0 1rem; }
28
+ .auth-card .error {
29
+ background: #fee; border: 1px solid #fcc; color: #c33;
30
+ padding: 0.5rem 0.75rem; border-radius: 6px; margin: 0 0 1rem;
31
+ font-size: 0.9rem;
32
+ }
33
+ .auth-card label { display: block; margin: 0 0 1rem; font-size: 0.9rem; color: #444; }
34
+ .auth-card input {
35
+ display: block; width: 100%; margin-top: 0.25rem;
36
+ padding: 0.5rem; border: 1px solid #ccc; border-radius: 6px; font: inherit;
37
+ }
38
+ .auth-card button {
39
+ width: 100%; padding: 0.6rem; background: #111; color: #fff;
40
+ border: none; border-radius: 6px; font: inherit; cursor: pointer;
41
+ }
42
+ .auth-card button:hover { background: #333; }
43
+
44
+ /* ---------------- app layout: sidebar + main ---------------- */
45
+ .app {
46
+ display: grid;
47
+ grid-template-columns: 260px 1fr;
48
+ height: 100vh;
49
+ }
50
+
51
+ /* ---- sidebar ---- */
52
+ .sidebar {
53
+ background: #1f1f23;
54
+ color: #ddd;
55
+ display: flex;
56
+ flex-direction: column;
57
+ height: 100vh;
58
+ }
59
+ .sidebar-head {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ padding: 1rem;
64
+ border-bottom: 1px solid #2c2c30;
65
+ }
66
+ .sidebar-head strong { color: #fff; font-size: 1rem; }
67
+ .sidebar-head button {
68
+ font: inherit; font-size: 0.85rem; padding: 0.3rem 0.7rem;
69
+ background: #2c2c30; color: #ddd;
70
+ border: 1px solid #3a3a3f; border-radius: 6px; cursor: pointer;
71
+ }
72
+ .sidebar-head button:hover { background: #38383d; color: #fff; }
73
+
74
+ .conv-list {
75
+ list-style: none; margin: 0; padding: 0.5rem;
76
+ flex: 1; overflow-y: auto;
77
+ }
78
+ .conv-list li {
79
+ padding: 0.5rem 0.75rem; margin-bottom: 0.25rem;
80
+ border-radius: 6px; cursor: pointer;
81
+ font-size: 0.9rem; color: #ccc;
82
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
83
+ }
84
+ .conv-list li:hover { background: #2c2c30; color: #fff; }
85
+ .conv-list li.active { background: #3a3a3f; color: #fff; }
86
+ .conv-list li.untitled { color: #888; font-style: italic; }
87
+
88
+ .sidebar-foot {
89
+ padding: 1rem; border-top: 1px solid #2c2c30;
90
+ }
91
+ .logout-form { margin: 0; }
92
+ .logout-form button {
93
+ width: 100%; font: inherit; font-size: 0.85rem; padding: 0.4rem;
94
+ background: transparent; border: 1px solid #3a3a3f; color: #ddd;
95
+ border-radius: 6px; cursor: pointer;
96
+ }
97
+ .logout-form button:hover { background: #2c2c30; color: #fff; }
98
+
99
+ /* ---- chat main ---- */
100
+ #chat {
101
+ display: flex; flex-direction: column;
102
+ height: 100vh; overflow: hidden;
103
+ }
104
+ .topbar {
105
+ display: flex; align-items: center; gap: 0.75rem;
106
+ padding: 0.5rem 1rem;
107
+ background: #fff; border-bottom: 1px solid #e5e5e5;
108
+ font-size: 0.85rem; color: #666;
109
+ }
110
+ .topbar .model { font-weight: 600; color: #333; }
111
+ .topbar .backend { font-family: ui-monospace, monospace; font-size: 0.75rem; }
112
+
113
+ .messages {
114
+ list-style: none; margin: 0; padding: 1rem;
115
+ flex: 1; overflow-y: auto;
116
+ max-width: 760px; width: 100%;
117
+ margin-left: auto; margin-right: auto;
118
+ }
119
+
120
+ .msg {
121
+ margin: 0 0 1rem; padding: 0.75rem 1rem;
122
+ border-radius: 12px; max-width: 85%;
123
+ white-space: pre-wrap; word-wrap: break-word;
124
+ }
125
+ .msg.user { background: #d8e9fb; margin-left: auto; }
126
+ .msg.assistant { background: #fff; border: 1px solid #e5e5e5; }
127
+ .msg.error { background: #fee; color: #c33; border: 1px solid #fcc; }
128
+
129
+ .msg.assistant p { margin: 0 0 0.5rem; }
130
+ .msg.assistant p:last-child { margin-bottom: 0; }
131
+ .msg.assistant code { background: #f3f3f3; padding: 0.05em 0.3em; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 0.9em; }
132
+ .msg.assistant pre { background: #f3f3f3; padding: 0.75rem; border-radius: 6px; overflow-x: auto; }
133
+ .msg.assistant pre code { background: transparent; padding: 0; }
134
+ .msg.assistant a { color: #0366d6; }
135
+
136
+ .composer {
137
+ display: flex; gap: 0.5rem; align-items: flex-end;
138
+ padding: 1rem; border-top: 1px solid #e5e5e5; background: #fafafa;
139
+ max-width: 760px; width: 100%; margin: 0 auto;
140
+ }
141
+ .composer textarea {
142
+ flex: 1; padding: 0.6rem; border: 1px solid #ccc; border-radius: 8px;
143
+ font: inherit; resize: vertical;
144
+ }
145
+ .composer button {
146
+ padding: 0.6rem 1.2rem; background: #111; color: #fff;
147
+ border: none; border-radius: 8px; font: inherit; cursor: pointer;
148
+ }
149
+ .composer button:disabled { background: #888; cursor: wait; }
150
+
151
+ .status {
152
+ margin: 0 1rem 0.5rem; font-size: 0.85rem; color: #888;
153
+ min-height: 1.5em;
154
+ }
155
+
156
+ /* ---- compare page ---- */
157
+ .back-link {
158
+ font-size: 0.85rem; color: #ccc; text-decoration: none;
159
+ padding: 0.3rem 0.6rem; border: 1px solid #3a3a3f; border-radius: 6px;
160
+ }
161
+ .back-link:hover { background: #2c2c30; color: #fff; }
162
+ .sidebar-section { padding: 0.5rem 1rem; font-size: 0.75rem;
163
+ text-transform: uppercase; letter-spacing: 0.05em;
164
+ color: #777; }
165
+ .sidebar-blurb { padding: 0 1rem; font-size: 0.85rem; color: #aaa;
166
+ line-height: 1.4; }
167
+ .sidebar-blurb code { background: #2c2c30; padding: 0 0.2em;
168
+ border-radius: 3px; }
169
+
170
+ #compare {
171
+ display: flex; flex-direction: column;
172
+ height: 100vh; overflow: hidden;
173
+ }
174
+ .compare-prompt {
175
+ display: flex; gap: 0.5rem; padding: 1rem;
176
+ background: #fff; border-bottom: 1px solid #e5e5e5;
177
+ }
178
+ .compare-prompt textarea {
179
+ flex: 1; padding: 0.6rem; border: 1px solid #ccc; border-radius: 8px;
180
+ font: inherit; resize: vertical;
181
+ }
182
+ .compare-prompt button {
183
+ padding: 0.6rem 1.2rem; background: #111; color: #fff;
184
+ border: none; border-radius: 8px; font: inherit; cursor: pointer;
185
+ }
186
+ .compare-prompt button:disabled { background: #888; cursor: wait; }
187
+
188
+ .compare-grid {
189
+ display: grid;
190
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
191
+ gap: 1rem; padding: 1rem; flex: 1; overflow-y: auto;
192
+ }
193
+ .compare-card {
194
+ background: #fff; border: 1px solid #e5e5e5; border-radius: 8px;
195
+ display: flex; flex-direction: column; max-height: 100%;
196
+ }
197
+ .card-head {
198
+ display: flex; justify-content: space-between; align-items: center;
199
+ padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e5e5;
200
+ }
201
+ .card-head strong { font-size: 0.9rem; }
202
+ .card-backend { font-family: ui-monospace, monospace;
203
+ font-size: 0.7rem; color: #666; }
204
+ .card-meta { padding: 0.25rem 0.75rem; font-size: 0.75rem; color: #888; }
205
+ .card-body { padding: 0.75rem; flex: 1; overflow-y: auto;
206
+ white-space: pre-wrap; word-wrap: break-word;
207
+ font-size: 0.9rem; }
208
+ .card-body p { margin: 0 0 0.5rem; }
209
+ .card-body p:last-child { margin-bottom: 0; }
210
+ .card-body code { background: #f3f3f3; padding: 0.05em 0.3em;
211
+ border-radius: 3px; font-family: ui-monospace, monospace;
212
+ font-size: 0.85em; }
213
+ .card-body pre { background: #f3f3f3; padding: 0.5rem;
214
+ border-radius: 4px; overflow-x: auto; }
215
+ .card-empty { color: #aaa; }
@@ -0,0 +1,25 @@
1
+ -- app_config: single-row k/v store. Holds password_hash (set on
2
+ -- first boot via /setup) and any future per-install settings.
3
+ CREATE TABLE IF NOT EXISTS app_config (
4
+ k TEXT PRIMARY KEY,
5
+ v TEXT
6
+ );
7
+
8
+ -- conversations + messages: the chat history. Phase A uses one
9
+ -- conversation per database (the first row), so the sidebar UI
10
+ -- can stay deferred until Phase C without changing the schema.
11
+ CREATE TABLE IF NOT EXISTS conversations (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ title TEXT,
14
+ created_at INTEGER
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS messages (
18
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ conversation_id INTEGER NOT NULL,
20
+ role TEXT NOT NULL, -- "user" | "assistant" | "system"
21
+ content TEXT NOT NULL,
22
+ created_at INTEGER NOT NULL
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS messages_by_conv ON messages (conversation_id, id);