Skip to content

Commit 91077e1

Browse files
committed
Update
1 parent c8b7be3 commit 91077e1

6 files changed

Lines changed: 184 additions & 32 deletions

File tree

.github/workflows/deploy-chat-worker.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
run: npm run sync-db
3333

3434
- name: Deploy to Cloudflare + sync secrets
35+
id: deploy
3536
uses: cloudflare/wrangler-action@v3
3637
with:
3738
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -41,3 +42,26 @@ jobs:
4142
ANTHROPIC_API_KEY
4243
env:
4344
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
45+
46+
# Smoke test: GET / hits the health endpoint, which doesn't call
47+
# Anthropic (so no API spend) and doesn't load the catalog DB (so
48+
# no WASM init either). It only confirms the worker booted and is
49+
# routing requests. Fails the build if the worker returns non-200.
50+
- name: Smoke test deployed worker
51+
env:
52+
WORKER_URL: ${{ steps.deploy.outputs.deployment-url }}
53+
run: |
54+
set -euo pipefail
55+
URL="${WORKER_URL:-https://bbl-datenkatalog-chat.dav-ras.workers.dev}"
56+
echo "Pinging $URL ..."
57+
for i in 1 2 3; do
58+
if curl -fsS --max-time 10 "$URL" | tee /tmp/health.json | grep -q '"ok":true'; then
59+
echo "Health check passed."
60+
exit 0
61+
fi
62+
echo "Attempt $i failed, retrying in 5s..."
63+
sleep 5
64+
done
65+
echo "Health check failed after 3 attempts."
66+
cat /tmp/health.json || true
67+
exit 1

chat-worker/src/index.js

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,23 @@ function getDb() {
4242
const db = new SQL.Database(new Uint8Array(catalogDbBytes));
4343
db.run('PRAGMA query_only = 1;');
4444
return db;
45-
})();
45+
})().catch(err => {
46+
// Don't poison the cache: a failed init shouldn't make every future
47+
// request fail with the same stale error. Clear the slot so the next
48+
// request retries from scratch.
49+
dbPromise = null;
50+
throw err;
51+
});
4652
return dbPromise;
4753
}
4854

4955
// ── Tool implementation ───────────────────────────────────────
50-
const FORBIDDEN_SQL = /\b(insert|update|delete|drop|alter|create|attach|detach|replace|truncate|vacuum|reindex|pragma)\b/i;
51-
56+
// Read-only is enforced at the engine level via `PRAGMA query_only = 1`
57+
// (set in getDb()). We don't try to filter SQL by regex — keyword-based
58+
// blocking has false positives (e.g. `name LIKE '%insert%'` literals) and
59+
// false negatives (comments, unicode, multi-statement). The engine flag
60+
// rejects mutations definitively; we trust it.
5261
function runCatalogQuery(db, sql) {
53-
if (FORBIDDEN_SQL.test(sql)) {
54-
return { error: 'Only read-only SELECT statements are permitted.' };
55-
}
5662
try {
5763
const rows = [];
5864
const stmt = db.prepare(sql);
@@ -199,9 +205,21 @@ async function runChat(env, userMessages) {
199205
}
200206

201207
// ── HTTP entry ────────────────────────────────────────────────
202-
function corsHeaders(env) {
208+
// CORS: echo back the request Origin only if it's on the allowlist;
209+
// otherwise return a non-matching value so the browser blocks the
210+
// response. ALLOWED_ORIGINS is comma-separated in wrangler.toml.
211+
// `*` is still honoured (any origin) for emergency overrides.
212+
function pickAllowedOrigin(env, req) {
213+
const list = (env.ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
214+
if (list.includes('*')) return '*';
215+
const origin = req?.headers?.get?.('Origin');
216+
return origin && list.includes(origin) ? origin : 'null';
217+
}
218+
219+
function corsHeaders(env, req) {
203220
return {
204-
'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN || '*',
221+
'Access-Control-Allow-Origin': pickAllowedOrigin(env, req),
222+
'Vary': 'Origin',
205223
'Access-Control-Allow-Methods': 'POST, OPTIONS',
206224
'Access-Control-Allow-Headers': 'Content-Type',
207225
'Access-Control-Max-Age': '86400'
@@ -211,7 +229,17 @@ function corsHeaders(env) {
211229
export default {
212230
async fetch(req, env) {
213231
if (req.method === 'OPTIONS') {
214-
return new Response(null, { headers: corsHeaders(env) });
232+
return new Response(null, { headers: corsHeaders(env, req) });
233+
}
234+
// Health endpoint: no auth, no API calls, costs nothing. Used by
235+
// the post-deploy smoke test in CI.
236+
if (req.method === 'GET') {
237+
return new Response(JSON.stringify({ ok: true, service: 'bbl-datenkatalog-chat' }), {
238+
headers: {
239+
'Content-Type': 'application/json; charset=utf-8',
240+
...corsHeaders(env, req)
241+
}
242+
});
215243
}
216244
if (req.method !== 'POST') {
217245
return new Response('Method not allowed', { status: 405 });
@@ -221,35 +249,40 @@ export default {
221249
try {
222250
body = await req.json();
223251
} catch {
224-
return jsonError('Invalid JSON body', 400, env);
252+
return jsonError('Invalid JSON body', 400, env, req);
225253
}
226254
const messages = Array.isArray(body.messages) ? body.messages : null;
227255
if (!messages || messages.length === 0) {
228-
return jsonError('Missing "messages" array', 400, env);
256+
return jsonError('Missing "messages" array', 400, env, req);
229257
}
230258

231259
try {
232260
const result = await runChat(env, messages);
233261
return new Response(JSON.stringify(result), {
234262
headers: {
235263
'Content-Type': 'application/json; charset=utf-8',
236-
...corsHeaders(env)
264+
...corsHeaders(env, req)
237265
}
238266
});
239267
} catch (e) {
268+
// Always log full detail to the Worker console (visible in `wrangler tail`
269+
// / CF dashboard logs). Only echo stack traces back to clients when
270+
// DEBUG=1 — otherwise leak just a generic message.
240271
console.error('chat error:', e?.stack || e);
241-
const detail = e?.stack ? `${e.message}\n${e.stack}` : (e?.message || String(e));
242-
return jsonError(detail, 500, env);
272+
const detail = env.DEBUG === '1'
273+
? (e?.stack ? `${e.message}\n${e.stack}` : (e?.message || String(e)))
274+
: 'Internal server error';
275+
return jsonError(detail, 500, env, req);
243276
}
244277
}
245278
};
246279

247-
function jsonError(message, status, env) {
280+
function jsonError(message, status, env, req) {
248281
return new Response(JSON.stringify({ error: message }), {
249282
status,
250283
headers: {
251284
'Content-Type': 'application/json; charset=utf-8',
252-
...corsHeaders(env)
285+
...corsHeaders(env, req)
253286
}
254287
});
255288
}

chat-worker/wrangler.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ type = "Data"
1414
globs = ["**/*.db"]
1515
fallthrough = true
1616

17-
# CORS origin allowed to call this worker. Override per-environment with
18-
# `wrangler deploy --var ALLOWED_ORIGIN:https://example.com`. The default
19-
# below permits local dev plus the published GitHub Pages site.
17+
# Origins allowed to call this worker (comma-separated). Use "*" to
18+
# accept any origin (not recommended for production). The browser blocks
19+
# responses to other origins; this isn't real auth, just abuse deterrence.
2020
[vars]
21-
ALLOWED_ORIGIN = "*"
21+
ALLOWED_ORIGINS = "https://bbl-dres.github.io,http://localhost:8000"
2222
ANTHROPIC_MODEL = "claude-sonnet-4-6"
2323

2424
# Secrets (set with: wrangler secret put ANTHROPIC_API_KEY):
2525
# ANTHROPIC_API_KEY
26+
#
27+
# Optional: set DEBUG=1 (via `wrangler secret put DEBUG`) to echo full
28+
# stack traces to error responses. Leave unset in production.

prototype-sqlite/css/styles.css

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2516,6 +2516,85 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
25162516
padding: 1px 4px;
25172517
border-radius: 3px;
25182518
}
2519+
.chat-message-body pre {
2520+
margin: var(--space-2) 0;
2521+
padding: var(--space-3);
2522+
background: rgba(0,0,0,0.06);
2523+
border-radius: var(--radius-md);
2524+
overflow-x: auto;
2525+
font-size: var(--text-small);
2526+
}
2527+
.chat-message-body pre code {
2528+
background: transparent;
2529+
padding: 0;
2530+
font-size: inherit;
2531+
}
2532+
.chat-message-body ul,
2533+
.chat-message-body ol {
2534+
margin: var(--space-2) 0;
2535+
padding-left: var(--space-5);
2536+
}
2537+
.chat-message-body li {
2538+
margin-bottom: var(--space-1);
2539+
}
2540+
.chat-message-body h1,
2541+
.chat-message-body h2,
2542+
.chat-message-body h3,
2543+
.chat-message-body h4 {
2544+
margin: var(--space-3) 0 var(--space-2) 0;
2545+
font-weight: 600;
2546+
line-height: 1.3;
2547+
}
2548+
.chat-message-body h1 { font-size: 1.25em; }
2549+
.chat-message-body h2 { font-size: 1.15em; }
2550+
.chat-message-body h3 { font-size: 1.05em; }
2551+
.chat-message-body h4 { font-size: 1em; }
2552+
.chat-message-body strong { font-weight: 600; }
2553+
.chat-message-body em { font-style: italic; }
2554+
.chat-message-body a {
2555+
color: var(--color-link, #1a5fb4);
2556+
text-decoration: underline;
2557+
}
2558+
.chat-message-body blockquote {
2559+
margin: var(--space-2) 0;
2560+
padding-left: var(--space-3);
2561+
border-left: 3px solid var(--color-border-default);
2562+
color: var(--color-text-secondary);
2563+
}
2564+
.chat-message-body hr {
2565+
border: 0;
2566+
border-top: 1px solid var(--color-border-subtle);
2567+
margin: var(--space-3) 0;
2568+
}
2569+
.chat-message-body table {
2570+
border-collapse: collapse;
2571+
margin: var(--space-3) 0;
2572+
font-size: var(--text-small);
2573+
width: 100%;
2574+
}
2575+
.chat-message-body th,
2576+
.chat-message-body td {
2577+
padding: var(--space-1) var(--space-2);
2578+
border: 1px solid var(--color-border-subtle);
2579+
text-align: left;
2580+
vertical-align: top;
2581+
}
2582+
.chat-message-body th {
2583+
background: rgba(0,0,0,0.04);
2584+
font-weight: 600;
2585+
}
2586+
/* Tables sit nicely on the user's accent bubble too, but borders
2587+
need lighter contrast since the background is dark. */
2588+
.chat-message-user .chat-message-body th,
2589+
.chat-message-user .chat-message-body td {
2590+
border-color: rgba(255,255,255,0.25);
2591+
}
2592+
.chat-message-user .chat-message-body th {
2593+
background: rgba(255,255,255,0.1);
2594+
}
2595+
.chat-message-user .chat-message-body a {
2596+
color: inherit;
2597+
}
25192598
.chat-thinking {
25202599
display: flex;
25212600
align-items: center;

prototype-sqlite/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.11.0/sql-wasm.js"></script>
1313
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
1414
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
15+
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js"></script>
1516
</head>
1617
<body>
1718

prototype-sqlite/js/views/search.js

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,22 +246,28 @@ function renderChatView() {
246246
function renderChatMessage(m) {
247247
const role = m.role === 'user' ? 'user' : 'assistant';
248248
const icon = role === 'user' ? 'user' : 'sparkles';
249+
const body = role === 'assistant'
250+
? renderAssistantMarkdown(m.content)
251+
: renderUserText(m.content);
249252
return `<div class="chat-message chat-message-${role}">
250253
<div class="chat-message-avatar"><i data-lucide="${icon}" style="width:14px;height:14px;"></i></div>
251-
<div class="chat-message-body">${formatChatText(m.content)}</div>
254+
<div class="chat-message-body">${body}</div>
252255
</div>`;
253256
}
254257

255-
// Minimal Markdown-ish formatting: paragraphs, line breaks, inline code.
256-
// Real markdown rendering would need a dependency; the catalog UI keeps
257-
// zero JS deps, so we stick to a tiny escape-then-decorate pass.
258-
function formatChatText(text) {
259-
const escaped = escapeHtml(text || '');
260-
return escaped
261-
.replace(/`([^`]+)`/g, '<code>$1</code>')
262-
.replace(/\n\n+/g, '</p><p>')
263-
.replace(/\n/g, '<br>')
264-
.replace(/^/, '<p>') + '</p>';
258+
// User input: escape HTML and convert newlines. No markdown parsing —
259+
// users shouldn't be able to inject formatting (or worse) into their
260+
// own bubbles by typing it.
261+
function renderUserText(text) {
262+
return '<p>' + escapeHtml(text || '').replace(/\n/g, '<br>') + '</p>';
263+
}
264+
265+
// Assistant output: full markdown via `marked` (CDN). Falls back to
266+
// plain rendering if marked failed to load. `breaks: true` makes single
267+
// newlines turn into <br>, matching what Claude tends to emit.
268+
function renderAssistantMarkdown(text) {
269+
if (typeof marked === 'undefined') return renderUserText(text);
270+
return marked.parse(text || '', { breaks: true, gfm: true });
265271
}
266272

267273
async function sendChatMessage() {
@@ -275,8 +281,13 @@ async function sendChatMessage() {
275281
renderChatView();
276282

277283
try {
284+
// Skip locally-generated error placeholders — they were never real
285+
// assistant turns, and feeding them to Claude as if they were makes
286+
// the model try to "explain" or "fix" its supposed previous failure.
278287
const payload = {
279-
messages: chatHistory.map(m => ({ role: m.role, content: m.content }))
288+
messages: chatHistory
289+
.filter(m => !m.isError)
290+
.map(m => ({ role: m.role, content: m.content }))
280291
};
281292
const resp = await fetch(CHAT_WORKER_URL, {
282293
method: 'POST',
@@ -292,6 +303,7 @@ async function sendChatMessage() {
292303
} catch (e) {
293304
chatHistory.push({
294305
role: 'assistant',
306+
isError: true,
295307
content: 'Fehler beim Aufruf des Chat-Backends: ' + (e.message || e)
296308
});
297309
} finally {

0 commit comments

Comments
 (0)