- 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
268 lines
7.5 KiB
TypeScript
268 lines
7.5 KiB
TypeScript
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 };
|