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:
2026-06-29 16:20:53 +02:00
commit 62728b2200
89 changed files with 11992 additions and 0 deletions

267
apps/web/src/code-block.tsx Normal file
View 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 };