Initial commit: SIC harness (backend, web, pi-adapter, configs, docs)
- pnpm monorepo: apps/api (Fastify + SQLite + SSE), apps/web (React+Vite), packages/shared, packages/pi-adapter - Local auth (admin/webhook-runner roles) + Keycloak JWT ready - Multi-session chat with reliable history (user persisted before LLM, assistant persisted after stream) - Markdown knowledge base with /api/docs/search + /api/docs/:id - YAML webhook catalog with backend-only execution, retry/backoff, audit (webhook_runs), and per-user rate limit - Skills config (sre-on-call, blameless-postmortem, security-incident) injected into LLM system prompt - LLM provider failover chain (config/models.yml fallback + LLM_FALLBACK_CHAIN override) - Context-aware webhooks panel + backend id-mention safety net - Per-message stats (time/duration/tokens/model), Markdown+GFM render, code & table copy/download buttons - Vitest suite, end-to-end smoke test (scripts/smoke.mjs), per-session system prompt override - /metrics Prometheus endpoint + /api/metrics JSON, request-id correlation - dotenv with explicit repo-root path; envString/envNumber helpers (handles empty-string env) - Runbooks + SOPs under knowledge/ in English; README, docs, and INDEX.md in English
This commit is contained in:
267
apps/web/src/code-block.tsx
Normal file
267
apps/web/src/code-block.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
const copyIcon = (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const downloadIcon = (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 4v12" />
|
||||
<path d="M6 12l6 6 6-6" />
|
||||
<path d="M4 20h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const checkIcon = (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const writeToClipboard = async (text: string): Promise<boolean> => {
|
||||
if (!text) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback for non-secure contexts.
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
let ok = false;
|
||||
try {
|
||||
ok = document.execCommand("copy");
|
||||
} catch {
|
||||
ok = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
};
|
||||
|
||||
const CodeBlock = ({ children }: { children?: ReactNode }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeRef = useRef<HTMLElement | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = codeRef.current?.innerText ?? "";
|
||||
await writeToClipboard(text);
|
||||
setCopied(true);
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const captureCodeRef = (element: HTMLElement | null) => {
|
||||
codeRef.current = element?.querySelector("code") ?? null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="codeBlock">
|
||||
<button
|
||||
aria-label={copied ? "Copied" : "Copy code"}
|
||||
className={`codeCopy${copied ? " copied" : ""}`}
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied" : "Copy code"}
|
||||
type="button"
|
||||
>
|
||||
{copied ? checkIcon : copyIcon}
|
||||
</button>
|
||||
<pre ref={captureCodeRef}>{children}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TableCell = { text: string; isHeader: boolean };
|
||||
|
||||
const readTable = (table: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
||||
const rows = Array.from(table.querySelectorAll("tr"));
|
||||
const matrix: TableCell[][] = rows.map((row) =>
|
||||
Array.from(row.querySelectorAll("th,td")).map((cell) => ({
|
||||
text: (cell.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
isHeader: cell.tagName.toLowerCase() === "th",
|
||||
})),
|
||||
);
|
||||
|
||||
if (matrix.length === 0) return { headers: [], rows: [] };
|
||||
|
||||
// If the first row is a header row, split it; otherwise synthesize generic headers.
|
||||
const firstRow = matrix[0] ?? [];
|
||||
const hasHeader = firstRow.length > 0 && firstRow.every((cell) => cell.isHeader);
|
||||
const headers = hasHeader
|
||||
? firstRow.map((cell) => cell.text)
|
||||
: (matrix[0] ?? []).map((_, index) => `Column ${index + 1}`);
|
||||
const dataRows = hasHeader ? matrix.slice(1) : matrix;
|
||||
|
||||
return {
|
||||
headers,
|
||||
rows: dataRows.map((row) => row.map((cell) => cell.text)),
|
||||
};
|
||||
};
|
||||
|
||||
const toTsv = (headers: string[], rows: string[][]): string => {
|
||||
const escape = (value: string) => value.replace(/\t/g, " ").replace(/\n/g, " ");
|
||||
return [headers, ...rows].map((row) => row.map(escape).join("\t")).join("\n");
|
||||
};
|
||||
|
||||
const toCsv = (headers: string[], rows: string[][]): string => {
|
||||
// RFC 4180: wrap fields containing comma, quote, or newline in double quotes;
|
||||
// escape internal double quotes by doubling them.
|
||||
const escape = (value: string) => {
|
||||
if (/[",\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return [headers, ...rows].map((row) => row.map(escape).join(",")).join("\r\n");
|
||||
};
|
||||
|
||||
const downloadFile = (filename: string, content: string, mime: string) => {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.rel = "noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// Defer revoke so the download starts cleanly.
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const TableBlock = ({ children }: { children?: ReactNode }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const tableRef = useRef<HTMLTableElement | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flashCopied = () => {
|
||||
setCopied(true);
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!tableRef.current) return;
|
||||
const { headers, rows } = readTable(tableRef.current);
|
||||
await writeToClipboard(toTsv(headers, rows));
|
||||
flashCopied();
|
||||
};
|
||||
|
||||
const handleDownloadCsv = () => {
|
||||
if (!tableRef.current) return;
|
||||
const { headers, rows } = readTable(tableRef.current);
|
||||
const filename = `table-${new Date().toISOString().replace(/[:.]/g, "-")}.csv`;
|
||||
// Prefix with UTF-8 BOM so Excel opens it correctly.
|
||||
downloadFile(filename, `\uFEFF${toCsv(headers, rows)}`, "text/csv;charset=utf-8");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tableBlock">
|
||||
<div className="tableBlockActions">
|
||||
<button
|
||||
aria-label="Download table as CSV"
|
||||
className="tableDownload"
|
||||
onClick={handleDownloadCsv}
|
||||
title="Download as CSV"
|
||||
type="button"
|
||||
>
|
||||
{downloadIcon}
|
||||
</button>
|
||||
<button
|
||||
aria-label={copied ? "Copied" : "Copy table as TSV"}
|
||||
className={`tableCopy${copied ? " copied" : ""}`}
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied" : "Copy as TSV"}
|
||||
type="button"
|
||||
>
|
||||
{copied ? checkIcon : copyIcon}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tableScroll">
|
||||
<table ref={tableRef}>{children}</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeMarkdown = (value: string) =>
|
||||
value.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
|
||||
|
||||
export const markdownComponents = {
|
||||
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
|
||||
<a href={href} rel="noreferrer" target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
pre: ({ children }: { children?: ReactNode }) => <CodeBlock>{children}</CodeBlock>,
|
||||
table: ({ children }: { children?: ReactNode }) => <TableBlock>{children}</TableBlock>,
|
||||
};
|
||||
|
||||
export const MarkdownView = ({ source }: { source: string }) => (
|
||||
<Markdown components={markdownComponents} remarkPlugins={[remarkGfm]} skipHtml>
|
||||
{normalizeMarkdown(source)}
|
||||
</Markdown>
|
||||
);
|
||||
|
||||
export { CodeBlock };
|
||||
Reference in New Issue
Block a user