feat: surface AI analysis duration across gateway, backend, and FE

Adds per-message AI moderation analysis time (ai_analysis_duration_ms)
so operators can see how long the LLM took to moderate each message.

Gateway:
- messagesTable: new ai_analysis_duration_ms (bigint) column.
- AIAnalysisUpdate + buildAIAnalysisSet: carry analysisDurationMs through
  both single and bulk update paths.
- ai-analysis-worker: measure wall-clock time around runModerationAnalysis
  and attach it to every result in the batch.

Backend:
- Mirror schema column; messageMapper maps ai_analysis_duration_ms;
  moderation-types + MappedMessage expose it.

Frontend:
- message.ts type gains ai_analysis_duration_ms.
- AiBadge (messages view) shows 'status · 1.2s' when duration is present;
  analysis view badge mirrors the same formatting.

DB:
- scripts/add-ai-analysis-duration.sql (idempotent ADD COLUMN IF NOT EXISTS).

No behavior change for moderation logic; null until new gateway build
records values.
This commit is contained in:
asepharyana
2026-08-16 00:11:00 +07:00
parent 2d7c7f2c35
commit 6244e307a3
10 changed files with 64 additions and 5 deletions
@@ -18,6 +18,12 @@ function aiTone(
return "neutral";
}
/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */
function formatAnalysisDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export function AnalysisView() {
const [query, setQuery] = useState("");
const search = useMessageSearch(query, query.trim().length >= 2);
@@ -99,7 +105,10 @@ export function AnalysisView() {
</span>
{m.ai_status && (
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
{m.ai_status}
{m.ai_analysis_duration_ms &&
m.ai_analysis_duration_ms > 0
? `${m.ai_status} · ${formatAnalysisDuration(m.ai_analysis_duration_ms)}`
: m.ai_status}
</Badge>
)}
</div>
@@ -174,7 +174,10 @@ export function MessagesView({
)}
</div>
</div>
<AiBadge status={m.ai_status} />
<AiBadge
status={m.ai_status}
durationMs={m.ai_analysis_duration_ms}
/>
</button>
))}
</div>
@@ -207,7 +210,13 @@ export function MessagesView({
);
}
function AiBadge({ status }: { status?: AiStatus | null }) {
function AiBadge({
status,
durationMs,
}: {
status?: AiStatus | null;
durationMs?: number | null;
}) {
if (!status) return null;
const tone = aiTone(status);
const icon =
@@ -222,14 +231,25 @@ function AiBadge({ status }: { status?: AiStatus | null }) {
) : (
<AlertTriangle className="size-3" />
);
const label =
durationMs && durationMs > 0
? `${status} · ${formatDuration(durationMs)}`
: status;
return (
<Badge tone={tone} dot={status === "processing" || status === "pending"}>
{icon}
{status}
{label}
</Badge>
);
}
/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function MessageDetail({
m,
attachments,
@@ -250,7 +270,10 @@ function MessageDetail({
</div>
</div>
<div className="ml-auto">
<AiBadge status={m.ai_status} />
<AiBadge
status={m.ai_status}
durationMs={m.ai_analysis_duration_ms}
/>
</div>
</div>
@@ -130,6 +130,7 @@ export interface MessageRecord {
ai_recommended_action?: AiRecommendedAction | null;
ai_error?: string | null;
ai_analyzed_at?: number | null;
ai_analysis_duration_ms?: number | null;
/** Detail-only: number of past edits (message_edits snapshots) */
edit_count?: number;
/** Detail-only: previous content snapshots, newest first */