ai: rich-output blocks via lazy-fetched typed-fence protocol
Assistant replies can now emit typed fenced blocks that render as @crema/*-ui components inline at their position in the reply. - message-body.tsx: segmented rendering — alternating prose chunks and block dispatch (was: all blocks appended at end). Renderers for kpi, table, chart-bar/-line/-donut/-spark, code, diff, flowchart, orgchart, steps, checklist, welcome, hint, plus the legacy card kinds. - block-schemas.ts: single source of truth — BLOCK_INDEX (one-line purpose per kind, always in prompt) + SCHEMAS (full JSON shape + example, fetched on demand). - admin-tools.ts: new get_block_schema(kind) tool the model calls once per kind per thread to fetch the exact schema. Keeps the always-on prompt small (~110 tokens vs ~400 inline). - assistant.tsx: replaces the inline schema dump with the generated thin index. - ai.tsx: empty-state preview button injects a synthetic assistant message exercising every block, for renderer/theme smoke-testing. - console.css + ai.tsx: shrink ATLAS headline so it doesn't slip under the composer with the added preview button. - tsconfig.json + app.css: wire lib-data-ui, lib-code-ui, lib-diagram-ui, lib-onboarding-ui as siblings. Adding a new block kind = add the lib paths, add a renderer case, add a schema entry. No prompt edits required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
// Renders an assistant message: GFM markdown for prose, custom ```card```
|
||||
// blocks rendered as rich UI (status pills, tenant cards, KPIs), pills for
|
||||
// command-bus action blocks, and tool-result cards (role: "tool").
|
||||
// Renders an assistant message: GFM markdown for prose, plus typed fenced
|
||||
// code blocks rendered as rich UI from `@crema/*-ui` libs (charts, tables,
|
||||
// KPIs, code, diffs, status pills, callouts), pills for command-bus action
|
||||
// blocks, and tool-result cards (role: "tool").
|
||||
//
|
||||
// Typed blocks recognized (each is a fenced ```<kind>\n<json>\n``` block):
|
||||
// action — command-bus DSL (handled by extractActionBlocks; replaced
|
||||
// with a "Ran N actions" pill)
|
||||
// card — { kind: "pill" | "stat" | "callout", ... } (legacy)
|
||||
// chart-spark — { values: number[], stroke?, fill? }
|
||||
// chart-bar — { data: [{ label, value, color? }] }
|
||||
// chart-line — { series: [{ x, y }] }
|
||||
// chart-donut — { data: [{ label, value, color? }] }
|
||||
// table — { columns: [{ id, header, accessor? }], rows: [...] }
|
||||
// kpi — { items: [{ label, value, unit? }] }
|
||||
// code — { code, language?, title?, lineNumbers? }
|
||||
// diff — { oldCode, newCode, language?, title? }
|
||||
|
||||
import { type ReactNode, useMemo } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
@@ -9,9 +23,33 @@ import { Sparkles, Wrench } from "lucide-react"
|
||||
|
||||
import { extractActionBlocks } from "@crema/action-bus"
|
||||
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
|
||||
import {
|
||||
Sparkline,
|
||||
BarChart,
|
||||
LineChart,
|
||||
Donut,
|
||||
type ChartDatum,
|
||||
type SeriesPoint,
|
||||
} from "@crema/chart-ui"
|
||||
import { DataTable, type Column } from "@crema/table-ui"
|
||||
import { KPIRow } from "@crema/data-ui"
|
||||
import { CodeBlock, DiffViewer } from "@crema/code-ui"
|
||||
import { FlowChart, OrgChart } from "@crema/diagram-ui"
|
||||
import { StepTrail, type AgentStep } from "@crema/agent-ui"
|
||||
import {
|
||||
OnboardingChecklist,
|
||||
WelcomeCard,
|
||||
HintCard,
|
||||
type ChecklistTask,
|
||||
type OnboardingTone,
|
||||
} from "@crema/onboarding-ui"
|
||||
|
||||
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
||||
const CARD_BLOCK_RE = /```card\s*\n([\s\S]*?)```/g
|
||||
|
||||
// Captures the kind tag and the body of every fenced block we render as UI.
|
||||
// Kept alongside the markdown ones — react-markdown ignores anything we strip.
|
||||
const TYPED_BLOCK_RE =
|
||||
/```(card|chart-spark|chart-bar|chart-line|chart-donut|table|kpi|code|diff|flowchart|orgchart|steps|checklist|welcome|hint)\s*\n([\s\S]*?)```/g
|
||||
|
||||
export type MessageBodyProps = {
|
||||
content: string
|
||||
@@ -19,33 +57,231 @@ export type MessageBodyProps = {
|
||||
toolCalls?: ToolCall[]
|
||||
}
|
||||
|
||||
type Segment =
|
||||
| { type: "prose"; text: string }
|
||||
| { type: "block"; kind: string; spec: unknown; raw: string }
|
||||
|
||||
function parseSegments(content: string): Segment[] {
|
||||
const segments: Segment[] = []
|
||||
TYPED_BLOCK_RE.lastIndex = 0
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = TYPED_BLOCK_RE.exec(content)) !== null) {
|
||||
const [raw, kind, body] = match
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: "prose", text: content.slice(lastIndex, match.index) })
|
||||
}
|
||||
let spec: unknown = null
|
||||
try {
|
||||
spec = JSON.parse(body.trim())
|
||||
} catch {
|
||||
// malformed → emit the raw fence as prose so the user sees the model output
|
||||
segments.push({ type: "prose", text: raw })
|
||||
lastIndex = match.index + raw.length
|
||||
continue
|
||||
}
|
||||
segments.push({ type: "block", kind, spec, raw })
|
||||
lastIndex = match.index + raw.length
|
||||
}
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({ type: "prose", text: content.slice(lastIndex) })
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
function renderBlock(kind: string, spec: any, key: number): ReactNode {
|
||||
switch (kind) {
|
||||
case "card":
|
||||
return <CardBlock key={key} spec={spec} />
|
||||
case "chart-spark":
|
||||
return (
|
||||
<div key={key} className="my-2 inline-block text-primary">
|
||||
<Sparkline
|
||||
values={spec.values ?? []}
|
||||
width={spec.width ?? 240}
|
||||
height={spec.height ?? 48}
|
||||
stroke={spec.stroke ?? "currentColor"}
|
||||
fill={spec.fill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "chart-bar":
|
||||
return (
|
||||
<ChartFrame key={key} title={spec.title}>
|
||||
<div className="text-primary">
|
||||
<BarChart data={(spec.data ?? []) as ChartDatum[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||
</div>
|
||||
<ChartLegend data={spec.data} />
|
||||
</ChartFrame>
|
||||
)
|
||||
case "chart-line":
|
||||
return (
|
||||
<ChartFrame key={key} title={spec.title}>
|
||||
<div className="text-primary">
|
||||
<LineChart series={(spec.series ?? []) as SeriesPoint[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||
</div>
|
||||
</ChartFrame>
|
||||
)
|
||||
case "chart-donut":
|
||||
return (
|
||||
<ChartFrame key={key} title={spec.title}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-primary">
|
||||
<Donut data={(spec.data ?? []) as ChartDatum[]} size={spec.size ?? 140} thickness={spec.thickness ?? 20} />
|
||||
</div>
|
||||
<ChartLegend data={spec.data} />
|
||||
</div>
|
||||
</ChartFrame>
|
||||
)
|
||||
case "table":
|
||||
return <TableBlock key={key} spec={spec} />
|
||||
case "kpi":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<KPIRow items={spec.items ?? []} />
|
||||
</div>
|
||||
)
|
||||
case "code":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<CodeBlock
|
||||
code={spec.code ?? ""}
|
||||
language={spec.language}
|
||||
title={spec.title}
|
||||
showLineNumbers={spec.lineNumbers ?? false}
|
||||
highlightLines={spec.highlightLines}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "diff":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<DiffViewer
|
||||
oldCode={spec.oldCode ?? ""}
|
||||
newCode={spec.newCode ?? ""}
|
||||
language={spec.language}
|
||||
title={spec.title}
|
||||
mode={spec.mode ?? "unified"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "flowchart":
|
||||
return (
|
||||
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||
<FlowChart nodes={spec.nodes ?? []} edges={spec.edges ?? []} />
|
||||
</div>
|
||||
)
|
||||
case "orgchart":
|
||||
return (
|
||||
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||
<OrgChart data={spec.data} horizontal={spec.horizontal} />
|
||||
</div>
|
||||
)
|
||||
case "steps":
|
||||
return (
|
||||
<div key={key} className="my-3 rounded-lg border bg-card/50 p-3">
|
||||
<StepTrail steps={(spec.steps ?? []) as AgentStep[]} />
|
||||
</div>
|
||||
)
|
||||
case "checklist":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<OnboardingChecklist
|
||||
title={spec.title}
|
||||
description={spec.description}
|
||||
tasks={(spec.tasks ?? []) as ChecklistTask[]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "welcome":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<WelcomeCard
|
||||
title={spec.title}
|
||||
description={spec.description}
|
||||
badge={spec.badge}
|
||||
primaryAction={spec.primaryAction}
|
||||
secondaryAction={spec.secondaryAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "hint":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<HintCard
|
||||
title={spec.title}
|
||||
tone={(spec.tone ?? "info") as OnboardingTone}
|
||||
action={spec.action}
|
||||
>
|
||||
{spec.body ?? ""}
|
||||
</HintCard>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<pre key={key} className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||
{JSON.stringify(spec, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function ChartFrame({ title, children }: { title?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="my-3 rounded-lg border bg-card/50 p-3">
|
||||
{title && <div className="mb-2 text-xs font-medium text-muted-foreground">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartLegend({ data }: { data?: ChartDatum[] }) {
|
||||
if (!data || data.length === 0) return null
|
||||
return (
|
||||
<ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{data.map((d) => (
|
||||
<li key={d.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2 rounded-sm"
|
||||
style={{ background: d.color ?? "currentColor" }}
|
||||
/>
|
||||
<span>{d.label}</span>
|
||||
<span className="tabular-nums text-foreground/70">{d.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBlock({ spec }: { spec: any }) {
|
||||
const rows: Record<string, unknown>[] = Array.isArray(spec.rows) ? spec.rows : []
|
||||
const columns: Column<Record<string, unknown>>[] = (spec.columns ?? []).map((c: any) => ({
|
||||
id: c.id,
|
||||
header: c.header ?? c.id,
|
||||
accessor: c.accessor ?? c.id,
|
||||
sortable: c.sortable ?? true,
|
||||
align: c.align,
|
||||
}))
|
||||
const idKey = spec.idKey ?? columns[0]?.id ?? "id"
|
||||
return (
|
||||
<div className="my-3 overflow-x-auto rounded-lg border bg-card/50">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(r) => String(r[idKey] ?? Math.random())}
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CardSpec =
|
||||
| { kind: "pill"; status: string; label?: string }
|
||||
| { kind: "stat"; label: string; value: string | number; tone?: string }
|
||||
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||
| { kind: string; [k: string]: unknown }
|
||||
|
||||
function parseCardBlocks(content: string): { blocks: CardSpec[]; stripped: string } {
|
||||
const blocks: CardSpec[] = []
|
||||
CARD_BLOCK_RE.lastIndex = 0
|
||||
const stripped = content.replace(CARD_BLOCK_RE, (_, body: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(body.trim()) as CardSpec
|
||||
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string") {
|
||||
blocks.push(parsed)
|
||||
return "" // strip from prose
|
||||
}
|
||||
} catch {
|
||||
// malformed — leave the original block in the prose so the user can see
|
||||
// what the model tried to emit.
|
||||
return _
|
||||
}
|
||||
return _
|
||||
})
|
||||
return { blocks, stripped }
|
||||
}
|
||||
|
||||
function renderCard(spec: CardSpec): ReactNode {
|
||||
function CardBlock({ spec }: { spec: CardSpec }) {
|
||||
switch (spec.kind) {
|
||||
case "pill": {
|
||||
const s = spec as { kind: "pill"; status: string; label?: string }
|
||||
@@ -58,9 +294,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
? "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300"
|
||||
: "border-border bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}
|
||||
>
|
||||
<span className={`my-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>
|
||||
{s.label ?? s.status}
|
||||
</span>
|
||||
)
|
||||
@@ -68,19 +302,14 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
case "stat": {
|
||||
const s = spec as { kind: "stat"; label: string; value: string | number }
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
||||
<span className="my-1 inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
||||
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||
<span className="font-semibold tabular-nums">{s.value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case "callout": {
|
||||
const s = spec as {
|
||||
kind: "callout"
|
||||
title?: string
|
||||
tone?: "info" | "warning" | "danger" | "success"
|
||||
body?: string
|
||||
}
|
||||
const s = spec as { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||
const tone = s.tone ?? "info"
|
||||
const palette: Record<string, string> = {
|
||||
info: "border-sky-500/40 bg-sky-500/10",
|
||||
@@ -89,7 +318,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
success: "border-emerald-500/40 bg-emerald-500/10",
|
||||
}
|
||||
return (
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
||||
<div className={`my-2 rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
||||
{s.title && <div className="mb-1 font-medium">{s.title}</div>}
|
||||
{s.body && <div className="text-muted-foreground">{s.body}</div>}
|
||||
</div>
|
||||
@@ -97,24 +326,67 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<pre className="rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||
<pre className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||
{JSON.stringify(spec, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const PROSE_COMPONENTS = {
|
||||
p: ({ children }: any) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
||||
code: ({ children, className }: any) => {
|
||||
const isBlock = className?.startsWith("language-")
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||
<code className="font-mono">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
},
|
||||
ul: ({ children }: any) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children }: any) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
||||
li: ({ children }: any) => <li className="my-0.5">{children}</li>,
|
||||
a: ({ children, href }: any) => (
|
||||
<a href={href} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }: any) => (
|
||||
<div className="my-2 overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: any) => <thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>,
|
||||
th: ({ children }: any) => <th className="px-3 py-2 text-left font-medium">{children}</th>,
|
||||
td: ({ children }: any) => <td className="border-t px-3 py-2">{children}</td>,
|
||||
input: ({ checked, type, ...rest }: any) =>
|
||||
type === "checkbox" ? (
|
||||
<input type="checkbox" checked={!!checked} readOnly {...rest} className="mr-1.5 align-middle" />
|
||||
) : (
|
||||
<input type={type} {...rest} />
|
||||
),
|
||||
}
|
||||
|
||||
function ProseChunk({ text }: { text: string }) {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={PROSE_COMPONENTS}>
|
||||
{trimmed}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
|
||||
const { prose, actionCount, cardBlocks } = useMemo(() => {
|
||||
const { segments, actionCount } = useMemo(() => {
|
||||
const blocks = extractActionBlocks(content)
|
||||
const cleaned = stripToolCallTags(content)
|
||||
.replace(ACTION_BLOCK_RE, "")
|
||||
.trim()
|
||||
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
|
||||
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "")
|
||||
return {
|
||||
prose: stripped.trim(),
|
||||
segments: parseSegments(cleaned),
|
||||
actionCount: blocks.length,
|
||||
cardBlocks,
|
||||
}
|
||||
}, [content])
|
||||
|
||||
@@ -134,76 +406,8 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{prose && (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.startsWith("language-")
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||
<code className="font-mono">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline underline-offset-2"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-2 overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }) => <td className="border-t px-3 py-2">{children}</td>,
|
||||
input: ({ checked, type, ...rest }) =>
|
||||
type === "checkbox" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked}
|
||||
readOnly
|
||||
{...rest}
|
||||
className="mr-1.5 align-middle"
|
||||
/>
|
||||
) : (
|
||||
<input type={type} {...rest} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{prose}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{cardBlocks.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{cardBlocks.map((spec, i) => (
|
||||
<span key={i} className={spec.kind === "callout" ? "block w-full" : ""}>
|
||||
{renderCard(spec)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{segments.map((seg, i) =>
|
||||
seg.type === "prose" ? <ProseChunk key={i} text={seg.text} /> : renderBlock(seg.kind, seg.spec, i),
|
||||
)}
|
||||
{actionCount > 0 && (
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user