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:
28
apps/web/index.html
Normal file
28
apps/web/index.html
Normal 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
25
apps/web/package.json
Normal 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
BIN
apps/web/public/agent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
apps/web/public/favicon-dark.png
Normal file
BIN
apps/web/public/favicon-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/web/public/favicon-light.png
Normal file
BIN
apps/web/public/favicon-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 832 KiB |
BIN
apps/web/public/logo-dark.png
Normal file
BIN
apps/web/public/logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 892 KiB |
BIN
apps/web/public/logo-light.png
Normal file
BIN
apps/web/public/logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 701 KiB |
85
apps/web/src/DocModal.tsx
Normal file
85
apps/web/src/DocModal.tsx
Normal 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;
|
||||
54
apps/web/src/ErrorBoundary.tsx
Normal file
54
apps/web/src/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
231
apps/web/src/WebhookFormTab.tsx
Normal file
231
apps/web/src/WebhookFormTab.tsx
Normal 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
93
apps/web/src/api.ts
Normal 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
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 };
|
||||
1710
apps/web/src/main.tsx
Normal file
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
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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
8
apps/web/tsconfig.json
Normal file
8
apps/web/tsconfig.json
Normal 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
14
apps/web/vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user