bb0c0feb4c
Phase 5 frontend. A new "AI Workspace" tab in the job details dialog hosts the five suggestion modules (Job Analysis, Career Match, Cover Letter with tone, Interview Prep, Application Review) with a generate flow, a dependency-free markdown renderer for results, and a history sidebar (reuse / compare / copy / delete). Everything is suggestion-only — copy to keep; nothing auto-applies. - aiWorkspace.ts (types + API), components/AiWorkspacePanel.tsx, components/Markdown.tsx (no HTML injection surface — renders React nodes) - mounted as the last tab in JobDetailsDialog (index-safe, no reindexing) - 3 tests (generate flow, cover-letter mode, markdown); tsc + build clean Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68 lines
2.6 KiB
TypeScript
68 lines
2.6 KiB
TypeScript
import React from "react";
|
|
import { Box, Link, Typography } from "@mui/material";
|
|
|
|
// Tiny, dependency-free markdown renderer for AI suggestions. Handles the subset the prompts emit:
|
|
// #/##/### headings, **bold**, *italic*, [text](url), and - / * bullet lists. Everything is rendered
|
|
// as React elements (no dangerouslySetInnerHTML), so there is no HTML-injection surface.
|
|
export default function Markdown({ text }: { text: string }) {
|
|
const blocks: React.ReactNode[] = [];
|
|
const lines = (text ?? "").replace(/\r\n/g, "\n").split("\n");
|
|
let list: string[] = [];
|
|
|
|
const flushList = (key: string) => {
|
|
if (list.length === 0) return;
|
|
blocks.push(
|
|
<Box key={key} component="ul" sx={{ pl: 3, my: 0.5 }}>
|
|
{list.map((li, i) => <li key={i}><Typography component="span" variant="body2">{inline(li)}</Typography></li>)}
|
|
</Box>,
|
|
);
|
|
list = [];
|
|
};
|
|
|
|
lines.forEach((raw, i) => {
|
|
const line = raw.trimEnd();
|
|
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
|
|
if (bullet) { list.push(bullet[1]); return; }
|
|
flushList(`ul-${i}`);
|
|
if (!line.trim()) return;
|
|
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
|
if (heading) {
|
|
const level = heading[1].length;
|
|
blocks.push(
|
|
<Typography key={i} variant={level === 1 ? "subtitle1" : "subtitle2"} sx={{ fontWeight: 800, mt: 1, mb: 0.25 }}>
|
|
{inline(heading[2])}
|
|
</Typography>,
|
|
);
|
|
return;
|
|
}
|
|
// A line that is only **bold** reads as a section heading in these prompts.
|
|
const boldOnly = line.match(/^\*\*(.+)\*\*:?$/);
|
|
if (boldOnly) {
|
|
blocks.push(<Typography key={i} variant="subtitle2" sx={{ fontWeight: 800, mt: 1, mb: 0.25 }}>{boldOnly[1]}</Typography>);
|
|
return;
|
|
}
|
|
blocks.push(<Typography key={i} variant="body2" sx={{ mb: 0.5 }}>{inline(line)}</Typography>);
|
|
});
|
|
flushList("ul-end");
|
|
|
|
return <Box>{blocks}</Box>;
|
|
}
|
|
|
|
// Inline **bold**, *italic*, [text](url) → React nodes.
|
|
function inline(text: string): React.ReactNode[] {
|
|
const tokens: React.ReactNode[] = [];
|
|
const rx = /\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
let k = 0;
|
|
while ((m = rx.exec(text)) !== null) {
|
|
if (m.index > last) tokens.push(text.slice(last, m.index));
|
|
if (m[1] !== undefined) tokens.push(<strong key={k++}>{m[1]}</strong>);
|
|
else if (m[2] !== undefined) tokens.push(<em key={k++}>{m[2]}</em>);
|
|
else if (m[3] !== undefined) tokens.push(<Link key={k++} href={m[4]} target="_blank" rel="noopener noreferrer">{m[3]}</Link>);
|
|
last = rx.lastIndex;
|
|
}
|
|
if (last < text.length) tokens.push(text.slice(last));
|
|
return tokens;
|
|
}
|