File size: 5,199 Bytes
e8a6c67 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | // <r-briefing> — the streaming-token, citation-chipped briefing panel.
//
// Replaces the agent.js renderMarkdown + rewriteCitations + paint
// scheduler. Token streaming becomes "append to a signal, re-render."
//
// Properties:
// text — full markdown text (set by parent on token / final events)
// streaming — bool; shows the blinking caret
// citeIndex — { doc_id: number } shared with <r-sources-footer>
// sourceLabels — passed through for chip tooltips
//
// Signals consumed:
// highlightedDocId — toggles `.hl` on chips reactively (set by
// <r-sources-footer> on hover)
// Signals updated:
// citeIndex — populated as citations are encountered in the text
// highlightedDocId — set on chip hover/click
import { html, LitElement } from "https://esm.sh/lit@3";
import { unsafeHTML } from "https://esm.sh/lit@3/directives/unsafe-html.js";
import { SignalWatcher } from "https://esm.sh/@lit-labs/signals@0.1.x";
import { citeIndex, highlightedDocId } from "./signals.js";
// Same minimal markdown subset as agent.js renderMarkdown — kept
// duplicated for now; will collapse when agent.js stops calling
// renderMarkdown. After full port this is the only impl.
function renderMarkdownPure(text) {
const lines = text.split("\n");
const out = [];
let para = []; let bullets = [];
const escapeHtml = (s) =>
String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
const flushPara = () => {
if (!para.length) return;
const safe = escapeHtml(para.join(" ").trim())
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
if (safe) out.push(`<p class="rsum-p">${safe}</p>`);
para = [];
};
const flushBullets = () => {
if (!bullets.length) return;
const items = bullets.map(b => {
const safe = escapeHtml(b.trim()).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
return `<li>${safe}</li>`;
}).join("");
out.push(`<ul class="rsum-list">${items}</ul>`);
bullets = [];
};
// Granite sometimes runs all bullets onto one line.
const expanded = [];
for (const line of lines) {
if (line.trim().startsWith("- ") && line.includes(" - ", 2)) {
const parts = line.split(/(?:^|(?<=\.\s))\s*-\s+/g).filter(p => p.trim());
for (const p of parts) expanded.push("- " + p.trim());
} else { expanded.push(line); }
}
for (const line of expanded) {
const m = line.match(/^\s*\*\*([A-Z][A-Za-z\s/]+)\.\*\*\s*$/);
if (m) { flushPara(); flushBullets(); out.push(`<h4 class="rsum-h">${escapeHtml(m[1])}</h4>`); }
else if (/^\s*[-*]\s+/.test(line)) { flushPara(); bullets.push(line.replace(/^\s*[-*]\s+/, "")); }
else { flushBullets(); para.push(line); }
}
flushPara(); flushBullets();
return out.join("");
}
function rewriteCitations(html, sourceLabels, indexMap) {
return html.replace(/\[([a-z0-9_]+)\]/gi, (_, id) => {
const norm = id.toLowerCase();
if (indexMap[norm] == null) indexMap[norm] = Object.keys(indexMap).length + 1;
const n = indexMap[norm];
const lab = sourceLabels[norm] || norm;
return `<span class="cite" data-src-id="${norm}" data-src-n="${n}" title="${lab.replace(/"/g, """)} — click to highlight in Sources">${n}</span>`;
});
}
export class Briefing extends SignalWatcher(LitElement) {
static properties = {
text: { type: String },
streaming: { type: Boolean, reflect: true },
sourceLabels: { type: Object },
};
// No shadow DOM — we use the parent's `.report-pane #paragraph` styles
// directly so the markdown renders match the legacy/print idiom.
createRenderRoot() { return this; }
constructor() {
super();
this.text = "";
this.streaming = false;
this.sourceLabels = {};
}
updated(changed) {
if (changed.has("text") && this.text) {
// Bind chip hover/click to the highlight signal post-render.
this._bindChips();
}
}
_bindChips() {
this.querySelectorAll(".cite").forEach(c => {
const id = c.dataset.srcId;
if (!id || c.dataset.signalBound) return;
c.dataset.signalBound = "1";
c.addEventListener("mouseenter", () => highlightedDocId.set(id));
c.addEventListener("click", (e) => {
e.stopPropagation();
const cur = highlightedDocId.get();
highlightedDocId.set(cur === id ? null : id);
});
});
// Apply highlight class reactively from current signal value.
const hl = highlightedDocId.get();
this.querySelectorAll(".cite").forEach(c => {
c.classList.toggle("hl", c.dataset.srcId === hl);
});
}
render() {
if (!this.text) return html`<div class="rsum-p" style="color:var(--text-muted)">Waiting for content…</div>`;
const indexMap = {};
const md = renderMarkdownPure(this.text);
const withCites = rewriteCitations(md, this.sourceLabels, indexMap);
// Push the citation index up to the shared signal so SourcesFooter
// re-renders. Done in render() because indexMap is computed here.
queueMicrotask(() => citeIndex.set({ ...indexMap }));
return html`${unsafeHTML(withCites)}`;
}
}
customElements.define("r-briefing", Briefing);
|