49171e841f
Rebuilds the Compliance Advisor floating widget from a plain chat into an Evidence
Workspace: pinned last question, markdown-rendered answer (clean prose), and separate
panes for Sources (hierarchical Knowledge Units), Figures (C8, conditional) and
Footnotes (C-FN), plus a stats bar (Quellen/Regelwerke/Diagramme/Fußnoten). Scrollable
turn history; stays a floating icon on every SDK page.
Architecture (user direction): the frontend renders ONLY structured evidence and NEVER
parses the answer text. The proxy now returns a JSON AdvisorEvidenceMeta line followed
by the streamed markdown answer; advisor-rag exposes structured results; an adapter maps
RAG/compiler output to the frontend envelope. Figures/footnotes wire in once the
RAG-ingestion contract lands (requested on the board) — figures pane is conditional.
- lib/sdk/advisor/{evidence,evidence-adapter}.ts (+ adapter test, 7 cases)
- components/sdk/advisor/* panes + in-house safe Markdown (no new dep, no dangerouslySetInnerHTML) + test
- useAdvisorStream (meta-line parse + streamed answer) + useAdvisorEmail (escaped)
- proxy: evidence-meta-v1 envelope + clean-prose prompt (no inline citations)
- tsc clean, 11 vitest pass, check-loc 0. ESLint not installed in this node_modules -> CI lints on push.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
154 lines
4.6 KiB
TypeScript
154 lines
4.6 KiB
TypeScript
'use client'
|
|
|
|
// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency.
|
|
// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links.
|
|
// (The Evidence Workspace renders citations in a separate pane, so links are rarely needed.)
|
|
|
|
const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\))/g
|
|
|
|
function renderInline(text: string, kp: string): React.ReactNode[] {
|
|
const nodes: React.ReactNode[] = []
|
|
let last = 0
|
|
let idx = 0
|
|
INLINE_RE.lastIndex = 0
|
|
let m: RegExpExecArray | null
|
|
while ((m = INLINE_RE.exec(text)) !== null) {
|
|
if (m.index > last) nodes.push(text.slice(last, m.index))
|
|
const tok = m[0]
|
|
const key = `${kp}-${idx++}`
|
|
if (tok.startsWith('`')) {
|
|
nodes.push(
|
|
<code key={key} className="rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.85em]">
|
|
{tok.slice(1, -1)}
|
|
</code>,
|
|
)
|
|
} else if (tok.startsWith('**')) {
|
|
nodes.push(
|
|
<strong key={key} className="font-semibold text-gray-900">
|
|
{tok.slice(2, -2)}
|
|
</strong>,
|
|
)
|
|
} else if (tok.startsWith('*') || tok.startsWith('_')) {
|
|
nodes.push(<em key={key}>{tok.slice(1, -1)}</em>)
|
|
} else {
|
|
const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
|
|
if (mm && /^https?:\/\//i.test(mm[2])) {
|
|
nodes.push(
|
|
<a
|
|
key={key}
|
|
href={mm[2]}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-indigo-600 underline hover:text-indigo-800"
|
|
>
|
|
{mm[1]}
|
|
</a>,
|
|
)
|
|
} else {
|
|
nodes.push(mm ? mm[1] : tok)
|
|
}
|
|
}
|
|
last = m.index + tok.length
|
|
}
|
|
if (last < text.length) nodes.push(text.slice(last))
|
|
return nodes
|
|
}
|
|
|
|
function Heading({ level, kp, text }: { level: number; kp: string; text: string }) {
|
|
const children = renderInline(text, kp)
|
|
if (level <= 1) return <h3 className="mb-1 mt-3 text-base font-bold text-gray-900">{children}</h3>
|
|
if (level === 2) return <h4 className="mb-1 mt-3 text-sm font-bold text-gray-900">{children}</h4>
|
|
return <h5 className="mb-1 mt-2 text-sm font-semibold text-gray-800">{children}</h5>
|
|
}
|
|
|
|
const UL_RE = /^\s*[-*]\s+/
|
|
const OL_RE = /^\s*\d+\.\s+/
|
|
const H_RE = /^(#{1,6})\s+(.*)$/
|
|
|
|
export function Markdown({ content }: { content: string }) {
|
|
const lines = (content || '').replace(/\r\n/g, '\n').split('\n')
|
|
const blocks: React.ReactNode[] = []
|
|
let i = 0
|
|
while (i < lines.length) {
|
|
const line = lines[i]
|
|
const key = `b${blocks.length}` // unique per pushed block (blocks.length is the next index)
|
|
|
|
if (line.trim().startsWith('```')) {
|
|
const buf: string[] = []
|
|
i++
|
|
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
|
buf.push(lines[i])
|
|
i++
|
|
}
|
|
i++
|
|
blocks.push(
|
|
<pre
|
|
key={key}
|
|
className="my-2 overflow-x-auto rounded bg-gray-900 p-3 font-mono text-xs text-gray-100"
|
|
>
|
|
<code>{buf.join('\n')}</code>
|
|
</pre>,
|
|
)
|
|
continue
|
|
}
|
|
if (line.trim() === '') {
|
|
i++
|
|
continue
|
|
}
|
|
const h = H_RE.exec(line)
|
|
if (h) {
|
|
blocks.push(<Heading key={key} kp={key} level={h[1].length} text={h[2]} />)
|
|
i++
|
|
continue
|
|
}
|
|
if (UL_RE.test(line)) {
|
|
const items: string[] = []
|
|
while (i < lines.length && UL_RE.test(lines[i])) {
|
|
items.push(lines[i].replace(UL_RE, ''))
|
|
i++
|
|
}
|
|
blocks.push(
|
|
<ul key={key} className="my-1.5 ml-4 list-disc space-y-1 text-gray-700">
|
|
{items.map((it, k) => (
|
|
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
|
|
))}
|
|
</ul>,
|
|
)
|
|
continue
|
|
}
|
|
if (OL_RE.test(line)) {
|
|
const items: string[] = []
|
|
while (i < lines.length && OL_RE.test(lines[i])) {
|
|
items.push(lines[i].replace(OL_RE, ''))
|
|
i++
|
|
}
|
|
blocks.push(
|
|
<ol key={key} className="my-1.5 ml-5 list-decimal space-y-1 text-gray-700">
|
|
{items.map((it, k) => (
|
|
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
|
|
))}
|
|
</ol>,
|
|
)
|
|
continue
|
|
}
|
|
const para: string[] = []
|
|
while (
|
|
i < lines.length &&
|
|
lines[i].trim() !== '' &&
|
|
!H_RE.test(lines[i]) &&
|
|
!UL_RE.test(lines[i]) &&
|
|
!OL_RE.test(lines[i]) &&
|
|
!lines[i].trim().startsWith('```')
|
|
) {
|
|
para.push(lines[i])
|
|
i++
|
|
}
|
|
blocks.push(
|
|
<p key={key} className="my-1.5 leading-relaxed text-gray-700">
|
|
{renderInline(para.join(' '), key)}
|
|
</p>,
|
|
)
|
|
}
|
|
return <div className="advisor-markdown text-sm">{blocks}</div>
|
|
}
|