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

28
apps/web/index.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>SIC — Super Incident Commander</title>
<link id="favicon" rel="icon" type="image/png" href="/favicon-dark.png" />
<script>
// Apply persisted theme before paint so the favicon/logo match the saved preference.
(function () {
try {
var stored = window.localStorage.getItem("supr.theme");
var theme = stored === "light" ? "light" : "dark";
document.documentElement.dataset.theme = theme;
var fav = document.getElementById("favicon");
if (fav) fav.href = theme === "light" ? "/favicon-light.png" : "/favicon-dark.png";
} catch (error) {
/* localStorage unavailable, keep defaults */
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
apps/web/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@pi-chat/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@pi-chat/shared": "workspace:*",
"@vitejs/plugin-react": "^4.5.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"vite": "^6.3.5"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"typescript": "^5.8.3"
}
}

BIN
apps/web/public/agent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

85
apps/web/src/DocModal.tsx Normal file
View File

@@ -0,0 +1,85 @@
import { useEffect } from "react";
import { MarkdownView } from "./code-block";
export type KnowledgeDoc = {
id: string;
title: string;
source: string;
tags: string[];
owner?: string;
updated?: string;
headings: string[];
content: string;
};
type DocModalProps = {
doc: KnowledgeDoc;
onClose: () => void;
labels: {
close: string;
tags: string;
owner: string;
updated: string;
};
};
const DocModal = ({ doc, onClose, labels }: DocModalProps) => {
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
aria-label={doc.title}
className="docModalBackdrop"
onClick={onClose}
role="dialog"
>
<article className="docModal" onClick={(event) => event.stopPropagation()}>
<header className="docModalHeader">
<div>
<h2>{doc.title}</h2>
<small>{doc.source}</small>
</div>
<button
aria-label={labels.close}
className="iconToggle"
onClick={onClose}
type="button"
>
</button>
</header>
<dl className="docMeta">
{doc.tags.length > 0 ? (
<div>
<dt>{labels.tags}</dt>
<dd>{doc.tags.join(", ")}</dd>
</div>
) : null}
{doc.owner ? (
<div>
<dt>{labels.owner}</dt>
<dd>{doc.owner}</dd>
</div>
) : null}
{doc.updated ? (
<div>
<dt>{labels.updated}</dt>
<dd>{doc.updated}</dd>
</div>
) : null}
</dl>
<div className="docModalBody">
<MarkdownView source={doc.content} />
</div>
</article>
</div>
);
};
export default DocModal;

View File

@@ -0,0 +1,54 @@
import { Component, type ReactNode } from "react";
type ErrorBoundaryProps = {
children: ReactNode;
};
type ErrorBoundaryState = {
error: Error | null;
};
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: { componentStack?: string }): void {
// Surface to the dev console; production telemetry would hook in here.
// eslint-disable-next-line no-console
console.error("[SIC] uncaught render error", error, info.componentStack);
}
private handleReload = () => {
window.location.reload();
};
private handleReset = () => {
this.setState({ error: null });
};
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="errorBoundary" role="alert">
<div className="errorBoundaryCard">
<strong className="panelHeading">{`Unrecoverable UI error`}</strong>
<p>Something went wrong while rendering SIC. Your sessions and messages are still saved on the server.</p>
<pre className="errorBoundaryMessage">{error.message}</pre>
<div className="errorBoundaryActions">
<button onClick={this.handleReset} type="button">
{`Try again`}
</button>
<button className="primaryAction" onClick={this.handleReload} type="button">
{`Reload page`}
</button>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { authorizedHeaders, authTokenFromStorage, jsonHeaders, api } from "./api";
type PublicWebhook = {
id: string;
label: string;
description?: string;
method: string;
required_roles: string[];
confirmation_required: boolean;
};
type RunResult =
| { kind: "idle" }
| { kind: "running" }
| { kind: "success"; responseStatus: number | null; runId: string }
| { kind: "error"; message: string };
type WebhookFormTabProps = {
webhookId: string;
sessionId: string;
onBack: () => void;
};
const labelsEn = {
title: "Run webhook",
description: "Description",
requiredRoles: "Required roles",
confirmation: "Requires confirmation",
method: "Method",
payload: "Payload (optional JSON)",
payloadHelp: "These fields are merged with the backend template. Available variables: {user}, {session}, {message}.",
run: "Run",
running: "Running...",
resultOk: "Webhook executed",
resultErr: "Failed to execute",
httpStatus: "HTTP",
runId: "Audit ID",
back: "Back to chat",
notFound: "Webhook not found or insufficient permissions",
loading: "Loading webhook...",
user: "User",
session: "Session",
};
const detectLanguage = (): "en" => "en";
const WebhookFormTabInner = ({ webhookId, sessionId, onBack }: WebhookFormTabProps) => {
const [labels] = useState(() => labelsEn);
const [webhook, setWebhook] = useState<PublicWebhook | null>(null);
const [payload, setPayload] = useState("{}");
const [result, setResult] = useState<RunResult>({ kind: "idle" });
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const data = await api<{ items: PublicWebhook[] }>("/api/webhooks");
const found = data.items.find((item) => item.id === webhookId);
if (!found) {
setError(labels.notFound);
return;
}
setWebhook(found);
} catch (err) {
console.error(err);
setError(labels.notFound);
}
})();
}, [webhookId, labels.notFound]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!webhook) return;
if (webhook.confirmation_required) {
const ok = window.confirm(`Run ${webhook.label}?`);
if (!ok) return;
}
let parsed: Record<string, unknown> = {};
if (payload.trim().length > 0) {
try {
const value = JSON.parse(payload);
if (value && typeof value === "object" && !Array.isArray(value)) {
parsed = value as Record<string, unknown>;
}
} catch {
setResult({ kind: "error", message: "Payload is not valid JSON" });
return;
}
}
setResult({ kind: "running" });
try {
const response = await fetch(`/api/webhooks/${webhook.id}/run`, {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({
sessionId,
confirmed: true,
lastUserMessage: undefined,
payload: parsed,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`http_${response.status}: ${detail.slice(0, 200)}`);
}
const body = (await response.json()) as { id: string; response_status: number | null };
setResult({ kind: "success", responseStatus: body.response_status, runId: body.id });
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : "error";
setResult({ kind: "error", message });
}
};
const tokenInfo = useMemo(() => {
const t = authTokenFromStorage();
return t ? `${t.slice(0, 12)}` : labels.notFound;
}, [labels.notFound]);
if (error) {
return (
<main className="formTab error">
<h1>{labels.title}</h1>
<p className="muted">{error}</p>
<button type="button" onClick={onBack}>{labels.back}</button>
</main>
);
}
if (!webhook) {
return (
<main className="formTab loading">
<h1>{labels.title}</h1>
<p className="muted">{labels.loading}</p>
</main>
);
}
return (
<main className="formTab">
<header className="formTabHeader">
<div>
<small>SIC</small>
<h1>{webhook.label}</h1>
</div>
<button type="button" onClick={onBack}>{labels.back}</button>
</header>
<dl className="formTabMeta">
{webhook.description ? (
<div>
<dt>{labels.description}</dt>
<dd>{webhook.description}</dd>
</div>
) : null}
<div>
<dt>{labels.method}</dt>
<dd><code>{webhook.method}</code></dd>
</div>
<div>
<dt>{labels.requiredRoles}</dt>
<dd>{webhook.required_roles.join(", ")}</dd>
</div>
<div>
<dt>{labels.confirmation}</dt>
<dd>{webhook.confirmation_required ? "Yes" : "No"}</dd>
</div>
<div>
<dt>{labels.session}</dt>
<dd><code>{sessionId.slice(0, 8)}</code></dd>
</div>
<div>
<dt>{labels.user}</dt>
<dd><code>{tokenInfo}</code></dd>
</div>
</dl>
<form onSubmit={submit} className="formTabForm">
<label>
<span>{labels.payload}</span>
<textarea
onChange={(e) => setPayload(e.target.value)}
rows={8}
spellCheck={false}
value={payload}
/>
</label>
<small className="muted">{labels.payloadHelp}</small>
<button
className="formTabRun"
disabled={result.kind === "running"}
type="submit"
>
{result.kind === "running" ? labels.running : labels.run}
</button>
</form>
{result.kind === "success" ? (
<section className="formTabResult success">
<strong>{labels.resultOk}</strong>
<small>{labels.httpStatus}: {result.responseStatus ?? "—"}</small>
<small>{labels.runId}: <code>{result.runId}</code></small>
</section>
) : null}
{result.kind === "error" ? (
<section className="formTabResult error">
<strong>{labels.resultErr}</strong>
<small>{result.message}</small>
</section>
) : null}
</main>
);
};
// Read query params helper for the main App.
export const getWebhookFormTabParams = () => {
const params = new URLSearchParams(window.location.search);
const webhook = params.get("webhook");
const session = params.get("session");
if (!webhook || !session) return null;
return { webhookId: webhook, sessionId: session };
};
const WebhookFormTab = WebhookFormTabInner;
export default WebhookFormTab;
export type { WebhookFormTabProps };

93
apps/web/src/api.ts Normal file
View File

@@ -0,0 +1,93 @@
export const AUTH_TOKEN_STORAGE_KEY = "pi-chat.authToken";
export const authTokenFromStorage = () => {
const stored = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)?.trim();
const configured = import.meta.env.VITE_AUTH_TOKEN?.trim();
return stored || configured || null;
};
// Only adds the Authorization header. Does NOT set content-type so callers
// that send a body without content-type don't get rejected by Fastify
// (DELETE/PATCH with content-type: application/json and an empty body 400s).
export const authorizedHeaders = (headers?: HeadersInit) => {
const result = new Headers(headers);
const token = authTokenFromStorage();
if (token && !result.has("authorization")) {
const authorization = token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
result.set("authorization", authorization);
}
return result;
};
// Convenience for requests that send a JSON body.
export const jsonHeaders = (headers?: HeadersInit) => {
const result = authorizedHeaders(headers);
if (!result.has("content-type")) {
result.set("content-type", "application/json");
}
return result;
};
export const api = async <T,>(path: string, init?: RequestInit): Promise<T> => {
// Only set content-type when there's actually a body. DELETE / PATCH /
// GET through this helper without an explicit body must NOT trigger the
// "Body cannot be empty when content-type is set to 'application/json'"
// 400 in Fastify. This makes `api()` safe for any verb.
const hasBody = init?.body !== undefined && init?.body !== null;
const headers = hasBody ? jsonHeaders(init?.headers) : authorizedHeaders(init?.headers);
const response = await fetch(path, { ...init, headers });
if (!response.ok) {
throw new Error(`api_error:${response.status}`);
}
// 204 No Content has no body to parse; don't blow up trying.
if (response.status === 204) return undefined as T;
// Some servers return 200 with empty body; guard the JSON parse too.
const text = await response.text();
if (text.length === 0) return undefined as T;
return JSON.parse(text) as T;
};
export const parseMetadata = (metadata: string | null) => {
if (!metadata) return null;
try {
return JSON.parse(metadata) as {
docs?: unknown[];
actions?: Array<{ id: string }>;
model?: string;
usage?: {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cachedTokens?: number;
durationMs?: number;
};
};
} catch {
return null;
}
};
export const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
};
export const formatNumber = (n: number): string => {
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`;
return String(n);
};
export const temporaryId = () => {
const randomUUID = window.crypto?.randomUUID?.bind(window.crypto);
if (randomUUID) return randomUUID();
return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
export const formatScore = (value: unknown) =>
typeof value === "number" ? value.toFixed(2) : "s/d";

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 };

1710
apps/web/src/main.tsx Normal file

File diff suppressed because it is too large Load Diff

1184
apps/web/src/styles.css Normal file

File diff suppressed because it is too large Load Diff

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

8
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["src", "vite.config.ts"]
}

14
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: Number(process.env.WEB_PORT ?? 3000),
proxy: {
"/api": "http://localhost:8787",
"/healthz": "http://localhost:8787",
"/readyz": "http://localhost:8787",
},
},
});