create-interview-cockpit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +62 -0
  2. package/index.js +302 -0
  3. package/package.json +44 -0
  4. package/template/.env.example +14 -0
  5. package/template/client/index.html +12 -0
  6. package/template/client/package-lock.json +6012 -0
  7. package/template/client/package.json +34 -0
  8. package/template/client/postcss.config.cjs +6 -0
  9. package/template/client/src/App.tsx +120 -0
  10. package/template/client/src/api.ts +132 -0
  11. package/template/client/src/components/AnnotationDialog.tsx +307 -0
  12. package/template/client/src/components/ChatMessage.tsx +89 -0
  13. package/template/client/src/components/ChatView.tsx +763 -0
  14. package/template/client/src/components/CodeContextPanel.tsx +470 -0
  15. package/template/client/src/components/FileAttachments.tsx +107 -0
  16. package/template/client/src/components/FileViewerModal.tsx +470 -0
  17. package/template/client/src/components/MarkdownRenderer.tsx +333 -0
  18. package/template/client/src/components/MermaidDiagram.tsx +157 -0
  19. package/template/client/src/components/Sidebar.tsx +419 -0
  20. package/template/client/src/components/TextAnnotator.tsx +476 -0
  21. package/template/client/src/index.css +61 -0
  22. package/template/client/src/main.tsx +10 -0
  23. package/template/client/src/store.ts +321 -0
  24. package/template/client/src/types.ts +65 -0
  25. package/template/client/src/vite-env.d.ts +1 -0
  26. package/template/client/tailwind.config.cjs +8 -0
  27. package/template/client/tsconfig.json +16 -0
  28. package/template/client/tsconfig.tsbuildinfo +1 -0
  29. package/template/client/vite.config.ts +12 -0
  30. package/template/cockpit.json +3 -0
  31. package/template/data/context-files/.gitkeep +0 -0
  32. package/template/data/questions/.gitkeep +0 -0
  33. package/template/data/topics.json +1 -0
  34. package/template/package.json +14 -0
  35. package/template/server/package-lock.json +2266 -0
  36. package/template/server/package.json +31 -0
  37. package/template/server/src/index.ts +758 -0
  38. package/template/server/src/storage.ts +303 -0
  39. package/template/server/tsconfig.json +14 -0
@@ -0,0 +1,333 @@
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+ import { useMemo, useRef, useEffect } from "react";
4
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5
+ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
6
+ import MermaidDiagram from "./MermaidDiagram";
7
+ import { useStore } from "../store";
8
+ import { Bookmark } from "lucide-react";
9
+
10
+ import type { Components } from "react-markdown";
11
+
12
+ interface Props {
13
+ content: string;
14
+ onAnnotationClick?: (annotId: string, rect: DOMRect) => void;
15
+ bookmarkedBlockIndex?: number;
16
+ onBookmarkBlock?: (blockIndex: number) => void;
17
+ }
18
+
19
+ const markdownComponents: Components = {
20
+ pre({ children }) {
21
+ return <>{children}</>;
22
+ },
23
+ p({ children }) {
24
+ return <p className="my-2 leading-relaxed text-slate-200">{children}</p>;
25
+ },
26
+ code({ className, children, ...props }) {
27
+ const match = /language-(\w+)/.exec(className || "");
28
+ const codeString = String(children).replace(/\n$/, "");
29
+
30
+ if (match) {
31
+ const lang = match[1];
32
+
33
+ if (lang === "mermaid") {
34
+ return <MermaidDiagram chart={codeString} />;
35
+ }
36
+
37
+ return (
38
+ <SyntaxHighlighter
39
+ style={oneDark}
40
+ language={lang}
41
+ PreTag="div"
42
+ customStyle={{
43
+ margin: "0.5rem 0",
44
+ borderRadius: "0.5rem",
45
+ fontSize: "0.8rem",
46
+ background: "#1e293b",
47
+ }}
48
+ >
49
+ {codeString}
50
+ </SyntaxHighlighter>
51
+ );
52
+ }
53
+
54
+ if (codeString.includes("\n")) {
55
+ return (
56
+ <SyntaxHighlighter
57
+ style={oneDark}
58
+ language="text"
59
+ PreTag="div"
60
+ customStyle={{
61
+ margin: "0.5rem 0",
62
+ borderRadius: "0.5rem",
63
+ fontSize: "0.8rem",
64
+ background: "#1e293b",
65
+ }}
66
+ >
67
+ {codeString}
68
+ </SyntaxHighlighter>
69
+ );
70
+ }
71
+
72
+ return (
73
+ <code
74
+ className="bg-slate-800 px-1.5 py-0.5 rounded text-cyan-300 text-xs"
75
+ {...props}
76
+ >
77
+ {children}
78
+ </code>
79
+ );
80
+ },
81
+ table({ children }) {
82
+ return (
83
+ <div className="overflow-x-auto my-3">
84
+ <table className="border-collapse text-xs w-full">{children}</table>
85
+ </div>
86
+ );
87
+ },
88
+ thead({ children }) {
89
+ return <thead className="bg-slate-800">{children}</thead>;
90
+ },
91
+ th({ children }) {
92
+ return (
93
+ <th className="border border-slate-700 px-3 py-1.5 text-left font-medium text-slate-300">
94
+ {children}
95
+ </th>
96
+ );
97
+ },
98
+ td({ children }) {
99
+ return (
100
+ <td className="border border-slate-700 px-3 py-1.5 text-slate-400">
101
+ {children}
102
+ </td>
103
+ );
104
+ },
105
+ blockquote({ children }) {
106
+ return (
107
+ <blockquote className="border-l-2 border-cyan-500/50 pl-3 my-2 text-slate-400 italic">
108
+ {children}
109
+ </blockquote>
110
+ );
111
+ },
112
+ ul({ children }) {
113
+ return (
114
+ <ul className="list-disc list-outside pl-5 space-y-1.5 my-2">
115
+ {children}
116
+ </ul>
117
+ );
118
+ },
119
+ ol({ children }) {
120
+ return (
121
+ <ol className="list-decimal list-outside pl-5 space-y-1.5 my-2">
122
+ {children}
123
+ </ol>
124
+ );
125
+ },
126
+ li({ children }) {
127
+ return (
128
+ <li className="pl-1 marker:text-slate-500 [&>p]:inline [&>p]:m-0 [&>ul]:mt-1.5 [&>ol]:mt-1.5">
129
+ {children}
130
+ </li>
131
+ );
132
+ },
133
+ a({ href, children }) {
134
+ return (
135
+ <a
136
+ href={href}
137
+ className="text-cyan-400 hover:underline"
138
+ target="_blank"
139
+ rel="noopener noreferrer"
140
+ >
141
+ {children}
142
+ </a>
143
+ );
144
+ },
145
+ strong({ children }) {
146
+ return <strong className="font-semibold text-slate-200">{children}</strong>;
147
+ },
148
+ h1({ children }) {
149
+ return (
150
+ <h1 className="text-xl font-bold mt-4 mb-2 text-slate-100">{children}</h1>
151
+ );
152
+ },
153
+ h2({ children }) {
154
+ return (
155
+ <h2 className="text-lg font-semibold mt-3 mb-1.5 text-slate-200">
156
+ {children}
157
+ </h2>
158
+ );
159
+ },
160
+ h3({ children }) {
161
+ return (
162
+ <h3 className="text-base font-medium mt-2 mb-1 text-slate-200">
163
+ {children}
164
+ </h3>
165
+ );
166
+ },
167
+ hr() {
168
+ return <hr className="border-slate-700 my-4" />;
169
+ },
170
+ };
171
+
172
+ export default function MarkdownRenderer({
173
+ content,
174
+ onAnnotationClick,
175
+ bookmarkedBlockIndex,
176
+ onBookmarkBlock,
177
+ }: Props) {
178
+ const openFileViewer = useStore((s) => s.openFileViewer);
179
+ const bookmarkElemRef = useRef<HTMLDivElement | null>(null);
180
+
181
+ // Scroll the bookmarked block into view when the bookmark changes or on mount
182
+ useEffect(() => {
183
+ if (bookmarkedBlockIndex === undefined) return;
184
+ const id = setTimeout(() => {
185
+ bookmarkElemRef.current?.scrollIntoView({
186
+ behavior: "smooth",
187
+ block: "center",
188
+ });
189
+ }, 150);
190
+ return () => clearTimeout(id);
191
+ }, [bookmarkedBlockIndex]);
192
+
193
+ const components = useMemo<Components>(() => {
194
+ // Wrap a block element with a hover-activated bookmark button.
195
+ // Uses node.position.start.line as a stable block identifier.
196
+ function wrapBlock(
197
+ lineIdx: number | undefined,
198
+ element: React.ReactElement,
199
+ ): React.ReactElement {
200
+ if (!onBookmarkBlock || lineIdx === undefined) return element;
201
+ const isThis = bookmarkedBlockIndex === lineIdx;
202
+ return (
203
+ <div
204
+ ref={
205
+ isThis
206
+ ? (el) => {
207
+ bookmarkElemRef.current = el;
208
+ }
209
+ : undefined
210
+ }
211
+ data-reading-bookmark={isThis ? "true" : undefined}
212
+ className={`group/bk relative ${
213
+ isThis ? "border-l-2 border-amber-500/50 pl-2 -ml-2" : ""
214
+ }`}
215
+ >
216
+ <button
217
+ onMouseDown={(e) => e.preventDefault()}
218
+ onClick={() => onBookmarkBlock(lineIdx)}
219
+ className={`absolute right-0 top-0 p-0.5 z-10 transition-opacity ${
220
+ isThis
221
+ ? "opacity-100 text-amber-400"
222
+ : "opacity-0 group-hover/bk:opacity-100 text-slate-600 hover:text-amber-400"
223
+ }`}
224
+ title={isThis ? "Remove bookmark" : "Bookmark here"}
225
+ >
226
+ <Bookmark className={`w-3 h-3 ${isThis ? "fill-amber-400" : ""}`} />
227
+ </button>
228
+ {element}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ return {
234
+ ...markdownComponents,
235
+ p({ children, node }) {
236
+ return wrapBlock(
237
+ (node as any)?.position?.start?.line,
238
+ <p className="my-2 leading-relaxed text-slate-200">{children}</p>,
239
+ );
240
+ },
241
+ h1({ children, node }) {
242
+ return wrapBlock(
243
+ (node as any)?.position?.start?.line,
244
+ <h1 className="text-xl font-bold mt-4 mb-2 text-slate-100">
245
+ {children}
246
+ </h1>,
247
+ );
248
+ },
249
+ h2({ children, node }) {
250
+ return wrapBlock(
251
+ (node as any)?.position?.start?.line,
252
+ <h2 className="text-lg font-semibold mt-3 mb-1.5 text-slate-200">
253
+ {children}
254
+ </h2>,
255
+ );
256
+ },
257
+ h3({ children, node }) {
258
+ return wrapBlock(
259
+ (node as any)?.position?.start?.line,
260
+ <h3 className="text-base font-medium mt-2 mb-1 text-slate-200">
261
+ {children}
262
+ </h3>,
263
+ );
264
+ },
265
+ a({ href, children }) {
266
+ if (href?.startsWith("annot://")) {
267
+ const annotId = href.slice("annot://".length);
268
+ return (
269
+ <span
270
+ onClick={
271
+ onAnnotationClick
272
+ ? (e) => {
273
+ const rect = (
274
+ e.currentTarget as HTMLElement
275
+ ).getBoundingClientRect();
276
+ onAnnotationClick(annotId, rect);
277
+ }
278
+ : undefined
279
+ }
280
+ className="underline decoration-cyan-400 decoration-dotted underline-offset-2 cursor-pointer text-slate-200 hover:text-cyan-300 transition-colors"
281
+ >
282
+ {children}
283
+ </span>
284
+ );
285
+ }
286
+ if (href?.startsWith("coderef://")) {
287
+ const filePath = href.slice("coderef://".length);
288
+ return (
289
+ <button
290
+ type="button"
291
+ onClick={() => openFileViewer(filePath)}
292
+ className="inline-flex items-center gap-1 text-amber-400 hover:text-amber-300 underline decoration-amber-500/50 underline-offset-2 transition-colors font-mono text-[0.8em]"
293
+ title={`Open ${filePath}`}
294
+ >
295
+ {children}
296
+ </button>
297
+ );
298
+ }
299
+ return (
300
+ <a
301
+ href={href}
302
+ className="text-cyan-400 hover:underline"
303
+ target="_blank"
304
+ rel="noopener noreferrer"
305
+ >
306
+ {children}
307
+ </a>
308
+ );
309
+ },
310
+ };
311
+ }, [
312
+ onAnnotationClick,
313
+ openFileViewer,
314
+ bookmarkedBlockIndex,
315
+ onBookmarkBlock,
316
+ ]);
317
+
318
+ return (
319
+ <ReactMarkdown
320
+ remarkPlugins={[remarkGfm]}
321
+ components={components}
322
+ urlTransform={(url) => {
323
+ // Allow our internal schemes; block javascript: for safety
324
+ if (url.startsWith("annot://")) return url;
325
+ if (url.startsWith("coderef://")) return url;
326
+ if (url.startsWith("javascript:")) return "";
327
+ return url;
328
+ }}
329
+ >
330
+ {content}
331
+ </ReactMarkdown>
332
+ );
333
+ }
@@ -0,0 +1,157 @@
1
+ import { memo, useEffect, useState } from "react";
2
+ import { RefreshCw, Loader2 } from "lucide-react";
3
+ import mermaid from "mermaid";
4
+
5
+ mermaid.initialize({
6
+ startOnLoad: false,
7
+ theme: "dark",
8
+ suppressErrorRendering: true,
9
+ themeVariables: {
10
+ primaryColor: "#0e7490",
11
+ primaryTextColor: "#e2e8f0",
12
+ primaryBorderColor: "#164e63",
13
+ lineColor: "#475569",
14
+ secondaryColor: "#1e293b",
15
+ tertiaryColor: "#0f172a",
16
+ },
17
+ });
18
+
19
+ let counter = 0;
20
+
21
+ // Auto-fix common Mermaid syntax issues the LLM produces
22
+ function sanitizeChart(raw: string): string {
23
+ return (
24
+ raw
25
+ // Quote node labels inside [] that contain unquoted parens/brackets
26
+ // e.g. A[Service A (primary)] → A["Service A (primary)"]
27
+ .replace(
28
+ /\[([^\]"]+\([^)]*\)[^\]]*)\]/g,
29
+ (_match, label) => `["${label}"]`,
30
+ )
31
+ // Quote edge labels inside || that contain parens — ( is parsed as stadium shape
32
+ // e.g. -->|event (publish)| B → -->|"event (publish)"| B
33
+ .replace(/\|([^|"]*[()][^|]*)\|/g, (_match, label) => `|"${label}"|`)
34
+ );
35
+ }
36
+
37
+ interface Props {
38
+ chart: string;
39
+ }
40
+
41
+ export default memo(function MermaidDiagram({ chart }: Props) {
42
+ const [activeChart, setActiveChart] = useState(chart);
43
+ const [svg, setSvg] = useState<string | null>(null);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [fixing, setFixing] = useState(false);
46
+
47
+ // Keep activeChart in sync when the prop changes (streaming / message reload)
48
+ useEffect(() => {
49
+ setActiveChart(chart);
50
+ }, [chart]);
51
+
52
+ useEffect(() => {
53
+ let cancelled = false;
54
+ setSvg(null);
55
+ setError(null);
56
+
57
+ // Debounce: wait for content to stabilise (streaming sends tokens rapidly)
58
+ const timer = setTimeout(async () => {
59
+ if (cancelled) return;
60
+ const id = `mermaid-${++counter}`;
61
+
62
+ try {
63
+ const { svg: rendered } = await mermaid.render(id, activeChart);
64
+ if (!cancelled) setSvg(rendered);
65
+ } catch (err) {
66
+ const errMsg = err instanceof Error ? err.message : String(err);
67
+ // Retry with sanitized chart (fix common LLM syntax mistakes)
68
+ const fixed = sanitizeChart(activeChart);
69
+ if (fixed !== activeChart) {
70
+ const retryId = `mermaid-${++counter}`;
71
+ try {
72
+ const { svg: rendered } = await mermaid.render(retryId, fixed);
73
+ if (!cancelled) setSvg(rendered);
74
+ return;
75
+ } catch {
76
+ // fall through to error
77
+ }
78
+ }
79
+ if (!cancelled) setError(errMsg || "Failed to render diagram");
80
+ }
81
+ }, 400);
82
+
83
+ return () => {
84
+ cancelled = true;
85
+ clearTimeout(timer);
86
+ // Clean up any mermaid elements that leak into document.body
87
+ document
88
+ .querySelectorAll('[id^="dmermaid-"]')
89
+ .forEach((el) => el.remove());
90
+ };
91
+ }, [activeChart]);
92
+
93
+ const handleFix = async () => {
94
+ setFixing(true);
95
+ try {
96
+ const res = await fetch("/api/fix-diagram", {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({ chart: activeChart, error }),
100
+ });
101
+ if (!res.ok) throw new Error("Fix request failed");
102
+ const { chart: fixed } = (await res.json()) as { chart: string };
103
+ setActiveChart(fixed);
104
+ } catch (err) {
105
+ console.error("Fix diagram error:", err);
106
+ } finally {
107
+ setFixing(false);
108
+ }
109
+ };
110
+
111
+ if (error) {
112
+ return (
113
+ <div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 my-2">
114
+ <div className="flex items-center justify-between mb-1">
115
+ <p className="text-xs text-red-400">Diagram error:</p>
116
+ <button
117
+ onClick={handleFix}
118
+ disabled={fixing}
119
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-50 transition-colors"
120
+ >
121
+ {fixing ? (
122
+ <>
123
+ <Loader2 className="w-3 h-3 animate-spin" />
124
+ Fixing…
125
+ </>
126
+ ) : (
127
+ <>
128
+ <RefreshCw className="w-3 h-3" />
129
+ Fix diagram
130
+ </>
131
+ )}
132
+ </button>
133
+ </div>
134
+ <pre className="text-xs text-slate-400 whitespace-pre-wrap">
135
+ {activeChart}
136
+ </pre>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ if (!svg) {
142
+ return (
143
+ <div className="my-3 flex justify-center bg-slate-800/30 rounded-lg p-4">
144
+ <span className="text-xs text-slate-500 animate-pulse">
145
+ Rendering diagram…
146
+ </span>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ return (
152
+ <div
153
+ className="my-3 flex justify-center bg-slate-800/30 rounded-lg p-4 overflow-x-auto mermaid-diagram"
154
+ dangerouslySetInnerHTML={{ __html: svg }}
155
+ />
156
+ );
157
+ });