Spaces:
Runtime error
Runtime error
Commit ·
9499163
1
Parent(s): a7da6ce
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- backend/app/llm/signatures.py +6 -2
- backend/app/pipeline/ingest.py +49 -6
- backend/tests/test_buildout.py +2 -1
- frontend/src/components/Markdown.tsx +110 -23
- frontend/src/styles/pages.css +154 -0
- frontend/tsconfig.tsbuildinfo +1 -1
backend/app/llm/signatures.py
CHANGED
|
@@ -117,8 +117,12 @@ class ResearchArchive(dspy.Signature):
|
|
| 117 |
If the archive does not cover the question, say so plainly and set unsupported=true.
|
| 118 |
Write in plain, warm prose formatted as Markdown: short paragraphs separated by blank lines,
|
| 119 |
bullet or numbered lists when comparing or listing things, **bold** for the key idea, and a
|
| 120 |
-
### heading only when the answer has distinct parts.
|
| 121 |
-
or
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
question: str = dspy.InputField(desc="A standalone question (follow ups already resolved)")
|
| 124 |
conversation: str = dspy.InputField(desc="Recent turns of this chat, oldest first. Use it for context "
|
|
|
|
| 117 |
If the archive does not cover the question, say so plainly and set unsupported=true.
|
| 118 |
Write in plain, warm prose formatted as Markdown: short paragraphs separated by blank lines,
|
| 119 |
bullet or numbered lists when comparing or listing things, **bold** for the key idea, and a
|
| 120 |
+
### heading only when the answer has distinct parts. Use a table for side by side comparisons.
|
| 121 |
+
When the posts contain code, commands or config, quote them in fenced code blocks with a language
|
| 122 |
+
tag (```python, ```bash, ```json) exactly as written, never paraphrased. When a post you read has a
|
| 123 |
+
relevant image line (), you may include that exact line; never invent image
|
| 124 |
+
URLs. Put [n] markers right after the sentence or list item they support (after a code block or
|
| 125 |
+
image, on the line below it). Never use em dashes."""
|
| 126 |
|
| 127 |
question: str = dspy.InputField(desc="A standalone question (follow ups already resolved)")
|
| 128 |
conversation: str = dspy.InputField(desc="Recent turns of this chat, oldest first. Use it for context "
|
backend/app/pipeline/ingest.py
CHANGED
|
@@ -168,7 +168,25 @@ def html_to_sections(html: str) -> list[tuple[str | None, str]]:
|
|
| 168 |
for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe", "noscript", "svg"]):
|
| 169 |
tag.decompose()
|
| 170 |
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
| 171 |
-
for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote", "pre"]):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
text = el.get_text(" ", strip=True)
|
| 173 |
if not text or (len(text) < 120 and _DROP.search(text)):
|
| 174 |
continue
|
|
@@ -179,6 +197,9 @@ def html_to_sections(html: str) -> list[tuple[str | None, str]]:
|
|
| 179 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 180 |
|
| 181 |
|
|
|
|
|
|
|
|
|
|
| 182 |
def markdown_to_sections(text: str) -> list[tuple[str | None, str]]:
|
| 183 |
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
| 184 |
para: list[str] = []
|
|
@@ -188,16 +209,32 @@ def markdown_to_sections(text: str) -> list[tuple[str | None, str]]:
|
|
| 188 |
sections[-1][1].append(" ".join(para).strip())
|
| 189 |
para.clear()
|
| 190 |
|
|
|
|
| 191 |
for line in text.splitlines():
|
| 192 |
stripped = line.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
heading = re.match(r"^#{1,3}\s+(.*)", stripped)
|
| 194 |
-
if
|
|
|
|
|
|
|
|
|
|
| 195 |
flush()
|
| 196 |
sections.append((heading.group(1).strip(), []))
|
| 197 |
elif not stripped:
|
| 198 |
flush()
|
| 199 |
else:
|
| 200 |
para.append(stripped)
|
|
|
|
|
|
|
| 201 |
flush()
|
| 202 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 203 |
|
|
@@ -239,13 +276,19 @@ class FeedEntry:
|
|
| 239 |
sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML
|
| 240 |
|
| 241 |
|
| 242 |
-
_MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
| 243 |
-
_MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
| 244 |
|
| 245 |
|
| 246 |
def _clean_markdown(md: str) -> str:
|
| 247 |
-
"""Firecrawl markdown to
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
|
| 251 |
def _firecrawl_entry(url: str) -> FeedEntry:
|
|
|
|
| 168 |
for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe", "noscript", "svg"]):
|
| 169 |
tag.decompose()
|
| 170 |
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
| 171 |
+
for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote", "pre", "img"]):
|
| 172 |
+
if el.name == "img":
|
| 173 |
+
# Keep images as Markdown so answers can show them (web images only, no tracking pixels).
|
| 174 |
+
src = (el.get("src") or el.get("data-src") or "").strip()
|
| 175 |
+
width = str(el.get("width") or "")
|
| 176 |
+
if src.startswith(("http://", "https://")) and not (width.isdigit() and int(width) < 50):
|
| 177 |
+
alt = re.sub(r"[\[\]\n]", " ", el.get("alt") or "").strip()
|
| 178 |
+
sections[-1][1].append(f"")
|
| 179 |
+
continue
|
| 180 |
+
if el.name == "pre":
|
| 181 |
+
# Code keeps its line breaks, as a fenced block.
|
| 182 |
+
code = el.get_text().strip("\n")
|
| 183 |
+
if code.strip():
|
| 184 |
+
lang = next((c.split("-", 1)[1] for c in (el.find("code") or el).get("class") or []
|
| 185 |
+
if c.startswith(("language-", "lang-"))), "")
|
| 186 |
+
sections[-1][1].append(f"```{lang}\n{code}\n```")
|
| 187 |
+
continue
|
| 188 |
+
if el.find_parent("pre"):
|
| 189 |
+
continue
|
| 190 |
text = el.get_text(" ", strip=True)
|
| 191 |
if not text or (len(text) < 120 and _DROP.search(text)):
|
| 192 |
continue
|
|
|
|
| 197 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 198 |
|
| 199 |
|
| 200 |
+
_MD_IMAGE_LINE = re.compile(r"^!\[[^\]]*\]\(https?://[^)\s]+[^)]*\)$")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
def markdown_to_sections(text: str) -> list[tuple[str | None, str]]:
|
| 204 |
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
| 205 |
para: list[str] = []
|
|
|
|
| 209 |
sections[-1][1].append(" ".join(para).strip())
|
| 210 |
para.clear()
|
| 211 |
|
| 212 |
+
fence: list[str] | None = None # lines of an open ``` block, kept verbatim
|
| 213 |
for line in text.splitlines():
|
| 214 |
stripped = line.strip()
|
| 215 |
+
if fence is not None:
|
| 216 |
+
fence.append(line)
|
| 217 |
+
if stripped.startswith("```"):
|
| 218 |
+
sections[-1][1].append("\n".join(fence))
|
| 219 |
+
fence = None
|
| 220 |
+
continue
|
| 221 |
+
if stripped.startswith("```"):
|
| 222 |
+
flush()
|
| 223 |
+
fence = [stripped]
|
| 224 |
+
continue
|
| 225 |
heading = re.match(r"^#{1,3}\s+(.*)", stripped)
|
| 226 |
+
if _MD_IMAGE_LINE.match(stripped):
|
| 227 |
+
flush()
|
| 228 |
+
sections[-1][1].append(stripped) # images stay on their own line
|
| 229 |
+
elif heading:
|
| 230 |
flush()
|
| 231 |
sections.append((heading.group(1).strip(), []))
|
| 232 |
elif not stripped:
|
| 233 |
flush()
|
| 234 |
else:
|
| 235 |
para.append(stripped)
|
| 236 |
+
if fence is not None: # unclosed fence: close it so the block still renders
|
| 237 |
+
sections[-1][1].append("\n".join([*fence, "```"]))
|
| 238 |
flush()
|
| 239 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 240 |
|
|
|
|
| 276 |
sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML
|
| 277 |
|
| 278 |
|
| 279 |
+
_MD_IMAGE = re.compile(r"!\[([^\]]*)\]\(([^)\s]*)[^)]*\)")
|
| 280 |
+
_MD_LINK = re.compile(r"(?<!!)\[([^\]]*)\]\([^)]*\)")
|
| 281 |
|
| 282 |
|
| 283 |
def _clean_markdown(md: str) -> str:
|
| 284 |
+
"""Firecrawl markdown to prose: keep link text, keep web images on their own line (drop data: URIs
|
| 285 |
+
and relative paths, which would not load outside the site)."""
|
| 286 |
+
|
| 287 |
+
def image(m: re.Match) -> str:
|
| 288 |
+
alt, src = m.group(1).replace("\n", " ").strip(), m.group(2)
|
| 289 |
+
return f"\n\n\n\n" if src.startswith(("http://", "https://")) else ""
|
| 290 |
+
|
| 291 |
+
return _MD_LINK.sub(r"\1", _MD_IMAGE.sub(image, md))
|
| 292 |
|
| 293 |
|
| 294 |
def _firecrawl_entry(url: str) -> FeedEntry:
|
backend/tests/test_buildout.py
CHANGED
|
@@ -178,7 +178,8 @@ def test_crawl_uses_firecrawl_when_configured(monkeypatch):
|
|
| 178 |
assert [e.title for e in entries] == ["Rendered Post"]
|
| 179 |
assert entries[0].published_at.year == 2026
|
| 180 |
body = entries[0].sections[0][1]
|
| 181 |
-
assert "See the docs." in body and "
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
def test_firecrawl_is_main_path_except_substack(feed, monkeypatch):
|
|
|
|
| 178 |
assert [e.title for e in entries] == ["Rendered Post"]
|
| 179 |
assert entries[0].published_at.year == 2026
|
| 180 |
body = entries[0].sections[0][1]
|
| 181 |
+
assert "See the docs." in body and "[the docs]" not in body
|
| 182 |
+
assert "" in [t for _, t in entries[0].sections][0] # images kept for answers
|
| 183 |
|
| 184 |
|
| 185 |
def test_firecrawl_is_main_path_except_substack(feed, monkeypatch):
|
frontend/src/components/Markdown.tsx
CHANGED
|
@@ -1,14 +1,71 @@
|
|
| 1 |
-
import
|
| 2 |
import type { Citation } from "../api/types";
|
|
|
|
| 3 |
|
| 4 |
/** Small, safe Markdown renderer for generated text. Builds React elements (never raw HTML), and turns
|
| 5 |
* [n] citation markers into buttons that open the cited lines.
|
| 6 |
-
* Supports: headings, paragraphs, bullet and
|
| 7 |
-
* code
|
|
|
|
| 8 |
|
| 9 |
type Cite = { citations?: Citation[]; onCite?: (c: Citation) => void };
|
| 10 |
|
| 11 |
-
const INLINE =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
|
| 14 |
const out: ReactNode[] = [];
|
|
@@ -19,17 +76,25 @@ function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
|
|
| 19 |
const idx = m.index ?? 0;
|
| 20 |
if (idx > last) out.push(text.slice(last, idx));
|
| 21 |
const key = `${keyBase}-${i++}`;
|
| 22 |
-
const [whole, code, bold, italic, strike, link, href, markers, bare] = m;
|
| 23 |
if (code) out.push(<code key={key}>{code.slice(1, -1)}</code>);
|
| 24 |
else if (bold) out.push(<strong key={key}>{inline(bold.slice(2, -2), cite, key)}</strong>);
|
| 25 |
else if (italic) out.push(<em key={key}>{inline(italic.slice(1, -1), cite, key)}</em>);
|
| 26 |
else if (strike) out.push(<del key={key}>{inline(strike.slice(2, -2), cite, key)}</del>);
|
| 27 |
-
else if (
|
| 28 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
out.push(
|
| 30 |
-
|
| 31 |
-
{
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
);
|
| 34 |
} else if (markers) {
|
| 35 |
for (const n of markers.matchAll(/\[(\d+)\]/g)) {
|
|
@@ -59,14 +124,16 @@ function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
|
|
| 59 |
return out;
|
| 60 |
}
|
| 61 |
|
| 62 |
-
type ListItem = { text: string; children: ListItem[] };
|
|
|
|
| 63 |
type Block =
|
| 64 |
| { kind: "h"; level: number; text: string }
|
| 65 |
| { kind: "p"; text: string }
|
| 66 |
| { kind: "ul" | "ol"; items: ListItem[]; start: number }
|
| 67 |
| { kind: "quote"; text: string }
|
| 68 |
| { kind: "code"; text: string; lang: string }
|
| 69 |
-
| { kind: "table"; head: string[]; rows: string[][] }
|
|
|
|
| 70 |
| { kind: "hr" };
|
| 71 |
|
| 72 |
const LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
|
@@ -98,7 +165,13 @@ function parse(src: string): Block[] {
|
|
| 98 |
blocks.push({ kind: "code", text: body.join("\n"), lang: fence[1] });
|
| 99 |
continue;
|
| 100 |
}
|
| 101 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
if (heading) {
|
| 103 |
blocks.push({ kind: "h", level: heading[1].length, text: heading[2] });
|
| 104 |
i++;
|
|
@@ -111,10 +184,13 @@ function parse(src: string): Block[] {
|
|
| 111 |
}
|
| 112 |
if (trimmed.startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
|
| 113 |
const head = splitRow(trimmed);
|
|
|
|
|
|
|
|
|
|
| 114 |
i += 2;
|
| 115 |
const rows: string[][] = [];
|
| 116 |
while (i < lines.length && lines[i].trim().startsWith("|")) rows.push(splitRow(lines[i++]));
|
| 117 |
-
blocks.push({ kind: "table", head, rows });
|
| 118 |
continue;
|
| 119 |
}
|
| 120 |
if (trimmed.startsWith(">")) {
|
|
@@ -131,7 +207,10 @@ function parse(src: string): Block[] {
|
|
| 131 |
while (i < lines.length) {
|
| 132 |
const m = lines[i].match(LIST);
|
| 133 |
if (m) {
|
| 134 |
-
const
|
|
|
|
|
|
|
|
|
|
| 135 |
if (m[1].length > baseIndent && items.length) items[items.length - 1].children.push(item);
|
| 136 |
else items.push(item);
|
| 137 |
i++;
|
|
@@ -149,7 +228,7 @@ function parse(src: string): Block[] {
|
|
| 149 |
continue;
|
| 150 |
}
|
| 151 |
const para: string[] = [];
|
| 152 |
-
while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^(#{1,
|
| 153 |
para.push(lines[i++].trim());
|
| 154 |
}
|
| 155 |
if (para.length === 0) {
|
|
@@ -170,7 +249,8 @@ function withBreaks(text: string, cite: Cite, key: string): ReactNode[] {
|
|
| 170 |
|
| 171 |
function renderItems(items: ListItem[], ordered: boolean, cite: Cite, key: string, start = 1): ReactNode {
|
| 172 |
const children = items.map((it, j) => (
|
| 173 |
-
<li key={`${key}-${j}`}>
|
|
|
|
| 174 |
{inline(it.text, cite, `${key}-${j}`)}
|
| 175 |
{it.children.length > 0 && renderItems(it.children, ordered, cite, `${key}-${j}-c`)}
|
| 176 |
</li>
|
|
@@ -192,7 +272,7 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
|
|
| 192 |
const key = `b${i}`;
|
| 193 |
switch (b.kind) {
|
| 194 |
case "h": {
|
| 195 |
-
const Tag = (["h3", "h3", "h4", "h5"] as const)[b.level - 1];
|
| 196 |
return <Tag key={key}>{inline(b.text, cite, key)}</Tag>;
|
| 197 |
}
|
| 198 |
case "p":
|
|
@@ -203,10 +283,13 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
|
|
| 203 |
case "quote":
|
| 204 |
return <blockquote key={key}>{withBreaks(b.text, cite, key)}</blockquote>;
|
| 205 |
case "code":
|
|
|
|
|
|
|
| 206 |
return (
|
| 207 |
-
<
|
| 208 |
-
<
|
| 209 |
-
|
|
|
|
| 210 |
);
|
| 211 |
case "table":
|
| 212 |
return (
|
|
@@ -215,7 +298,9 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
|
|
| 215 |
<thead>
|
| 216 |
<tr>
|
| 217 |
{b.head.map((h, j) => (
|
| 218 |
-
<th key={j}
|
|
|
|
|
|
|
| 219 |
))}
|
| 220 |
</tr>
|
| 221 |
</thead>
|
|
@@ -223,7 +308,9 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
|
|
| 223 |
{b.rows.map((r, j) => (
|
| 224 |
<tr key={j}>
|
| 225 |
{r.map((c, k) => (
|
| 226 |
-
<td key={k}
|
|
|
|
|
|
|
| 227 |
))}
|
| 228 |
</tr>
|
| 229 |
))}
|
|
|
|
| 1 |
+
import { useState, type ReactNode } from "react";
|
| 2 |
import type { Citation } from "../api/types";
|
| 3 |
+
import { highlight, languageLabel } from "../lib/highlight";
|
| 4 |
|
| 5 |
/** Small, safe Markdown renderer for generated text. Builds React elements (never raw HTML), and turns
|
| 6 |
* [n] citation markers into buttons that open the cited lines.
|
| 7 |
+
* Supports: headings, paragraphs, bullet, numbered and task lists (one nested level), blockquotes,
|
| 8 |
+
* fenced code (highlighted, copyable, collapsible), tables with alignment, images, horizontal rules,
|
| 9 |
+
* bold, italic, strikethrough, inline code, links and bare URLs. */
|
| 10 |
|
| 11 |
type Cite = { citations?: Citation[]; onCite?: (c: Citation) => void };
|
| 12 |
|
| 13 |
+
const INLINE =
|
| 14 |
+
/(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|_[^_\s][^_]*_)|(~~[^~]+~~)|(!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\))|(\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\))|(\[\d+\](?:\[\d+\])*)|(https?:\/\/[^\s)<]+[^\s)<.,;:!?'"])/g;
|
| 15 |
+
|
| 16 |
+
/** Links: web, mail and in app paths only (no javascript: and friends). Images: web and app paths. */
|
| 17 |
+
const safeHref = (url: string) => (/^(https?:\/\/|mailto:|\/(?!\/)|#)/i.test(url) ? url : null);
|
| 18 |
+
const safeSrc = (url: string) => (/^(https?:\/\/|\/(?!\/))/i.test(url) ? url : null);
|
| 19 |
+
|
| 20 |
+
function Img({ src, alt, title }: { src: string; alt: string; title?: string }) {
|
| 21 |
+
const [broken, setBroken] = useState(false);
|
| 22 |
+
if (broken) return <span className="md-img-broken mono">[image: {alt || src}]</span>;
|
| 23 |
+
return (
|
| 24 |
+
<a className="md-img" href={src} target="_blank" rel="noreferrer" title={title || alt || "Open image"}>
|
| 25 |
+
<img src={src} alt={alt} loading="lazy" decoding="async" referrerPolicy="no-referrer" onError={() => setBroken(true)} />
|
| 26 |
+
</a>
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const COLLAPSE_AT = 24; // lines
|
| 31 |
+
|
| 32 |
+
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
| 33 |
+
const [copied, setCopied] = useState(false);
|
| 34 |
+
const lines = code.split("\n");
|
| 35 |
+
const long = lines.length > COLLAPSE_AT;
|
| 36 |
+
const [open, setOpen] = useState(!long);
|
| 37 |
+
const shown = open ? code : lines.slice(0, COLLAPSE_AT).join("\n");
|
| 38 |
+
return (
|
| 39 |
+
<div className={"md-code" + (open ? "" : " collapsed")}>
|
| 40 |
+
<div className="md-code-bar">
|
| 41 |
+
<span className="mono">{languageLabel(lang)}</span>
|
| 42 |
+
<button
|
| 43 |
+
type="button"
|
| 44 |
+
className="md-code-copy mono"
|
| 45 |
+
onClick={async () => {
|
| 46 |
+
try {
|
| 47 |
+
await navigator.clipboard.writeText(code);
|
| 48 |
+
setCopied(true);
|
| 49 |
+
setTimeout(() => setCopied(false), 1500);
|
| 50 |
+
} catch {
|
| 51 |
+
/* clipboard blocked */
|
| 52 |
+
}
|
| 53 |
+
}}
|
| 54 |
+
>
|
| 55 |
+
{copied ? "Copied" : "Copy"}
|
| 56 |
+
</button>
|
| 57 |
+
</div>
|
| 58 |
+
<pre data-lang={lang || undefined}>
|
| 59 |
+
<code>{highlight(shown, lang)}</code>
|
| 60 |
+
</pre>
|
| 61 |
+
{long && (
|
| 62 |
+
<button type="button" className="md-code-more mono" onClick={() => setOpen((o) => !o)}>
|
| 63 |
+
{open ? "Show less" : "Show all " + lines.length + " lines"}
|
| 64 |
+
</button>
|
| 65 |
+
)}
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
|
| 70 |
function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
|
| 71 |
const out: ReactNode[] = [];
|
|
|
|
| 76 |
const idx = m.index ?? 0;
|
| 77 |
if (idx > last) out.push(text.slice(last, idx));
|
| 78 |
const key = `${keyBase}-${i++}`;
|
| 79 |
+
const [whole, code, bold, italic, strike, image, alt, src, title, link, label, href, markers, bare] = m;
|
| 80 |
if (code) out.push(<code key={key}>{code.slice(1, -1)}</code>);
|
| 81 |
else if (bold) out.push(<strong key={key}>{inline(bold.slice(2, -2), cite, key)}</strong>);
|
| 82 |
else if (italic) out.push(<em key={key}>{inline(italic.slice(1, -1), cite, key)}</em>);
|
| 83 |
else if (strike) out.push(<del key={key}>{inline(strike.slice(2, -2), cite, key)}</del>);
|
| 84 |
+
else if (image) {
|
| 85 |
+
const url = safeSrc(src);
|
| 86 |
+
out.push(url ? <Img key={key} src={url} alt={alt} title={title} /> : alt);
|
| 87 |
+
} else if (link) {
|
| 88 |
+
const url = safeHref(href);
|
| 89 |
+
const external = url ? /^https?:/i.test(url) : false;
|
| 90 |
out.push(
|
| 91 |
+
url ? (
|
| 92 |
+
<a key={key} href={url} {...(external ? { target: "_blank", rel: "noreferrer" } : {})}>
|
| 93 |
+
{inline(label, cite, key)}
|
| 94 |
+
</a>
|
| 95 |
+
) : (
|
| 96 |
+
<span key={key}>{inline(label, cite, key)}</span>
|
| 97 |
+
),
|
| 98 |
);
|
| 99 |
} else if (markers) {
|
| 100 |
for (const n of markers.matchAll(/\[(\d+)\]/g)) {
|
|
|
|
| 124 |
return out;
|
| 125 |
}
|
| 126 |
|
| 127 |
+
type ListItem = { text: string; children: ListItem[]; task?: boolean; done?: boolean };
|
| 128 |
+
type Align = "left" | "center" | "right" | undefined;
|
| 129 |
type Block =
|
| 130 |
| { kind: "h"; level: number; text: string }
|
| 131 |
| { kind: "p"; text: string }
|
| 132 |
| { kind: "ul" | "ol"; items: ListItem[]; start: number }
|
| 133 |
| { kind: "quote"; text: string }
|
| 134 |
| { kind: "code"; text: string; lang: string }
|
| 135 |
+
| { kind: "table"; head: string[]; rows: string[][]; align: Align[] }
|
| 136 |
+
| { kind: "img"; src: string; alt: string; title?: string }
|
| 137 |
| { kind: "hr" };
|
| 138 |
|
| 139 |
const LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
|
|
|
| 165 |
blocks.push({ kind: "code", text: body.join("\n"), lang: fence[1] });
|
| 166 |
continue;
|
| 167 |
}
|
| 168 |
+
const image = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/);
|
| 169 |
+
if (image && safeSrc(image[2])) {
|
| 170 |
+
blocks.push({ kind: "img", alt: image[1], src: image[2], title: image[3] });
|
| 171 |
+
i++;
|
| 172 |
+
continue;
|
| 173 |
+
}
|
| 174 |
+
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
| 175 |
if (heading) {
|
| 176 |
blocks.push({ kind: "h", level: heading[1].length, text: heading[2] });
|
| 177 |
i++;
|
|
|
|
| 184 |
}
|
| 185 |
if (trimmed.startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
|
| 186 |
const head = splitRow(trimmed);
|
| 187 |
+
const align: Align[] = splitRow(lines[i + 1]).map((c) =>
|
| 188 |
+
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : undefined,
|
| 189 |
+
);
|
| 190 |
i += 2;
|
| 191 |
const rows: string[][] = [];
|
| 192 |
while (i < lines.length && lines[i].trim().startsWith("|")) rows.push(splitRow(lines[i++]));
|
| 193 |
+
blocks.push({ kind: "table", head, rows, align });
|
| 194 |
continue;
|
| 195 |
}
|
| 196 |
if (trimmed.startsWith(">")) {
|
|
|
|
| 207 |
while (i < lines.length) {
|
| 208 |
const m = lines[i].match(LIST);
|
| 209 |
if (m) {
|
| 210 |
+
const task = m[3].match(/^\[([ xX])\]\s+(.*)$/);
|
| 211 |
+
const item: ListItem = task
|
| 212 |
+
? { text: task[2], children: [], task: true, done: task[1] !== " " }
|
| 213 |
+
: { text: m[3], children: [] };
|
| 214 |
if (m[1].length > baseIndent && items.length) items[items.length - 1].children.push(item);
|
| 215 |
else items.push(item);
|
| 216 |
i++;
|
|
|
|
| 228 |
continue;
|
| 229 |
}
|
| 230 |
const para: string[] = [];
|
| 231 |
+
while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^(#{1,6}\s|>|```|\||!\[)/.test(lines[i].trim())) {
|
| 232 |
para.push(lines[i++].trim());
|
| 233 |
}
|
| 234 |
if (para.length === 0) {
|
|
|
|
| 249 |
|
| 250 |
function renderItems(items: ListItem[], ordered: boolean, cite: Cite, key: string, start = 1): ReactNode {
|
| 251 |
const children = items.map((it, j) => (
|
| 252 |
+
<li key={`${key}-${j}`} className={it.task ? `md-task${it.done ? " done" : ""}` : undefined}>
|
| 253 |
+
{it.task && <input type="checkbox" checked={it.done} readOnly tabIndex={-1} aria-label={it.done ? "Done" : "Not done"} />}
|
| 254 |
{inline(it.text, cite, `${key}-${j}`)}
|
| 255 |
{it.children.length > 0 && renderItems(it.children, ordered, cite, `${key}-${j}-c`)}
|
| 256 |
</li>
|
|
|
|
| 272 |
const key = `b${i}`;
|
| 273 |
switch (b.kind) {
|
| 274 |
case "h": {
|
| 275 |
+
const Tag = (["h3", "h3", "h4", "h5", "h5", "h5"] as const)[b.level - 1];
|
| 276 |
return <Tag key={key}>{inline(b.text, cite, key)}</Tag>;
|
| 277 |
}
|
| 278 |
case "p":
|
|
|
|
| 283 |
case "quote":
|
| 284 |
return <blockquote key={key}>{withBreaks(b.text, cite, key)}</blockquote>;
|
| 285 |
case "code":
|
| 286 |
+
return <CodeBlock key={key} code={b.text} lang={b.lang} />;
|
| 287 |
+
case "img":
|
| 288 |
return (
|
| 289 |
+
<figure key={key} className="md-figure">
|
| 290 |
+
<Img src={b.src} alt={b.alt} title={b.title} />
|
| 291 |
+
{(b.title || b.alt) && <figcaption className="muted">{b.title || b.alt}</figcaption>}
|
| 292 |
+
</figure>
|
| 293 |
);
|
| 294 |
case "table":
|
| 295 |
return (
|
|
|
|
| 298 |
<thead>
|
| 299 |
<tr>
|
| 300 |
{b.head.map((h, j) => (
|
| 301 |
+
<th key={j} style={b.align[j] ? { textAlign: b.align[j] } : undefined}>
|
| 302 |
+
{inline(h, cite, `${key}-h${j}`)}
|
| 303 |
+
</th>
|
| 304 |
))}
|
| 305 |
</tr>
|
| 306 |
</thead>
|
|
|
|
| 308 |
{b.rows.map((r, j) => (
|
| 309 |
<tr key={j}>
|
| 310 |
{r.map((c, k) => (
|
| 311 |
+
<td key={k} style={b.align[k] ? { textAlign: b.align[k] } : undefined}>
|
| 312 |
+
{inline(c, cite, `${key}-${j}-${k}`)}
|
| 313 |
+
</td>
|
| 314 |
))}
|
| 315 |
</tr>
|
| 316 |
))}
|
frontend/src/styles/pages.css
CHANGED
|
@@ -1627,6 +1627,160 @@ select.input option {
|
|
| 1627 |
line-height: 1.6;
|
| 1628 |
}
|
| 1629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1630 |
.md blockquote {
|
| 1631 |
padding: 4px 0 4px 16px;
|
| 1632 |
border-left: 3px solid var(--blue);
|
|
|
|
| 1627 |
line-height: 1.6;
|
| 1628 |
}
|
| 1629 |
|
| 1630 |
+
/* Code blocks: language bar, copy, highlighting, collapse */
|
| 1631 |
+
.md-code {
|
| 1632 |
+
position: relative;
|
| 1633 |
+
border: 1px solid var(--line);
|
| 1634 |
+
border-radius: var(--radius-sm);
|
| 1635 |
+
background: rgba(255, 255, 255, 0.03);
|
| 1636 |
+
overflow: hidden;
|
| 1637 |
+
max-width: 100%;
|
| 1638 |
+
}
|
| 1639 |
+
|
| 1640 |
+
.md-code-bar {
|
| 1641 |
+
display: flex;
|
| 1642 |
+
align-items: center;
|
| 1643 |
+
justify-content: space-between;
|
| 1644 |
+
gap: 8px;
|
| 1645 |
+
padding: 6px 8px 6px 14px;
|
| 1646 |
+
border-bottom: 1px solid var(--line);
|
| 1647 |
+
background: rgba(33, 124, 255, 0.06);
|
| 1648 |
+
font-size: 0.7rem;
|
| 1649 |
+
letter-spacing: 0.08em;
|
| 1650 |
+
text-transform: uppercase;
|
| 1651 |
+
color: var(--text-3);
|
| 1652 |
+
}
|
| 1653 |
+
|
| 1654 |
+
.md-code-copy,
|
| 1655 |
+
.md-code-more {
|
| 1656 |
+
border: 1px solid var(--line);
|
| 1657 |
+
border-radius: 999px;
|
| 1658 |
+
background: transparent;
|
| 1659 |
+
color: var(--text-2);
|
| 1660 |
+
font-size: 0.7rem;
|
| 1661 |
+
letter-spacing: 0.06em;
|
| 1662 |
+
padding: 3px 10px;
|
| 1663 |
+
cursor: pointer;
|
| 1664 |
+
}
|
| 1665 |
+
|
| 1666 |
+
.md-code-copy:hover,
|
| 1667 |
+
.md-code-more:hover {
|
| 1668 |
+
color: var(--white);
|
| 1669 |
+
border-color: var(--blue-60);
|
| 1670 |
+
}
|
| 1671 |
+
|
| 1672 |
+
.md-code pre {
|
| 1673 |
+
margin: 0;
|
| 1674 |
+
border: none;
|
| 1675 |
+
border-radius: 0;
|
| 1676 |
+
background: transparent;
|
| 1677 |
+
max-height: 520px;
|
| 1678 |
+
overflow: auto;
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
+
.md-code.collapsed pre {
|
| 1682 |
+
mask-image: linear-gradient(180deg, #000 70%, transparent);
|
| 1683 |
+
}
|
| 1684 |
+
|
| 1685 |
+
.md-code-more {
|
| 1686 |
+
display: block;
|
| 1687 |
+
margin: 0 auto 10px;
|
| 1688 |
+
}
|
| 1689 |
+
|
| 1690 |
+
.md pre code {
|
| 1691 |
+
color: rgba(255, 255, 255, 0.82);
|
| 1692 |
+
white-space: pre;
|
| 1693 |
+
tab-size: 2;
|
| 1694 |
+
}
|
| 1695 |
+
|
| 1696 |
+
.tok-kw {
|
| 1697 |
+
color: var(--blue);
|
| 1698 |
+
font-weight: 600;
|
| 1699 |
+
}
|
| 1700 |
+
|
| 1701 |
+
.tok-str {
|
| 1702 |
+
color: var(--white);
|
| 1703 |
+
}
|
| 1704 |
+
|
| 1705 |
+
.tok-com {
|
| 1706 |
+
color: var(--text-3);
|
| 1707 |
+
font-style: italic;
|
| 1708 |
+
}
|
| 1709 |
+
|
| 1710 |
+
.tok-num,
|
| 1711 |
+
.tok-prop {
|
| 1712 |
+
color: color-mix(in srgb, var(--blue) 55%, var(--white));
|
| 1713 |
+
}
|
| 1714 |
+
|
| 1715 |
+
.tok-fn {
|
| 1716 |
+
color: var(--white);
|
| 1717 |
+
font-weight: 600;
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
.tok-type {
|
| 1721 |
+
color: var(--white);
|
| 1722 |
+
text-decoration: underline;
|
| 1723 |
+
text-decoration-color: var(--blue-35);
|
| 1724 |
+
text-underline-offset: 3px;
|
| 1725 |
+
}
|
| 1726 |
+
|
| 1727 |
+
/* Images */
|
| 1728 |
+
.md-figure {
|
| 1729 |
+
margin: 0;
|
| 1730 |
+
display: grid;
|
| 1731 |
+
gap: 6px;
|
| 1732 |
+
}
|
| 1733 |
+
|
| 1734 |
+
.md-figure figcaption {
|
| 1735 |
+
font-size: 0.8rem;
|
| 1736 |
+
}
|
| 1737 |
+
|
| 1738 |
+
.md-img {
|
| 1739 |
+
display: inline-block;
|
| 1740 |
+
max-width: 100%;
|
| 1741 |
+
border-radius: var(--radius-sm);
|
| 1742 |
+
overflow: hidden;
|
| 1743 |
+
border: 1px solid var(--line);
|
| 1744 |
+
line-height: 0;
|
| 1745 |
+
vertical-align: middle;
|
| 1746 |
+
}
|
| 1747 |
+
|
| 1748 |
+
.md-img img {
|
| 1749 |
+
display: block;
|
| 1750 |
+
max-width: 100%;
|
| 1751 |
+
max-height: 420px;
|
| 1752 |
+
height: auto;
|
| 1753 |
+
object-fit: contain;
|
| 1754 |
+
background: rgba(255, 255, 255, 0.03);
|
| 1755 |
+
}
|
| 1756 |
+
|
| 1757 |
+
.md-img:hover {
|
| 1758 |
+
border-color: var(--blue-60);
|
| 1759 |
+
box-shadow: 0 0 18px var(--blue-20);
|
| 1760 |
+
}
|
| 1761 |
+
|
| 1762 |
+
.md-img-broken {
|
| 1763 |
+
font-size: 0.8rem;
|
| 1764 |
+
color: var(--text-3);
|
| 1765 |
+
}
|
| 1766 |
+
|
| 1767 |
+
/* Task lists */
|
| 1768 |
+
.md li.md-task {
|
| 1769 |
+
list-style: none;
|
| 1770 |
+
margin-left: -1.2em;
|
| 1771 |
+
}
|
| 1772 |
+
|
| 1773 |
+
.md li.md-task input {
|
| 1774 |
+
accent-color: var(--blue);
|
| 1775 |
+
margin: 0 8px 0 0;
|
| 1776 |
+
vertical-align: -2px;
|
| 1777 |
+
}
|
| 1778 |
+
|
| 1779 |
+
.md li.md-task.done {
|
| 1780 |
+
color: var(--text-3);
|
| 1781 |
+
text-decoration: line-through;
|
| 1782 |
+
}
|
| 1783 |
+
|
| 1784 |
.md blockquote {
|
| 1785 |
padding: 4px 0 4px 16px;
|
| 1786 |
border-left: 3px solid var(--blue);
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/highlight.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|