stagent 0.6.2 → 0.6.3

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.
Files changed (68) hide show
  1. package/dist/cli.js +47 -1
  2. package/package.json +1 -2
  3. package/src/app/api/documents/[id]/route.ts +5 -1
  4. package/src/app/api/documents/[id]/versions/route.ts +53 -0
  5. package/src/app/api/documents/route.ts +5 -1
  6. package/src/app/api/projects/[id]/documents/route.ts +124 -0
  7. package/src/app/api/projects/[id]/route.ts +35 -3
  8. package/src/app/api/projects/__tests__/delete-project.test.ts +1 -0
  9. package/src/app/api/schedules/route.ts +19 -1
  10. package/src/app/api/tasks/[id]/route.ts +37 -2
  11. package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
  12. package/src/app/api/tasks/route.ts +8 -9
  13. package/src/app/api/workflows/[id]/documents/route.ts +209 -0
  14. package/src/app/api/workflows/[id]/execute/route.ts +6 -2
  15. package/src/app/api/workflows/[id]/route.ts +16 -3
  16. package/src/app/api/workflows/[id]/status/route.ts +18 -2
  17. package/src/app/api/workflows/route.ts +13 -2
  18. package/src/app/documents/page.tsx +5 -1
  19. package/src/app/layout.tsx +0 -1
  20. package/src/app/manifest.ts +3 -3
  21. package/src/app/projects/[id]/page.tsx +62 -2
  22. package/src/components/documents/document-chip-bar.tsx +17 -1
  23. package/src/components/documents/document-detail-view.tsx +51 -0
  24. package/src/components/documents/document-grid.tsx +5 -0
  25. package/src/components/documents/document-table.tsx +4 -0
  26. package/src/components/documents/types.ts +3 -0
  27. package/src/components/projects/project-form-sheet.tsx +133 -2
  28. package/src/components/schedules/schedule-form.tsx +113 -1
  29. package/src/components/shared/document-picker-sheet.tsx +283 -0
  30. package/src/components/tasks/task-card.tsx +8 -1
  31. package/src/components/tasks/task-create-panel.tsx +137 -14
  32. package/src/components/tasks/task-detail-view.tsx +47 -0
  33. package/src/components/tasks/task-edit-dialog.tsx +125 -2
  34. package/src/components/workflows/workflow-form-view.tsx +231 -7
  35. package/src/components/workflows/workflow-kanban-card.tsx +8 -1
  36. package/src/components/workflows/workflow-list.tsx +90 -45
  37. package/src/components/workflows/workflow-status-view.tsx +167 -22
  38. package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
  39. package/src/lib/agents/profiles/registry.ts +6 -3
  40. package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
  41. package/src/lib/book/chapter-generator.ts +4 -19
  42. package/src/lib/book/chapter-mapping.ts +17 -0
  43. package/src/lib/book/content.ts +5 -16
  44. package/src/lib/book/update-detector.ts +3 -16
  45. package/src/lib/chat/engine.ts +1 -0
  46. package/src/lib/chat/system-prompt.ts +9 -1
  47. package/src/lib/chat/tool-catalog.ts +1 -0
  48. package/src/lib/chat/tools/settings-tools.ts +109 -0
  49. package/src/lib/chat/tools/workflow-tools.ts +145 -2
  50. package/src/lib/data/clear.ts +12 -0
  51. package/src/lib/db/bootstrap.ts +48 -0
  52. package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
  53. package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
  54. package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
  55. package/src/lib/db/schema.ts +77 -0
  56. package/src/lib/docs/reader.ts +2 -3
  57. package/src/lib/documents/context-builder.ts +75 -2
  58. package/src/lib/documents/document-resolver.ts +119 -0
  59. package/src/lib/documents/processors/spreadsheet.ts +2 -1
  60. package/src/lib/schedules/scheduler.ts +31 -1
  61. package/src/lib/utils/app-root.ts +20 -0
  62. package/src/lib/validators/__tests__/task.test.ts +43 -10
  63. package/src/lib/validators/task.ts +7 -1
  64. package/src/lib/workflows/blueprints/registry.ts +3 -3
  65. package/src/lib/workflows/engine.ts +24 -8
  66. package/src/lib/workflows/types.ts +14 -0
  67. package/public/icon.svg +0 -13
  68. package/src/components/tasks/file-upload.tsx +0 -120
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Resolve document selectors against the project document pool.
3
+ * Used at workflow creation time for auto-discovery of relevant documents.
4
+ */
5
+
6
+ import { db } from "@/lib/db";
7
+ import { documents, workflows, tasks } from "@/lib/db/schema";
8
+ import { and, eq, desc, like, inArray } from "drizzle-orm";
9
+ import type { DocumentRow } from "@/lib/db/schema";
10
+ import type { DocumentSelector } from "@/lib/workflows/types";
11
+
12
+ /**
13
+ * Resolve a DocumentSelector against the project pool, returning matching documents.
14
+ * Used for auto-discovery at workflow creation time (not at execution time).
15
+ */
16
+ export async function resolveDocumentSelector(
17
+ projectId: string,
18
+ selector: DocumentSelector
19
+ ): Promise<DocumentRow[]> {
20
+ const conditions = [eq(documents.projectId, projectId)];
21
+
22
+ if (selector.direction) {
23
+ conditions.push(eq(documents.direction, selector.direction));
24
+ }
25
+
26
+ if (selector.category) {
27
+ conditions.push(eq(documents.category, selector.category));
28
+ }
29
+
30
+ if (selector.mimeType) {
31
+ conditions.push(eq(documents.mimeType, selector.mimeType));
32
+ }
33
+
34
+ if (selector.namePattern) {
35
+ // Convert glob pattern to SQL LIKE: * → %, ? → _
36
+ const likePattern = selector.namePattern
37
+ .replace(/\*/g, "%")
38
+ .replace(/\?/g, "_");
39
+ conditions.push(like(documents.originalName, likePattern));
40
+ }
41
+
42
+ // Filter by source workflow (via task → workflow relationship)
43
+ if (selector.fromWorkflowId) {
44
+ const workflowTaskIds = await db
45
+ .select({ id: tasks.id })
46
+ .from(tasks)
47
+ .where(eq(tasks.workflowId, selector.fromWorkflowId));
48
+
49
+ const taskIds = workflowTaskIds.map((t) => t.id);
50
+ if (taskIds.length === 0) return [];
51
+ conditions.push(inArray(documents.taskId, taskIds));
52
+ } else if (selector.fromWorkflowName) {
53
+ // Look up workflow by name, then get its task IDs
54
+ const matchingWorkflows = await db
55
+ .select({ id: workflows.id })
56
+ .from(workflows)
57
+ .where(
58
+ and(
59
+ eq(workflows.projectId, projectId),
60
+ like(workflows.name, `%${selector.fromWorkflowName}%`)
61
+ )
62
+ );
63
+
64
+ if (matchingWorkflows.length === 0) return [];
65
+
66
+ const wfIds = matchingWorkflows.map((w) => w.id);
67
+ const workflowTaskIds = await db
68
+ .select({ id: tasks.id })
69
+ .from(tasks)
70
+ .where(inArray(tasks.workflowId, wfIds));
71
+
72
+ const taskIds = workflowTaskIds.map((t) => t.id);
73
+ if (taskIds.length === 0) return [];
74
+ conditions.push(inArray(documents.taskId, taskIds));
75
+ }
76
+
77
+ // Only return ready documents (processed and available)
78
+ conditions.push(eq(documents.status, "ready"));
79
+
80
+ let query = db
81
+ .select()
82
+ .from(documents)
83
+ .where(and(...conditions))
84
+ .orderBy(desc(documents.createdAt));
85
+
86
+ if (selector.latest) {
87
+ query = query.limit(selector.latest) as typeof query;
88
+ }
89
+
90
+ return query;
91
+ }
92
+
93
+ /**
94
+ * Get all output documents from completed workflows in a project.
95
+ * Useful for browsing the project document pool.
96
+ */
97
+ export async function getProjectDocumentPool(
98
+ projectId: string,
99
+ options?: { direction?: "input" | "output"; search?: string }
100
+ ): Promise<DocumentRow[]> {
101
+ const conditions = [
102
+ eq(documents.projectId, projectId),
103
+ eq(documents.status, "ready"),
104
+ ];
105
+
106
+ if (options?.direction) {
107
+ conditions.push(eq(documents.direction, options.direction));
108
+ }
109
+
110
+ if (options?.search) {
111
+ conditions.push(like(documents.originalName, `%${options.search}%`));
112
+ }
113
+
114
+ return db
115
+ .select()
116
+ .from(documents)
117
+ .where(and(...conditions))
118
+ .orderBy(desc(documents.createdAt));
119
+ }
@@ -6,7 +6,8 @@ export async function processSpreadsheet(filePath: string): Promise<ProcessorRes
6
6
  const ExcelJS = await import("exceljs");
7
7
  const workbook = new ExcelJS.Workbook();
8
8
  const buffer = await readFile(filePath);
9
- await workbook.xlsx.load(buffer as unknown as Buffer);
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ await workbook.xlsx.load(buffer as any);
10
11
 
11
12
  const sheets: string[] = [];
12
13
  workbook.eachSheet((worksheet) => {
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { db } from "@/lib/db";
15
- import { schedules, tasks, agentLogs } from "@/lib/db/schema";
15
+ import { schedules, tasks, agentLogs, scheduleDocumentInputs, documents } from "@/lib/db/schema";
16
16
  import { eq, and, lte, inArray, sql } from "drizzle-orm";
17
17
  import { computeNextFireTime } from "./interval-parser";
18
18
  import { executeTaskWithRuntime } from "@/lib/agents/runtime";
@@ -178,6 +178,21 @@ async function fireSchedule(
178
178
  updatedAt: now,
179
179
  });
180
180
 
181
+ // Link schedule's documents to the created task
182
+ try {
183
+ const schedDocs = await db
184
+ .select({ documentId: scheduleDocumentInputs.documentId })
185
+ .from(scheduleDocumentInputs)
186
+ .where(eq(scheduleDocumentInputs.scheduleId, schedule.id));
187
+ for (const { documentId } of schedDocs) {
188
+ await db.update(documents)
189
+ .set({ taskId, projectId: schedule.projectId, updatedAt: now })
190
+ .where(eq(documents.id, documentId));
191
+ }
192
+ } catch (err) {
193
+ console.error(`[scheduler] Document linking failed for schedule ${schedule.id}:`, err);
194
+ }
195
+
181
196
  // Update schedule counters
182
197
  const isOneShot = !schedule.recurs;
183
198
  const reachedMax =
@@ -335,6 +350,21 @@ async function fireHeartbeat(
335
350
  updatedAt: now,
336
351
  });
337
352
 
353
+ // Link schedule's documents to the heartbeat task
354
+ try {
355
+ const schedDocs = await db
356
+ .select({ documentId: scheduleDocumentInputs.documentId })
357
+ .from(scheduleDocumentInputs)
358
+ .where(eq(scheduleDocumentInputs.scheduleId, schedule.id));
359
+ for (const { documentId } of schedDocs) {
360
+ await db.update(documents)
361
+ .set({ taskId: evalTaskId, projectId: schedule.projectId, updatedAt: now })
362
+ .where(eq(documents.id, documentId));
363
+ }
364
+ } catch (err) {
365
+ console.error(`[scheduler] Document linking failed for heartbeat ${schedule.id}:`, err);
366
+ }
367
+
338
368
  // 5. Execute and wait for result (with timeout)
339
369
  try {
340
370
  await executeTaskWithRuntime(evalTaskId);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolve the app root directory.
3
+ * - import.meta.dirname works under npx (real path to installed package)
4
+ * - Turbopack compiles it to /ROOT/... (virtual, doesn't exist) → fall back to process.cwd()
5
+ *
6
+ * Uses dynamic require() for fs/path to avoid bundling Node built-ins
7
+ * into client components (content.ts is shared with book-reader.tsx).
8
+ */
9
+ export function getAppRoot(metaDirname: string | undefined, depth: number): string {
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ const { existsSync } = require("fs") as typeof import("fs");
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
13
+ const { join } = require("path") as typeof import("path");
14
+
15
+ if (metaDirname) {
16
+ const candidate = join(metaDirname, ...Array(depth).fill(".."));
17
+ if (existsSync(join(candidate, "package.json"))) return candidate;
18
+ }
19
+ return process.cwd();
20
+ }
@@ -72,40 +72,63 @@ describe("createTaskSchema", () => {
72
72
  }
73
73
  });
74
74
 
75
- it("accepts fileIds as optional array of strings", () => {
75
+ it("accepts documentIds as optional array of strings", () => {
76
76
  const result = createTaskSchema.safeParse({
77
77
  title: "Test",
78
- fileIds: ["abc-123", "def-456"],
78
+ documentIds: ["abc-123", "def-456"],
79
79
  });
80
80
  expect(result.success).toBe(true);
81
81
  if (result.success) {
82
- expect(result.data.fileIds).toEqual(["abc-123", "def-456"]);
82
+ expect(result.data.documentIds).toEqual(["abc-123", "def-456"]);
83
83
  }
84
84
  });
85
85
 
86
- it("accepts task without fileIds", () => {
86
+ it("accepts task without documentIds", () => {
87
87
  const result = createTaskSchema.safeParse({ title: "Test" });
88
88
  expect(result.success).toBe(true);
89
89
  if (result.success) {
90
- expect(result.data.fileIds).toBeUndefined();
90
+ expect(result.data.documentIds).toBeUndefined();
91
91
  }
92
92
  });
93
93
 
94
- it("accepts empty fileIds array", () => {
95
- const result = createTaskSchema.safeParse({ title: "Test", fileIds: [] });
94
+ it("accepts empty documentIds array", () => {
95
+ const result = createTaskSchema.safeParse({ title: "Test", documentIds: [] });
96
96
  expect(result.success).toBe(true);
97
97
  if (result.success) {
98
- expect(result.data.fileIds).toEqual([]);
98
+ expect(result.data.documentIds).toEqual([]);
99
99
  }
100
100
  });
101
101
 
102
- it("rejects fileIds with non-string elements", () => {
102
+ it("rejects documentIds with non-string elements", () => {
103
103
  const result = createTaskSchema.safeParse({
104
104
  title: "Test",
105
- fileIds: [123, true],
105
+ documentIds: [123, true],
106
106
  });
107
107
  expect(result.success).toBe(false);
108
108
  });
109
+
110
+ it("accepts deprecated fileIds and transforms to documentIds", () => {
111
+ const result = createTaskSchema.safeParse({
112
+ title: "Test",
113
+ fileIds: ["file-1", "file-2"],
114
+ });
115
+ expect(result.success).toBe(true);
116
+ if (result.success) {
117
+ expect(result.data.documentIds).toEqual(["file-1", "file-2"]);
118
+ }
119
+ });
120
+
121
+ it("prefers documentIds over fileIds when both provided", () => {
122
+ const result = createTaskSchema.safeParse({
123
+ title: "Test",
124
+ documentIds: ["doc-1"],
125
+ fileIds: ["file-1"],
126
+ });
127
+ expect(result.success).toBe(true);
128
+ if (result.success) {
129
+ expect(result.data.documentIds).toEqual(["doc-1"]);
130
+ }
131
+ });
109
132
  });
110
133
 
111
134
  describe("updateTaskSchema", () => {
@@ -141,4 +164,14 @@ describe("updateTaskSchema", () => {
141
164
  });
142
165
  expect(result.success).toBe(false);
143
166
  });
167
+
168
+ it("accepts documentIds in update", () => {
169
+ const result = updateTaskSchema.safeParse({
170
+ documentIds: ["doc-1", "doc-2"],
171
+ });
172
+ expect(result.success).toBe(true);
173
+ if (result.success) {
174
+ expect(result.data.documentIds).toEqual(["doc-1", "doc-2"]);
175
+ }
176
+ });
144
177
  });
@@ -10,8 +10,13 @@ export const createTaskSchema = z.object({
10
10
  priority: z.number().min(0).max(3).default(2),
11
11
  assignedAgent: assignedAgentSchema.optional(),
12
12
  agentProfile: z.string().optional(),
13
+ documentIds: z.array(z.string()).optional(),
14
+ /** @deprecated Use documentIds instead */
13
15
  fileIds: z.array(z.string()).optional(),
14
- });
16
+ }).transform((data) => ({
17
+ ...data,
18
+ documentIds: data.documentIds ?? data.fileIds,
19
+ }));
15
20
 
16
21
  export const updateTaskSchema = z.object({
17
22
  title: z.string().min(1).max(200).optional(),
@@ -24,6 +29,7 @@ export const updateTaskSchema = z.object({
24
29
  agentProfile: z.string().optional(),
25
30
  result: z.string().optional(),
26
31
  sessionId: z.string().optional(),
32
+ documentIds: z.array(z.string()).optional(),
27
33
  });
28
34
 
29
35
  export type CreateTaskInput = z.infer<typeof createTaskSchema>;
@@ -3,12 +3,12 @@ import path from "node:path";
3
3
  import yaml from "js-yaml";
4
4
  import { BlueprintSchema } from "@/lib/validators/blueprint";
5
5
  import { getStagentBlueprintsDir } from "@/lib/utils/stagent-paths";
6
+ import { getAppRoot } from "@/lib/utils/app-root";
6
7
  import type { WorkflowBlueprint } from "./types";
7
8
 
8
- // Use fileURLToPath for ESM compatibility in Next.js
9
9
  const BUILTINS_DIR = path.resolve(
10
- import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname),
11
- "builtins"
10
+ getAppRoot(import.meta.dirname, 4),
11
+ "src", "lib", "workflows", "blueprints", "builtins"
12
12
  );
13
13
 
14
14
  const USER_BLUEPRINTS_DIR = getStagentBlueprintsDir();
@@ -20,7 +20,10 @@ import {
20
20
  openLearningSession,
21
21
  closeLearningSession,
22
22
  } from "@/lib/agents/learning-session";
23
- import { buildWorkflowDocumentContext } from "@/lib/documents/context-builder";
23
+ import {
24
+ buildWorkflowDocumentContext,
25
+ buildPoolDocumentContext,
26
+ } from "@/lib/documents/context-builder";
24
27
 
25
28
  /**
26
29
  * Execute a workflow by advancing through its steps according to the pattern.
@@ -359,7 +362,8 @@ async function executeParallel(
359
362
  step.prompt,
360
363
  step.assignedAgent,
361
364
  step.agentProfile,
362
- parentTaskId
365
+ parentTaskId,
366
+ step.id
363
367
  );
364
368
 
365
369
  const completedAt = new Date().toISOString();
@@ -433,7 +437,8 @@ async function executeParallel(
433
437
  synthesisPrompt,
434
438
  synthesisStep.assignedAgent,
435
439
  synthesisStep.agentProfile,
436
- parentTaskId
440
+ parentTaskId,
441
+ synthesisStep.id
437
442
  );
438
443
 
439
444
  await commitState((draft) => {
@@ -559,7 +564,8 @@ async function executeSwarm(
559
564
  workerPrompt,
560
565
  step.assignedAgent,
561
566
  step.agentProfile,
562
- parentTaskId
567
+ parentTaskId,
568
+ step.id
563
569
  );
564
570
 
565
571
  const completedAt = new Date().toISOString();
@@ -680,7 +686,8 @@ async function runSwarmRefinery(input: {
680
686
  refineryPrompt,
681
687
  refineryStep.assignedAgent,
682
688
  refineryStep.agentProfile,
683
- parentTaskId
689
+ parentTaskId,
690
+ refineryStep.id
684
691
  );
685
692
 
686
693
  refineryState.taskId = refineryResult.taskId;
@@ -716,7 +723,8 @@ export async function executeChildTask(
716
723
  prompt: string,
717
724
  assignedAgent?: string,
718
725
  agentProfile?: string,
719
- parentTaskId?: string
726
+ parentTaskId?: string,
727
+ stepId?: string
720
728
  ): Promise<{ taskId: string; status: string; result?: string; error?: string }> {
721
729
  const [workflow] = await db
722
730
  .select()
@@ -735,10 +743,16 @@ export async function executeChildTask(
735
743
  if (parentTaskId) {
736
744
  const docContext = await buildWorkflowDocumentContext(parentTaskId);
737
745
  if (docContext) {
738
- enrichedPrompt = `${docContext}\n\n${prompt}`;
746
+ enrichedPrompt = `${docContext}\n\n${enrichedPrompt}`;
739
747
  }
740
748
  }
741
749
 
750
+ // Inject pool document context from workflow_document_inputs junction table
751
+ const poolContext = await buildPoolDocumentContext(workflowId, stepId);
752
+ if (poolContext) {
753
+ enrichedPrompt = `${poolContext}\n\n${enrichedPrompt}`;
754
+ }
755
+
742
756
  const taskId = crypto.randomUUID();
743
757
  await db.insert(tasks).values({
744
758
  id: taskId,
@@ -751,6 +765,7 @@ export async function executeChildTask(
751
765
  priority: 1,
752
766
  assignedAgent: assignedAgent ?? null,
753
767
  agentProfile: resolvedProfile ?? null,
768
+ workflowRunNumber: workflow?.runNumber ?? null,
754
769
  createdAt: new Date(),
755
770
  updatedAt: new Date(),
756
771
  });
@@ -807,7 +822,8 @@ async function executeStep(
807
822
  prompt,
808
823
  assignedAgent,
809
824
  agentProfile,
810
- parentTaskId
825
+ parentTaskId,
826
+ stepId
811
827
  );
812
828
 
813
829
  stepState.taskId = result.taskId;
@@ -14,6 +14,20 @@ export interface WorkflowStep {
14
14
  dependsOn?: string[];
15
15
  assignedAgent?: string;
16
16
  agentProfile?: string;
17
+ /** Document IDs from the project pool to inject as context for this step */
18
+ documentIds?: string[];
19
+ }
20
+
21
+ /** Selector for auto-discovering documents from the project pool */
22
+ export interface DocumentSelector {
23
+ fromWorkflowId?: string;
24
+ fromWorkflowName?: string;
25
+ category?: string;
26
+ direction?: "input" | "output";
27
+ mimeType?: string;
28
+ namePattern?: string;
29
+ /** Take only the N most recent matching documents */
30
+ latest?: number;
17
31
  }
18
32
 
19
33
  export interface LoopConfig {
package/public/icon.svg DELETED
@@ -1,13 +0,0 @@
1
- <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <rect width="512" height="512" rx="112" fill="#0f172a"/>
3
- <defs>
4
- <linearGradient id="s-grad" x1="150" y1="80" x2="362" y2="432" gradientUnits="userSpaceOnUse">
5
- <stop offset="0%" stop-color="#22d3ee"/>
6
- <stop offset="50%" stop-color="#3b82f6"/>
7
- <stop offset="100%" stop-color="#2563eb"/>
8
- </linearGradient>
9
- </defs>
10
- <path d="M340 130c0 0-60-10-110 30s-50 90-10 115c40 25 70 30 70 30s50 15 30 65c-20 50-80 40-80 40" stroke="url(#s-grad)" stroke-width="56" stroke-linecap="round" fill="none"/>
11
- <circle cx="340" cy="130" r="36" fill="#22d3ee"/>
12
- <circle cx="240" cy="410" r="36" fill="#2563eb"/>
13
- </svg>
@@ -1,120 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useRef } from "react";
4
- import { Button } from "@/components/ui/button";
5
- import { Upload, X, FileText, Image, FileCode } from "lucide-react";
6
-
7
- interface UploadedFile {
8
- id: string;
9
- filename: string;
10
- originalName: string;
11
- size: number;
12
- type: string;
13
- }
14
-
15
- interface FileUploadProps {
16
- onUploaded: (file: UploadedFile) => void;
17
- uploads: UploadedFile[];
18
- onRemove: (id: string) => void;
19
- }
20
-
21
- function formatSize(bytes: number): string {
22
- if (bytes < 1024) return `${bytes} B`;
23
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
24
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
25
- }
26
-
27
- function getFileIcon(type: string) {
28
- if (type.startsWith("image/")) return Image;
29
- if (type.includes("pdf") || type.includes("document") || type.includes("text")) return FileText;
30
- if (type.includes("javascript") || type.includes("typescript") || type.includes("json") || type.includes("xml")) return FileCode;
31
- return FileText;
32
- }
33
-
34
- export function FileUpload({ onUploaded, uploads, onRemove }: FileUploadProps) {
35
- const [uploading, setUploading] = useState(false);
36
- const inputRef = useRef<HTMLInputElement>(null);
37
-
38
- async function handleFile(file: File) {
39
- setUploading(true);
40
- try {
41
- const formData = new FormData();
42
- formData.append("file", file);
43
-
44
- const res = await fetch("/api/uploads", {
45
- method: "POST",
46
- body: formData,
47
- });
48
-
49
- if (res.ok) {
50
- const data = await res.json();
51
- onUploaded(data);
52
- }
53
- } finally {
54
- setUploading(false);
55
- }
56
- }
57
-
58
- function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
59
- const file = e.target.files?.[0];
60
- if (file) handleFile(file);
61
- e.target.value = "";
62
- }
63
-
64
- function handleDrop(e: React.DragEvent) {
65
- e.preventDefault();
66
- const file = e.dataTransfer.files?.[0];
67
- if (file) handleFile(file);
68
- }
69
-
70
- return (
71
- <div className="space-y-2">
72
- <div
73
- role="button"
74
- tabIndex={0}
75
- aria-label="Upload a file — click or drag and drop"
76
- className="border-2 border-dashed rounded-lg p-4 text-center cursor-pointer hover:bg-accent/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
77
- onClick={() => inputRef.current?.click()}
78
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); } }}
79
- onDrop={handleDrop}
80
- onDragOver={(e) => e.preventDefault()}
81
- >
82
- <Upload className="h-5 w-5 mx-auto text-muted-foreground mb-1" />
83
- <p className="text-xs text-muted-foreground">
84
- {uploading ? "Uploading..." : "Click or drop a file"}
85
- </p>
86
- <p className="text-xs text-muted-foreground mt-0.5">Max 50MB per file</p>
87
- <input
88
- ref={inputRef}
89
- type="file"
90
- className="hidden"
91
- onChange={handleChange}
92
- />
93
- </div>
94
- {uploads.length > 0 && (
95
- <div className="space-y-1">
96
- {uploads.map((f) => {
97
- const Icon = getFileIcon(f.type);
98
- return (
99
- <div key={f.id} className="flex items-center gap-2 text-sm">
100
- <Icon className="h-3 w-3 text-muted-foreground" />
101
- <span className="flex-1 truncate">{f.originalName}</span>
102
- <span className="text-xs text-muted-foreground">{formatSize(f.size)}</span>
103
- <Button
104
- type="button"
105
- variant="ghost"
106
- size="icon"
107
- className="h-5 w-5"
108
- onClick={() => onRemove(f.id)}
109
- aria-label={`Remove ${f.originalName}`}
110
- >
111
- <X className="h-3 w-3" />
112
- </Button>
113
- </div>
114
- );
115
- })}
116
- </div>
117
- )}
118
- </div>
119
- );
120
- }