building out the components, updating the UI in overview and adding a little sqlite viewer

This commit is contained in:
itsamejms
2026-07-17 09:31:51 +01:00
parent 82f2dcf3df
commit b89a39ec9d
30 changed files with 2664 additions and 635 deletions
+91
View File
@@ -34,11 +34,102 @@ export function parseLlmJson<T>(raw: string): T | null {
try {
return JSON.parse(jsonStr) as T;
} catch (e) {
// ponytail: LLMs often emit *almost*-valid JSON. Strict parse runs first so
// valid output is never altered; only on failure do we try a conservative
// repair pass (surface fixes → merge stray array literals → balance brackets).
const repaired = repairJson(jsonStr);
if (repaired !== jsonStr) {
try {
const parsed = JSON.parse(repaired) as T;
if (parsed) return parsed;
} catch (e2) {
console.error("JSON parse error (after repair):", e2, "Input:", jsonStr);
}
}
console.error("JSON parse error:", e, "Input:", jsonStr);
return null;
}
}
/** Conservative repairs for common LLM JSON mistakes. String-aware (never
* touches text inside double-quoted strings — that was the bug that turned
* "Coop's" / "crows'" into garbage). Only applied on parse failure. */
function repairJson(input: string): string {
return balanceJson(mergeSeparateArrays(surfaceFixes(input)));
}
/** Drop trailing commas before closers and convert smart quotes → straight,
* only outside double-quoted strings. */
function surfaceFixes(s: string): string {
let out = "";
let i = 0;
let inStr = false;
while (i < s.length) {
const ch = s[i];
if (inStr) {
out += ch;
if (ch === "\\") { out += s[i + 1] ?? ""; i += 2; continue; }
if (ch === '"') inStr = false;
i++;
continue;
}
if (ch === '"') { inStr = true; out += ch; i++; continue; }
if (ch === "“" || ch === "”" || ch === "„") { out += '"'; i++; continue; }
if (ch === ",") {
let j = i + 1;
while (j < s.length && /\s/.test(s[j])) j++;
if (s[j] === "}" || s[j] === "]") { i++; continue; } // drop trailing comma
}
out += ch;
i++;
}
return out;
}
/** Collapse the common "array of strings written as separate array literals"
* mistake: ["a"], ["b"], ["c"] → "a", "b", "c" (inside one array). */
function mergeSeparateArrays(s: string): string {
return s.replace(/"\s*\]\s*,\s*\[\s*"/g, '", "');
}
/** Stack-based bracket balance. On a closer that doesn't match the top, close
* the open intermediate openers first (inserts the `}` the LLM forgot before a
* `]`), then match. Closers with no matching opener anywhere are stray → skip.
* Any still-open openers at the end get their closers appended. */
function balanceJson(s: string): string {
let out = "";
const st: string[] = [];
let inStr = false;
let esc = false;
const closeOf = (o: string) => (o === "{" ? "}" : "]");
for (const ch of s) {
if (inStr) {
out += ch;
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') { inStr = true; out += ch; continue; }
if (ch === "{" || ch === "[") { st.push(ch); out += ch; continue; }
if (ch === "}" || ch === "]") {
const want = ch === "}" ? "{" : "[";
if (st.includes(want)) {
while (st.length) {
const top = st.pop()!;
out += top === want ? ch : closeOf(top);
if (top === want) break;
}
}
// else: no matching opener anywhere → stray closer, skip
continue;
}
out += ch;
}
while (st.length) out += closeOf(st.pop()!);
return out;
}
/**
* Flatten nested objects into display-friendly strings.
* If a value is an object, stringify it nicely.
+63
View File
@@ -0,0 +1,63 @@
// ponytail: minimal Markdown → HTML for session notes/summaries. Renders the
// 80% a DM actually uses (headings, bold/italic, lists, code, paragraphs) in
// ~40 lines. No dependency. Escapes HTML first so LLM output can't inject.
// Upgrade path: swap for @uiw/react-md-editor if you need tables/images/GFM.
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Render a subset of Markdown to HTML. Input is treated as untrusted. */
export function renderMarkdown(md: string): string {
const lines = md.split("\n");
const html: string[] = [];
let inList = false;
const closeList = () => { if (inList) { html.push("</ul>"); inList = false; } };
for (const raw of lines) {
const line = raw.trimEnd();
if (!line.trim()) { closeList(); continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
closeList();
const level = h[1].length;
html.push(`<h${level}>${inline(h[2])}</h${level}>`);
continue;
}
const li = line.match(/^[-*+]\s+(.*)$/);
if (li) {
if (!inList) { html.push("<ul>"); inList = true; }
html.push(`<li>${inline(li[1])}</li>`);
continue;
}
const ol = line.match(/^\d+[.)]\s+(.*)$/);
if (ol) {
// ponytail: render ordered items as an unordered list too — keeping it
// one-element-type saves a state machine. A DM reading notes won't mind.
if (!inList) { html.push("<ul>"); inList = true; }
html.push(`<li>${inline(ol[1])}</li>`);
continue;
}
closeList();
html.push(`<p>${inline(line)}</p>`);
}
closeList();
return html.join("\n");
}
// inline formatting: **bold**, *italic*, `code`, [text](url).
function inline(s: string): string {
let out = escapeHtml(s);
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
return out;
}
// (self-check lives in the repo's node test mirror; this module is pure).