create-interview-cockpit 0.4.0 → 0.6.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.
- package/package.json +1 -1
- package/template/client/package-lock.json +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +219 -2
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +274 -3
|
@@ -1,28 +1,55 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
X,
|
|
4
|
-
|
|
4
|
+
GripVertical,
|
|
5
|
+
Maximize2,
|
|
6
|
+
Minimize2,
|
|
5
7
|
Save,
|
|
8
|
+
Download,
|
|
6
9
|
ChevronDown,
|
|
7
10
|
ChevronRight,
|
|
8
11
|
Check,
|
|
9
12
|
Loader2,
|
|
10
13
|
Plus,
|
|
11
14
|
Trash2,
|
|
15
|
+
Terminal,
|
|
16
|
+
Play,
|
|
17
|
+
StopCircle,
|
|
12
18
|
} from "lucide-react";
|
|
13
19
|
import { useStore } from "../store";
|
|
14
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
AiSettings,
|
|
22
|
+
PromptGroup,
|
|
23
|
+
InfraCommandStreamMessage,
|
|
24
|
+
} from "../api";
|
|
25
|
+
import * as api from "../api";
|
|
26
|
+
import { DEFAULT_INFRA_LAB } from "../infraLab";
|
|
27
|
+
|
|
28
|
+
const MIN_W = 480;
|
|
29
|
+
const MIN_H = 420;
|
|
30
|
+
const DEFAULT_W = 860;
|
|
31
|
+
const DEFAULT_H = 700;
|
|
32
|
+
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
33
|
+
|
|
34
|
+
interface ConsoleLine {
|
|
35
|
+
id: string;
|
|
36
|
+
kind: "stdout" | "stderr" | "info" | "input";
|
|
37
|
+
text: string;
|
|
38
|
+
}
|
|
15
39
|
|
|
16
40
|
// ── Committed baseline — mirrors data/ai-settings.json ───────────────────────
|
|
17
41
|
const BASELINE: AiSettings = {
|
|
18
42
|
systemPrompt:
|
|
19
|
-
"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.",
|
|
43
|
+
"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, plot blocks, 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.\n\nYou can produce graphs, curves, distributions, and data charts using ```plot blocks. Call getPlotGuide() first to get the full plotting spec reference before writing a plot block.",
|
|
20
44
|
responseProfiles: {
|
|
21
45
|
concise: { maxOutputTokens: 1000, maxSteps: 3 },
|
|
22
46
|
moderate: { maxOutputTokens: 1000, maxSteps: 5 },
|
|
23
47
|
normal: { maxOutputTokens: 3000, maxSteps: 5 },
|
|
48
|
+
brief: { maxOutputTokens: 10000, maxSteps: 5 },
|
|
24
49
|
},
|
|
25
50
|
vizGuide: "",
|
|
51
|
+
plotGuide: "",
|
|
52
|
+
alwaysSendPrefsDefault: false,
|
|
26
53
|
promptGroups: {
|
|
27
54
|
length: {
|
|
28
55
|
label: "Response Length",
|
|
@@ -62,6 +89,19 @@ const BASELINE: AiSettings = {
|
|
|
62
89
|
"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.",
|
|
63
90
|
},
|
|
64
91
|
},
|
|
92
|
+
"diagram-use": {
|
|
93
|
+
label: "Diagram Usage",
|
|
94
|
+
description: "Which diagrams to use and how often",
|
|
95
|
+
default: "none",
|
|
96
|
+
options: {
|
|
97
|
+
none: "",
|
|
98
|
+
vizcraft:
|
|
99
|
+
"Prioritize using vizcraft (viz) diagrams as much as possible. *NB:* Character limits do not apply to these diagrams",
|
|
100
|
+
mermaid:
|
|
101
|
+
"Prioritize using mermaid diagrams as much as possible. *NB:* Character limits do not apply to these diagrams",
|
|
102
|
+
plot: "Prioritize using plot blocks for graphs, curves, distributions, and data charts when they would make the explanation clearer. *NB:* Character limits do not apply to these plots",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
65
105
|
},
|
|
66
106
|
};
|
|
67
107
|
|
|
@@ -347,13 +387,197 @@ function PromptGroupSection({
|
|
|
347
387
|
// ── Main modal ───────────────────────────────────────────────────────────────
|
|
348
388
|
|
|
349
389
|
export default function AiSettingsModal() {
|
|
350
|
-
const { aiSettings, saveAiSettings, closeSettings } =
|
|
390
|
+
const { aiSettings, saveAiSettings, closeSettings, currentQuestion } =
|
|
391
|
+
useStore();
|
|
351
392
|
const [draft, setDraft] = useState<AiSettings>(() =>
|
|
352
393
|
JSON.parse(JSON.stringify(aiSettings)),
|
|
353
394
|
);
|
|
354
395
|
const [saving, setSaving] = useState(false);
|
|
355
396
|
const [saved, setSaved] = useState(false);
|
|
356
|
-
|
|
397
|
+
|
|
398
|
+
// ── Drag / resize ─────────────────────────────────────────────
|
|
399
|
+
const [pos, setPos] = useState(() => ({
|
|
400
|
+
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
|
401
|
+
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
|
402
|
+
}));
|
|
403
|
+
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
|
404
|
+
const [maximized, setMaximized] = useState(false);
|
|
405
|
+
|
|
406
|
+
const dragStart = useRef<{
|
|
407
|
+
mx: number;
|
|
408
|
+
my: number;
|
|
409
|
+
ox: number;
|
|
410
|
+
oy: number;
|
|
411
|
+
} | null>(null);
|
|
412
|
+
const resizeDir = useRef<ResizeDir>(null);
|
|
413
|
+
const resizeStart = useRef<{
|
|
414
|
+
mx: number;
|
|
415
|
+
my: number;
|
|
416
|
+
ox: number;
|
|
417
|
+
oy: number;
|
|
418
|
+
ow: number;
|
|
419
|
+
oh: number;
|
|
420
|
+
} | null>(null);
|
|
421
|
+
const savedPos = useRef(pos);
|
|
422
|
+
const savedSize = useRef(size);
|
|
423
|
+
|
|
424
|
+
const onTitleMouseDown = useCallback(
|
|
425
|
+
(e: React.MouseEvent) => {
|
|
426
|
+
if (maximized) return;
|
|
427
|
+
e.preventDefault();
|
|
428
|
+
dragStart.current = {
|
|
429
|
+
mx: e.clientX,
|
|
430
|
+
my: e.clientY,
|
|
431
|
+
ox: pos.x,
|
|
432
|
+
oy: pos.y,
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
[maximized, pos],
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const startResize = useCallback(
|
|
439
|
+
(dir: ResizeDir) => (e: React.MouseEvent) => {
|
|
440
|
+
if (maximized) return;
|
|
441
|
+
e.preventDefault();
|
|
442
|
+
e.stopPropagation();
|
|
443
|
+
resizeDir.current = dir;
|
|
444
|
+
resizeStart.current = {
|
|
445
|
+
mx: e.clientX,
|
|
446
|
+
my: e.clientY,
|
|
447
|
+
ox: pos.x,
|
|
448
|
+
oy: pos.y,
|
|
449
|
+
ow: size.w,
|
|
450
|
+
oh: size.h,
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
[maximized, pos, size],
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const toggleMax = useCallback(() => {
|
|
457
|
+
if (!maximized) {
|
|
458
|
+
savedPos.current = pos;
|
|
459
|
+
savedSize.current = size;
|
|
460
|
+
setMaximized(true);
|
|
461
|
+
} else {
|
|
462
|
+
setPos(savedPos.current);
|
|
463
|
+
setSize(savedSize.current);
|
|
464
|
+
setMaximized(false);
|
|
465
|
+
}
|
|
466
|
+
}, [maximized, pos, size]);
|
|
467
|
+
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
const onMove = (e: MouseEvent) => {
|
|
470
|
+
const drag = dragStart.current;
|
|
471
|
+
const resize = resizeStart.current;
|
|
472
|
+
const dir = resizeDir.current;
|
|
473
|
+
if (drag) {
|
|
474
|
+
setPos({
|
|
475
|
+
x: Math.max(0, drag.ox + e.clientX - drag.mx),
|
|
476
|
+
y: Math.max(0, drag.oy + e.clientY - drag.my),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (resize && dir) {
|
|
480
|
+
const dx = e.clientX - resize.mx;
|
|
481
|
+
const dy = e.clientY - resize.my;
|
|
482
|
+
setSize((prev) => {
|
|
483
|
+
let w = prev.w,
|
|
484
|
+
h = prev.h;
|
|
485
|
+
if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
|
|
486
|
+
if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
|
|
487
|
+
if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
|
|
488
|
+
if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
|
|
489
|
+
return { w, h };
|
|
490
|
+
});
|
|
491
|
+
if (dir.includes("w"))
|
|
492
|
+
setPos((p) => ({
|
|
493
|
+
...p,
|
|
494
|
+
x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
|
|
495
|
+
}));
|
|
496
|
+
if (dir.includes("n"))
|
|
497
|
+
setPos((p) => ({
|
|
498
|
+
...p,
|
|
499
|
+
y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
|
|
500
|
+
}));
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
const onUp = () => {
|
|
504
|
+
dragStart.current = null;
|
|
505
|
+
resizeStart.current = null;
|
|
506
|
+
resizeDir.current = null;
|
|
507
|
+
};
|
|
508
|
+
document.addEventListener("mousemove", onMove);
|
|
509
|
+
document.addEventListener("mouseup", onUp);
|
|
510
|
+
return () => {
|
|
511
|
+
document.removeEventListener("mousemove", onMove);
|
|
512
|
+
document.removeEventListener("mouseup", onUp);
|
|
513
|
+
};
|
|
514
|
+
}, []);
|
|
515
|
+
|
|
516
|
+
// ── Console ───────────────────────────────────────────────────
|
|
517
|
+
const [consoleOpen, setConsoleOpen] = useState(false);
|
|
518
|
+
const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
|
|
519
|
+
const [cmdInput, setCmdInput] = useState("");
|
|
520
|
+
const [consoleRunning, setConsoleRunning] = useState(false);
|
|
521
|
+
const consoleOutputRef = useRef<HTMLDivElement>(null);
|
|
522
|
+
const cmdAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
|
|
523
|
+
|
|
524
|
+
const appendLine = useCallback((kind: ConsoleLine["kind"], text: string) => {
|
|
525
|
+
setConsoleLines((prev) => [
|
|
526
|
+
...prev,
|
|
527
|
+
{ id: crypto.randomUUID(), kind, text },
|
|
528
|
+
]);
|
|
529
|
+
}, []);
|
|
530
|
+
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
if (consoleOutputRef.current) {
|
|
533
|
+
consoleOutputRef.current.scrollTop =
|
|
534
|
+
consoleOutputRef.current.scrollHeight;
|
|
535
|
+
}
|
|
536
|
+
}, [consoleLines]);
|
|
537
|
+
|
|
538
|
+
const handleRunCommand = useCallback(async () => {
|
|
539
|
+
const cmd = cmdInput.trim();
|
|
540
|
+
if (!cmd || consoleRunning) return;
|
|
541
|
+
setCmdInput("");
|
|
542
|
+
setConsoleRunning(true);
|
|
543
|
+
const abort = { aborted: false };
|
|
544
|
+
cmdAbortRef.current = abort;
|
|
545
|
+
appendLine("input", `$ ${cmd}`);
|
|
546
|
+
try {
|
|
547
|
+
await api.streamInfraCommand(
|
|
548
|
+
{
|
|
549
|
+
questionId: currentQuestion?.id,
|
|
550
|
+
command: cmd,
|
|
551
|
+
workspace: DEFAULT_INFRA_LAB,
|
|
552
|
+
},
|
|
553
|
+
(msg: InfraCommandStreamMessage) => {
|
|
554
|
+
if (abort.aborted) return;
|
|
555
|
+
if (msg.type === "output") appendLine(msg.kind, msg.text);
|
|
556
|
+
else if (msg.type === "error") appendLine("stderr", msg.error);
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
} catch (err: unknown) {
|
|
560
|
+
if (!abort.aborted)
|
|
561
|
+
appendLine("stderr", (err as Error)?.message ?? "Command failed");
|
|
562
|
+
} finally {
|
|
563
|
+
if (!abort.aborted) setConsoleRunning(false);
|
|
564
|
+
}
|
|
565
|
+
}, [cmdInput, consoleRunning, currentQuestion, appendLine]);
|
|
566
|
+
|
|
567
|
+
const handleStop = useCallback(() => {
|
|
568
|
+
cmdAbortRef.current.aborted = true;
|
|
569
|
+
setConsoleRunning(false);
|
|
570
|
+
appendLine("info", "^C");
|
|
571
|
+
}, [appendLine]);
|
|
572
|
+
|
|
573
|
+
// ── New profile form state ────────────────────────────────────
|
|
574
|
+
const [showNewProfileForm, setShowNewProfileForm] = useState(false);
|
|
575
|
+
const [newProfileKey, setNewProfileKey] = useState("");
|
|
576
|
+
|
|
577
|
+
function resetNewProfileForm() {
|
|
578
|
+
setShowNewProfileForm(false);
|
|
579
|
+
setNewProfileKey("");
|
|
580
|
+
}
|
|
357
581
|
|
|
358
582
|
// ── New group form state ─────────────────────
|
|
359
583
|
const [showNewGroupForm, setShowNewGroupForm] = useState(false);
|
|
@@ -406,6 +630,26 @@ export default function AiSettingsModal() {
|
|
|
406
630
|
}));
|
|
407
631
|
}
|
|
408
632
|
|
|
633
|
+
function addProfile(key: string) {
|
|
634
|
+
const k = key.trim().toLowerCase().replace(/\s+/g, "-");
|
|
635
|
+
if (!k || k in draft.responseProfiles) return;
|
|
636
|
+
setDraft((d) => ({
|
|
637
|
+
...d,
|
|
638
|
+
responseProfiles: {
|
|
639
|
+
...d.responseProfiles,
|
|
640
|
+
[k]: { maxOutputTokens: 2000, maxSteps: 5 },
|
|
641
|
+
},
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function removeProfile(key: string) {
|
|
646
|
+
setDraft((d) => {
|
|
647
|
+
const next = { ...d.responseProfiles };
|
|
648
|
+
delete next[key];
|
|
649
|
+
return { ...d, responseProfiles: next };
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
409
653
|
function patchGroupOption(groupKey: string, optKey: string, value: string) {
|
|
410
654
|
setDraft((d) => ({
|
|
411
655
|
...d,
|
|
@@ -529,298 +773,661 @@ export default function AiSettingsModal() {
|
|
|
529
773
|
}
|
|
530
774
|
}
|
|
531
775
|
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
776
|
+
function handleSaveAs() {
|
|
777
|
+
const blob = new Blob([JSON.stringify(draft, null, 2)], {
|
|
778
|
+
type: "application/json",
|
|
779
|
+
});
|
|
780
|
+
const url = URL.createObjectURL(blob);
|
|
781
|
+
const a = document.createElement("a");
|
|
782
|
+
a.href = url;
|
|
783
|
+
a.download = "ai-settings.json";
|
|
784
|
+
a.click();
|
|
785
|
+
URL.revokeObjectURL(url);
|
|
536
786
|
}
|
|
537
787
|
|
|
538
788
|
// ── Render ────────────────────────────────────────────────────
|
|
539
789
|
const profileKeys = Object.keys(draft.responseProfiles);
|
|
540
790
|
|
|
791
|
+
const windowStyle: React.CSSProperties = maximized
|
|
792
|
+
? {
|
|
793
|
+
position: "fixed",
|
|
794
|
+
inset: 0,
|
|
795
|
+
width: "100vw",
|
|
796
|
+
height: "100vh",
|
|
797
|
+
borderRadius: 0,
|
|
798
|
+
}
|
|
799
|
+
: {
|
|
800
|
+
position: "fixed",
|
|
801
|
+
left: pos.x,
|
|
802
|
+
top: pos.y,
|
|
803
|
+
width: size.w,
|
|
804
|
+
height: size.h,
|
|
805
|
+
minWidth: MIN_W,
|
|
806
|
+
minHeight: MIN_H,
|
|
807
|
+
};
|
|
808
|
+
|
|
541
809
|
return (
|
|
542
810
|
<div
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
onMouseDown={(e) => {
|
|
546
|
-
if (e.target === overlayRef.current) closeSettings();
|
|
547
|
-
}}
|
|
811
|
+
className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
|
|
812
|
+
style={windowStyle}
|
|
548
813
|
>
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
<
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
814
|
+
{/* ── Resize handles ── */}
|
|
815
|
+
{!maximized && (
|
|
816
|
+
<>
|
|
817
|
+
<div
|
|
818
|
+
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize z-10"
|
|
819
|
+
onMouseDown={startResize("n")}
|
|
820
|
+
/>
|
|
821
|
+
<div
|
|
822
|
+
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize z-10"
|
|
823
|
+
onMouseDown={startResize("s")}
|
|
824
|
+
/>
|
|
825
|
+
<div
|
|
826
|
+
className="absolute top-0 left-0 bottom-0 w-1 cursor-w-resize z-10"
|
|
827
|
+
onMouseDown={startResize("w")}
|
|
828
|
+
/>
|
|
829
|
+
<div
|
|
830
|
+
className="absolute top-0 right-0 bottom-0 w-1 cursor-e-resize z-10"
|
|
831
|
+
onMouseDown={startResize("e")}
|
|
832
|
+
/>
|
|
833
|
+
<div
|
|
834
|
+
className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
|
|
835
|
+
onMouseDown={startResize("nw")}
|
|
836
|
+
/>
|
|
837
|
+
<div
|
|
838
|
+
className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
|
|
839
|
+
onMouseDown={startResize("ne")}
|
|
840
|
+
/>
|
|
841
|
+
<div
|
|
842
|
+
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
|
|
843
|
+
onMouseDown={startResize("sw")}
|
|
844
|
+
/>
|
|
845
|
+
<div
|
|
846
|
+
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
|
|
847
|
+
onMouseDown={startResize("se")}
|
|
848
|
+
/>
|
|
849
|
+
</>
|
|
850
|
+
)}
|
|
574
851
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
852
|
+
{/* ── Title bar ── */}
|
|
853
|
+
<div
|
|
854
|
+
className="flex items-center gap-2 px-3 py-2.5 bg-slate-800 border-b border-slate-700 shrink-0"
|
|
855
|
+
onMouseDown={onTitleMouseDown}
|
|
856
|
+
style={{ cursor: maximized ? "default" : "grab" }}
|
|
857
|
+
>
|
|
858
|
+
<GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
859
|
+
<span className="text-sm font-semibold text-slate-100 flex-1">
|
|
860
|
+
AI Settings
|
|
861
|
+
</span>
|
|
862
|
+
<button
|
|
863
|
+
type="button"
|
|
864
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
865
|
+
onClick={toggleMax}
|
|
866
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
867
|
+
title={maximized ? "Restore" : "Maximise"}
|
|
868
|
+
>
|
|
869
|
+
{maximized ? (
|
|
870
|
+
<Minimize2 className="w-3.5 h-3.5" />
|
|
871
|
+
) : (
|
|
872
|
+
<Maximize2 className="w-3.5 h-3.5" />
|
|
873
|
+
)}
|
|
874
|
+
</button>
|
|
875
|
+
<button
|
|
876
|
+
type="button"
|
|
877
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
878
|
+
onClick={closeSettings}
|
|
879
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
|
|
880
|
+
title="Close (Esc)"
|
|
881
|
+
>
|
|
882
|
+
<X className="w-3.5 h-3.5" />
|
|
883
|
+
</button>
|
|
884
|
+
</div>
|
|
885
|
+
|
|
886
|
+
{/* Scrollable body */}
|
|
887
|
+
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4 min-h-0">
|
|
888
|
+
{/* ── System Prompt ─────────────────────────────────── */}
|
|
889
|
+
<Section title="System Prompt">
|
|
890
|
+
<Textarea
|
|
891
|
+
value={draft.systemPrompt}
|
|
892
|
+
onChange={(v) => patchTop("systemPrompt", v)}
|
|
893
|
+
rows={8}
|
|
894
|
+
/>
|
|
895
|
+
</Section>
|
|
585
896
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
897
|
+
{/* ── Response Profiles ─────────────────────────────── */}
|
|
898
|
+
<Section title="Response Profiles (token limits per length setting)">
|
|
899
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
|
900
|
+
{profileKeys.map((key) => (
|
|
901
|
+
<div key={key} className="space-y-3">
|
|
902
|
+
<div className="flex items-center justify-between">
|
|
591
903
|
<p className="text-xs font-semibold text-cyan-400 capitalize">
|
|
592
904
|
{key}
|
|
593
905
|
</p>
|
|
594
|
-
|
|
595
|
-
<
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
step={100}
|
|
604
|
-
/>
|
|
605
|
-
</div>
|
|
606
|
-
<div>
|
|
607
|
-
<Label>Max Steps</Label>
|
|
608
|
-
<NumberInput
|
|
609
|
-
value={draft.responseProfiles[key].maxSteps}
|
|
610
|
-
onChange={(v) => patchProfileField(key, "maxSteps", v)}
|
|
611
|
-
min={1}
|
|
612
|
-
max={20}
|
|
613
|
-
/>
|
|
614
|
-
</div>
|
|
615
|
-
</div>
|
|
616
|
-
))}
|
|
617
|
-
</div>
|
|
618
|
-
</Section>
|
|
619
|
-
|
|
620
|
-
{/* ── Prompt Groups ─────────────────────────────────── */}
|
|
621
|
-
{Object.entries(draft.promptGroups).map(([groupKey, group]) => (
|
|
622
|
-
<PromptGroupSection
|
|
623
|
-
key={groupKey}
|
|
624
|
-
groupKey={groupKey}
|
|
625
|
-
group={group}
|
|
626
|
-
onOptionChange={(optKey, value) =>
|
|
627
|
-
patchGroupOption(groupKey, optKey, value)
|
|
628
|
-
}
|
|
629
|
-
onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
|
|
630
|
-
onAddOption={(optKey, prompt) =>
|
|
631
|
-
addGroupOption(groupKey, optKey, prompt)
|
|
632
|
-
}
|
|
633
|
-
onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
|
|
634
|
-
onRemoveGroup={() => removeGroup(groupKey)}
|
|
635
|
-
onMetaChange={(field, value) =>
|
|
636
|
-
patchGroupMeta(groupKey, field, value)
|
|
637
|
-
}
|
|
638
|
-
/>
|
|
639
|
-
))}
|
|
640
|
-
|
|
641
|
-
{/* ── Add group form ─────────────────────────────── */}
|
|
642
|
-
{showNewGroupForm ? (
|
|
643
|
-
<div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
|
|
644
|
-
<p className="text-sm font-medium text-cyan-300">
|
|
645
|
-
New setting group
|
|
646
|
-
</p>
|
|
647
|
-
<div className="grid grid-cols-3 gap-3">
|
|
648
|
-
<div>
|
|
649
|
-
<Label>Key (internal ID)</Label>
|
|
650
|
-
<input
|
|
651
|
-
type="text"
|
|
652
|
-
value={newGroupKey}
|
|
653
|
-
onChange={(e) =>
|
|
654
|
-
setNewGroupKey(
|
|
655
|
-
e.target.value.toLowerCase().replace(/\s+/g, "-"),
|
|
656
|
-
)
|
|
657
|
-
}
|
|
658
|
-
placeholder="e.g. tone"
|
|
659
|
-
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
660
|
-
/>
|
|
661
|
-
{newGroupKey && newGroupKey in draft.promptGroups && (
|
|
662
|
-
<p className="text-xs text-red-400 mt-1">
|
|
663
|
-
Key already exists.
|
|
664
|
-
</p>
|
|
906
|
+
{profileKeys.length > 1 && (
|
|
907
|
+
<button
|
|
908
|
+
type="button"
|
|
909
|
+
title={`Remove "${key}" profile`}
|
|
910
|
+
onClick={() => removeProfile(key)}
|
|
911
|
+
className="p-0.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
912
|
+
>
|
|
913
|
+
<Trash2 className="w-3 h-3" />
|
|
914
|
+
</button>
|
|
665
915
|
)}
|
|
666
916
|
</div>
|
|
667
917
|
<div>
|
|
668
|
-
<Label>
|
|
669
|
-
<
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
918
|
+
<Label>Max Output Tokens</Label>
|
|
919
|
+
<NumberInput
|
|
920
|
+
value={draft.responseProfiles[key].maxOutputTokens}
|
|
921
|
+
onChange={(v) =>
|
|
922
|
+
patchProfileField(key, "maxOutputTokens", v)
|
|
923
|
+
}
|
|
924
|
+
min={100}
|
|
925
|
+
max={32000}
|
|
926
|
+
step={100}
|
|
675
927
|
/>
|
|
676
928
|
</div>
|
|
677
929
|
<div>
|
|
678
|
-
<Label>
|
|
679
|
-
<
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
930
|
+
<Label>Max Steps</Label>
|
|
931
|
+
<NumberInput
|
|
932
|
+
value={draft.responseProfiles[key].maxSteps}
|
|
933
|
+
onChange={(v) => patchProfileField(key, "maxSteps", v)}
|
|
934
|
+
min={1}
|
|
935
|
+
max={20}
|
|
685
936
|
/>
|
|
686
937
|
</div>
|
|
687
938
|
</div>
|
|
939
|
+
))}
|
|
940
|
+
</div>
|
|
688
941
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
};
|
|
704
|
-
setNewGroupOptions(upd);
|
|
705
|
-
}}
|
|
706
|
-
placeholder="key"
|
|
707
|
-
className="w-28 shrink-0 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
708
|
-
/>
|
|
709
|
-
<input
|
|
710
|
-
type="text"
|
|
711
|
-
value={opt.prompt}
|
|
712
|
-
onChange={(e) => {
|
|
713
|
-
const upd = [...newGroupOptions];
|
|
714
|
-
upd[i] = { ...upd[i], prompt: e.target.value };
|
|
715
|
-
setNewGroupOptions(upd);
|
|
716
|
-
}}
|
|
717
|
-
placeholder="Prompt text appended to message when selected (can be empty)"
|
|
718
|
-
className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
719
|
-
/>
|
|
720
|
-
{newGroupOptions.length > 1 && (
|
|
721
|
-
<button
|
|
722
|
-
type="button"
|
|
723
|
-
onClick={() =>
|
|
724
|
-
setNewGroupOptions((prev) =>
|
|
725
|
-
prev.filter((_, j) => j !== i),
|
|
726
|
-
)
|
|
727
|
-
}
|
|
728
|
-
className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
729
|
-
>
|
|
730
|
-
<Trash2 className="w-3.5 h-3.5" />
|
|
731
|
-
</button>
|
|
732
|
-
)}
|
|
733
|
-
</div>
|
|
734
|
-
))}
|
|
735
|
-
<button
|
|
736
|
-
type="button"
|
|
737
|
-
onClick={() =>
|
|
738
|
-
setNewGroupOptions((prev) => [
|
|
739
|
-
...prev,
|
|
740
|
-
{ key: "", prompt: "" },
|
|
741
|
-
])
|
|
942
|
+
{/* Add profile */}
|
|
943
|
+
{showNewProfileForm ? (
|
|
944
|
+
<div className="mt-4 border border-slate-700 rounded-lg p-3 space-y-2 bg-slate-800/40">
|
|
945
|
+
<p className="text-xs font-medium text-slate-400">
|
|
946
|
+
New profile key
|
|
947
|
+
</p>
|
|
948
|
+
<div className="flex gap-2">
|
|
949
|
+
<input
|
|
950
|
+
type="text"
|
|
951
|
+
value={newProfileKey}
|
|
952
|
+
onChange={(e) =>
|
|
953
|
+
setNewProfileKey(
|
|
954
|
+
e.target.value.toLowerCase().replace(/\s+/g, "-"),
|
|
955
|
+
)
|
|
742
956
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
</button>
|
|
747
|
-
</div>
|
|
748
|
-
|
|
749
|
-
<div className="flex gap-2 pt-1">
|
|
957
|
+
placeholder="e.g. brief"
|
|
958
|
+
className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
959
|
+
/>
|
|
750
960
|
<button
|
|
751
961
|
type="button"
|
|
752
|
-
onClick={
|
|
962
|
+
onClick={() => {
|
|
963
|
+
addProfile(newProfileKey);
|
|
964
|
+
resetNewProfileForm();
|
|
965
|
+
}}
|
|
753
966
|
disabled={
|
|
754
|
-
!
|
|
755
|
-
|
|
756
|
-
newGroupOptions.every((o) => !o.key.trim())
|
|
967
|
+
!newProfileKey.trim() ||
|
|
968
|
+
newProfileKey.trim() in draft.responseProfiles
|
|
757
969
|
}
|
|
758
|
-
className="px-
|
|
970
|
+
className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
|
|
759
971
|
>
|
|
760
|
-
|
|
972
|
+
Add
|
|
761
973
|
</button>
|
|
762
974
|
<button
|
|
763
975
|
type="button"
|
|
764
|
-
onClick={
|
|
765
|
-
className="px-
|
|
976
|
+
onClick={resetNewProfileForm}
|
|
977
|
+
className="px-3 py-1 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
766
978
|
>
|
|
767
979
|
Cancel
|
|
768
980
|
</button>
|
|
769
981
|
</div>
|
|
982
|
+
{newProfileKey.trim() in draft.responseProfiles && (
|
|
983
|
+
<p className="text-xs text-red-400">
|
|
984
|
+
A profile with that key already exists.
|
|
985
|
+
</p>
|
|
986
|
+
)}
|
|
770
987
|
</div>
|
|
771
988
|
) : (
|
|
772
989
|
<button
|
|
773
990
|
type="button"
|
|
774
|
-
onClick={() =>
|
|
775
|
-
className="flex items-center gap-
|
|
991
|
+
onClick={() => setShowNewProfileForm(true)}
|
|
992
|
+
className="mt-3 flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
|
|
776
993
|
>
|
|
777
|
-
<Plus className="w-
|
|
994
|
+
<Plus className="w-3.5 h-3.5" /> Add profile
|
|
778
995
|
</button>
|
|
779
996
|
)}
|
|
997
|
+
</Section>
|
|
780
998
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
999
|
+
{/* ── Prompt Groups ─────────────────────────────────── */}
|
|
1000
|
+
{Object.entries(draft.promptGroups).map(([groupKey, group]) => (
|
|
1001
|
+
<PromptGroupSection
|
|
1002
|
+
key={groupKey}
|
|
1003
|
+
groupKey={groupKey}
|
|
1004
|
+
group={group}
|
|
1005
|
+
onOptionChange={(optKey, value) =>
|
|
1006
|
+
patchGroupOption(groupKey, optKey, value)
|
|
1007
|
+
}
|
|
1008
|
+
onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
|
|
1009
|
+
onAddOption={(optKey, prompt) =>
|
|
1010
|
+
addGroupOption(groupKey, optKey, prompt)
|
|
1011
|
+
}
|
|
1012
|
+
onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
|
|
1013
|
+
onRemoveGroup={() => removeGroup(groupKey)}
|
|
1014
|
+
onMetaChange={(field, value) =>
|
|
1015
|
+
patchGroupMeta(groupKey, field, value)
|
|
1016
|
+
}
|
|
1017
|
+
/>
|
|
1018
|
+
))}
|
|
1019
|
+
|
|
1020
|
+
{/* ── Preferences behaviour ─────────────────────────── */}
|
|
1021
|
+
<Section title="Preference Sending Behaviour">
|
|
1022
|
+
<div className="flex items-center justify-between">
|
|
1023
|
+
<div>
|
|
1024
|
+
<p className="text-sm text-slate-200">Always send preferences</p>
|
|
1025
|
+
<p className="text-xs text-slate-500 mt-0.5">
|
|
1026
|
+
When on, preference prompt texts are appended to every message.
|
|
1027
|
+
When off, they are only sent when you change a setting (saves
|
|
1028
|
+
tokens).
|
|
1029
|
+
</p>
|
|
1030
|
+
</div>
|
|
1031
|
+
<button
|
|
1032
|
+
type="button"
|
|
1033
|
+
onClick={() =>
|
|
1034
|
+
patchTop(
|
|
1035
|
+
"alwaysSendPrefsDefault",
|
|
1036
|
+
!draft.alwaysSendPrefsDefault,
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
className={`relative shrink-0 w-10 h-5 rounded-full transition-colors ${
|
|
1040
|
+
draft.alwaysSendPrefsDefault ? "bg-amber-500" : "bg-slate-700"
|
|
1041
|
+
}`}
|
|
1042
|
+
>
|
|
1043
|
+
<span
|
|
1044
|
+
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
|
1045
|
+
draft.alwaysSendPrefsDefault ? "translate-x-5" : ""
|
|
1046
|
+
}`}
|
|
1047
|
+
/>
|
|
1048
|
+
</button>
|
|
1049
|
+
</div>
|
|
1050
|
+
</Section>
|
|
1051
|
+
|
|
1052
|
+
{/* ── Model / Thinking ───────────────────────────────── */}
|
|
1053
|
+
<Section title="Model & Thinking" defaultOpen={false}>
|
|
1054
|
+
{/* Current model info (read-only) */}
|
|
1055
|
+
<div className="flex gap-3 mb-4">
|
|
1056
|
+
<div className="flex-1">
|
|
1057
|
+
<Label>Provider</Label>
|
|
1058
|
+
<div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono">
|
|
1059
|
+
{draft.provider || "openai"}
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
<div className="flex-1">
|
|
1063
|
+
<Label>Model</Label>
|
|
1064
|
+
<div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono truncate">
|
|
1065
|
+
{draft.model || "(default)"}
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
|
|
1070
|
+
{/* Thinking budget — only useful for Google/Gemini */}
|
|
1071
|
+
{["google", "gemini"].includes(draft.provider ?? "") ? (
|
|
1072
|
+
<div>
|
|
1073
|
+
<Label>Thinking Budget</Label>
|
|
1074
|
+
<p className="text-xs text-slate-500 mb-2">
|
|
1075
|
+
Number of tokens Gemini can use for internal reasoning before
|
|
1076
|
+
responding. 0 = disabled. Shows a collapsible "Thinking…" block
|
|
1077
|
+
in the chat. Recommended: 8000 for medium, 0 to save tokens.
|
|
1078
|
+
</p>
|
|
1079
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
1080
|
+
{[
|
|
1081
|
+
{ label: "Off", value: 0 },
|
|
1082
|
+
{ label: "Low", value: 1024 },
|
|
1083
|
+
{ label: "Medium", value: 8192 },
|
|
1084
|
+
{ label: "High", value: 24576 },
|
|
1085
|
+
].map((preset) => (
|
|
1086
|
+
<button
|
|
1087
|
+
key={preset.label}
|
|
1088
|
+
type="button"
|
|
1089
|
+
onClick={() => patchTop("thinkingBudget", preset.value)}
|
|
1090
|
+
className={`px-3 py-1 text-xs rounded-md border transition-colors ${
|
|
1091
|
+
(draft.thinkingBudget ?? 0) === preset.value
|
|
1092
|
+
? "bg-cyan-600/30 text-cyan-300 border-cyan-600/50"
|
|
1093
|
+
: "text-slate-500 hover:text-slate-300 border-slate-700 hover:border-slate-500"
|
|
1094
|
+
}`}
|
|
1095
|
+
>
|
|
1096
|
+
{preset.label}
|
|
1097
|
+
{preset.value > 0 && (
|
|
1098
|
+
<span className="ml-1 opacity-60">
|
|
1099
|
+
({preset.value.toLocaleString()})
|
|
1100
|
+
</span>
|
|
1101
|
+
)}
|
|
1102
|
+
</button>
|
|
1103
|
+
))}
|
|
1104
|
+
<div className="flex items-center gap-1.5">
|
|
1105
|
+
<span className="text-xs text-slate-500">Custom:</span>
|
|
1106
|
+
<input
|
|
1107
|
+
type="number"
|
|
1108
|
+
value={draft.thinkingBudget ?? 0}
|
|
1109
|
+
min={0}
|
|
1110
|
+
max={32768}
|
|
1111
|
+
step={256}
|
|
1112
|
+
onChange={(e) =>
|
|
1113
|
+
patchTop("thinkingBudget", Number(e.target.value))
|
|
1114
|
+
}
|
|
1115
|
+
className="w-24 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
1116
|
+
/>
|
|
1117
|
+
</div>
|
|
1118
|
+
</div>
|
|
1119
|
+
</div>
|
|
1120
|
+
) : (
|
|
1121
|
+
<p className="text-xs text-slate-500">
|
|
1122
|
+
Thinking / reasoning display is only available for Google / Gemini
|
|
1123
|
+
models. Switch{" "}
|
|
1124
|
+
<code className="bg-slate-800 px-1 rounded">AI_PROVIDER</code> to{" "}
|
|
1125
|
+
<code className="bg-slate-800 px-1 rounded">google</code> in your{" "}
|
|
1126
|
+
<code className="bg-slate-800 px-1 rounded">.env</code> to enable
|
|
1127
|
+
it.
|
|
789
1128
|
</p>
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
onChange={(v) => patchTop("vizGuide", v)}
|
|
793
|
-
rows={16}
|
|
794
|
-
mono
|
|
795
|
-
/>
|
|
796
|
-
</Section>
|
|
797
|
-
</div>
|
|
1129
|
+
)}
|
|
1130
|
+
</Section>
|
|
798
1131
|
|
|
799
|
-
{/*
|
|
800
|
-
|
|
801
|
-
<
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1132
|
+
{/* ── Add group form ─────────────────────────────── */}
|
|
1133
|
+
{showNewGroupForm ? (
|
|
1134
|
+
<div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
|
|
1135
|
+
<p className="text-sm font-medium text-cyan-300">
|
|
1136
|
+
New setting group
|
|
1137
|
+
</p>
|
|
1138
|
+
<div className="grid grid-cols-3 gap-3">
|
|
1139
|
+
<div>
|
|
1140
|
+
<Label>Key (internal ID)</Label>
|
|
1141
|
+
<input
|
|
1142
|
+
type="text"
|
|
1143
|
+
value={newGroupKey}
|
|
1144
|
+
onChange={(e) =>
|
|
1145
|
+
setNewGroupKey(
|
|
1146
|
+
e.target.value.toLowerCase().replace(/\s+/g, "-"),
|
|
1147
|
+
)
|
|
1148
|
+
}
|
|
1149
|
+
placeholder="e.g. tone"
|
|
1150
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
1151
|
+
/>
|
|
1152
|
+
{newGroupKey && newGroupKey in draft.promptGroups && (
|
|
1153
|
+
<p className="text-xs text-red-400 mt-1">
|
|
1154
|
+
Key already exists.
|
|
1155
|
+
</p>
|
|
1156
|
+
)}
|
|
1157
|
+
</div>
|
|
1158
|
+
<div>
|
|
1159
|
+
<Label>Label (shown in bottom bar)</Label>
|
|
1160
|
+
<input
|
|
1161
|
+
type="text"
|
|
1162
|
+
value={newGroupLabel}
|
|
1163
|
+
onChange={(e) => setNewGroupLabel(e.target.value)}
|
|
1164
|
+
placeholder="e.g. Response Tone"
|
|
1165
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
1166
|
+
/>
|
|
1167
|
+
</div>
|
|
1168
|
+
<div>
|
|
1169
|
+
<Label>Description (optional)</Label>
|
|
1170
|
+
<input
|
|
1171
|
+
type="text"
|
|
1172
|
+
value={newGroupDescription}
|
|
1173
|
+
onChange={(e) => setNewGroupDescription(e.target.value)}
|
|
1174
|
+
placeholder="Shown in the settings panel"
|
|
1175
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
1176
|
+
/>
|
|
1177
|
+
</div>
|
|
1178
|
+
</div>
|
|
1179
|
+
|
|
1180
|
+
<div className="space-y-2">
|
|
1181
|
+
<Label>Options (first will be the default)</Label>
|
|
1182
|
+
{newGroupOptions.map((opt, i) => (
|
|
1183
|
+
<div key={i} className="flex gap-2 items-start">
|
|
1184
|
+
<input
|
|
1185
|
+
type="text"
|
|
1186
|
+
value={opt.key}
|
|
1187
|
+
onChange={(e) => {
|
|
1188
|
+
const upd = [...newGroupOptions];
|
|
1189
|
+
upd[i] = {
|
|
1190
|
+
...upd[i],
|
|
1191
|
+
key: e.target.value.toLowerCase().replace(/\s+/g, "-"),
|
|
1192
|
+
};
|
|
1193
|
+
setNewGroupOptions(upd);
|
|
1194
|
+
}}
|
|
1195
|
+
placeholder="key"
|
|
1196
|
+
className="w-28 shrink-0 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
1197
|
+
/>
|
|
1198
|
+
<input
|
|
1199
|
+
type="text"
|
|
1200
|
+
value={opt.prompt}
|
|
1201
|
+
onChange={(e) => {
|
|
1202
|
+
const upd = [...newGroupOptions];
|
|
1203
|
+
upd[i] = { ...upd[i], prompt: e.target.value };
|
|
1204
|
+
setNewGroupOptions(upd);
|
|
1205
|
+
}}
|
|
1206
|
+
placeholder="Prompt text appended to message when selected (can be empty)"
|
|
1207
|
+
className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
1208
|
+
/>
|
|
1209
|
+
{newGroupOptions.length > 1 && (
|
|
1210
|
+
<button
|
|
1211
|
+
type="button"
|
|
1212
|
+
onClick={() =>
|
|
1213
|
+
setNewGroupOptions((prev) =>
|
|
1214
|
+
prev.filter((_, j) => j !== i),
|
|
1215
|
+
)
|
|
1216
|
+
}
|
|
1217
|
+
className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
1218
|
+
>
|
|
1219
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
1220
|
+
</button>
|
|
1221
|
+
)}
|
|
1222
|
+
</div>
|
|
1223
|
+
))}
|
|
1224
|
+
<button
|
|
1225
|
+
type="button"
|
|
1226
|
+
onClick={() =>
|
|
1227
|
+
setNewGroupOptions((prev) => [
|
|
1228
|
+
...prev,
|
|
1229
|
+
{ key: "", prompt: "" },
|
|
1230
|
+
])
|
|
1231
|
+
}
|
|
1232
|
+
className="flex items-center gap-1 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
|
|
1233
|
+
>
|
|
1234
|
+
<Plus className="w-3.5 h-3.5" /> Add option row
|
|
1235
|
+
</button>
|
|
1236
|
+
</div>
|
|
1237
|
+
|
|
1238
|
+
<div className="flex gap-2 pt-1">
|
|
1239
|
+
<button
|
|
1240
|
+
type="button"
|
|
1241
|
+
onClick={addNewGroup}
|
|
1242
|
+
disabled={
|
|
1243
|
+
!newGroupKey.trim() ||
|
|
1244
|
+
newGroupKey in draft.promptGroups ||
|
|
1245
|
+
newGroupOptions.every((o) => !o.key.trim())
|
|
1246
|
+
}
|
|
1247
|
+
className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
|
|
1248
|
+
>
|
|
1249
|
+
Create group
|
|
1250
|
+
</button>
|
|
1251
|
+
<button
|
|
1252
|
+
type="button"
|
|
1253
|
+
onClick={resetNewGroupForm}
|
|
1254
|
+
className="px-4 py-1.5 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
1255
|
+
>
|
|
1256
|
+
Cancel
|
|
1257
|
+
</button>
|
|
1258
|
+
</div>
|
|
1259
|
+
</div>
|
|
1260
|
+
) : (
|
|
808
1261
|
<button
|
|
809
1262
|
type="button"
|
|
810
|
-
onClick={
|
|
811
|
-
|
|
812
|
-
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-60 text-white transition-colors"
|
|
1263
|
+
onClick={() => setShowNewGroupForm(true)}
|
|
1264
|
+
className="flex items-center gap-2 w-full justify-center py-2.5 border border-dashed border-slate-700 hover:border-cyan-600/50 rounded-lg text-sm text-slate-500 hover:text-cyan-400 transition-colors"
|
|
813
1265
|
>
|
|
814
|
-
|
|
815
|
-
<Loader2 className="w-4 h-4 animate-spin" />
|
|
816
|
-
) : saved ? (
|
|
817
|
-
<Check className="w-4 h-4" />
|
|
818
|
-
) : (
|
|
819
|
-
<Save className="w-4 h-4" />
|
|
820
|
-
)}
|
|
821
|
-
{saved ? "Saved!" : "Save changes"}
|
|
1266
|
+
<Plus className="w-4 h-4" /> Add setting group
|
|
822
1267
|
</button>
|
|
823
|
-
|
|
1268
|
+
)}
|
|
1269
|
+
|
|
1270
|
+
{/* ── Viz Guide ─────────────────────────────────────── */}
|
|
1271
|
+
<Section
|
|
1272
|
+
title="Viz Guide (returned by getVizGuide tool)"
|
|
1273
|
+
defaultOpen={false}
|
|
1274
|
+
>
|
|
1275
|
+
<p className="text-xs text-slate-500 -mt-1">
|
|
1276
|
+
The full VizCraft spec reference the AI receives when it calls{" "}
|
|
1277
|
+
<code className="text-cyan-400">getVizGuide()</code>.
|
|
1278
|
+
</p>
|
|
1279
|
+
<Textarea
|
|
1280
|
+
value={draft.vizGuide}
|
|
1281
|
+
onChange={(v) => patchTop("vizGuide", v)}
|
|
1282
|
+
rows={16}
|
|
1283
|
+
mono
|
|
1284
|
+
/>
|
|
1285
|
+
</Section>
|
|
1286
|
+
|
|
1287
|
+
<Section
|
|
1288
|
+
title="Plot Guide (returned by getPlotGuide tool)"
|
|
1289
|
+
defaultOpen={false}
|
|
1290
|
+
>
|
|
1291
|
+
<p className="text-xs text-slate-500 -mt-1">
|
|
1292
|
+
The plotting spec reference the AI receives when it calls{" "}
|
|
1293
|
+
<code className="text-cyan-400">getPlotGuide()</code>.
|
|
1294
|
+
</p>
|
|
1295
|
+
<Textarea
|
|
1296
|
+
value={draft.plotGuide}
|
|
1297
|
+
onChange={(v) => patchTop("plotGuide", v)}
|
|
1298
|
+
rows={16}
|
|
1299
|
+
mono
|
|
1300
|
+
/>
|
|
1301
|
+
</Section>
|
|
1302
|
+
</div>
|
|
1303
|
+
|
|
1304
|
+
{/* ── Practice Console ── */}
|
|
1305
|
+
<div className="shrink-0 border-t border-slate-700">
|
|
1306
|
+
<button
|
|
1307
|
+
type="button"
|
|
1308
|
+
onClick={() => setConsoleOpen((v) => !v)}
|
|
1309
|
+
className="w-full flex items-center gap-2 px-4 py-2.5 bg-slate-800/60 hover:bg-slate-800 text-left transition-colors"
|
|
1310
|
+
>
|
|
1311
|
+
<Terminal className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
|
1312
|
+
<span className="text-xs font-medium text-slate-300 flex-1">
|
|
1313
|
+
Practice Console
|
|
1314
|
+
</span>
|
|
1315
|
+
<span className="text-[10px] text-slate-500 mr-1">terraform</span>
|
|
1316
|
+
{consoleOpen ? (
|
|
1317
|
+
<ChevronDown className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
|
1318
|
+
) : (
|
|
1319
|
+
<ChevronRight className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
|
1320
|
+
)}
|
|
1321
|
+
</button>
|
|
1322
|
+
|
|
1323
|
+
{consoleOpen && (
|
|
1324
|
+
<div className="flex flex-col bg-slate-950" style={{ height: 220 }}>
|
|
1325
|
+
{/* Output area */}
|
|
1326
|
+
<div
|
|
1327
|
+
ref={consoleOutputRef}
|
|
1328
|
+
className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs leading-5"
|
|
1329
|
+
>
|
|
1330
|
+
{consoleLines.length === 0 && (
|
|
1331
|
+
<p className="text-slate-600">
|
|
1332
|
+
Type a terraform command and press Enter to run it. e.g.{" "}
|
|
1333
|
+
<span className="text-slate-500">terraform version</span>
|
|
1334
|
+
</p>
|
|
1335
|
+
)}
|
|
1336
|
+
{consoleLines.map((line) => (
|
|
1337
|
+
<div
|
|
1338
|
+
key={line.id}
|
|
1339
|
+
className={
|
|
1340
|
+
line.kind === "input"
|
|
1341
|
+
? "text-cyan-400"
|
|
1342
|
+
: line.kind === "stderr"
|
|
1343
|
+
? "text-red-400"
|
|
1344
|
+
: line.kind === "info"
|
|
1345
|
+
? "text-slate-500"
|
|
1346
|
+
: "text-slate-200"
|
|
1347
|
+
}
|
|
1348
|
+
style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}
|
|
1349
|
+
>
|
|
1350
|
+
{line.text}
|
|
1351
|
+
</div>
|
|
1352
|
+
))}
|
|
1353
|
+
{consoleRunning && (
|
|
1354
|
+
<div className="flex items-center gap-1.5 text-slate-500 mt-1">
|
|
1355
|
+
<Loader2 className="w-3 h-3 animate-spin" /> running…
|
|
1356
|
+
</div>
|
|
1357
|
+
)}
|
|
1358
|
+
</div>
|
|
1359
|
+
|
|
1360
|
+
{/* Input bar */}
|
|
1361
|
+
<div className="flex items-center gap-1 px-3 py-2 border-t border-slate-800 bg-slate-900/60">
|
|
1362
|
+
<span className="text-emerald-400 font-mono text-xs select-none shrink-0">
|
|
1363
|
+
$
|
|
1364
|
+
</span>
|
|
1365
|
+
<input
|
|
1366
|
+
type="text"
|
|
1367
|
+
value={cmdInput}
|
|
1368
|
+
onChange={(e) => setCmdInput(e.target.value)}
|
|
1369
|
+
onKeyDown={(e) => {
|
|
1370
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1371
|
+
e.preventDefault();
|
|
1372
|
+
void handleRunCommand();
|
|
1373
|
+
}
|
|
1374
|
+
}}
|
|
1375
|
+
placeholder="terraform version"
|
|
1376
|
+
disabled={consoleRunning}
|
|
1377
|
+
className="flex-1 bg-transparent font-mono text-xs text-slate-200 placeholder-slate-600 outline-none disabled:opacity-50"
|
|
1378
|
+
autoComplete="off"
|
|
1379
|
+
spellCheck={false}
|
|
1380
|
+
/>
|
|
1381
|
+
{consoleRunning ? (
|
|
1382
|
+
<button
|
|
1383
|
+
type="button"
|
|
1384
|
+
onClick={handleStop}
|
|
1385
|
+
className="p-1 rounded text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
|
1386
|
+
title="Stop"
|
|
1387
|
+
>
|
|
1388
|
+
<StopCircle className="w-3.5 h-3.5" />
|
|
1389
|
+
</button>
|
|
1390
|
+
) : (
|
|
1391
|
+
<button
|
|
1392
|
+
type="button"
|
|
1393
|
+
onClick={() => void handleRunCommand()}
|
|
1394
|
+
disabled={!cmdInput.trim()}
|
|
1395
|
+
className="p-1 rounded text-slate-600 hover:text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40 transition-colors shrink-0"
|
|
1396
|
+
title="Run (Enter)"
|
|
1397
|
+
>
|
|
1398
|
+
<Play className="w-3.5 h-3.5" />
|
|
1399
|
+
</button>
|
|
1400
|
+
)}
|
|
1401
|
+
</div>
|
|
1402
|
+
</div>
|
|
1403
|
+
)}
|
|
1404
|
+
</div>
|
|
1405
|
+
|
|
1406
|
+
{/* Footer */}
|
|
1407
|
+
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-slate-700 shrink-0 bg-slate-900">
|
|
1408
|
+
<button
|
|
1409
|
+
type="button"
|
|
1410
|
+
onClick={handleSaveAs}
|
|
1411
|
+
className="flex items-center gap-2 px-4 py-2 text-sm rounded border border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800 transition-colors"
|
|
1412
|
+
>
|
|
1413
|
+
<Download className="w-4 h-4" />
|
|
1414
|
+
Save As
|
|
1415
|
+
</button>
|
|
1416
|
+
<button
|
|
1417
|
+
type="button"
|
|
1418
|
+
onClick={handleSave}
|
|
1419
|
+
disabled={saving}
|
|
1420
|
+
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-60 text-white transition-colors"
|
|
1421
|
+
>
|
|
1422
|
+
{saving ? (
|
|
1423
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
1424
|
+
) : saved ? (
|
|
1425
|
+
<Check className="w-4 h-4" />
|
|
1426
|
+
) : (
|
|
1427
|
+
<Save className="w-4 h-4" />
|
|
1428
|
+
)}
|
|
1429
|
+
{saved ? "Saved!" : "Save"}
|
|
1430
|
+
</button>
|
|
824
1431
|
</div>
|
|
825
1432
|
</div>
|
|
826
1433
|
);
|