create-interview-cockpit 0.3.0 → 0.4.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.
@@ -0,0 +1,502 @@
1
+ import { memo, useEffect, useRef, useState } from "react";
2
+ import {
3
+ ChevronLeft,
4
+ ChevronRight,
5
+ RotateCcw,
6
+ Maximize2,
7
+ RefreshCw,
8
+ Loader2,
9
+ ZoomIn,
10
+ ZoomOut,
11
+ } from "lucide-react";
12
+ import { parse as parseYaml } from "yaml";
13
+ import {
14
+ fromSpec,
15
+ DEFAULT_VIZ_CSS,
16
+ type VizSpec,
17
+ type MountController,
18
+ type StepController,
19
+ type VizNode,
20
+ type PanZoomController,
21
+ } from "vizcraft";
22
+
23
+ // Dark-theme overrides for VizCraft — applied on top of DEFAULT_VIZ_CSS
24
+ const VIZ_DARK_THEME_CSS = `
25
+ /* ── VizCraft dark-theme overrides ── */
26
+
27
+ /* Allow pan/zoom on empty SVG space; set grab cursor */
28
+ .viz-embed-host svg {
29
+ pointer-events: all;
30
+ cursor: grab;
31
+ }
32
+ .viz-embed-host svg:active {
33
+ cursor: grabbing;
34
+ }
35
+ /* Block browser touch-scroll so VizCraft pan/zoom can take over */
36
+ .viz-embed-host {
37
+ touch-action: none;
38
+ user-select: none;
39
+ cursor: grab;
40
+ }
41
+ .viz-embed-host:active {
42
+ cursor: grabbing;
43
+ }
44
+
45
+ .viz-embed-host .viz-node-shape {
46
+ fill: #1e293b;
47
+ stroke: #475569;
48
+ stroke-width: 1.5;
49
+ }
50
+ .viz-embed-host .viz-node-label {
51
+ fill: #e2e8f0;
52
+ font-size: 12px;
53
+ font-family: ui-sans-serif, system-ui, sans-serif;
54
+ }
55
+ .viz-embed-host .viz-edge {
56
+ stroke: #64748b;
57
+ stroke-width: 1.5;
58
+ }
59
+ .viz-embed-host .viz-edge-label {
60
+ fill: #94a3b8;
61
+ font-size: 11px;
62
+ font-family: ui-sans-serif, system-ui, sans-serif;
63
+ }
64
+ .viz-embed-host .viz-grid-label {
65
+ fill: #64748b;
66
+ }
67
+ .viz-embed-host .viz-signal {
68
+ fill: #38bdf8;
69
+ }
70
+ .viz-embed-host .viz-signal:hover {
71
+ fill: #7dd3fc;
72
+ }
73
+ .viz-embed-host .viz-anim-flow .viz-edge {
74
+ stroke: #0ea5e9;
75
+ }
76
+ `;
77
+
78
+ // Inject VizCraft default CSS + dark overrides once into <head>
79
+ let cssInjected = false;
80
+ function ensureVizCss() {
81
+ if (cssInjected) return;
82
+ cssInjected = true;
83
+ const style = document.createElement("style");
84
+ style.id = "vizcraft-css";
85
+ style.textContent = DEFAULT_VIZ_CSS + VIZ_DARK_THEME_CSS;
86
+ document.head.appendChild(style);
87
+ }
88
+
89
+ function parseSpec(raw: string): VizSpec {
90
+ const trimmed = raw.trim();
91
+ // Try JSON first, then YAML
92
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
93
+ return JSON.parse(trimmed) as VizSpec;
94
+ }
95
+ return parseYaml(trimmed) as VizSpec;
96
+ }
97
+
98
+ /**
99
+ * Walk each VizNode in the (already-built) scene and inject a sensible
100
+ * maxWidth + overflow on any label that doesn't have one yet.
101
+ *
102
+ * We read the shape from the built VizNode (which uses the internal NodeShape
103
+ * discriminated union, e.g. {kind:'rect', w:120, h:40}), not from VizSpec.
104
+ */
105
+ function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
106
+ const scene = builder.build();
107
+ for (const node of scene.nodes) {
108
+ if (
109
+ !node.label ||
110
+ (node.label as VizNode["label"] & { maxWidth?: number })?.maxWidth !==
111
+ undefined
112
+ )
113
+ continue;
114
+ const shape = node.shape as Record<string, unknown>;
115
+ let w = 60;
116
+ if (typeof shape.w === "number") w = shape.w;
117
+ else if (typeof shape.r === "number") w = shape.r * 2;
118
+ else if (typeof shape.rx === "number") w = (shape.rx as number) * 2;
119
+ else if (typeof shape.size === "number") w = shape.size as number;
120
+ else if (typeof shape.outerR === "number") w = (shape.outerR as number) * 2;
121
+ builder.updateNode(node.id, {
122
+ label: {
123
+ ...node.label,
124
+ maxWidth: Math.max(w - 10, 20),
125
+ overflow:
126
+ (node.label as { overflow?: "visible" | "ellipsis" | "clip" })
127
+ .overflow ?? "ellipsis",
128
+ },
129
+ });
130
+ }
131
+ }
132
+
133
+ interface StepState {
134
+ index: number;
135
+ total: number;
136
+ label: string;
137
+ isReady: boolean;
138
+ }
139
+
140
+ interface Props {
141
+ spec: string;
142
+ }
143
+
144
+ export default memo(function VizCraftEmbed({ spec }: Props) {
145
+ const containerRef = useRef<HTMLDivElement>(null);
146
+ const controllerRef = useRef<MountController | StepController | null>(null);
147
+ // Tracks the active step's MountController (set in custom step mode) so panZoom is accessible
148
+ const mountControllerRef = useRef<MountController | null>(null);
149
+ const [activeSpec, setActiveSpec] = useState(spec);
150
+ const [error, setError] = useState<string | null>(null);
151
+ const [fixing, setFixing] = useState(false);
152
+ const [stepState, setStepState] = useState<StepState | null>(null);
153
+
154
+ // Keep activeSpec in sync when the prop changes (streaming / message reload)
155
+ useEffect(() => {
156
+ setActiveSpec(spec);
157
+ }, [spec]);
158
+
159
+ const handleFix = async () => {
160
+ setFixing(true);
161
+ try {
162
+ const res = await fetch("/api/fix-viz", {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({ spec: activeSpec, error }),
166
+ });
167
+ if (!res.ok) throw new Error("Fix request failed");
168
+ const { spec: fixed } = (await res.json()) as { spec: string };
169
+ setActiveSpec(fixed);
170
+ } catch (err) {
171
+ console.error("Fix viz error:", err);
172
+ } finally {
173
+ setFixing(false);
174
+ }
175
+ };
176
+
177
+ // Prevent the parent chat scroll container from scrolling when the user
178
+ // wheels over the viz. We need a non-passive listener so preventDefault works.
179
+ // (React's synthetic onWheel is passive in some environments and can't prevent scroll.)
180
+ useEffect(() => {
181
+ const el = containerRef.current;
182
+ if (!el) return;
183
+ const stopScroll = (e: WheelEvent) => e.stopPropagation();
184
+ el.addEventListener("wheel", stopScroll, { passive: true });
185
+ return () => el.removeEventListener("wheel", stopScroll);
186
+ }, []);
187
+
188
+ useEffect(() => {
189
+ const container = containerRef.current;
190
+ if (!container) return;
191
+
192
+ // Debounce: wait for streaming to stabilise before mounting
193
+ const timer = setTimeout(() => {
194
+ // Tear down any previous mount
195
+ controllerRef.current?.destroy();
196
+ controllerRef.current = null;
197
+ setError(null);
198
+ setStepState(null);
199
+
200
+ let parsed: VizSpec;
201
+ try {
202
+ parsed = parseSpec(activeSpec);
203
+ } catch (e) {
204
+ setError(e instanceof Error ? e.message : "Invalid spec");
205
+ return;
206
+ }
207
+
208
+ ensureVizCss();
209
+
210
+ try {
211
+ if (parsed.steps?.length) {
212
+ // Step-through mode — custom implementation so each step is mounted
213
+ // with { panZoom: true }, making zoom/fit buttons work in step mode.
214
+ const steps = parsed.steps;
215
+ const total = steps.length;
216
+ let currentIndex = 0;
217
+ let currentMount: MountController | null = null;
218
+ let cancelled = false;
219
+
220
+ // Build a per-step VizSpec: merge node highlights, overlays, and
221
+ // include step-level signals as autoSignals so they auto-play on mount.
222
+ const buildStepSpec = (index: number): VizSpec => {
223
+ const step = steps[index];
224
+ const highlighted = new Set(step.highlight ?? []);
225
+ const nodes = parsed.nodes.map((n) =>
226
+ !highlighted.size || highlighted.has(n.id)
227
+ ? n
228
+ : {
229
+ ...n,
230
+ opacity:
231
+ ((n as unknown as { opacity?: number }).opacity ?? 1) *
232
+ 0.3,
233
+ },
234
+ );
235
+ const overlays = [
236
+ ...(parsed.overlays ?? []),
237
+ ...(step.overlays ?? []),
238
+ ];
239
+ return {
240
+ ...parsed,
241
+ nodes,
242
+ overlays: overlays.length ? overlays : undefined,
243
+ autoSignals: step.signals?.map((s) => ({ ...s, loop: false })),
244
+ steps: undefined,
245
+ };
246
+ };
247
+
248
+ const goTo = (index: number) => {
249
+ if (cancelled || index < 0 || index >= total) return;
250
+ // Preserve the current viewport so navigating steps doesn't reset zoom/pan.
251
+ // getState() is only available after the first mount (prevState is undefined on step 0).
252
+ const prevState = mountControllerRef.current?.panZoom?.getState();
253
+ currentMount?.destroy();
254
+ currentMount = null;
255
+
256
+ const builder = fromSpec(buildStepSpec(index));
257
+ injectLabelMaxWidth(builder);
258
+ currentMount = builder.mount(container, {
259
+ panZoom: true,
260
+ // Restore previous zoom if the user had already panned/zoomed; otherwise fit.
261
+ initialZoom: prevState?.zoom ?? "fit",
262
+ initialPan: prevState?.pan,
263
+ minZoom: 0.1,
264
+ maxZoom: 8,
265
+ });
266
+ mountControllerRef.current = currentMount;
267
+ currentIndex = index;
268
+
269
+ const step = steps[index];
270
+ setStepState({
271
+ index,
272
+ total,
273
+ label: step.label ?? "",
274
+ isReady: false,
275
+ });
276
+
277
+ // Track non-looping signals to know when the step animation completes
278
+ const signals = (step.signals ?? []).filter((s) => !s.loop);
279
+ if (signals.length === 0) {
280
+ setStepState((prev) => prev && { ...prev, isReady: true });
281
+ if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
282
+ } else {
283
+ let done = 0;
284
+ signals.forEach((s) => {
285
+ currentMount!.onSignalComplete(s.id, () => {
286
+ done++;
287
+ if (done >= signals.length) {
288
+ setStepState((prev) => prev && { ...prev, isReady: true });
289
+ if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
290
+ }
291
+ });
292
+ });
293
+ }
294
+ };
295
+
296
+ goTo(0);
297
+
298
+ // Expose a StepController-shaped object for the nav buttons
299
+ controllerRef.current = {
300
+ next: () => goTo(currentIndex + 1),
301
+ prev: () => goTo(currentIndex - 1),
302
+ reset: () => goTo(0),
303
+ destroy: () => {
304
+ cancelled = true;
305
+ currentMount?.destroy();
306
+ currentMount = null;
307
+ mountControllerRef.current = null;
308
+ },
309
+ } as unknown as StepController;
310
+ } else {
311
+ // Auto-signal / static mode — inject maxWidth then mount with pan/zoom
312
+ const builder = fromSpec(parsed);
313
+ injectLabelMaxWidth(builder);
314
+ const ctrl = builder.mount(container, {
315
+ panZoom: true,
316
+ initialZoom: "fit",
317
+ minZoom: 0.1,
318
+ maxZoom: 8,
319
+ });
320
+ controllerRef.current = ctrl;
321
+ }
322
+ } catch (e) {
323
+ setError(e instanceof Error ? e.message : "Failed to render");
324
+ }
325
+ }, 400);
326
+
327
+ return () => {
328
+ clearTimeout(timer);
329
+ controllerRef.current?.destroy();
330
+ controllerRef.current = null;
331
+ mountControllerRef.current = null;
332
+ };
333
+ }, [activeSpec]);
334
+
335
+ const handleNext = () => {
336
+ (controllerRef.current as StepController)?.next?.();
337
+ };
338
+ const handlePrev = () => {
339
+ (controllerRef.current as StepController)?.prev?.();
340
+ };
341
+ const handleRestart = () => {
342
+ (controllerRef.current as StepController)?.reset?.();
343
+ };
344
+
345
+ const panZoom = () =>
346
+ // Non-step mode: MountController is stored directly in controllerRef
347
+ // Step mode: each step's MountController is stored in mountControllerRef
348
+ ((controllerRef.current as MountController)?.panZoom ??
349
+ mountControllerRef.current?.panZoom) as PanZoomController | undefined;
350
+
351
+ const handleFitView = () => panZoom()?.fitToContent?.();
352
+
353
+ const handleZoomIn = () => {
354
+ const pz = panZoom();
355
+ if (!pz) return;
356
+ pz.setZoom(Math.min(pz.zoom * 1.3, 8));
357
+ };
358
+
359
+ const handleZoomOut = () => {
360
+ const pz = panZoom();
361
+ if (!pz) return;
362
+ pz.setZoom(Math.max(pz.zoom / 1.3, 0.1));
363
+ };
364
+
365
+ const isFirstStep = stepState?.index === 0;
366
+ const isLastStep =
367
+ stepState != null && stepState.index === stepState.total - 1;
368
+
369
+ return (
370
+ <div className="my-3 rounded-lg overflow-hidden border border-slate-700/50 bg-slate-900/60">
371
+ {/* Toolbar — zoom controls (always shown unless there's an error) */}
372
+ {!error && (
373
+ <div className="flex items-center justify-end gap-1 px-2 pt-1.5 pb-0">
374
+ {/* Zoom out */}
375
+ <button
376
+ onClick={handleZoomOut}
377
+ title="Zoom out"
378
+ className="flex items-center justify-center w-6 h-6 rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
379
+ >
380
+ <ZoomOut className="w-3.5 h-3.5" />
381
+ </button>
382
+ {/* Zoom in */}
383
+ <button
384
+ onClick={handleZoomIn}
385
+ title="Zoom in"
386
+ className="flex items-center justify-center w-6 h-6 rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
387
+ >
388
+ <ZoomIn className="w-3.5 h-3.5" />
389
+ </button>
390
+ {/* Divider */}
391
+ <span className="w-px h-3 bg-slate-700 mx-0.5" />
392
+ {/* Fit */}
393
+ <button
394
+ onClick={handleFitView}
395
+ title="Fit to view"
396
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
397
+ >
398
+ <Maximize2 className="w-3 h-3" />
399
+ Fit
400
+ </button>
401
+ </div>
402
+ )}
403
+
404
+ {/* Canvas */}
405
+ <div className="relative">
406
+ {error ? (
407
+ <div className="p-3">
408
+ <div className="w-full bg-red-500/10 border border-red-500/20 rounded p-3">
409
+ <div className="flex items-center justify-between mb-1.5">
410
+ <p className="text-xs text-red-400">Viz error:</p>
411
+ <button
412
+ onClick={handleFix}
413
+ disabled={fixing}
414
+ 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"
415
+ >
416
+ {fixing ? (
417
+ <>
418
+ <Loader2 className="w-3 h-3 animate-spin" />
419
+ Fixing…
420
+ </>
421
+ ) : (
422
+ <>
423
+ <RefreshCw className="w-3 h-3" />
424
+ Fix diagram
425
+ </>
426
+ )}
427
+ </button>
428
+ </div>
429
+ <pre className="text-xs text-slate-400 whitespace-pre-wrap break-all">
430
+ {error}
431
+ </pre>
432
+ </div>
433
+ </div>
434
+ ) : (
435
+ <div
436
+ ref={containerRef}
437
+ className="viz-embed-host"
438
+ style={{ width: "100%", height: "360px" }}
439
+ />
440
+ )}
441
+ </div>
442
+
443
+ {/* Step controls — only rendered when spec has steps */}
444
+ {stepState && !error && (
445
+ <div className="flex items-center justify-between px-3 py-2 border-t border-slate-700/50 bg-slate-800/60 gap-3">
446
+ {/* Prev */}
447
+ <button
448
+ onClick={handlePrev}
449
+ disabled={isFirstStep}
450
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
451
+ >
452
+ <ChevronLeft className="w-3.5 h-3.5" />
453
+ Prev
454
+ </button>
455
+
456
+ {/* Step label + counter */}
457
+ <div className="flex-1 text-center overflow-hidden">
458
+ <span className="text-[11px] text-slate-400 block truncate">
459
+ <span className="text-cyan-400 font-medium mr-1.5">
460
+ {stepState.index + 1} / {stepState.total}
461
+ </span>
462
+ {stepState.label}
463
+ </span>
464
+ {/* Progress dots */}
465
+ <div className="flex justify-center gap-1 mt-1">
466
+ {Array.from({ length: stepState.total }).map((_, i) => (
467
+ <span
468
+ key={i}
469
+ className={`inline-block rounded-full transition-all ${
470
+ i === stepState.index
471
+ ? "w-3 h-1.5 bg-cyan-400"
472
+ : "w-1.5 h-1.5 bg-slate-600"
473
+ }`}
474
+ />
475
+ ))}
476
+ </div>
477
+ </div>
478
+
479
+ {/* Next / Restart */}
480
+ {isLastStep ? (
481
+ <button
482
+ onClick={handleRestart}
483
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition-colors"
484
+ >
485
+ <RotateCcw className="w-3.5 h-3.5" />
486
+ Restart
487
+ </button>
488
+ ) : (
489
+ <button
490
+ onClick={handleNext}
491
+ disabled={!stepState.isReady}
492
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded bg-cyan-700 text-white hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
493
+ >
494
+ Next
495
+ <ChevronRight className="w-3.5 h-3.5" />
496
+ </button>
497
+ )}
498
+ </div>
499
+ )}
500
+ </div>
501
+ );
502
+ });
@@ -1,7 +1,58 @@
1
1
  import { create } from "zustand";
2
2
  import type { Topic, Question, CodeSnippet, WorkspaceMeta } from "./types";
3
+ import type { AiSettings } from "./api";
3
4
  import * as api from "./api";
4
5
 
6
+ const DEFAULT_AI_SETTINGS: AiSettings = {
7
+ systemPrompt: "",
8
+ responseProfiles: {
9
+ concise: { maxOutputTokens: 1000, maxSteps: 3 },
10
+ moderate: { maxOutputTokens: 1000, maxSteps: 5 },
11
+ normal: { maxOutputTokens: 3000, maxSteps: 5 },
12
+ },
13
+ vizGuide: "",
14
+ promptGroups: {
15
+ length: {
16
+ label: "Response Length",
17
+ description:
18
+ "Appended to the user message when the selected length changes.",
19
+ default: "normal",
20
+ options: {
21
+ concise:
22
+ "Keep the response concise. Aim for roughly 300 characters of text when possible. These limits do not apply to mermaid diagrams. You can generate as many as you want to explain the solution effectively. Prioritize diagrams over text.",
23
+ moderate:
24
+ "Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
25
+ normal:
26
+ "Use a fuller answer with enough context to explain the idea clearly.",
27
+ },
28
+ },
29
+ style: {
30
+ label: "Response Style",
31
+ description:
32
+ "Appended to the user message when the selected style changes.",
33
+ default: "prose",
34
+ options: {
35
+ prose:
36
+ "Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
37
+ bullets: "Use bullet points and short lists as the main format.",
38
+ structured:
39
+ "Use structured sections with headings and numbered steps when helpful.",
40
+ },
41
+ },
42
+ audience: {
43
+ label: "Response Audience",
44
+ description:
45
+ "Appended to the user message when the selected audience changes.",
46
+ default: "normal",
47
+ options: {
48
+ normal: "",
49
+ beginner:
50
+ "When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'idempotent [an operation that produces the same result no matter how many times it is applied]'. Do this throughout your response so I never need to look anything up.",
51
+ },
52
+ },
53
+ },
54
+ };
55
+
5
56
  interface Store {
6
57
  topics: Topic[];
7
58
  questionsByTopic: Record<string, Question[]>;
@@ -101,6 +152,14 @@ interface Store {
101
152
  addSnippet: (snippet: CodeSnippet) => void;
102
153
  removeSnippet: (id: string) => void;
103
154
  clearSnippets: () => void;
155
+
156
+ // ── AI Settings ──────────────────────────────────────────────
157
+ aiSettings: AiSettings;
158
+ fetchAiSettings: () => Promise<void>;
159
+ saveAiSettings: (patch: Partial<AiSettings>) => Promise<void>;
160
+ showSettings: boolean;
161
+ openSettings: () => void;
162
+ closeSettings: () => void;
104
163
  }
105
164
 
106
165
  export const useStore = create<Store>((set, get) => ({
@@ -115,6 +174,8 @@ export const useStore = create<Store>((set, get) => ({
115
174
  showSidebar: true,
116
175
  viewingFile: null,
117
176
  codeSnippets: [],
177
+ aiSettings: DEFAULT_AI_SETTINGS,
178
+ showSettings: false,
118
179
 
119
180
  // ── Workspaces ───────────────────────────────────────────────
120
181
  workspaces: [],
@@ -507,4 +568,17 @@ export const useStore = create<Store>((set, get) => ({
507
568
 
508
569
  openFileViewer: (path) => set({ viewingFile: path }),
509
570
  closeFileViewer: () => set({ viewingFile: null }),
571
+
572
+ fetchAiSettings: async () => {
573
+ const settings = await api.fetchAiSettings();
574
+ set({ aiSettings: settings });
575
+ },
576
+
577
+ saveAiSettings: async (patch) => {
578
+ const updated = await api.saveAiSettings(patch);
579
+ set({ aiSettings: updated });
580
+ },
581
+
582
+ openSettings: () => set({ showSettings: true }),
583
+ closeSettings: () => set({ showSettings: false }),
510
584
  }));
@@ -36,6 +36,7 @@ export interface Message {
36
36
  id: string;
37
37
  role: "user" | "assistant";
38
38
  content: string;
39
+ parts?: { type: string; [key: string]: any }[];
39
40
  createdAt?: string;
40
41
  }
41
42
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.2.0"
2
+ "version": "0.3.0"
3
3
  }
@@ -0,0 +1,49 @@
1
+ {
2
+ "systemPrompt": "You are a senior engineering interview coach.\n\nHighest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.\nExplain clearly, accurately, and practically.\nOnly include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.\nIf you show code, use a fenced code block with the correct language.\n\nMermaid syntax rules (follow strictly):\n- Wrap node labels in quotes when they contain special characters: A[\"Microservice A (Producer)\"]\n- Edge labels use |text| syntax: A -->|sends message| B\n- Never put parentheses or brackets inside [] without quoting the label\n- Use simple node IDs (letters/numbers) and put descriptive text in the label\n\nYou can produce animated diagrams using ```viz blocks for flows, step-through walkthroughs, or any explanation that benefits from animation. Call getVizGuide() first to get the full spec reference before writing a viz block.",
3
+ "responseProfiles": {
4
+ "concise": {
5
+ "maxOutputTokens": 1000,
6
+ "maxSteps": 3
7
+ },
8
+ "moderate": {
9
+ "maxOutputTokens": 1000,
10
+ "maxSteps": 5
11
+ },
12
+ "normal": {
13
+ "maxOutputTokens": 3000,
14
+ "maxSteps": 5
15
+ }
16
+ },
17
+ "vizGuide": "Animated viz diagrams — full spec reference\n\nThe content of a ```viz block is a YAML (preferred) or JSON object:\n\n view: { width: 900, height: 360 } # required — scene canvas size\n nodes: # required — list of nodes\n - id: client # required, kebab-case\n label: Client # display text\n x: 80 # required — absolute X centre in view\n y: 180 # required — absolute Y centre in view\n shape: rect # rect (default) | circle | cylinder | diamond | hexagon | ellipse | cloud | document | note\n width: 120 # optional, default 120\n height: 40 # optional, default 40\n fill: '#0e7490' # optional hex colour\n stroke: '#164e63' # optional\n edges: # optional\n - from: client\n to: lb\n label: HTTP Request # optional\n animate: flow # optional — adds marching-ants stroke animation\n style: straight # straight (default) | curved | orthogonal\n autoSignals: # optional — self-animating signal dots, no code needed\n - id: req-flow # unique key\n chain: [client, lb, server] # ordered node ids — dot travels this path\n loop: true # restart after reaching the end\n durationPerHop: 700 # ms per hop, default 800\n loopDelay: 0 # ms pause before restart\n color: '#7dd3fc' # optional dot colour\n steps: # optional — replaces autoSignals; adds step-through UI\n - label: Client sends a request # shown in step indicator\n highlight: [client, lb] # node ids to highlight on this step\n signals: # one-shot signals that play on this step\n - id: s1\n chain: [client, lb]\n durationPerHop: 800\n autoAdvance: false # true = auto-proceeds after signals complete\n\nRules:\n- x/y are ABSOLUTE coordinates within the view. Plan layout so nodes don't overlap (typical node is 120×40).\n- Use autoSignals (loop: true) for animated overview diagrams. Use steps for walkthroughs where you explain each phase.\n- Do NOT mix autoSignals and steps in the same spec.\n- Keep node ids simple, lowercase, no spaces (use hyphens).\n- Prefer YAML — it is more readable. Only use JSON if the structure is trivial.\n\nMinimal animated example (autoSignal loop):\n```viz\nview: { width: 860, height: 260 }\nnodes:\n - { id: client, label: Client, x: 80, y: 130, width: 120, height: 40, fill: '#0e7490' }\n - { id: lb, label: Load Balancer, x: 430, y: 130, width: 160, height: 40, fill: '#7c3aed' }\n - { id: srv, label: Server, x: 770, y: 130, width: 120, height: 40, fill: '#065f46' }\nedges:\n - { from: client, to: lb }\n - { from: lb, to: srv }\nautoSignals:\n - { id: flow, chain: [client, lb, srv], loop: true, durationPerHop: 700 }\n```\n\nMinimal step-through example:\n```viz\nview: { width: 860, height: 260 }\nnodes:\n - { id: client, label: Client, x: 80, y: 130, width: 120, height: 40, fill: '#0e7490' }\n - { id: cache, label: Cache, x: 430, y: 130, width: 120, height: 40, fill: '#7c3aed' }\n - { id: db, label: Database, x: 770, y: 130, width: 120, height: 40, fill: '#065f46' }\nedges:\n - { from: client, to: cache }\n - { from: cache, to: db }\nsteps:\n - label: Client queries the cache\n highlight: [client, cache]\n signals: [{ id: s1, chain: [client, cache], durationPerHop: 800 }]\n - label: Cache miss — forward to DB\n highlight: [cache, db]\n signals: [{ id: s2, chain: [cache, db], durationPerHop: 800 }]\n - label: DB responds, cache is populated\n highlight: [db, cache, client]\n signals:\n - { id: s3a, chain: [db, cache], durationPerHop: 600 }\n - { id: s3b, chain: [cache, client], durationPerHop: 600 }\n```",
18
+ "promptGroups": {
19
+ "length": {
20
+ "label": "Response Length",
21
+ "description": "Appended to the user message when the selected length changes.",
22
+ "default": "normal",
23
+ "options": {
24
+ "concise": "Keep the response very concise",
25
+ "moderate": "Keep the response moderately detailed.",
26
+ "normal": "Use a fuller answer with enough context to explain the idea clearly."
27
+ }
28
+ },
29
+ "style": {
30
+ "label": "Response Style",
31
+ "description": "Appended to the user message when the selected style changes.",
32
+ "default": "structured",
33
+ "options": {
34
+ "prose": "Use natural prose with short paragraphs.",
35
+ "bullets": "Use bullet points and short lists as the main format.",
36
+ "structured": "Use structured sections with headings and numbered steps when helpful."
37
+ }
38
+ },
39
+ "audience": {
40
+ "label": "Response Audience",
41
+ "description": "Appended to the user message when the selected audience changes.",
42
+ "default": "normal",
43
+ "options": {
44
+ "normal": "",
45
+ "beginner": "When using technical terms or abbreviations, immediately expand their meaning in square brackets."
46
+ }
47
+ }
48
+ }
49
+ }
@@ -6,7 +6,7 @@
6
6
  "dev": "concurrently -n server,client -c blue,green \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
7
7
  "build": "npm run build --prefix client",
8
8
  "start": "npm run start --prefix server",
9
- "sync:template": "node scripts/sync-template.js"
9
+ "sync:template": "npx create-interview-cockpit upgrade"
10
10
  },
11
11
  "devDependencies": {
12
12
  "concurrently": "^9.1.0"