create-interview-cockpit 0.1.1 → 0.3.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/src/App.tsx +27 -2
- package/template/client/src/api.ts +190 -1
- package/template/client/src/components/ChatMessage.tsx +27 -1
- package/template/client/src/components/ChatView.tsx +110 -6
- package/template/client/src/components/Sidebar.tsx +342 -182
- package/template/client/src/components/WorkspaceSwitcher.tsx +891 -0
- package/template/client/src/store.ts +190 -1
- package/template/client/src/types.ts +20 -0
- package/template/cockpit.json +1 -1
- package/template/server/package-lock.json +286 -0
- package/template/server/package.json +1 -0
- package/template/server/src/google-drive.ts +714 -0
- package/template/server/src/index.ts +193 -4
- package/template/server/src/storage.ts +332 -32
package/package.json
CHANGED
|
@@ -9,6 +9,9 @@ import { Code, Plane, PanelLeftClose, PanelLeft } from "lucide-react";
|
|
|
9
9
|
export default function App() {
|
|
10
10
|
const {
|
|
11
11
|
fetchTopics,
|
|
12
|
+
fetchWorkspaces,
|
|
13
|
+
fetchQuestions,
|
|
14
|
+
selectQuestion,
|
|
12
15
|
currentQuestion,
|
|
13
16
|
showCodePanel,
|
|
14
17
|
toggleCodePanel,
|
|
@@ -19,8 +22,30 @@ export default function App() {
|
|
|
19
22
|
} = useStore();
|
|
20
23
|
|
|
21
24
|
useEffect(() => {
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const init = async () => {
|
|
26
|
+
await fetchWorkspaces();
|
|
27
|
+
await fetchTopics();
|
|
28
|
+
// Restore last-viewed question after page refresh
|
|
29
|
+
const topicId = sessionStorage.getItem("lastTopicId");
|
|
30
|
+
const questionId = sessionStorage.getItem("lastQuestionId");
|
|
31
|
+
if (topicId && questionId) {
|
|
32
|
+
try {
|
|
33
|
+
await fetchQuestions(topicId);
|
|
34
|
+
await selectQuestion(topicId, questionId);
|
|
35
|
+
// Expand the topic so it's visible in the sidebar
|
|
36
|
+
const { expandedTopics } = useStore.getState();
|
|
37
|
+
if (!expandedTopics.includes(topicId)) {
|
|
38
|
+
useStore.getState().toggleTopic(topicId);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Question may no longer exist — ignore
|
|
42
|
+
sessionStorage.removeItem("lastTopicId");
|
|
43
|
+
sessionStorage.removeItem("lastQuestionId");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
init();
|
|
48
|
+
}, []);
|
|
24
49
|
|
|
25
50
|
return (
|
|
26
51
|
<div className="flex h-screen bg-slate-950">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Topic, Question, ContextFile } from "./types";
|
|
1
|
+
import type { Topic, Question, ContextFile, WorkspacesRegistry } from "./types";
|
|
2
2
|
|
|
3
3
|
const BASE = "/api";
|
|
4
4
|
|
|
@@ -130,3 +130,192 @@ export async function fetchCodeContextTree(): Promise<string[]> {
|
|
|
130
130
|
const res = await fetch(`${BASE}/code-context/tree`);
|
|
131
131
|
return res.json();
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
// --- Workspaces ---
|
|
135
|
+
|
|
136
|
+
export async function fetchWorkspaces(): Promise<WorkspacesRegistry> {
|
|
137
|
+
const res = await fetch(`${BASE}/workspaces`);
|
|
138
|
+
return res.json();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function createWorkspace(
|
|
142
|
+
name: string,
|
|
143
|
+
type: "local" | "google_drive",
|
|
144
|
+
): Promise<WorkspacesRegistry> {
|
|
145
|
+
const res = await fetch(`${BASE}/workspaces`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ name, type }),
|
|
149
|
+
});
|
|
150
|
+
return res.json();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function renameWorkspace(
|
|
154
|
+
id: string,
|
|
155
|
+
name: string,
|
|
156
|
+
): Promise<WorkspacesRegistry> {
|
|
157
|
+
const res = await fetch(`${BASE}/workspaces/${id}`, {
|
|
158
|
+
method: "PATCH",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify({ name }),
|
|
161
|
+
});
|
|
162
|
+
return res.json();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function patchWorkspace(
|
|
166
|
+
id: string,
|
|
167
|
+
data: object,
|
|
168
|
+
): Promise<WorkspacesRegistry> {
|
|
169
|
+
const res = await fetch(`${BASE}/workspaces/${id}`, {
|
|
170
|
+
method: "PATCH",
|
|
171
|
+
headers: { "Content-Type": "application/json" },
|
|
172
|
+
body: JSON.stringify(data),
|
|
173
|
+
});
|
|
174
|
+
return res.json();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function deleteWorkspaceApi(
|
|
178
|
+
id: string,
|
|
179
|
+
): Promise<WorkspacesRegistry> {
|
|
180
|
+
const res = await fetch(`${BASE}/workspaces/${id}`, { method: "DELETE" });
|
|
181
|
+
return res.json();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function activateWorkspaceApi(
|
|
185
|
+
id: string,
|
|
186
|
+
): Promise<WorkspacesRegistry> {
|
|
187
|
+
const res = await fetch(`${BASE}/workspaces/${id}/activate`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
});
|
|
190
|
+
return res.json();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function syncWorkspaceApi(id: string): Promise<{
|
|
194
|
+
topicsUpserted: number;
|
|
195
|
+
filesImported: number;
|
|
196
|
+
filesSkipped: number;
|
|
197
|
+
errors: string[];
|
|
198
|
+
}> {
|
|
199
|
+
const res = await fetch(`${BASE}/workspaces/${id}/sync`, { method: "POST" });
|
|
200
|
+
return res.json();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type ExportWorkspaceResult =
|
|
204
|
+
| { needsAuth: true; authUrl: string }
|
|
205
|
+
| {
|
|
206
|
+
needsAuth?: false;
|
|
207
|
+
topicsExported: number;
|
|
208
|
+
questionsExported: number;
|
|
209
|
+
filesExported: number;
|
|
210
|
+
errors: string[];
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export async function exportWorkspaceToDrive(
|
|
214
|
+
id: string,
|
|
215
|
+
targetFolderId?: string,
|
|
216
|
+
): Promise<ExportWorkspaceResult> {
|
|
217
|
+
const res = await fetch(`${BASE}/workspaces/${id}/export-drive`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { "Content-Type": "application/json" },
|
|
220
|
+
body: JSON.stringify({ targetFolderId }),
|
|
221
|
+
});
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
const body = await res.json().catch(() => ({}));
|
|
224
|
+
throw new Error((body as any).error || `Export failed (${res.status})`);
|
|
225
|
+
}
|
|
226
|
+
return res.json();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface DriveFolder {
|
|
230
|
+
id: string;
|
|
231
|
+
name: string;
|
|
232
|
+
mimeType: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function fetchDriveSubfolders(
|
|
236
|
+
workspaceId: string,
|
|
237
|
+
): Promise<DriveFolder[]> {
|
|
238
|
+
const res = await fetch(`${BASE}/workspaces/${workspaceId}/drive-subfolders`);
|
|
239
|
+
if (!res.ok) throw new Error(`Failed to load subfolders (${res.status})`);
|
|
240
|
+
return res.json();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function createDriveSubfolder(
|
|
244
|
+
workspaceId: string,
|
|
245
|
+
name: string,
|
|
246
|
+
): Promise<DriveFolder> {
|
|
247
|
+
const res = await fetch(
|
|
248
|
+
`${BASE}/workspaces/${workspaceId}/drive-subfolders`,
|
|
249
|
+
{
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({ name }),
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
const body = await res.json().catch(() => ({}));
|
|
257
|
+
throw new Error(
|
|
258
|
+
(body as any).error || `Failed to create folder (${res.status})`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return res.json();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function linkDriveFolder(
|
|
265
|
+
workspaceId: string,
|
|
266
|
+
url: string,
|
|
267
|
+
): Promise<{
|
|
268
|
+
registry: WorkspacesRegistry;
|
|
269
|
+
folders: DriveFolder[];
|
|
270
|
+
}> {
|
|
271
|
+
const res = await fetch(`${BASE}/workspaces/${workspaceId}/link-drive`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({ url }),
|
|
275
|
+
});
|
|
276
|
+
if (!res.ok) {
|
|
277
|
+
const body = await res.json().catch(() => ({}));
|
|
278
|
+
throw new Error(
|
|
279
|
+
(body as any).error || `Failed to link Drive folder (${res.status})`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return res.json();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function selectDriveSubfolder(
|
|
286
|
+
workspaceId: string,
|
|
287
|
+
subFolderId: string | null,
|
|
288
|
+
subFolderName: string | null,
|
|
289
|
+
): Promise<WorkspacesRegistry> {
|
|
290
|
+
const res = await fetch(`${BASE}/workspaces/${workspaceId}`, {
|
|
291
|
+
method: "PATCH",
|
|
292
|
+
headers: { "Content-Type": "application/json" },
|
|
293
|
+
body: JSON.stringify({ driveConfig: { subFolderId, subFolderName } }),
|
|
294
|
+
});
|
|
295
|
+
if (!res.ok) {
|
|
296
|
+
const body = await res.json().catch(() => ({}));
|
|
297
|
+
throw new Error(
|
|
298
|
+
(body as any).error || `Failed to update workspace (${res.status})`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return res.json();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function attachDriveFolder(
|
|
305
|
+
workspaceId: string,
|
|
306
|
+
url: string,
|
|
307
|
+
): Promise<WorkspacesRegistry> {
|
|
308
|
+
const res = await fetch(`${BASE}/workspaces/${workspaceId}/attach-drive`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "Content-Type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ url }),
|
|
312
|
+
});
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
const body = await res.json().catch(() => ({}));
|
|
315
|
+
throw new Error(
|
|
316
|
+
(body as any).error || `Failed to attach Drive folder (${res.status})`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const data = await res.json();
|
|
320
|
+
return data.registry;
|
|
321
|
+
}
|
|
@@ -61,7 +61,33 @@ const ChatMessage = memo(function ChatMessage({
|
|
|
61
61
|
</div>
|
|
62
62
|
<div className="text-sm leading-relaxed text-slate-200">
|
|
63
63
|
{isUser ? (
|
|
64
|
-
<
|
|
64
|
+
<div>
|
|
65
|
+
{(message.parts ?? [])
|
|
66
|
+
.filter(
|
|
67
|
+
(
|
|
68
|
+
p,
|
|
69
|
+
): p is {
|
|
70
|
+
type: "file";
|
|
71
|
+
mediaType: string;
|
|
72
|
+
url: string;
|
|
73
|
+
filename?: string;
|
|
74
|
+
} =>
|
|
75
|
+
p.type === "file" &&
|
|
76
|
+
typeof (p as any).mediaType === "string" &&
|
|
77
|
+
(p as any).mediaType.startsWith("image/"),
|
|
78
|
+
)
|
|
79
|
+
.map((p, i) => (
|
|
80
|
+
<img
|
|
81
|
+
key={i}
|
|
82
|
+
src={p.url}
|
|
83
|
+
alt={p.filename ?? "Attached image"}
|
|
84
|
+
className="max-w-xs rounded-lg mb-2 border border-slate-700"
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
{content && (
|
|
88
|
+
<p className="whitespace-pre-wrap text-slate-300">{content}</p>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
65
91
|
) : (
|
|
66
92
|
<TextAnnotator
|
|
67
93
|
content={content}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { useChat } from "@ai-sdk/react";
|
|
2
2
|
import { DefaultChatTransport } from "ai";
|
|
3
|
+
import type { FileUIPart } from "ai";
|
|
3
4
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|
4
5
|
import type { Question, Annotation, ReadingBookmark } from "../types";
|
|
5
6
|
import { useStore } from "../store";
|
|
6
7
|
import ChatMessage from "./ChatMessage";
|
|
7
8
|
import FileAttachments from "./FileAttachments";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Send,
|
|
11
|
+
Loader2,
|
|
12
|
+
Settings2,
|
|
13
|
+
RotateCcw,
|
|
14
|
+
ImagePlus,
|
|
15
|
+
X,
|
|
16
|
+
} from "lucide-react";
|
|
9
17
|
|
|
10
18
|
interface Props {
|
|
11
19
|
question: Question;
|
|
@@ -170,10 +178,53 @@ export default function ChatView({ question }: Props) {
|
|
|
170
178
|
const bookmarkRef = useRef<HTMLDivElement | null>(null);
|
|
171
179
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
172
180
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
181
|
+
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
173
182
|
const systemContextSaveTimeoutRef = useRef<number | null>(null);
|
|
174
183
|
const responsePreferenceCacheRef = useRef<ResponsePreferenceCache>({});
|
|
175
184
|
const pendingResponsePreferenceCacheRef =
|
|
176
185
|
useRef<ResponsePreferenceCache | null>(null);
|
|
186
|
+
|
|
187
|
+
// ── Inline image attachments (per-message, ephemeral) ──────────────────────
|
|
188
|
+
const [attachedImages, setAttachedImages] = useState<
|
|
189
|
+
Array<FileUIPart & { _id: string }>
|
|
190
|
+
>([]);
|
|
191
|
+
|
|
192
|
+
const addImageFiles = useCallback(async (files: File[]) => {
|
|
193
|
+
const imageFiles = files.filter((f) => f.type.startsWith("image/"));
|
|
194
|
+
if (!imageFiles.length) return;
|
|
195
|
+
const parts = await Promise.all(
|
|
196
|
+
imageFiles.map(
|
|
197
|
+
(file) =>
|
|
198
|
+
new Promise<FileUIPart & { _id: string }>((resolve) => {
|
|
199
|
+
const reader = new FileReader();
|
|
200
|
+
reader.onload = () =>
|
|
201
|
+
resolve({
|
|
202
|
+
_id: crypto.randomUUID(),
|
|
203
|
+
type: "file",
|
|
204
|
+
mediaType: file.type,
|
|
205
|
+
filename: file.name,
|
|
206
|
+
url: reader.result as string,
|
|
207
|
+
});
|
|
208
|
+
reader.readAsDataURL(file);
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
setAttachedImages((prev) => [...prev, ...parts]);
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
const handleImagePaste = useCallback(
|
|
216
|
+
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
217
|
+
const imageItems = Array.from(e.clipboardData.items).filter((item) =>
|
|
218
|
+
item.type.startsWith("image/"),
|
|
219
|
+
);
|
|
220
|
+
if (!imageItems.length) return;
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
addImageFiles(imageItems.map((item) => item.getAsFile()!));
|
|
223
|
+
},
|
|
224
|
+
[addImageFiles],
|
|
225
|
+
);
|
|
226
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
177
228
|
const requestOptionsRef = useRef({
|
|
178
229
|
questionId: question.id,
|
|
179
230
|
topicId: question.topicId,
|
|
@@ -391,10 +442,14 @@ export default function ChatView({ question }: Props) {
|
|
|
391
442
|
|
|
392
443
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
393
444
|
e.preventDefault();
|
|
394
|
-
|
|
445
|
+
const hasText = input.trim().length > 0;
|
|
446
|
+
const hasImages = attachedImages.length > 0;
|
|
447
|
+
if ((!hasText && !hasImages) || isLoading) return;
|
|
395
448
|
const text = input;
|
|
449
|
+
const files = attachedImages.length > 0 ? attachedImages : undefined;
|
|
396
450
|
setInput("");
|
|
397
|
-
|
|
451
|
+
setAttachedImages([]);
|
|
452
|
+
sendMessage({ text, ...(files ? { files } : {}) });
|
|
398
453
|
};
|
|
399
454
|
|
|
400
455
|
return (
|
|
@@ -568,7 +623,52 @@ export default function ChatView({ question }: Props) {
|
|
|
568
623
|
</div>
|
|
569
624
|
|
|
570
625
|
<form onSubmit={handleSubmit}>
|
|
626
|
+
{/* Attached image thumbnails */}
|
|
627
|
+
{attachedImages.length > 0 && (
|
|
628
|
+
<div className="flex flex-wrap gap-2 mb-2">
|
|
629
|
+
{attachedImages.map((img) => (
|
|
630
|
+
<div key={img._id} className="relative group">
|
|
631
|
+
<img
|
|
632
|
+
src={img.url}
|
|
633
|
+
alt={img.filename ?? "image"}
|
|
634
|
+
className="h-16 w-16 object-cover rounded-lg border border-slate-700"
|
|
635
|
+
/>
|
|
636
|
+
<button
|
|
637
|
+
type="button"
|
|
638
|
+
onClick={() =>
|
|
639
|
+
setAttachedImages((prev) =>
|
|
640
|
+
prev.filter((i) => i._id !== img._id),
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-slate-950 border border-slate-700 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
644
|
+
>
|
|
645
|
+
<X className="w-2.5 h-2.5 text-slate-400" />
|
|
646
|
+
</button>
|
|
647
|
+
</div>
|
|
648
|
+
))}
|
|
649
|
+
</div>
|
|
650
|
+
)}
|
|
571
651
|
<div className="flex gap-2">
|
|
652
|
+
{/* Hidden image file input */}
|
|
653
|
+
<input
|
|
654
|
+
ref={imageInputRef}
|
|
655
|
+
type="file"
|
|
656
|
+
accept="image/*"
|
|
657
|
+
multiple
|
|
658
|
+
className="hidden"
|
|
659
|
+
onChange={(e) => {
|
|
660
|
+
if (e.target.files) addImageFiles(Array.from(e.target.files));
|
|
661
|
+
e.target.value = "";
|
|
662
|
+
}}
|
|
663
|
+
/>
|
|
664
|
+
<button
|
|
665
|
+
type="button"
|
|
666
|
+
onClick={() => imageInputRef.current?.click()}
|
|
667
|
+
className="self-end pb-2.5 text-slate-500 hover:text-cyan-400 transition-colors shrink-0"
|
|
668
|
+
title="Attach image"
|
|
669
|
+
>
|
|
670
|
+
<ImagePlus className="w-4 h-4" />
|
|
671
|
+
</button>
|
|
572
672
|
<textarea
|
|
573
673
|
ref={textareaRef}
|
|
574
674
|
value={input}
|
|
@@ -576,11 +676,13 @@ export default function ChatView({ question }: Props) {
|
|
|
576
676
|
onKeyDown={(e) => {
|
|
577
677
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
578
678
|
e.preventDefault();
|
|
579
|
-
if (!input.trim() || isLoading)
|
|
679
|
+
if ((!input.trim() && !attachedImages.length) || isLoading)
|
|
680
|
+
return;
|
|
580
681
|
handleSubmit(e as any);
|
|
581
682
|
}
|
|
582
683
|
}}
|
|
583
|
-
|
|
684
|
+
onPaste={handleImagePaste}
|
|
685
|
+
placeholder="Ask about this topic…"
|
|
584
686
|
rows={1}
|
|
585
687
|
className="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500 transition-colors resize-none overflow-y-auto"
|
|
586
688
|
style={{ minHeight: "2.625rem", maxHeight: "8rem" }}
|
|
@@ -588,7 +690,9 @@ export default function ChatView({ question }: Props) {
|
|
|
588
690
|
/>
|
|
589
691
|
<button
|
|
590
692
|
type="submit"
|
|
591
|
-
disabled={
|
|
693
|
+
disabled={
|
|
694
|
+
isLoading || (!input.trim() && attachedImages.length === 0)
|
|
695
|
+
}
|
|
592
696
|
className="px-4 py-2.5 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg transition-colors flex items-center gap-2"
|
|
593
697
|
>
|
|
594
698
|
{isLoading ? (
|