building out the components, updating the UI in overview and adding a little sqlite viewer
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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).
|
||||
Reference in New Issue
Block a user