create-interview-cockpit 0.5.0 → 0.7.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 +734 -1
- package/template/client/package.json +1 -0
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +384 -4
- package/template/client/src/components/AiSettingsModal.tsx +818 -425
- package/template/client/src/components/ChatMessage.tsx +34 -12
- package/template/client/src/components/ChatView.tsx +298 -121
- package/template/client/src/components/CodeContextPanel.tsx +530 -2
- package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
- package/template/client/src/components/DocRefModal.tsx +55 -6
- package/template/client/src/components/FileAttachments.tsx +20 -4
- 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 +22 -8
- 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 +184 -0
- package/template/client/src/components/VizCraftEmbed.tsx +257 -13
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +960 -0
- package/template/client/src/store.ts +250 -6
- package/template/client/src/types.ts +36 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +39 -3
- package/template/server/src/index.ts +954 -52
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +22 -3
|
@@ -1,22 +1,46 @@
|
|
|
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 },
|
|
@@ -24,6 +48,7 @@ const BASELINE: AiSettings = {
|
|
|
24
48
|
brief: { maxOutputTokens: 10000, maxSteps: 5 },
|
|
25
49
|
},
|
|
26
50
|
vizGuide: "",
|
|
51
|
+
plotGuide: "",
|
|
27
52
|
alwaysSendPrefsDefault: false,
|
|
28
53
|
promptGroups: {
|
|
29
54
|
length: {
|
|
@@ -64,6 +89,19 @@ const BASELINE: AiSettings = {
|
|
|
64
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.",
|
|
65
90
|
},
|
|
66
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
|
+
},
|
|
67
105
|
},
|
|
68
106
|
};
|
|
69
107
|
|
|
@@ -349,13 +387,188 @@ function PromptGroupSection({
|
|
|
349
387
|
// ── Main modal ───────────────────────────────────────────────────────────────
|
|
350
388
|
|
|
351
389
|
export default function AiSettingsModal() {
|
|
352
|
-
const { aiSettings, saveAiSettings, closeSettings } =
|
|
390
|
+
const { aiSettings, saveAiSettings, closeSettings, currentQuestion } =
|
|
391
|
+
useStore();
|
|
353
392
|
const [draft, setDraft] = useState<AiSettings>(() =>
|
|
354
393
|
JSON.parse(JSON.stringify(aiSettings)),
|
|
355
394
|
);
|
|
356
395
|
const [saving, setSaving] = useState(false);
|
|
357
396
|
const [saved, setSaved] = useState(false);
|
|
358
|
-
|
|
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]);
|
|
359
572
|
|
|
360
573
|
// ── New profile form state ────────────────────────────────────
|
|
361
574
|
const [showNewProfileForm, setShowNewProfileForm] = useState(false);
|
|
@@ -560,481 +773,661 @@ export default function AiSettingsModal() {
|
|
|
560
773
|
}
|
|
561
774
|
}
|
|
562
775
|
|
|
563
|
-
function
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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);
|
|
567
786
|
}
|
|
568
787
|
|
|
569
788
|
// ── Render ────────────────────────────────────────────────────
|
|
570
789
|
const profileKeys = Object.keys(draft.responseProfiles);
|
|
571
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
|
+
|
|
572
809
|
return (
|
|
573
810
|
<div
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
onMouseDown={(e) => {
|
|
577
|
-
if (e.target === overlayRef.current) closeSettings();
|
|
578
|
-
}}
|
|
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}
|
|
579
813
|
>
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
<
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
+
)}
|
|
851
|
+
|
|
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>
|
|
896
|
+
|
|
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">
|
|
903
|
+
<p className="text-xs font-semibold text-cyan-400 capitalize">
|
|
904
|
+
{key}
|
|
905
|
+
</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>
|
|
915
|
+
)}
|
|
916
|
+
</div>
|
|
917
|
+
<div>
|
|
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}
|
|
927
|
+
/>
|
|
928
|
+
</div>
|
|
929
|
+
<div>
|
|
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}
|
|
936
|
+
/>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
))}
|
|
940
|
+
</div>
|
|
941
|
+
|
|
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
|
+
)
|
|
956
|
+
}
|
|
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
|
+
/>
|
|
960
|
+
<button
|
|
961
|
+
type="button"
|
|
962
|
+
onClick={() => {
|
|
963
|
+
addProfile(newProfileKey);
|
|
964
|
+
resetNewProfileForm();
|
|
965
|
+
}}
|
|
966
|
+
disabled={
|
|
967
|
+
!newProfileKey.trim() ||
|
|
968
|
+
newProfileKey.trim() in draft.responseProfiles
|
|
969
|
+
}
|
|
970
|
+
className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
|
|
971
|
+
>
|
|
972
|
+
Add
|
|
973
|
+
</button>
|
|
974
|
+
<button
|
|
975
|
+
type="button"
|
|
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"
|
|
978
|
+
>
|
|
979
|
+
Cancel
|
|
980
|
+
</button>
|
|
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
|
+
)}
|
|
987
|
+
</div>
|
|
988
|
+
) : (
|
|
587
989
|
<button
|
|
588
990
|
type="button"
|
|
589
|
-
onClick={
|
|
590
|
-
|
|
591
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
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"
|
|
592
993
|
>
|
|
593
|
-
<
|
|
594
|
-
Reset to defaults
|
|
994
|
+
<Plus className="w-3.5 h-3.5" /> Add profile
|
|
595
995
|
</button>
|
|
996
|
+
)}
|
|
997
|
+
</Section>
|
|
998
|
+
|
|
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>
|
|
596
1031
|
<button
|
|
597
1032
|
type="button"
|
|
598
|
-
onClick={
|
|
599
|
-
|
|
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
|
+
}`}
|
|
600
1042
|
>
|
|
601
|
-
<
|
|
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
|
+
/>
|
|
602
1048
|
</button>
|
|
603
1049
|
</div>
|
|
604
|
-
</
|
|
1050
|
+
</Section>
|
|
605
1051
|
|
|
606
|
-
{/*
|
|
607
|
-
<
|
|
608
|
-
{/*
|
|
609
|
-
<
|
|
610
|
-
<
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
</Section>
|
|
616
|
-
|
|
617
|
-
{/* ── Response Profiles ─────────────────────────────── */}
|
|
618
|
-
<Section title="Response Profiles (token limits per length setting)">
|
|
619
|
-
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
|
620
|
-
{profileKeys.map((key) => (
|
|
621
|
-
<div key={key} className="space-y-3">
|
|
622
|
-
<div className="flex items-center justify-between">
|
|
623
|
-
<p className="text-xs font-semibold text-cyan-400 capitalize">
|
|
624
|
-
{key}
|
|
625
|
-
</p>
|
|
626
|
-
{profileKeys.length > 1 && (
|
|
627
|
-
<button
|
|
628
|
-
type="button"
|
|
629
|
-
title={`Remove "${key}" profile`}
|
|
630
|
-
onClick={() => removeProfile(key)}
|
|
631
|
-
className="p-0.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
632
|
-
>
|
|
633
|
-
<Trash2 className="w-3 h-3" />
|
|
634
|
-
</button>
|
|
635
|
-
)}
|
|
636
|
-
</div>
|
|
637
|
-
<div>
|
|
638
|
-
<Label>Max Output Tokens</Label>
|
|
639
|
-
<NumberInput
|
|
640
|
-
value={draft.responseProfiles[key].maxOutputTokens}
|
|
641
|
-
onChange={(v) =>
|
|
642
|
-
patchProfileField(key, "maxOutputTokens", v)
|
|
643
|
-
}
|
|
644
|
-
min={100}
|
|
645
|
-
max={32000}
|
|
646
|
-
step={100}
|
|
647
|
-
/>
|
|
648
|
-
</div>
|
|
649
|
-
<div>
|
|
650
|
-
<Label>Max Steps</Label>
|
|
651
|
-
<NumberInput
|
|
652
|
-
value={draft.responseProfiles[key].maxSteps}
|
|
653
|
-
onChange={(v) => patchProfileField(key, "maxSteps", v)}
|
|
654
|
-
min={1}
|
|
655
|
-
max={20}
|
|
656
|
-
/>
|
|
657
|
-
</div>
|
|
658
|
-
</div>
|
|
659
|
-
))}
|
|
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>
|
|
660
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>
|
|
661
1069
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
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"
|
|
679
|
-
/>
|
|
680
|
-
<button
|
|
681
|
-
type="button"
|
|
682
|
-
onClick={() => {
|
|
683
|
-
addProfile(newProfileKey);
|
|
684
|
-
resetNewProfileForm();
|
|
685
|
-
}}
|
|
686
|
-
disabled={
|
|
687
|
-
!newProfileKey.trim() ||
|
|
688
|
-
newProfileKey.trim() in draft.responseProfiles
|
|
689
|
-
}
|
|
690
|
-
className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
|
|
691
|
-
>
|
|
692
|
-
Add
|
|
693
|
-
</button>
|
|
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) => (
|
|
694
1086
|
<button
|
|
1087
|
+
key={preset.label}
|
|
695
1088
|
type="button"
|
|
696
|
-
onClick={
|
|
697
|
-
className=
|
|
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
|
+
}`}
|
|
698
1095
|
>
|
|
699
|
-
|
|
1096
|
+
{preset.label}
|
|
1097
|
+
{preset.value > 0 && (
|
|
1098
|
+
<span className="ml-1 opacity-60">
|
|
1099
|
+
({preset.value.toLocaleString()})
|
|
1100
|
+
</span>
|
|
1101
|
+
)}
|
|
700
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
|
+
/>
|
|
701
1117
|
</div>
|
|
702
|
-
{newProfileKey.trim() in draft.responseProfiles && (
|
|
703
|
-
<p className="text-xs text-red-400">
|
|
704
|
-
A profile with that key already exists.
|
|
705
|
-
</p>
|
|
706
|
-
)}
|
|
707
1118
|
</div>
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
>
|
|
714
|
-
|
|
715
|
-
</
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
{Object.entries(draft.promptGroups).map(([groupKey, group]) => (
|
|
721
|
-
<PromptGroupSection
|
|
722
|
-
key={groupKey}
|
|
723
|
-
groupKey={groupKey}
|
|
724
|
-
group={group}
|
|
725
|
-
onOptionChange={(optKey, value) =>
|
|
726
|
-
patchGroupOption(groupKey, optKey, value)
|
|
727
|
-
}
|
|
728
|
-
onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
|
|
729
|
-
onAddOption={(optKey, prompt) =>
|
|
730
|
-
addGroupOption(groupKey, optKey, prompt)
|
|
731
|
-
}
|
|
732
|
-
onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
|
|
733
|
-
onRemoveGroup={() => removeGroup(groupKey)}
|
|
734
|
-
onMetaChange={(field, value) =>
|
|
735
|
-
patchGroupMeta(groupKey, field, value)
|
|
736
|
-
}
|
|
737
|
-
/>
|
|
738
|
-
))}
|
|
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.
|
|
1128
|
+
</p>
|
|
1129
|
+
)}
|
|
1130
|
+
</Section>
|
|
739
1131
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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">
|
|
743
1139
|
<div>
|
|
744
|
-
<
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
onClick={() =>
|
|
756
|
-
patchTop(
|
|
757
|
-
"alwaysSendPrefsDefault",
|
|
758
|
-
!draft.alwaysSendPrefsDefault,
|
|
759
|
-
)
|
|
760
|
-
}
|
|
761
|
-
className={`relative shrink-0 w-10 h-5 rounded-full transition-colors ${
|
|
762
|
-
draft.alwaysSendPrefsDefault ? "bg-amber-500" : "bg-slate-700"
|
|
763
|
-
}`}
|
|
764
|
-
>
|
|
765
|
-
<span
|
|
766
|
-
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
|
767
|
-
draft.alwaysSendPrefsDefault ? "translate-x-5" : ""
|
|
768
|
-
}`}
|
|
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"
|
|
769
1151
|
/>
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
<Section title="Model & Thinking" defaultOpen={false}>
|
|
776
|
-
{/* Current model info (read-only) */}
|
|
777
|
-
<div className="flex gap-3 mb-4">
|
|
778
|
-
<div className="flex-1">
|
|
779
|
-
<Label>Provider</Label>
|
|
780
|
-
<div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono">
|
|
781
|
-
{draft.provider || "openai"}
|
|
782
|
-
</div>
|
|
1152
|
+
{newGroupKey && newGroupKey in draft.promptGroups && (
|
|
1153
|
+
<p className="text-xs text-red-400 mt-1">
|
|
1154
|
+
Key already exists.
|
|
1155
|
+
</p>
|
|
1156
|
+
)}
|
|
783
1157
|
</div>
|
|
784
|
-
<div
|
|
785
|
-
<Label>
|
|
786
|
-
<
|
|
787
|
-
|
|
788
|
-
|
|
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
|
+
/>
|
|
789
1167
|
</div>
|
|
790
|
-
</div>
|
|
791
|
-
|
|
792
|
-
{/* Thinking budget — only useful for Google/Gemini */}
|
|
793
|
-
{["google", "gemini"].includes(draft.provider ?? "") ? (
|
|
794
1168
|
<div>
|
|
795
|
-
<Label>
|
|
796
|
-
<
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
{[
|
|
804
|
-
{ label: "Off", value: 0 },
|
|
805
|
-
{ label: "Low", value: 1024 },
|
|
806
|
-
{ label: "Medium", value: 8192 },
|
|
807
|
-
{ label: "High", value: 24576 },
|
|
808
|
-
].map((preset) => (
|
|
809
|
-
<button
|
|
810
|
-
key={preset.label}
|
|
811
|
-
type="button"
|
|
812
|
-
onClick={() => patchTop("thinkingBudget", preset.value)}
|
|
813
|
-
className={`px-3 py-1 text-xs rounded-md border transition-colors ${
|
|
814
|
-
(draft.thinkingBudget ?? 0) === preset.value
|
|
815
|
-
? "bg-cyan-600/30 text-cyan-300 border-cyan-600/50"
|
|
816
|
-
: "text-slate-500 hover:text-slate-300 border-slate-700 hover:border-slate-500"
|
|
817
|
-
}`}
|
|
818
|
-
>
|
|
819
|
-
{preset.label}
|
|
820
|
-
{preset.value > 0 && (
|
|
821
|
-
<span className="ml-1 opacity-60">
|
|
822
|
-
({preset.value.toLocaleString()})
|
|
823
|
-
</span>
|
|
824
|
-
)}
|
|
825
|
-
</button>
|
|
826
|
-
))}
|
|
827
|
-
<div className="flex items-center gap-1.5">
|
|
828
|
-
<span className="text-xs text-slate-500">Custom:</span>
|
|
829
|
-
<input
|
|
830
|
-
type="number"
|
|
831
|
-
value={draft.thinkingBudget ?? 0}
|
|
832
|
-
min={0}
|
|
833
|
-
max={32768}
|
|
834
|
-
step={256}
|
|
835
|
-
onChange={(e) =>
|
|
836
|
-
patchTop("thinkingBudget", Number(e.target.value))
|
|
837
|
-
}
|
|
838
|
-
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"
|
|
839
|
-
/>
|
|
840
|
-
</div>
|
|
841
|
-
</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
|
+
/>
|
|
842
1177
|
</div>
|
|
843
|
-
|
|
844
|
-
<p className="text-xs text-slate-500">
|
|
845
|
-
Thinking / reasoning display is only available for Google /
|
|
846
|
-
Gemini models. Switch{" "}
|
|
847
|
-
<code className="bg-slate-800 px-1 rounded">AI_PROVIDER</code>{" "}
|
|
848
|
-
to <code className="bg-slate-800 px-1 rounded">google</code> in
|
|
849
|
-
your <code className="bg-slate-800 px-1 rounded">.env</code> to
|
|
850
|
-
enable it.
|
|
851
|
-
</p>
|
|
852
|
-
)}
|
|
853
|
-
</Section>
|
|
1178
|
+
</div>
|
|
854
1179
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
New setting group
|
|
860
|
-
</p>
|
|
861
|
-
<div className="grid grid-cols-3 gap-3">
|
|
862
|
-
<div>
|
|
863
|
-
<Label>Key (internal ID)</Label>
|
|
864
|
-
<input
|
|
865
|
-
type="text"
|
|
866
|
-
value={newGroupKey}
|
|
867
|
-
onChange={(e) =>
|
|
868
|
-
setNewGroupKey(
|
|
869
|
-
e.target.value.toLowerCase().replace(/\s+/g, "-"),
|
|
870
|
-
)
|
|
871
|
-
}
|
|
872
|
-
placeholder="e.g. tone"
|
|
873
|
-
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"
|
|
874
|
-
/>
|
|
875
|
-
{newGroupKey && newGroupKey in draft.promptGroups && (
|
|
876
|
-
<p className="text-xs text-red-400 mt-1">
|
|
877
|
-
Key already exists.
|
|
878
|
-
</p>
|
|
879
|
-
)}
|
|
880
|
-
</div>
|
|
881
|
-
<div>
|
|
882
|
-
<Label>Label (shown in bottom bar)</Label>
|
|
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">
|
|
883
1184
|
<input
|
|
884
1185
|
type="text"
|
|
885
|
-
value={
|
|
886
|
-
onChange={(e) =>
|
|
887
|
-
|
|
888
|
-
|
|
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"
|
|
889
1197
|
/>
|
|
890
|
-
</div>
|
|
891
|
-
<div>
|
|
892
|
-
<Label>Description (optional)</Label>
|
|
893
1198
|
<input
|
|
894
1199
|
type="text"
|
|
895
|
-
value={
|
|
896
|
-
onChange={(e) =>
|
|
897
|
-
|
|
898
|
-
|
|
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"
|
|
899
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
|
+
)}
|
|
900
1222
|
</div>
|
|
901
|
-
|
|
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>
|
|
902
1237
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
+
) : (
|
|
1261
|
+
<button
|
|
1262
|
+
type="button"
|
|
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"
|
|
1265
|
+
>
|
|
1266
|
+
<Plus className="w-4 h-4" /> Add setting group
|
|
1267
|
+
</button>
|
|
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"
|
|
956
1347
|
}
|
|
957
|
-
|
|
1348
|
+
style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}
|
|
958
1349
|
>
|
|
959
|
-
|
|
960
|
-
</
|
|
961
|
-
|
|
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>
|
|
962
1359
|
|
|
963
|
-
|
|
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 ? (
|
|
964
1382
|
<button
|
|
965
1383
|
type="button"
|
|
966
|
-
onClick={
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
newGroupKey in draft.promptGroups ||
|
|
970
|
-
newGroupOptions.every((o) => !o.key.trim())
|
|
971
|
-
}
|
|
972
|
-
className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
|
|
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"
|
|
973
1387
|
>
|
|
974
|
-
|
|
1388
|
+
<StopCircle className="w-3.5 h-3.5" />
|
|
975
1389
|
</button>
|
|
1390
|
+
) : (
|
|
976
1391
|
<button
|
|
977
1392
|
type="button"
|
|
978
|
-
onClick={
|
|
979
|
-
|
|
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)"
|
|
980
1397
|
>
|
|
981
|
-
|
|
1398
|
+
<Play className="w-3.5 h-3.5" />
|
|
982
1399
|
</button>
|
|
983
|
-
|
|
1400
|
+
)}
|
|
984
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" />
|
|
985
1426
|
) : (
|
|
986
|
-
<
|
|
987
|
-
type="button"
|
|
988
|
-
onClick={() => setShowNewGroupForm(true)}
|
|
989
|
-
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"
|
|
990
|
-
>
|
|
991
|
-
<Plus className="w-4 h-4" /> Add setting group
|
|
992
|
-
</button>
|
|
1427
|
+
<Save className="w-4 h-4" />
|
|
993
1428
|
)}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
<Section
|
|
997
|
-
title="Viz Guide (returned by getVizGuide tool)"
|
|
998
|
-
defaultOpen={false}
|
|
999
|
-
>
|
|
1000
|
-
<p className="text-xs text-slate-500 -mt-1">
|
|
1001
|
-
The full VizCraft spec reference the AI receives when it calls{" "}
|
|
1002
|
-
<code className="text-cyan-400">getVizGuide()</code>.
|
|
1003
|
-
</p>
|
|
1004
|
-
<Textarea
|
|
1005
|
-
value={draft.vizGuide}
|
|
1006
|
-
onChange={(v) => patchTop("vizGuide", v)}
|
|
1007
|
-
rows={16}
|
|
1008
|
-
mono
|
|
1009
|
-
/>
|
|
1010
|
-
</Section>
|
|
1011
|
-
</div>
|
|
1012
|
-
|
|
1013
|
-
{/* Footer */}
|
|
1014
|
-
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-700 shrink-0">
|
|
1015
|
-
<button
|
|
1016
|
-
type="button"
|
|
1017
|
-
onClick={closeSettings}
|
|
1018
|
-
className="px-4 py-2 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
1019
|
-
>
|
|
1020
|
-
Cancel
|
|
1021
|
-
</button>
|
|
1022
|
-
<button
|
|
1023
|
-
type="button"
|
|
1024
|
-
onClick={handleSave}
|
|
1025
|
-
disabled={saving}
|
|
1026
|
-
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"
|
|
1027
|
-
>
|
|
1028
|
-
{saving ? (
|
|
1029
|
-
<Loader2 className="w-4 h-4 animate-spin" />
|
|
1030
|
-
) : saved ? (
|
|
1031
|
-
<Check className="w-4 h-4" />
|
|
1032
|
-
) : (
|
|
1033
|
-
<Save className="w-4 h-4" />
|
|
1034
|
-
)}
|
|
1035
|
-
{saved ? "Saved!" : "Save changes"}
|
|
1036
|
-
</button>
|
|
1037
|
-
</div>
|
|
1429
|
+
{saved ? "Saved!" : "Save"}
|
|
1430
|
+
</button>
|
|
1038
1431
|
</div>
|
|
1039
1432
|
</div>
|
|
1040
1433
|
);
|