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,27 +1,49 @@
|
|
|
1
|
-
import { useRef } from "react";
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
2
|
import type { ContextFile } from "../types";
|
|
3
|
-
import { Paperclip, X, FileText } from "lucide-react";
|
|
3
|
+
import { Paperclip, X, FileText, Download, Link, Eye } from "lucide-react";
|
|
4
|
+
import FilePickerModal from "./FilePickerModal";
|
|
5
|
+
import { useStore } from "../store";
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
8
|
files: ContextFile[];
|
|
7
9
|
onUpload: (files: FileList) => Promise<void>;
|
|
8
10
|
onRemove: (fileId: string) => Promise<void>;
|
|
11
|
+
onLink?: (fileId: string, originalName: string) => Promise<void>;
|
|
12
|
+
/** URL prefix for downloads, e.g. "/api/topics/abc/context-files" */
|
|
13
|
+
downloadBase?: string;
|
|
9
14
|
label: string;
|
|
10
15
|
compact?: boolean;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
function downloadFile(url: string, filename: string) {
|
|
19
|
+
const a = document.createElement("a");
|
|
20
|
+
a.href = url;
|
|
21
|
+
a.download = filename;
|
|
22
|
+
a.click();
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
export default function FileAttachments({
|
|
14
26
|
files,
|
|
15
27
|
onUpload,
|
|
16
28
|
onRemove,
|
|
29
|
+
onLink,
|
|
30
|
+
downloadBase,
|
|
17
31
|
label,
|
|
18
32
|
compact,
|
|
19
33
|
}: Props) {
|
|
20
34
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
35
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
36
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
37
|
+
const openDocViewer = useStore((s) => s.openDocViewer);
|
|
21
38
|
|
|
22
39
|
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
23
40
|
if (e.target.files?.length) {
|
|
24
|
-
|
|
41
|
+
setUploadError(null);
|
|
42
|
+
try {
|
|
43
|
+
await onUpload(e.target.files);
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
setUploadError(err?.message ?? "Upload failed");
|
|
46
|
+
}
|
|
25
47
|
e.target.value = "";
|
|
26
48
|
}
|
|
27
49
|
};
|
|
@@ -29,13 +51,23 @@ export default function FileAttachments({
|
|
|
29
51
|
if (compact) {
|
|
30
52
|
return (
|
|
31
53
|
<div className="flex items-center gap-1 flex-wrap">
|
|
54
|
+
{showPicker && onLink && (
|
|
55
|
+
<FilePickerModal
|
|
56
|
+
currentFiles={files}
|
|
57
|
+
onLink={async (fileId, originalName) => {
|
|
58
|
+
await onLink(fileId, originalName);
|
|
59
|
+
setShowPicker(false);
|
|
60
|
+
}}
|
|
61
|
+
onClose={() => setShowPicker(false)}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
32
64
|
<input
|
|
33
65
|
ref={inputRef}
|
|
34
66
|
type="file"
|
|
35
67
|
multiple
|
|
36
68
|
onChange={handleChange}
|
|
37
69
|
className="hidden"
|
|
38
|
-
accept="
|
|
70
|
+
accept="*"
|
|
39
71
|
/>
|
|
40
72
|
{files.map((f) => (
|
|
41
73
|
<span
|
|
@@ -44,6 +76,27 @@ export default function FileAttachments({
|
|
|
44
76
|
>
|
|
45
77
|
<FileText className="w-2.5 h-2.5" />
|
|
46
78
|
{f.originalName}
|
|
79
|
+
<button
|
|
80
|
+
onClick={() => openDocViewer(f.id, "", f.originalName)}
|
|
81
|
+
className="hover:text-cyan-300 transition-colors"
|
|
82
|
+
title="View file"
|
|
83
|
+
>
|
|
84
|
+
<Eye className="w-2.5 h-2.5" />
|
|
85
|
+
</button>
|
|
86
|
+
{downloadBase && (
|
|
87
|
+
<button
|
|
88
|
+
onClick={() =>
|
|
89
|
+
downloadFile(
|
|
90
|
+
`${downloadBase}/${f.id}/download`,
|
|
91
|
+
f.originalName,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
className="hover:text-cyan-300 transition-colors"
|
|
95
|
+
title="Download"
|
|
96
|
+
>
|
|
97
|
+
<Download className="w-2.5 h-2.5" />
|
|
98
|
+
</button>
|
|
99
|
+
)}
|
|
47
100
|
<button
|
|
48
101
|
onClick={() => onRemove(f.id)}
|
|
49
102
|
className="hover:text-red-400 transition-colors"
|
|
@@ -60,19 +113,44 @@ export default function FileAttachments({
|
|
|
60
113
|
<Paperclip className="w-2.5 h-2.5" />
|
|
61
114
|
Attach
|
|
62
115
|
</button>
|
|
116
|
+
{onLink && (
|
|
117
|
+
<button
|
|
118
|
+
onClick={() => setShowPicker(true)}
|
|
119
|
+
className="inline-flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-cyan-400 transition-colors"
|
|
120
|
+
title="Link an already-uploaded file"
|
|
121
|
+
>
|
|
122
|
+
<Link className="w-2.5 h-2.5" />
|
|
123
|
+
Link
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
{uploadError && (
|
|
127
|
+
<span className="text-[10px] text-red-400 ml-1" title={uploadError}>
|
|
128
|
+
⚠ {uploadError}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
63
131
|
</div>
|
|
64
132
|
);
|
|
65
133
|
}
|
|
66
134
|
|
|
67
135
|
return (
|
|
68
136
|
<div>
|
|
137
|
+
{showPicker && onLink && (
|
|
138
|
+
<FilePickerModal
|
|
139
|
+
currentFiles={files}
|
|
140
|
+
onLink={async (fileId, originalName) => {
|
|
141
|
+
await onLink(fileId, originalName);
|
|
142
|
+
setShowPicker(false);
|
|
143
|
+
}}
|
|
144
|
+
onClose={() => setShowPicker(false)}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
69
147
|
<input
|
|
70
148
|
ref={inputRef}
|
|
71
149
|
type="file"
|
|
72
150
|
multiple
|
|
73
151
|
onChange={handleChange}
|
|
74
152
|
className="hidden"
|
|
75
|
-
accept="
|
|
153
|
+
accept="*"
|
|
76
154
|
/>
|
|
77
155
|
|
|
78
156
|
{files.length > 0 && (
|
|
@@ -84,6 +162,27 @@ export default function FileAttachments({
|
|
|
84
162
|
>
|
|
85
163
|
<FileText className="w-3 h-3 shrink-0" />
|
|
86
164
|
<span className="truncate flex-1">{f.originalName}</span>
|
|
165
|
+
<button
|
|
166
|
+
onClick={() => openDocViewer(f.id, "", f.originalName)}
|
|
167
|
+
className="shrink-0 hover:text-cyan-300 transition-colors"
|
|
168
|
+
title="View file"
|
|
169
|
+
>
|
|
170
|
+
<Eye className="w-3 h-3" />
|
|
171
|
+
</button>
|
|
172
|
+
{downloadBase && (
|
|
173
|
+
<button
|
|
174
|
+
onClick={() =>
|
|
175
|
+
downloadFile(
|
|
176
|
+
`${downloadBase}/${f.id}/download`,
|
|
177
|
+
f.originalName,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
className="shrink-0 hover:text-cyan-300 transition-colors"
|
|
181
|
+
title="Download"
|
|
182
|
+
>
|
|
183
|
+
<Download className="w-3 h-3" />
|
|
184
|
+
</button>
|
|
185
|
+
)}
|
|
87
186
|
<button
|
|
88
187
|
onClick={() => onRemove(f.id)}
|
|
89
188
|
className="shrink-0 hover:text-red-400 transition-colors"
|
|
@@ -95,13 +194,30 @@ export default function FileAttachments({
|
|
|
95
194
|
</div>
|
|
96
195
|
)}
|
|
97
196
|
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
197
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
198
|
+
<button
|
|
199
|
+
onClick={() => inputRef.current?.click()}
|
|
200
|
+
className="flex items-center gap-1 text-xs text-slate-600 hover:text-violet-400 transition-colors"
|
|
201
|
+
>
|
|
202
|
+
<Paperclip className="w-3 h-3" />
|
|
203
|
+
Attach files to {label}
|
|
204
|
+
</button>
|
|
205
|
+
{onLink && (
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => setShowPicker(true)}
|
|
208
|
+
className="flex items-center gap-1 text-xs text-slate-600 hover:text-cyan-400 transition-colors"
|
|
209
|
+
title="Link an already-uploaded file"
|
|
210
|
+
>
|
|
211
|
+
<Link className="w-3 h-3" />
|
|
212
|
+
Link existing
|
|
213
|
+
</button>
|
|
214
|
+
)}
|
|
215
|
+
{uploadError && (
|
|
216
|
+
<span className="text-xs text-red-400" title={uploadError}>
|
|
217
|
+
⚠ {uploadError}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
105
221
|
</div>
|
|
106
222
|
);
|
|
107
223
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
X,
|
|
4
|
+
FileText,
|
|
5
|
+
Search,
|
|
6
|
+
Globe,
|
|
7
|
+
BookOpen,
|
|
8
|
+
MessageSquare,
|
|
9
|
+
Link,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import * as api from "../api";
|
|
12
|
+
import type { PickableFile } from "../api";
|
|
13
|
+
import type { ContextFile } from "../types";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Files already attached to the target — shown as already linked */
|
|
17
|
+
currentFiles: ContextFile[];
|
|
18
|
+
onLink: (fileId: string, originalName: string) => Promise<void>;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SOURCE_ICON = {
|
|
23
|
+
workspace: <Globe className="w-3 h-3 text-violet-400 shrink-0" />,
|
|
24
|
+
topic: <BookOpen className="w-3 h-3 text-cyan-400 shrink-0" />,
|
|
25
|
+
question: <MessageSquare className="w-3 h-3 text-slate-400 shrink-0" />,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SOURCE_LABEL = {
|
|
29
|
+
workspace: "Workspace",
|
|
30
|
+
topic: "Topic",
|
|
31
|
+
question: "Question",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default function FilePickerModal({
|
|
35
|
+
currentFiles,
|
|
36
|
+
onLink,
|
|
37
|
+
onClose,
|
|
38
|
+
}: Props) {
|
|
39
|
+
const [files, setFiles] = useState<PickableFile[]>([]);
|
|
40
|
+
const [loading, setLoading] = useState(true);
|
|
41
|
+
const [query, setQuery] = useState("");
|
|
42
|
+
const [linking, setLinking] = useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const linkedIds = new Set(currentFiles.map((f) => f.id));
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
api.fetchAllContextFiles().then((f) => {
|
|
48
|
+
setFiles(f);
|
|
49
|
+
setLoading(false);
|
|
50
|
+
});
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const filtered = files.filter(
|
|
54
|
+
(f) =>
|
|
55
|
+
f.originalName.toLowerCase().includes(query.toLowerCase()) ||
|
|
56
|
+
f.sourceName.toLowerCase().includes(query.toLowerCase()),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Group by source type → source name
|
|
60
|
+
const groups = new Map<string, PickableFile[]>();
|
|
61
|
+
for (const f of filtered) {
|
|
62
|
+
const key = `${f.source}:${f.sourceName}`;
|
|
63
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
64
|
+
groups.get(key)!.push(f);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handleLink = async (f: PickableFile) => {
|
|
68
|
+
if (linkedIds.has(f.fileId) || linking) return;
|
|
69
|
+
setLinking(f.fileId);
|
|
70
|
+
try {
|
|
71
|
+
await onLink(f.fileId, f.originalName);
|
|
72
|
+
} finally {
|
|
73
|
+
setLinking(null);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
80
|
+
onClick={(e) => e.target === e.currentTarget && onClose()}
|
|
81
|
+
>
|
|
82
|
+
<div className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col">
|
|
83
|
+
{/* Header */}
|
|
84
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800">
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
<Link className="w-4 h-4 text-cyan-400" />
|
|
87
|
+
<span className="text-sm font-semibold text-slate-200">
|
|
88
|
+
Link Existing File
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
<button
|
|
92
|
+
onClick={onClose}
|
|
93
|
+
className="text-slate-500 hover:text-slate-300 transition-colors"
|
|
94
|
+
>
|
|
95
|
+
<X className="w-4 h-4" />
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Search */}
|
|
100
|
+
<div className="px-4 py-2 border-b border-slate-800">
|
|
101
|
+
<div className="flex items-center gap-2 bg-slate-800 rounded-lg px-2 py-1.5">
|
|
102
|
+
<Search className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
103
|
+
<input
|
|
104
|
+
autoFocus
|
|
105
|
+
value={query}
|
|
106
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
107
|
+
placeholder="Search files…"
|
|
108
|
+
className="flex-1 bg-transparent text-sm text-slate-200 placeholder-slate-600 focus:outline-none"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* File list */}
|
|
114
|
+
<div className="overflow-y-auto flex-1 px-2 py-2">
|
|
115
|
+
{loading && (
|
|
116
|
+
<p className="text-xs text-slate-500 text-center py-6">Loading…</p>
|
|
117
|
+
)}
|
|
118
|
+
{!loading && groups.size === 0 && (
|
|
119
|
+
<p className="text-xs text-slate-500 text-center py-6">
|
|
120
|
+
No files found.
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
123
|
+
{!loading &&
|
|
124
|
+
Array.from(groups.entries()).map(([key, items]) => {
|
|
125
|
+
const [sourceType, sourceName] = key.split(":") as [
|
|
126
|
+
PickableFile["source"],
|
|
127
|
+
string,
|
|
128
|
+
];
|
|
129
|
+
return (
|
|
130
|
+
<div key={key} className="mb-3">
|
|
131
|
+
<div className="flex items-center gap-1.5 px-2 py-0.5 mb-1">
|
|
132
|
+
{SOURCE_ICON[sourceType]}
|
|
133
|
+
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
|
|
134
|
+
{SOURCE_LABEL[sourceType]}: {sourceName}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
{items.map((f) => {
|
|
138
|
+
const alreadyLinked = linkedIds.has(f.fileId);
|
|
139
|
+
const isLinking = linking === f.fileId;
|
|
140
|
+
return (
|
|
141
|
+
<button
|
|
142
|
+
key={f.fileId}
|
|
143
|
+
onClick={() => handleLink(f)}
|
|
144
|
+
disabled={alreadyLinked || !!linking}
|
|
145
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
|
146
|
+
alreadyLinked
|
|
147
|
+
? "text-slate-600 cursor-default"
|
|
148
|
+
: "text-slate-300 hover:bg-slate-800 cursor-pointer"
|
|
149
|
+
}`}
|
|
150
|
+
>
|
|
151
|
+
<FileText className="w-3.5 h-3.5 shrink-0 text-violet-400" />
|
|
152
|
+
<span className="truncate flex-1 text-left">
|
|
153
|
+
{f.originalName}
|
|
154
|
+
</span>
|
|
155
|
+
{alreadyLinked && (
|
|
156
|
+
<span className="text-[10px] text-slate-600 shrink-0">
|
|
157
|
+
linked
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
{isLinking && (
|
|
161
|
+
<span className="text-[10px] text-cyan-500 shrink-0">
|
|
162
|
+
linking…
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</button>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="px-4 py-2 border-t border-slate-800">
|
|
174
|
+
<p className="text-[10px] text-slate-600">
|
|
175
|
+
Linked files share content with the original — no re-upload needed.
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|