create-interview-cockpit 0.1.0 → 0.2.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/index.js CHANGED
@@ -4,6 +4,7 @@ import fs from "fs";
4
4
  import path from "path";
5
5
  import readline from "readline";
6
6
  import { fileURLToPath } from "url";
7
+ import { execSync } from "child_process";
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
@@ -159,11 +160,20 @@ async function runCreate() {
159
160
 
160
161
  rl.close();
161
162
 
163
+ // ── 3. Install dependencies ───────────────────────────
164
+ console.log(" Installing dependencies (this may take a minute)…");
165
+ const installDirs = [targetDir, path.join(targetDir, "client"), path.join(targetDir, "server")];
166
+ for (const dir of installDirs) {
167
+ const label = path.relative(path.dirname(targetDir), dir) || projectName;
168
+ process.stdout.write(` Installing ${label}/… `);
169
+ execSync("npm install", { cwd: dir, stdio: "ignore" });
170
+ process.stdout.write("✔\n");
171
+ }
172
+
162
173
  console.log("");
163
174
  console.log(" ✔ Done! Get started:");
164
175
  console.log("");
165
176
  console.log(` cd ${projectName}`);
166
- console.log(" npm install");
167
177
  console.log(" npm run dev");
168
178
  console.log("");
169
179
  console.log(" The app opens at http://localhost:5173");
@@ -281,8 +291,8 @@ async function runUpgrade() {
281
291
  console.log("");
282
292
  console.log(` ✔ Upgraded to v${CLI_VERSION}!`);
283
293
  console.log("");
284
- console.log(" Run npm install to pick up any dependency changes,");
285
- console.log(" then npm run dev to start.");
294
+ console.log(" Run npm install in the root, client/, and server/ directories");
295
+ console.log(" to pick up any dependency changes, then npm run dev to start.");
286
296
  console.log("");
287
297
  }
288
298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@ import { Code, Plane, PanelLeftClose, PanelLeft } from "lucide-react";
9
9
  export default function App() {
10
10
  const {
11
11
  fetchTopics,
12
+ fetchWorkspaces,
12
13
  currentQuestion,
13
14
  showCodePanel,
14
15
  toggleCodePanel,
@@ -19,8 +20,12 @@ export default function App() {
19
20
  } = useStore();
20
21
 
21
22
  useEffect(() => {
22
- fetchTopics();
23
- }, [fetchTopics]);
23
+ const init = async () => {
24
+ await fetchWorkspaces();
25
+ await fetchTopics();
26
+ };
27
+ init();
28
+ }, []);
24
29
 
25
30
  return (
26
31
  <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
- <p className="whitespace-pre-wrap text-slate-300">{content}</p>
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 { Send, Loader2, Settings2, RotateCcw } from "lucide-react";
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
- if (!input.trim() || isLoading) return;
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
- sendMessage({ text });
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) return;
679
+ if ((!input.trim() && !attachedImages.length) || isLoading)
680
+ return;
580
681
  handleSubmit(e as any);
581
682
  }
582
683
  }}
583
- placeholder="Ask about this topic..."
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={isLoading || !input.trim()}
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 ? (