diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index b6c3ccf4..531db3fd 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -294,6 +294,13 @@ async function handlePOST(req: Request) { // Prepare provider options // The Gateway passes these through to the specific provider const isClaudeModel = modelId.includes("claude"); + const vertexProject = process.env.GOOGLE_VERTEX_PROJECT; + const vertexLocation = process.env.GOOGLE_VERTEX_LOCATION || "us-central1"; + const vertexClientEmail = process.env.GOOGLE_CLIENT_EMAIL; + const vertexPrivateKey = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n"); + const hasVertexByok = + vertexProject && vertexClientEmail && vertexPrivateKey; + let providerOptions: any = { gateway: { // Route Claude models through Vertex AI only @@ -302,6 +309,22 @@ async function handlePOST(req: Request) { models: ["google/gemini-2.5-flash"], // Track usage per end-user for analytics ...(userId ? { user: userId } : {}), + // BYOK for Vertex: use GCP credentials so Claude (Haiku, etc.) bypasses Vercel balance + ...(isClaudeModel && + hasVertexByok && { + byok: { + vertex: [ + { + project: vertexProject!, + location: vertexLocation, + googleCredentials: { + clientEmail: vertexClientEmail!, + privateKey: vertexPrivateKey!, + }, + }, + ], + }, + }), // Fail fast if provider doesn't start streaming within 30s (BYOK only) providerTimeouts: { byok: { diff --git a/src/app/api/upload-file/route.ts b/src/app/api/upload-file/route.ts index 6d48fbf2..06c11b96 100644 --- a/src/app/api/upload-file/route.ts +++ b/src/app/api/upload-file/route.ts @@ -5,6 +5,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { writeFile, mkdir } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; +import { + isOfficeDocument, + getOfficeDocumentConvertUrl, +} from "@/lib/uploads/office-document-validation"; export const maxDuration = 30; @@ -61,8 +65,17 @@ export async function POST(request: NextRequest) { ); } - // Accept all file types (removed image-only restriction) - // File type validation can be done client-side if needed + // Reject Office documents — convert to PDF at ilovepdf.com + const convertUrl = getOfficeDocumentConvertUrl(file); + if (convertUrl) { + return NextResponse.json( + { + error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.", + convertUrl, + }, + { status: 400 } + ); + } // Validate file size (50MB limit) const maxSize = 50 * 1024 * 1024; // 50MB diff --git a/src/app/api/upload-url/route.ts b/src/app/api/upload-url/route.ts index 79edcb96..eb186f84 100644 --- a/src/app/api/upload-url/route.ts +++ b/src/app/api/upload-url/route.ts @@ -3,6 +3,7 @@ import { auth } from "@/lib/auth"; import { createClient } from '@supabase/supabase-js'; import { NextRequest, NextResponse } from 'next/server'; import { withApiLogging } from "@/lib/with-api-logging"; +import { getOfficeDocumentConvertUrlFromMeta } from "@/lib/uploads/office-document-validation"; export const maxDuration = 10; @@ -28,7 +29,7 @@ async function handlePOST(request: NextRequest) { } const body = await request.json(); - const { filename, contentType } = body; + const { filename, contentType = "" } = body; if (!filename || typeof filename !== 'string') { return NextResponse.json( @@ -37,6 +38,18 @@ async function handlePOST(request: NextRequest) { ); } + // Reject Office documents — convert to PDF at ilovepdf.com + const convertUrl = getOfficeDocumentConvertUrlFromMeta(filename, contentType); + if (convertUrl) { + return NextResponse.json( + { + error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.", + convertUrl, + }, + { status: 400 } + ); + } + const storageType = process.env.STORAGE_TYPE || 'supabase'; if (storageType === 'local') { diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index 27ef8c3e..3f1f9f05 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -1,6 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; + +/** Strip ocrPages/textContent from PDF items — client doesn't need them for display. */ +function stripPdfOcrFromItem(item: { type?: string; data?: unknown }): void { + if (item?.type === "pdf" && item.data && typeof item.data === "object") { + const d = item.data as Record; + delete d.ocrPages; + delete d.textContent; + } +} + +function stripPdfOcrFromState(state: { items?: Array<{ type?: string; data?: unknown }> } | undefined): void { + state?.items?.forEach(stripPdfOcrFromItem); +} + +function stripPdfOcrFromEventPayload(event: WorkspaceEvent): void { + const p = event.payload as Record; + if (event.type === "ITEM_CREATED" && p?.item) stripPdfOcrFromItem(p.item as { type?: string; data?: unknown }); + if (event.type === "ITEM_UPDATED") { + const changes = p?.changes as Record | undefined; + if (changes?.data && typeof changes.data === "object") { + const d = changes.data as Record; + delete d.ocrPages; + delete d.textContent; + } + } + if (event.type === "BULK_ITEMS_UPDATED") { + (p?.addedItems as Array<{ type?: string; data?: unknown }> | undefined)?.forEach(stripPdfOcrFromItem); + (p?.items as Array<{ type?: string; data?: unknown }> | undefined)?.forEach(stripPdfOcrFromItem); + } + if (event.type === "WORKSPACE_SNAPSHOT" && p?.items) { + (p.items as Array<{ type?: string; data?: unknown }>).forEach(stripPdfOcrFromItem); + } +} + import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { hasDuplicateName } from "@/lib/workspace/unique-name"; import { db, workspaceEvents } from "@/lib/db/client"; @@ -177,6 +211,10 @@ async function handleGET( ? Math.max(...eventsData.map(e => e.version)) : (snapshotVersion || 0); + // Strip ocrPages/textContent from PDF items — client doesn't need them for display + if (latestSnapshot?.state) stripPdfOcrFromState(latestSnapshot.state as { items?: Array<{ type?: string; data?: unknown }> }); + events.forEach(stripPdfOcrFromEventPayload); + const response: EventResponse = { events, version: maxVersion, @@ -324,6 +362,7 @@ async function handlePOST( userName: e.userName || undefined, id: e.eventId, } as WorkspaceEvent)); + events.forEach(stripPdfOcrFromEventPayload); const totalTime = Date.now() - startTime; timings.total = totalTime; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 999475e5..4746e99f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -28,6 +28,7 @@ import { DashboardLayout } from "@/components/layout/DashboardLayout"; import { SplitViewLayout } from "@/components/layout/SplitViewLayout"; import { ItemPanelContent } from "@/components/workspace-canvas/ItemPanelContent"; import WorkspaceHeader from "@/components/workspace-canvas/WorkspaceHeader"; +import { WorkspaceSearchDialog } from "@/components/workspace-canvas/WorkspaceSearchDialog"; import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; import { MobileWarning } from "@/components/ui/MobileWarning"; import { AnonymousSessionHandler, SidebarCoordinator } from "@/components/layout/SessionHandler"; @@ -216,13 +217,11 @@ function DashboardContent({ const isDesktop = useMediaQuery("(min-width: 768px)"); const showJsonView = useUIStore((state) => state.showJsonView); const isChatExpanded = useUIStore((state) => state.isChatExpanded); - const searchQuery = useUIStore((state) => state.searchQuery); const isChatMaximized = useUIStore((state) => state.isChatMaximized); const workspacePanelSize = useUIStore((state) => state.workspacePanelSize); const showCreateWorkspaceModal = useUIStore((state) => state.showCreateWorkspaceModal); const setShowJsonView = useUIStore((state) => state.setShowJsonView); const setIsChatExpanded = useUIStore((state) => state.setIsChatExpanded); - const setSearchQuery = useUIStore((state) => state.setSearchQuery); const setIsChatMaximized = useUIStore((state) => state.setIsChatMaximized); const setOpenModalItemId = useUIStore((state) => state.setOpenModalItemId); const setShowCreateWorkspaceModal = useUIStore((state) => state.setShowCreateWorkspaceModal); @@ -265,6 +264,9 @@ function DashboardContent({ isDesktop, }); + // Search dialog state for Cmd+K command palette + const [searchDialogOpen, setSearchDialogOpen] = useState(false); + // Keyboard shortcuts useKeyboardShortcuts( toggleChatExpanded, @@ -272,7 +274,9 @@ function DashboardContent({ onToggleSidebar: toggleSidebar, onToggleChatMaximize: toggleChatMaximized, onFocusSearch: () => { - titleInputRef.current?.focus(); + if (currentWorkspaceId && !isLoadingWorkspace) { + setSearchDialogOpen(true); + } }, } ); @@ -455,8 +459,7 @@ function DashboardContent({ currentSlug={currentSlug} state={state} showJsonView={showJsonView} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onOpenSearch={() => setSearchDialogOpen(true)} isSaving={isSaving} lastSavedAt={lastSavedAt} hasUnsavedChanges={hasUnsavedChanges} @@ -494,7 +497,7 @@ function DashboardContent({ onFlushPendingChanges={operations.flushPendingChanges} /> ); - }, [viewMode, state.items, loadingCurrentWorkspace, isLoadingWorkspace, currentWorkspaceId, currentSlug, state, showJsonView, searchQuery, isSaving, lastSavedAt, hasUnsavedChanges, isChatMaximized, isDesktop, isChatExpanded, currentWorkspaceTitle, currentWorkspaceIcon, currentWorkspaceColor, operations, manualSave, setSearchQuery, setIsChatExpanded, setOpenModalItemId, handleShowHistory, titleInputRef, scrollAreaRef, getStatePreviewJSON]); + }, [viewMode, state.items, loadingCurrentWorkspace, isLoadingWorkspace, currentWorkspaceId, currentSlug, state, showJsonView, isSaving, lastSavedAt, hasUnsavedChanges, isChatMaximized, isDesktop, isChatExpanded, currentWorkspaceTitle, currentWorkspaceIcon, currentWorkspaceColor, operations, manualSave, setSearchDialogOpen, setIsChatExpanded, setOpenModalItemId, handleShowHistory, titleInputRef, scrollAreaRef, getStatePreviewJSON]); // Build the single panel content (for workspace+panel mode) const panelContent = useMemo(() => { @@ -553,8 +556,7 @@ function DashboardContent({ !showJsonView && !isChatMaximized && currentWorkspaceId && !isLoadingWorkspace ? ( } - searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onOpenSearch={() => setSearchDialogOpen(true)} currentWorkspaceId={currentWorkspaceId} isSaving={isSaving} lastSavedAt={lastSavedAt} @@ -626,8 +628,7 @@ function DashboardContent({ currentSlug={currentSlug} state={state} showJsonView={showJsonView} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onOpenSearch={() => setSearchDialogOpen(true)} isSaving={isSaving} lastSavedAt={lastSavedAt} hasUnsavedChanges={hasUnsavedChanges} @@ -695,6 +696,13 @@ function DashboardContent({ onRevertToVersion={revertToVersion} items={currentWorkspace?.state?.items || []} /> + ); } @@ -745,12 +753,6 @@ function DashboardView() { lastTrackedWorkspaceIdRef.current = currentWorkspaceId; }, [currentWorkspaceId, markWorkspaceOpened]); - // Reset search query when workspace changes - const setSearchQuery = useUIStore((state) => state.setSearchQuery); - useEffect(() => { - setSearchQuery(''); - }, [currentWorkspaceId, setSearchQuery]); - // Sync active folder with URL query param (?folder=) // Reset folder/panels when workspace changes // and enables browser-native back/forward for folder navigation diff --git a/src/components/assistant-ui/AIFeedbackDialog.tsx b/src/components/assistant-ui/AIFeedbackDialog.tsx new file mode 100644 index 00000000..24532bac --- /dev/null +++ b/src/components/assistant-ui/AIFeedbackDialog.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { usePostHog } from "posthog-js/react"; +import { toast } from "sonner"; +import { useAuiState } from "@assistant-ui/react"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; + +interface AIFeedbackDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AIFeedbackDialog({ open, onOpenChange }: AIFeedbackDialogProps) { + const [feedback, setFeedback] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const posthog = usePostHog(); + + const threadListItemId = useAuiState(({ threadListItem }) => (threadListItem as { id?: string })?.id); + const mainThreadId = useAuiState(({ threads }) => (threads as { mainThreadId?: string })?.mainThreadId); + const threadId = threadListItemId ?? mainThreadId; + const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + + const handleSubmit = useCallback(() => { + setIsSubmitting(true); + + if (posthog) { + posthog.capture("ai-feedback-reported", { + feedback: feedback.trim(), + thread_id: threadId ?? undefined, + workspace_id: workspaceId ?? undefined, + source: "composer", + }); + } + + toast.success("Feedback submitted—thank you!"); + setFeedback(""); + onOpenChange(false); + setIsSubmitting(false); + }, [feedback, threadId, workspaceId, posthog, onOpenChange]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen && !isSubmitting) { + setFeedback(""); + } + onOpenChange(nextOpen); + }, + [onOpenChange, isSubmitting] + ); + + return ( + + + + Report AI Issue + + What went wrong with the AI? Your feedback helps us improve. + + + +
+