1fc1e9f24c
AI Security (EGAI 4.3.1.3, 4.3.1.4, 4.1.3.1, IAAI 3.6.26): - AI Disclaimer banner in ChatPanel: "AI responses may be inaccurate" - "AI Generated" violet badge on: chat messages, AI summaries, project narratives, AI-generated cover images - HITL: system prompt now requires explicit user confirmation before any data mutation (strongly worded instruction) - Mutation tool audit logging: all 31 write tools logged with tool name, params, userId, userRole via Pino PostgreSQL Hardening (PG Standard V1.6): - Audit logging: log_connections, log_disconnections, log_statement=ddl, log_min_duration_statement=1000 in docker-compose - SUPERUSER removal script: scripts/harden-postgres.sh (NOSUPERUSER + minimal GRANT for app user) - Health check: pg_isready -U capakraken -d capakraken - Documentation: security-architecture.md Section 12 updated Controls closed: EGAI 4.1.3.1, 4.3.1.3, 4.3.1.4, PG 3.3, 3.5 Co-Authored-By: claude-flow <ruv@ruv.net>
151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
|
|
interface ChatMessageProps {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
}
|
|
|
|
/**
|
|
* Lightweight inline markdown renderer — handles bold, italic, code,
|
|
* bullet lists, and numbered lists without a full markdown library.
|
|
*/
|
|
function renderMarkdown(text: string) {
|
|
const lines = text.split("\n");
|
|
const elements: React.ReactNode[] = [];
|
|
let listItems: React.ReactNode[] = [];
|
|
let listType: "ul" | "ol" | null = null;
|
|
|
|
const flushList = () => {
|
|
if (listItems.length > 0 && listType) {
|
|
const Tag = listType;
|
|
elements.push(
|
|
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
|
|
{listItems}
|
|
</Tag>,
|
|
);
|
|
listItems = [];
|
|
listType = null;
|
|
}
|
|
};
|
|
|
|
for (const [i, line] of lines.entries()) {
|
|
// Bullet list: "- item" or "* item"
|
|
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
|
|
if (bulletMatch?.[1]) {
|
|
if (listType !== "ul") flushList();
|
|
listType = "ul";
|
|
listItems.push(<li key={`li-${i}`}>{inlineFormat(bulletMatch[1])}</li>);
|
|
continue;
|
|
}
|
|
|
|
// Numbered list: "1. item"
|
|
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
|
|
if (numMatch?.[1]) {
|
|
if (listType !== "ol") flushList();
|
|
listType = "ol";
|
|
listItems.push(<li key={`li-${i}`}>{inlineFormat(numMatch[1])}</li>);
|
|
continue;
|
|
}
|
|
|
|
// Not a list item — flush any pending list
|
|
flushList();
|
|
|
|
// Empty line → spacing
|
|
if (line.trim() === "") {
|
|
elements.push(<div key={`br-${i}`} className="h-2" />);
|
|
continue;
|
|
}
|
|
|
|
// Regular paragraph
|
|
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
|
|
}
|
|
flushList();
|
|
return elements;
|
|
}
|
|
|
|
/** Parse inline formatting: **bold**, *italic*, `code` */
|
|
function inlineFormat(text: string): React.ReactNode {
|
|
// Split by inline patterns, preserving delimiters
|
|
const parts: React.ReactNode[] = [];
|
|
// Regex: **bold**, *italic*, `code`
|
|
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
// Text before this match
|
|
if (match.index > lastIndex) {
|
|
parts.push(text.slice(lastIndex, match.index));
|
|
}
|
|
if (match[2]) {
|
|
// **bold**
|
|
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
|
|
} else if (match[3]) {
|
|
// *italic*
|
|
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
|
|
} else if (match[4]) {
|
|
// `code`
|
|
parts.push(
|
|
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
|
|
{match[4]}
|
|
</code>,
|
|
);
|
|
}
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Remaining text
|
|
if (lastIndex < text.length) {
|
|
parts.push(text.slice(lastIndex));
|
|
}
|
|
|
|
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
|
}
|
|
|
|
export function ChatMessage({ role, content }: ChatMessageProps) {
|
|
const isUser = role === "user";
|
|
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
|
|
|
|
return (
|
|
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
|
|
<div
|
|
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
|
isUser
|
|
? "bg-brand-600 text-white"
|
|
: "bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-gray-200"
|
|
}`}
|
|
>
|
|
{isUser ? (
|
|
<span className="whitespace-pre-wrap break-words">{content}</span>
|
|
) : (
|
|
<>
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 mb-1.5">
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
</svg>
|
|
AI Generated
|
|
</span>
|
|
<div className="space-y-0.5 break-words">{rendered}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TypingIndicator() {
|
|
return (
|
|
<div className="flex justify-start">
|
|
<div className="rounded-2xl bg-gray-100 px-4 py-3 dark:bg-slate-800">
|
|
<div className="flex gap-1.5">
|
|
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "0ms" }} />
|
|
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "150ms" }} />
|
|
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "300ms" }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|