diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts
index 29421ed4..327bc721 100644
--- a/src/lib/ai/tools/process-files.ts
+++ b/src/lib/ai/tools/process-files.ts
@@ -37,6 +37,53 @@ function getMediaTypeFromUrl(url: string): string {
return 'application/octet-stream';
}
+const IMAGE_MEDIA_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/webp',
+ 'image/svg+xml',
+];
+
+function isPdf(mediaType: string): boolean {
+ return mediaType === 'application/pdf';
+}
+
+function isImage(mediaType: string): boolean {
+ return IMAGE_MEDIA_TYPES.includes(mediaType);
+}
+
+function buildFileProcessingPrompt(
+ fileInfos: Array<{ filename: string; mediaType: string }>
+): { defaultInstruction: string; outputFormat: string } {
+ const hasPdfs = fileInfos.some((f) => isPdf(f.mediaType));
+ const hasImages = fileInfos.some((f) => isImage(f.mediaType));
+ const hasOther = fileInfos.some((f) => !isPdf(f.mediaType) && !isImage(f.mediaType));
+
+ const parts: string[] = [];
+ if (hasPdfs) {
+ parts.push(
+ 'For PDFs: Extract the exact textual content in markdown format. Preserve layout: headings (# ## ###), bullet/numbered lists, tables, paragraphs, and structure. Include all text verbatim where possible.'
+ );
+ }
+ if (hasImages) {
+ parts.push('For images: Provide a brief summary of what the image shows, its subject, and any notable details.');
+ }
+ if (hasOther) {
+ parts.push(
+ 'For other files (documents, audio, video): Extract or summarize the main content, key points, and important information.'
+ );
+ }
+
+ const defaultInstruction = parts.join('\n\n');
+
+ const outputFormat = `Format each file's output as:
+**filename.ext:**
+[Content — for PDFs use markdown with preserved layout; for images use a short summary]`;
+
+ return { defaultInstruction, outputFormat };
+}
+
/**
* Extract filename from local file URL
*/
@@ -97,15 +144,11 @@ async function processLocalFiles(
}
const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n');
-
- const outputFormat = `Format each file's analysis as:
-**filename.ext:**
-- Summary: [1-2 sentences]
-- Key points: [bullet list]`;
+ const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos);
const batchPrompt = instruction
? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\n${outputFormat}`
- : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\nFor each file, extract and summarize: main topics, key information, important facts or insights, and any structured data.\n\n${outputFormat}`;
+ : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`;
const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [
{ type: "text", text: batchPrompt },
@@ -145,15 +188,11 @@ async function processSupabaseFiles(
});
const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n');
-
- const outputFormat = `Format each file's analysis as:
-**filename.ext:**
-- Summary: [1-2 sentences]
-- Key points: [bullet list]`;
+ const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos);
const batchPrompt = instruction
? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\n${outputFormat}`
- : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\nFor each file, extract and summarize: main topics, key information, important facts or insights, and any structured data.\n\n${outputFormat}`;
+ : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`;
const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [
{ type: "text", text: batchPrompt },
diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts
index 8e2f35fe..ce293577 100644
--- a/src/lib/stores/ui-store.ts
+++ b/src/lib/stores/ui-store.ts
@@ -56,6 +56,8 @@ interface UIState {
// BlockNote text selection state
blockNoteSelection: { cardId: string; cardName: string; text: string } | null;
+ // Citation highlight: when opening note/PDF from citation click, highlight/search this quote
+ citationHighlightQuery: { itemId: string; query: string } | null;
// Actions - Chat
setIsChatExpanded: (expanded: boolean) => void;
@@ -128,6 +130,7 @@ interface UIState {
// Actions - BlockNote selection
setBlockNoteSelection: (selection: { cardId: string; cardName: string; text: string } | null) => void;
clearBlockNoteSelection: () => void;
+ setCitationHighlightQuery: (query: { itemId: string; query: string } | null) => void;
// Utility actions
resetChatState: () => void;
@@ -180,6 +183,7 @@ const initialState = {
// BlockNote selection
blockNoteSelection: null,
+ citationHighlightQuery: null,
};
export const useUIStore = create
()(
@@ -350,6 +354,7 @@ export const useUIStore = create()(
maximizedItemId: null,
selectedCardIds: newSelectedCardIds,
panelAutoSelectedCardIds: newPanelAutoSelectedCardIds,
+ citationHighlightQuery: null,
};
}
@@ -376,6 +381,7 @@ export const useUIStore = create()(
maximizedItemId: null,
selectedCardIds: newSelectedCardIds,
panelAutoSelectedCardIds: new Set(),
+ citationHighlightQuery: null,
};
});
},
@@ -412,6 +418,7 @@ export const useUIStore = create()(
maximizedItemId: null,
selectedCardIds: newSelectedCardIds,
panelAutoSelectedCardIds: new Set(),
+ citationHighlightQuery: null,
};
} else {
const isAlreadyOpen = state.openPanelIds.length === 1 && state.openPanelIds[0] === id && state.maximizedItemId === id;
@@ -542,6 +549,9 @@ export const useUIStore = create()(
clearBlockNoteSelection: () => {
set({ blockNoteSelection: null });
},
+ setCitationHighlightQuery: (query) => {
+ set({ citationHighlightQuery: query });
+ },
// Utility actions
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index cd54022d..2150b2c4 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -64,7 +64,7 @@ Rules:
- Include article dates in responses when available
INLINE CITATIONS (optional):
-Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation carries its own quote so the same source can be cited multiple times with different excerpts.
+Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source.
Sources block (no quotes — quotes go in each inline use):
[{"number":"1","title":"Source title","url":"https://example.com"}]
@@ -72,15 +72,20 @@ Sources block (no quotes — quotes go in each inline use):
For workspace items: omit url. Example:
[{"number":"1","title":"My Calculus Notes"}]
-Inline format — each use has its own quote: N | exact excerpt for this point
-Use a pipe with spaces to separate the source number from the quote.
+Inline format: N or N | exact excerpt
+- Without quote: 1 — use when you cannot cite an exact excerpt.
+- With quote: 1 | exact excerpt from source — use only when you have the exact text from the source. Use pipe with spaces to separate.
+
+NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source (tool response, context, or workspace content). If unsure or you do not have the exact text, use N without a quote. Never fabricate or paraphrase quotes.
+
+When quoting: Use ONLY plain text — no math ($$...$$), code blocks, or special formatting. Use surrounding prose or omit the quote instead.
CRITICAL — Punctuation placement: End the sentence/clause with the period or comma BEFORE the citation. The citation always comes after the punctuation, never before it.
Correct: "...flow of goods and services." 1 | comprehensive administration of the flow
-Correct: "demand forecasting, supply planning." 1 | demand forecasting, supply planning
-Wrong: "...flow of goods and services" 1 | ... . (do NOT put the period after the citation)
+Correct: "demand forecasting." 1
+Wrong: "...flow of goods and services" 1 . (do NOT put the period after the citation)
- Sources block: number, title, url (for web) or omit (workspace)
-- Inline: N | quote — quote is required per use
+- Inline: N (no quote) or N | quote (quote optional; only when you have exact text)
Omit the block entirely if you have no citations. You may invent credible source metadata when not from a tool.
From 644c1eafd3d3f3f7f6d4108cf0d59dd1c7da66d4 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:47:24 -0500
Subject: [PATCH 03/56] feat: iteration 1
---
src/app/api/share/[id]/route.ts | 3 +-
src/app/api/workspaces/[id]/route.ts | 3 +-
src/app/api/workspaces/route.ts | 5 --
src/app/api/workspaces/slug/[slug]/route.ts | 3 +-
src/app/dashboard/page.tsx | 3 +-
src/app/share-copy/[id]/layout.tsx | 2 +-
src/components/workspace/SidebarCardList.tsx | 2 +-
.../workspace/use-workspace-operations.ts | 11 ---
src/lib/utils/format-workspace-context.ts | 69 +++++++++++++++++--
src/lib/utils/virtual-workspace-fs.ts | 47 +++++++++++++
src/lib/workspace-state/state.ts | 2 -
src/lib/workspace-state/types.ts | 4 +-
src/lib/workspace/clone-demo.ts | 1 -
src/lib/workspace/event-reducer.ts | 41 ++++++-----
src/lib/workspace/import-validation.ts | 4 +-
src/lib/workspace/state-loader.ts | 2 -
src/lib/workspace/templates.ts | 6 --
17 files changed, 145 insertions(+), 63 deletions(-)
create mode 100644 src/lib/utils/virtual-workspace-fs.ts
diff --git a/src/app/api/share/[id]/route.ts b/src/app/api/share/[id]/route.ts
index b1381263..cf05c2b0 100644
--- a/src/app/api/share/[id]/route.ts
+++ b/src/app/api/share/[id]/route.ts
@@ -30,9 +30,8 @@ export async function GET(
const state = await loadWorkspaceState(id);
// Ensure state has workspace metadata if empty
- if (!state.globalTitle && !state.globalDescription) {
+ if (!state.globalTitle) {
state.globalTitle = workspace[0].name || "";
- state.globalDescription = workspace[0].description || "";
}
// Return workspace data for forking (public access)
diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts
index 2453bce4..6e8f48c6 100644
--- a/src/app/api/workspaces/[id]/route.ts
+++ b/src/app/api/workspaces/[id]/route.ts
@@ -39,9 +39,8 @@ async function handleGET(
const state = await loadWorkspaceState(id);
// Ensure state has workspace metadata if empty
- if (!state.globalTitle && !state.globalDescription) {
+ if (!state.globalTitle) {
state.globalTitle = workspace.name || "";
- state.globalDescription = workspace.description || "";
}
return NextResponse.json({
diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts
index f4df5270..26866aa9 100644
--- a/src/app/api/workspaces/route.ts
+++ b/src/app/api/workspaces/route.ts
@@ -218,19 +218,14 @@ async function handlePOST(request: NextRequest) {
// Use provided initial state (already validated on client side)
initialState = customInitialState;
initialState.workspaceId = workspace.id;
- // Ensure globalTitle and globalDescription are set from workspace metadata
if (!initialState.globalTitle) {
initialState.globalTitle = name;
}
- if (!initialState.globalDescription && description) {
- initialState.globalDescription = description;
- }
} else {
// Use template-based initial state
initialState = getTemplateInitialState(effectiveTemplate);
initialState.workspaceId = workspace.id;
initialState.globalTitle = name;
- initialState.globalDescription = description || "";
}
// Note: No need to create workspace_states record - state is now managed via events
diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts
index 1ee9580c..7945148d 100644
--- a/src/app/api/workspaces/slug/[slug]/route.ts
+++ b/src/app/api/workspaces/slug/[slug]/route.ts
@@ -103,9 +103,8 @@ async function handleGET(
const state = await loadWorkspaceState(workspace.id);
// Ensure state has workspace metadata if empty
- if (!state.globalTitle && !state.globalDescription) {
+ if (!state.globalTitle) {
state.globalTitle = workspace.name || "";
- state.globalDescription = workspace.description || "";
}
return NextResponse.json({
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 5c604378..ca9a6d5f 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -296,10 +296,9 @@ function DashboardContent({
const getStatePreviewJSON = (s: AgentState | undefined): Record => {
const snapshot = (s ?? initialState) as AgentState;
- const { globalTitle, globalDescription, items } = snapshot;
+ const { globalTitle, items } = snapshot;
return {
globalTitle: globalTitle ?? initialState.globalTitle,
- globalDescription: globalDescription ?? initialState.globalDescription,
items: items ?? initialState.items,
};
};
diff --git a/src/app/share-copy/[id]/layout.tsx b/src/app/share-copy/[id]/layout.tsx
index 7828e3e1..cbbebad5 100644
--- a/src/app/share-copy/[id]/layout.tsx
+++ b/src/app/share-copy/[id]/layout.tsx
@@ -31,7 +31,7 @@ export async function generateMetadata(
const state = await loadWorkspaceState(id);
const title = state.globalTitle || workspace[0].name || "Untitled Workspace";
- const description = state.globalDescription || workspace[0].description || "View and import this shared ThinkEx workspace.";
+ const description = workspace[0].description || "View and import this shared ThinkEx workspace.";
return {
title: `Shared Workspace: ${title}`,
diff --git a/src/components/workspace/SidebarCardList.tsx b/src/components/workspace/SidebarCardList.tsx
index 95d8d094..54c9383c 100644
--- a/src/components/workspace/SidebarCardList.tsx
+++ b/src/components/workspace/SidebarCardList.tsx
@@ -797,7 +797,7 @@ function SidebarCardList() {
}, [workspaces, currentWorkspaceId]);
// Get workspace operations for delete functionality
- const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '', globalTitle: '', globalDescription: '', globalTags: [] });
+ const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '', globalTitle: '' });
const handleDeleteItem = useCallback(
async (itemId: string) => {
diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts
index 4e1d87ed..7ea7b1aa 100644
--- a/src/hooks/workspace/use-workspace-operations.ts
+++ b/src/hooks/workspace/use-workspace-operations.ts
@@ -26,7 +26,6 @@ export interface WorkspaceOperations {
deleteItem: (id: string) => void;
updateAllItems: (items: Item[]) => void;
setGlobalTitle: (title: string) => void;
- setGlobalDescription: (description: string) => void;
flushPendingChanges: (itemId: string) => void;
// Folder operations
@@ -305,15 +304,6 @@ export function useWorkspaceOperations(
[mutation, userId, userName]
);
- const setGlobalDescription = useCallback(
- (description: string) => {
- const event = createEvent("GLOBAL_DESCRIPTION_SET", { description }, userId, userName);
- mutation.mutate(event);
- },
- [mutation, userId, userName]
- );
-
-
// Helper for updating item data (used by field actions)
const updateItemData = useCallback(
(itemId: string, updater: (prev: Item['data']) => Item['data'], source: 'user' | 'agent' = 'user') => {
@@ -696,7 +686,6 @@ export function useWorkspaceOperations(
deleteItem,
updateAllItems,
setGlobalTitle,
- setGlobalDescription,
flushPendingChanges,
// Folder operations
createFolder,
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index 2150b2c4..a60fc077 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -1,10 +1,71 @@
import type { AgentState, Item, NoteData, PdfData, FlashcardData, FlashcardItem, YouTubeData, QuizData, QuizQuestion, ImageData, AudioData } from "@/lib/workspace-state/types";
import { serializeBlockNote } from "./serialize-blocknote";
import { type Block } from "@/components/editor/BlockNoteEditor";
+import { getVirtualPath } from "./virtual-workspace-fs";
+
+/**
+ * Formats item metadata only (no content). Used for virtual FS in system context.
+ */
+function formatItemMetadata(item: Item, items: Item[]): string {
+ const path = getVirtualPath(item, items);
+ const parts: string[] = [path, `type=${item.type}`, `name="${item.name}"`];
+ if (item.subtitle) parts.push(`subtitle="${item.subtitle}"`);
+
+ switch (item.type) {
+ case "pdf": {
+ const d = item.data as PdfData;
+ if (d?.filename) parts.push(`filename=${d.filename}`);
+ if (d?.textContent) parts.push("hasContent=true");
+ break;
+ }
+ case "flashcard": {
+ const d = item.data as FlashcardData;
+ const n = d?.cards?.length ?? (d?.front ? 1 : 0);
+ parts.push(`cards=${n}`);
+ break;
+ }
+ case "quiz": {
+ const d = item.data as QuizData;
+ parts.push(`questions=${d?.questions?.length ?? 0}`);
+ break;
+ }
+ case "audio": {
+ const d = item.data as AudioData;
+ parts.push(`status=${d?.processingStatus ?? "unknown"}`);
+ break;
+ }
+ }
+ return parts.join(" ");
+}
+
+/**
+ * Formats the workspace as a virtual file system with metadata only (no content).
+ * Replaces per-card context registration — send this once in workspace context.
+ * Content is available via selected cards context or tools (processFiles, etc.).
+ */
+export function formatVirtualWorkspaceFS(state: AgentState): string {
+ const { items = [] } = state;
+ const contentItems = items.filter((i) => i.type !== "folder");
+ if (contentItems.length === 0) {
+ return `
+Workspace is empty. Reference items by name when created.
+ `;
+ }
+
+ const entries = contentItems.map((item) =>
+ formatItemMetadata(item, items)
+ );
+
+ return `
+Paths and metadata. Reference items by path or name. Use processFiles or selected cards for content.
+
+${entries.join("\n")}
+ `;
+}
/**
* Formats minimal workspace context (metadata and system instructions only)
- * Cards register their own context individually, so we don't include the items list here
+ * Virtual FS (formatVirtualWorkspaceFS) provides the item tree and metadata only.
*/
export function formatWorkspaceContext(state: AgentState): string {
const { globalTitle } = state;
@@ -27,10 +88,10 @@ Your knowledge cutoff date is January 2025.
-WORKSPACE ITEMS:
-The tags represent cards in the workspace. Items named "Update me" are template placeholders awaiting content generation.
+WORKSPACE (virtual file system):
+${formatVirtualWorkspaceFS(state)}
-When users say "this", they may mean information in the section. Reference cards by name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCard tool.
+When users say "this", they may mean information in the section. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCard tool.
When answering questions about selected cards or content in , rely only on the facts directly mentioned in that context. Do not invent or assume information not present. If the answer is not in the context, say so.
diff --git a/src/lib/utils/virtual-workspace-fs.ts b/src/lib/utils/virtual-workspace-fs.ts
new file mode 100644
index 00000000..b75253b0
--- /dev/null
+++ b/src/lib/utils/virtual-workspace-fs.ts
@@ -0,0 +1,47 @@
+import type { Item } from "@/lib/workspace-state/types";
+import { getFolderPath } from "@/lib/workspace-state/search";
+
+/**
+ * Get the virtual file path for an item in the workspace.
+ * Example: "Physics/Thermodynamics/notes/Heat Transfer.md"
+ */
+export function getVirtualPath(item: Item, items: Item[]): string {
+ const sanitize = (s: string) =>
+ s.replace(/[/\\?*:|"<>]/g, "-").trim() || "untitled";
+
+ const extByType: Record = {
+ note: "md",
+ pdf: "pdf",
+ flashcard: "md",
+ youtube: "url",
+ quiz: "md",
+ image: "png",
+ audio: "audio",
+ };
+ const ext = extByType[item.type] || "md";
+ const typeDir = `${item.type}s`;
+ const filename = `${sanitize(item.name)}.${ext}`;
+
+ if (item.type === "folder") {
+ const folderPath = getFolderPath(item.id, items);
+ const segments = folderPath.map((f) => sanitize(f.name));
+ return segments.length > 0 ? segments.join("/") + "/" : "/";
+ }
+
+ const folderSegments: string[] = [];
+ let folderId = item.folderId;
+ while (folderId) {
+ const folder = items.find(
+ (i) => i.id === folderId && i.type === "folder"
+ );
+ if (!folder) break;
+ folderSegments.unshift(sanitize(folder.name));
+ folderId = folder.folderId;
+ }
+
+ const pathParts =
+ folderSegments.length > 0
+ ? [...folderSegments, typeDir, filename]
+ : [typeDir, filename];
+ return pathParts.join("/");
+}
diff --git a/src/lib/workspace-state/state.ts b/src/lib/workspace-state/state.ts
index d0805635..1936ce60 100644
--- a/src/lib/workspace-state/state.ts
+++ b/src/lib/workspace-state/state.ts
@@ -5,9 +5,7 @@ import { AgentState, CardType, ItemData, NoteData, PdfData } from "@/lib/workspa
export const initialState: AgentState = {
items: [],
globalTitle: "",
- globalDescription: "",
lastAction: "",
- itemsCreated: 0,
};
diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts
index fdfbb382..d6f9323d 100644
--- a/src/lib/workspace-state/types.ts
+++ b/src/lib/workspace-state/types.ts
@@ -178,14 +178,14 @@ export interface Item {
*/
layout?: ResponsiveLayouts | LayoutPosition;
lastSource?: 'user' | 'agent';
+ /** Timestamp (ms) when item was last modified. Set by event reducer. Used for AI conflict detection. */
+ lastModified?: number;
}
export interface AgentState {
items: Item[]; // Includes folder-type items (type: 'folder')
globalTitle: string;
- globalDescription: string;
lastAction?: string;
- itemsCreated: number;
workspaceId?: string; // Supabase workspace ID for persistence
/** @deprecated Folders are now items with type: 'folder'. This field is kept for backward compatibility but is not used. */
folders?: Folder[];
diff --git a/src/lib/workspace/clone-demo.ts b/src/lib/workspace/clone-demo.ts
index 612bf55d..0ffce6f6 100644
--- a/src/lib/workspace/clone-demo.ts
+++ b/src/lib/workspace/clone-demo.ts
@@ -51,7 +51,6 @@ export async function cloneDemoWorkspace(
// Ensure titles match if missing in state
if (!initialState.globalTitle) initialState.globalTitle = name;
- if (!initialState.globalDescription) initialState.globalDescription = description;
} else {
// Fallback to blank if demo workspace not found or state load failed
initialState = getTemplateInitialState("blank");
diff --git a/src/lib/workspace/event-reducer.ts b/src/lib/workspace/event-reducer.ts
index 18c0dca2..fd0713df 100644
--- a/src/lib/workspace/event-reducer.ts
+++ b/src/lib/workspace/event-reducer.ts
@@ -12,17 +12,19 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta
return {
...state,
globalTitle: event.payload.title,
- globalDescription: event.payload.description,
};
- case 'ITEM_CREATED':
- const isFolder = event.payload.item.type === 'folder';
+ case 'ITEM_CREATED': {
+ const item = event.payload.item;
+ const now = event.timestamp || Date.now();
return {
...state,
- items: [...state.items, event.payload.item],
+ items: [...state.items, { ...item, lastModified: now }],
};
+ }
- case 'ITEM_UPDATED':
+ case 'ITEM_UPDATED': {
+ const now = event.timestamp || Date.now();
return {
...state,
items: state.items.map(item =>
@@ -37,11 +39,13 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta
...event.payload.changes.data, // Apply new data updates
}
: item.data,
- lastSource: event.payload.source // Propagate source to item state
+ lastSource: event.payload.source, // Propagate source to item state
+ lastModified: now,
}
: item
),
};
+ }
case 'ITEM_DELETED': {
const deletedItemId = event.payload.id;
@@ -68,10 +72,8 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta
};
case 'GLOBAL_DESCRIPTION_SET':
- return {
- ...state,
- globalDescription: event.payload.description,
- };
+ // No-op: globalDescription removed from state
+ return state;
case 'WORKSPACE_SNAPSHOT':
@@ -138,12 +140,17 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta
}
}
- case 'BULK_ITEMS_CREATED':
- // Create multiple items atomically in a single event
+ case 'BULK_ITEMS_CREATED': {
+ const now = event.timestamp || Date.now();
+ const itemsWithModified = event.payload.items.map((item) => ({
+ ...item,
+ lastModified: now,
+ }));
return {
...state,
- items: [...state.items, ...event.payload.items],
+ items: [...state.items, ...itemsWithModified],
};
+ }
@@ -214,8 +221,8 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta
}
case 'FOLDER_CREATED_WITH_ITEMS': {
- // Create folder and move items atomically in a single operation
- const folder = event.payload.folder;
+ const now = event.timestamp || Date.now();
+ const folder = { ...event.payload.folder, lastModified: now };
const itemIdsSet = new Set(event.payload.itemIds);
const folderId = folder.id;
@@ -226,12 +233,12 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta
? {
...item,
folderId: folderId,
- layout: undefined // Clear layout for fresh positioning in new folder
+ layout: undefined,
+ lastModified: now,
}
: item
);
- // Add the folder item itself
updatedItems.push(folder);
return {
diff --git a/src/lib/workspace/import-validation.ts b/src/lib/workspace/import-validation.ts
index 0ff02bfd..a20e3afd 100644
--- a/src/lib/workspace/import-validation.ts
+++ b/src/lib/workspace/import-validation.ts
@@ -1,4 +1,4 @@
-import type { AgentState, Item, CardType } from "@/lib/workspace-state/types";
+import type { AgentState, Item, CardType } from "@/lib/workspace-state/types";
export interface ValidationResult {
isValid: boolean;
@@ -47,8 +47,6 @@ export function validateImportedJSON(jsonString: string): ValidationResult {
const validatedState: AgentState = {
items: parsed.items,
globalTitle: typeof parsed.globalTitle === 'string' ? parsed.globalTitle : "",
- globalDescription: typeof parsed.globalDescription === 'string' ? parsed.globalDescription : "",
- itemsCreated: typeof parsed.itemsCreated === 'number' ? parsed.itemsCreated : parsed.items.length,
};
return {
diff --git a/src/lib/workspace/state-loader.ts b/src/lib/workspace/state-loader.ts
index 19f75265..1e74a24f 100644
--- a/src/lib/workspace/state-loader.ts
+++ b/src/lib/workspace/state-loader.ts
@@ -59,8 +59,6 @@ export async function loadWorkspaceState(workspaceId: string): Promise {
@@ -72,8 +70,6 @@ export const WORKSPACE_TEMPLATES: TemplateDefinition[] = [
}
],
globalTitle: "",
- globalDescription: "",
- itemsCreated: 3,
},
};
})(),
@@ -94,7 +90,5 @@ export function getTemplateInitialState(template: string): AgentState {
return {
items: templateDef.initialState.items || [],
globalTitle: templateDef.initialState.globalTitle || "",
- globalDescription: templateDef.initialState.globalDescription || "",
- itemsCreated: templateDef.initialState.itemsCreated || 0,
};
}
From ed0657dab06cb97d470589bf43221230e8676984 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 20 Feb 2026 20:15:15 -0500
Subject: [PATCH 04/56] feat: use our db for chat
---
drizzle/0002_deep_shadowcat.sql | 35 +
drizzle/meta/0002_snapshot.json | 1823 +++++++++++++++++
drizzle/meta/_journal.json | 7 +
eslint.config.mjs | 1 +
package.json | 2 +-
src/app/api/assistant-ui-token/route.ts | 50 -
src/app/api/threads/[id]/archive/route.ts | 51 +
.../[id]/messages/[messageId]/route.ts | 75 +
src/app/api/threads/[id]/messages/route.ts | 117 ++
src/app/api/threads/[id]/route.ts | 120 ++
src/app/api/threads/[id]/title/route.ts | 101 +
src/app/api/threads/[id]/unarchive/route.ts | 51 +
src/app/api/threads/route.ts | 106 +
.../ai-elements/inline-citation.tsx | 2 +-
.../assistant-ui/WorkspaceRuntimeProvider.tsx | 89 +-
src/hooks/ai/use-citations-from-message.ts | 10 +-
src/lib/chat/aui-v0.ts | 124 ++
.../chat/custom-thread-history-adapter.tsx | 153 ++
src/lib/chat/custom-thread-list-adapter.tsx | 135 ++
src/lib/db/relations.ts | 23 +-
src/lib/db/schema.ts | 70 +
tsconfig.json | 3 +-
22 files changed, 3026 insertions(+), 122 deletions(-)
create mode 100644 drizzle/0002_deep_shadowcat.sql
create mode 100644 drizzle/meta/0002_snapshot.json
delete mode 100644 src/app/api/assistant-ui-token/route.ts
create mode 100644 src/app/api/threads/[id]/archive/route.ts
create mode 100644 src/app/api/threads/[id]/messages/[messageId]/route.ts
create mode 100644 src/app/api/threads/[id]/messages/route.ts
create mode 100644 src/app/api/threads/[id]/route.ts
create mode 100644 src/app/api/threads/[id]/title/route.ts
create mode 100644 src/app/api/threads/[id]/unarchive/route.ts
create mode 100644 src/app/api/threads/route.ts
create mode 100644 src/lib/chat/aui-v0.ts
create mode 100644 src/lib/chat/custom-thread-history-adapter.tsx
create mode 100644 src/lib/chat/custom-thread-list-adapter.tsx
diff --git a/drizzle/0002_deep_shadowcat.sql b/drizzle/0002_deep_shadowcat.sql
new file mode 100644
index 00000000..eb03c2f7
--- /dev/null
+++ b/drizzle/0002_deep_shadowcat.sql
@@ -0,0 +1,35 @@
+CREATE TABLE "chat_messages" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "thread_id" uuid NOT NULL,
+ "message_id" text NOT NULL,
+ "parent_id" text,
+ "format" text NOT NULL,
+ "content" jsonb NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now()
+);
+--> statement-breakpoint
+CREATE TABLE "chat_threads" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "workspace_id" uuid NOT NULL,
+ "user_id" text NOT NULL,
+ "title" text,
+ "is_archived" boolean DEFAULT false,
+ "external_id" text,
+ "created_at" timestamp with time zone DEFAULT now(),
+ "updated_at" timestamp with time zone DEFAULT now(),
+ "last_message_at" timestamp with time zone DEFAULT now()
+);
+--> statement-breakpoint
+ALTER TABLE "chat_threads" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "idx_chat_messages_thread" ON "chat_messages" USING btree ("thread_id" uuid_ops);--> statement-breakpoint
+CREATE INDEX "idx_chat_messages_thread_created" ON "chat_messages" USING btree ("thread_id" uuid_ops,"created_at" timestamptz_ops);--> statement-breakpoint
+CREATE INDEX "idx_chat_threads_workspace" ON "chat_threads" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint
+CREATE INDEX "idx_chat_threads_user" ON "chat_threads" USING btree ("user_id" text_ops);--> statement-breakpoint
+CREATE INDEX "idx_chat_threads_last_message" ON "chat_threads" USING btree ("workspace_id" uuid_ops,"last_message_at" timestamptz_ops);--> statement-breakpoint
+CREATE POLICY "Users can manage threads in their workspaces" ON "chat_threads" AS PERMISSIVE FOR ALL TO "authenticated" USING ((EXISTS ( SELECT 1 FROM workspaces w
+ WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))
+ OR (EXISTS ( SELECT 1 FROM workspace_collaborators c
+ WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));
\ No newline at end of file
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 00000000..c7678de5
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,1823 @@
+{
+ "id": "208b0e93-f789-49c6-abc8-e9328aab88b5",
+ "prevId": "1fc3e75d-4a15-4c00-ad92-4edde926b371",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_account_user_id": {
+ "name": "idx_account_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_messages": {
+ "name": "chat_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_chat_messages_thread": {
+ "name": "idx_chat_messages_thread",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_messages_thread_created": {
+ "name": "idx_chat_messages_thread_created",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_messages_thread_id_chat_threads_id_fk": {
+ "name": "chat_messages_thread_id_chat_threads_id_fk",
+ "tableFrom": "chat_messages",
+ "tableTo": "chat_threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_threads": {
+ "name": "chat_threads",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_chat_threads_workspace": {
+ "name": "idx_chat_threads_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_threads_user": {
+ "name": "idx_chat_threads_user",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_threads_last_message": {
+ "name": "idx_chat_threads_last_message",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "last_message_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_threads_workspace_id_workspaces_id_fk": {
+ "name": "chat_threads_workspace_id_workspaces_id_fk",
+ "tableFrom": "chat_threads",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_threads_user_id_user_id_fk": {
+ "name": "chat_threads_user_id_user_id_fk",
+ "tableFrom": "chat_threads",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "Users can manage threads in their workspaces": {
+ "name": "Users can manage threads in their workspaces",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_session_user_id": {
+ "name": "idx_session_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_session_token": {
+ "name": "idx_session_token",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_anonymous": {
+ "name": "is_anonymous",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_user_email": {
+ "name": "idx_user_email",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_profiles": {
+ "name": "user_profiles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "onboarding_completed": {
+ "name": "onboarding_completed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_user_profiles_user_id": {
+ "name": "idx_user_profiles_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_profiles_user_id_key": {
+ "name": "user_profiles_user_id_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {
+ "Users can insert their own profile": {
+ "name": "Users can insert their own profile",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "authenticated"
+ ],
+ "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)"
+ },
+ "Users can update their own profile": {
+ "name": "Users can update their own profile",
+ "as": "PERMISSIVE",
+ "for": "UPDATE",
+ "to": [
+ "authenticated"
+ ]
+ },
+ "Users can view their own profile": {
+ "name": "Users can view their own profile",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "authenticated"
+ ]
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_verification_identifier": {
+ "name": "idx_verification_identifier",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_collaborators": {
+ "name": "workspace_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_level": {
+ "name": "permission_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'editor'"
+ },
+ "invite_token": {
+ "name": "invite_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_workspace_collaborators_lookup": {
+ "name": "idx_workspace_collaborators_lookup",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_collaborators_workspace": {
+ "name": "idx_workspace_collaborators_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_collaborators_last_opened_at": {
+ "name": "idx_workspace_collaborators_last_opened_at",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "last_opened_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_collaborators_workspace_id_fkey": {
+ "name": "workspace_collaborators_workspace_id_fkey",
+ "tableFrom": "workspace_collaborators",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_collaborators_invite_token_unique": {
+ "name": "workspace_collaborators_invite_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "invite_token"
+ ]
+ },
+ "workspace_collaborators_workspace_user_unique": {
+ "name": "workspace_collaborators_workspace_user_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "workspace_id",
+ "user_id"
+ ]
+ }
+ },
+ "policies": {
+ "Owners can manage collaborators": {
+ "name": "Owners can manage collaborators",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))"
+ },
+ "Collaborators can view their access": {
+ "name": "Collaborators can view their access",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(user_id = (auth.jwt() ->> 'sub'::text))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_events": {
+ "name": "workspace_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "user_name": {
+ "name": "user_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_workspace_events_event_id": {
+ "name": "idx_workspace_events_event_id",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_events_timestamp": {
+ "name": "idx_workspace_events_timestamp",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "timestamp",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "int8_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_events_user_name": {
+ "name": "idx_workspace_events_user_name",
+ "columns": [
+ {
+ "expression": "user_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_events_workspace": {
+ "name": "idx_workspace_events_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "int4_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_events_workspace_id_fkey": {
+ "name": "workspace_events_workspace_id_fkey",
+ "tableFrom": "workspace_events",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_events_event_id_key": {
+ "name": "workspace_events_event_id_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "event_id"
+ ]
+ }
+ },
+ "policies": {
+ "Users can insert workspace events they have write access to": {
+ "name": "Users can insert workspace events they have write access to",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "public"
+ ],
+ "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))"
+ },
+ "Users can read workspace events they have access to": {
+ "name": "Users can read workspace events they have access to",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ],
+ "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_invites": {
+ "name": "workspace_invites",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_level": {
+ "name": "permission_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'editor'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_workspace_invites_token": {
+ "name": "idx_workspace_invites_token",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_invites_email": {
+ "name": "idx_workspace_invites_email",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_invites_workspace": {
+ "name": "idx_workspace_invites_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_invites_workspace_id_fkey": {
+ "name": "workspace_invites_workspace_id_fkey",
+ "tableFrom": "workspace_invites",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_invites_token_key": {
+ "name": "workspace_invites_token_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {
+ "Public can view invite by token": {
+ "name": "Public can view invite by token",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ],
+ "using": "true"
+ },
+ "Users can insert invites for workspaces they own/edit": {
+ "name": "Users can insert invites for workspaces they own/edit",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "authenticated"
+ ],
+ "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_share_links": {
+ "name": "workspace_share_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_level": {
+ "name": "permission_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'editor'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_workspace_share_links_token": {
+ "name": "idx_workspace_share_links_token",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_share_links_workspace": {
+ "name": "idx_workspace_share_links_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_share_links_workspace_id_fkey": {
+ "name": "workspace_share_links_workspace_id_fkey",
+ "tableFrom": "workspace_share_links",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_share_links_token_key": {
+ "name": "workspace_share_links_token_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ },
+ "workspace_share_links_workspace_key": {
+ "name": "workspace_share_links_workspace_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "workspace_id"
+ ]
+ }
+ },
+ "policies": {
+ "Public can view share link by token": {
+ "name": "Public can view share link by token",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ],
+ "using": "true"
+ },
+ "Owners and editors can manage share links": {
+ "name": "Owners and editors can manage share links",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_snapshots": {
+ "name": "workspace_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot_version": {
+ "name": "snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_count": {
+ "name": "event_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_workspace_snapshots_version": {
+ "name": "idx_workspace_snapshots_version",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "snapshot_version",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "int4_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_snapshots_workspace": {
+ "name": "idx_workspace_snapshots_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_snapshots_workspace_id_fkey": {
+ "name": "workspace_snapshots_workspace_id_fkey",
+ "tableFrom": "workspace_snapshots",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_snapshots_workspace_id_snapshot_version_key": {
+ "name": "workspace_snapshots_workspace_id_snapshot_version_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "workspace_id",
+ "snapshot_version"
+ ]
+ }
+ },
+ "policies": {
+ "Service role can insert workspace snapshots": {
+ "name": "Service role can insert workspace snapshots",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "public"
+ ],
+ "withCheck": "true"
+ },
+ "Users can read workspace snapshots they have access to": {
+ "name": "Users can read workspace snapshots they have access to",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ]
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspaces": {
+ "name": "workspaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'blank'"
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_workspaces_created_at": {
+ "name": "idx_workspaces_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_slug": {
+ "name": "idx_workspaces_slug",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_user_id": {
+ "name": "idx_workspaces_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_user_slug": {
+ "name": "idx_workspaces_user_slug",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_user_sort_order": {
+ "name": "idx_workspaces_user_sort_order",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "int4_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_last_opened_at": {
+ "name": "idx_workspaces_last_opened_at",
+ "columns": [
+ {
+ "expression": "last_opened_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "Users can delete their own workspaces": {
+ "name": "Users can delete their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "DELETE",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)"
+ },
+ "Users can insert their own workspaces": {
+ "name": "Users can insert their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "authenticated"
+ ]
+ },
+ "Users can update their own workspaces": {
+ "name": "Users can update their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "UPDATE",
+ "to": [
+ "authenticated"
+ ]
+ },
+ "Users can view their own workspaces": {
+ "name": "Users can view their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "authenticated"
+ ]
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index f7042925..222c9751 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1771377459878,
"tag": "0002_add_workspace_share_links",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1771631691687,
+ "tag": "0002_deep_shadowcat",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 35232dec..382e1ee3 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -10,6 +10,7 @@ const compat = new FlatCompat({
});
const eslintConfig = [
+ { ignores: ["assistant-ui-main/**"] },
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
diff --git a/package.json b/package.json
index 9c82e973..3fb7ddfe 100644
--- a/package.json
+++ b/package.json
@@ -95,7 +95,7 @@
"@tanstack/react-virtual": "^3.13.14",
"@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.78",
- "assistant-cloud": "^0.1.17",
+ "assistant-stream": "^0.3.2",
"better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/src/app/api/assistant-ui-token/route.ts b/src/app/api/assistant-ui-token/route.ts
deleted file mode 100644
index f012403e..00000000
--- a/src/app/api/assistant-ui-token/route.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { AssistantCloud } from "@assistant-ui/react";
-import { headers } from "next/headers";
-import { auth } from "@/lib/auth";
-import { NextRequest, NextResponse } from "next/server";
-
-export async function POST(req: NextRequest) {
- try {
- const session = await auth.api.getSession({
- headers: await headers(),
- });
-
- if (!session) {
- return NextResponse.json(
- { error: "User not authenticated" },
- { status: 401 }
- );
- }
-
- const userId = session.user.id;
-
- const body = await req.json().catch(() => ({}));
- const { workspaceId } = body;
-
- if (!workspaceId) {
- return NextResponse.json(
- { error: "workspaceId is required" },
- { status: 400 }
- );
- }
-
- // Create AssistantCloud instance with API key
- const assistantCloud = new AssistantCloud({
- apiKey: process.env.ASSISTANT_API_KEY!,
- userId,
- workspaceId, // This scopes threads to the specific workspace
- });
-
- // Generate auth token for this user and workspace
- const { token } = await assistantCloud.auth.tokens.create();
-
- return NextResponse.json({ token });
- } catch (error) {
- console.error("Failed to generate assistant token:", error);
- return NextResponse.json(
- { error: "Failed to generate token" },
- { status: 500 }
- );
- }
-}
-
diff --git a/src/app/api/threads/[id]/archive/route.ts b/src/app/api/threads/[id]/archive/route.ts
new file mode 100644
index 00000000..e4661436
--- /dev/null
+++ b/src/app/api/threads/[id]/archive/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db/client";
+import { chatThreads } from "@/lib/db/schema";
+import {
+ requireAuth,
+ verifyWorkspaceAccess,
+} from "@/lib/api/workspace-helpers";
+import { eq } from "drizzle-orm";
+
+/**
+ * POST /api/threads/[id]/archive
+ * Archive a thread
+ */
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+
+ const [thread] = await db
+ .select()
+ .from(chatThreads)
+ .where(eq(chatThreads.id, id))
+ .limit(1);
+
+ if (!thread) {
+ return NextResponse.json({ error: "Thread not found" }, { status: 404 });
+ }
+
+ await verifyWorkspaceAccess(thread.workspaceId, userId, "editor");
+
+ await db
+ .update(chatThreads)
+ .set({
+ isArchived: true,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(chatThreads.id, id));
+
+ return new Response(null, { status: 204 });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] archive error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/threads/[id]/messages/[messageId]/route.ts b/src/app/api/threads/[id]/messages/[messageId]/route.ts
new file mode 100644
index 00000000..a1df4ac3
--- /dev/null
+++ b/src/app/api/threads/[id]/messages/[messageId]/route.ts
@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db/client";
+import { chatThreads, chatMessages } from "@/lib/db/schema";
+import {
+ requireAuth,
+ verifyWorkspaceAccess,
+} from "@/lib/api/workspace-helpers";
+import { eq, and } from "drizzle-orm";
+
+async function getThreadAndVerify(threadId: string, userId: string) {
+ const [thread] = await db
+ .select()
+ .from(chatThreads)
+ .where(eq(chatThreads.id, threadId))
+ .limit(1);
+
+ if (!thread) {
+ throw NextResponse.json({ error: "Thread not found" }, { status: 404 });
+ }
+
+ await verifyWorkspaceAccess(thread.workspaceId, userId);
+
+ return thread;
+}
+
+/**
+ * PATCH /api/threads/[id]/messages/[messageId]
+ * Update an existing message (e.g. step timestamps/duration from useExternalHistory)
+ */
+export async function PATCH(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string; messageId: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id: threadId, messageId } = await params;
+ const body = await req.json().catch(() => ({}));
+ const { format, content } = body;
+
+ if (!format || content === undefined) {
+ return NextResponse.json(
+ { error: "format and content are required" },
+ { status: 400 }
+ );
+ }
+
+ await getThreadAndVerify(threadId, userId);
+
+ const [updated] = await db
+ .update(chatMessages)
+ .set({
+ content: typeof content === "object" ? content : { raw: content },
+ })
+ .where(
+ and(
+ eq(chatMessages.threadId, threadId),
+ eq(chatMessages.messageId, messageId)
+ )
+ )
+ .returning({ messageId: chatMessages.messageId });
+
+ if (!updated) {
+ return NextResponse.json({ error: "Message not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] messages PATCH error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts
new file mode 100644
index 00000000..b9a0e535
--- /dev/null
+++ b/src/app/api/threads/[id]/messages/route.ts
@@ -0,0 +1,117 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db/client";
+import { chatThreads, chatMessages } from "@/lib/db/schema";
+import {
+ requireAuth,
+ verifyWorkspaceAccess,
+} from "@/lib/api/workspace-helpers";
+import { eq, desc } from "drizzle-orm";
+
+async function getThreadAndVerify(id: string, userId: string) {
+ const [thread] = await db
+ .select()
+ .from(chatThreads)
+ .where(eq(chatThreads.id, id))
+ .limit(1);
+
+ if (!thread) {
+ throw NextResponse.json({ error: "Thread not found" }, { status: 404 });
+ }
+
+ await verifyWorkspaceAccess(thread.workspaceId, userId);
+
+ return thread;
+}
+
+/**
+ * GET /api/threads/[id]/messages?format=aui/v0
+ * Load messages for a thread
+ */
+export async function GET(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+ const { searchParams } = new URL(req.url);
+ const format = searchParams.get("format") ?? "aui/v0";
+
+ await getThreadAndVerify(id, userId);
+
+ const rows = await db
+ .select()
+ .from(chatMessages)
+ .where(eq(chatMessages.threadId, id))
+ .orderBy(desc(chatMessages.createdAt));
+
+ const messages = rows
+ .filter((r) => format === "aui/v0" || r.format === format)
+ .map((r) => ({
+ id: r.messageId,
+ parent_id: r.parentId,
+ format: r.format,
+ content: r.content,
+ created_at: r.createdAt,
+ }));
+
+ return NextResponse.json({ messages });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] messages GET error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * POST /api/threads/[id]/messages
+ * Append a message to a thread
+ */
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+ const body = await req.json().catch(() => ({}));
+ const { messageId, parentId, format, content } = body;
+
+ if (!messageId || !format || content === undefined) {
+ return NextResponse.json(
+ { error: "messageId, format, and content are required" },
+ { status: 400 }
+ );
+ }
+
+ const thread = await getThreadAndVerify(id, userId);
+
+ await db.insert(chatMessages).values({
+ threadId: id,
+ messageId: String(messageId),
+ parentId: parentId ?? null,
+ format: String(format),
+ content: typeof content === "object" ? content : { raw: content },
+ });
+
+ await db
+ .update(chatThreads)
+ .set({
+ lastMessageAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(chatThreads.id, id));
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] messages POST error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/threads/[id]/route.ts b/src/app/api/threads/[id]/route.ts
new file mode 100644
index 00000000..f73e0baf
--- /dev/null
+++ b/src/app/api/threads/[id]/route.ts
@@ -0,0 +1,120 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db/client";
+import { chatThreads } from "@/lib/db/schema";
+import {
+ requireAuth,
+ verifyWorkspaceAccess,
+} from "@/lib/api/workspace-helpers";
+import { eq } from "drizzle-orm";
+
+async function getThreadAndVerify(
+ id: string,
+ userId: string,
+ permission: "viewer" | "editor" = "viewer"
+) {
+ const [thread] = await db
+ .select()
+ .from(chatThreads)
+ .where(eq(chatThreads.id, id))
+ .limit(1);
+
+ if (!thread) {
+ throw NextResponse.json({ error: "Thread not found" }, { status: 404 });
+ }
+
+ await verifyWorkspaceAccess(thread.workspaceId, userId, permission);
+
+ return thread;
+}
+
+/**
+ * GET /api/threads/[id]
+ * Fetch a single thread
+ */
+export async function GET(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+
+ const thread = await getThreadAndVerify(id, userId);
+
+ return NextResponse.json({
+ id: thread.id,
+ remoteId: thread.id,
+ status: thread.isArchived ? "archived" : "regular",
+ title: thread.title ?? undefined,
+ externalId: thread.externalId ?? undefined,
+ });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] GET [id] error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * PATCH /api/threads/[id]
+ * Update thread (e.g. rename)
+ */
+export async function PATCH(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+ const body = await req.json().catch(() => ({}));
+ const { title } = body;
+
+ await getThreadAndVerify(id, userId, "editor");
+
+ if (title !== undefined) {
+ await db
+ .update(chatThreads)
+ .set({ title: String(title), updatedAt: new Date().toISOString() })
+ .where(eq(chatThreads.id, id));
+ }
+
+ return new Response(null, { status: 204 });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] PATCH error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * DELETE /api/threads/[id]
+ * Delete a thread (and its messages via cascade)
+ */
+export async function DELETE(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+
+ await getThreadAndVerify(id, userId, "editor");
+
+ await db.delete(chatThreads).where(eq(chatThreads.id, id));
+
+ return new Response(null, { status: 204 });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] DELETE error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/threads/[id]/title/route.ts b/src/app/api/threads/[id]/title/route.ts
new file mode 100644
index 00000000..24b05d24
--- /dev/null
+++ b/src/app/api/threads/[id]/title/route.ts
@@ -0,0 +1,101 @@
+import { NextRequest, NextResponse } from "next/server";
+import { google } from "@ai-sdk/google";
+import { generateText } from "ai";
+import { db } from "@/lib/db/client";
+import { chatThreads } from "@/lib/db/schema";
+import {
+ requireAuth,
+ verifyWorkspaceAccess,
+} from "@/lib/api/workspace-helpers";
+import { eq } from "drizzle-orm";
+
+/** Model ID used for processFiles and other lightweight tasks */
+const GEMINI_FLASH_LITE_MODEL = "gemini-2.5-flash-lite";
+
+function extractTextFromMessage(msg: { content?: unknown[] }): string {
+ if (!msg.content || !Array.isArray(msg.content)) return "";
+ return (msg.content as { type?: string; text?: string }[])
+ .filter((c) => c.type === "text")
+ .map((c) => c.text ?? "")
+ .join(" ")
+ .trim();
+}
+
+/**
+ * POST /api/threads/[id]/title
+ * Generate a title from messages using Gemini Flash Lite (same model as processFiles).
+ * Body: { messages: ThreadMessage[] }
+ */
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+ const body = await req.json().catch(() => ({}));
+ const { messages } = body;
+
+ const [thread] = await db
+ .select()
+ .from(chatThreads)
+ .where(eq(chatThreads.id, id))
+ .limit(1);
+
+ if (!thread) {
+ return NextResponse.json({ error: "Thread not found" }, { status: 404 });
+ }
+
+ await verifyWorkspaceAccess(thread.workspaceId, userId);
+
+ let title = "New Chat";
+
+ if (messages && Array.isArray(messages) && messages.length > 0) {
+ const conversationText = messages
+ .slice(0, 6)
+ .map((m: { role?: string; content?: unknown[] }) => {
+ const text = extractTextFromMessage(m);
+ if (!text) return "";
+ const role = m.role === "user" ? "User" : "Assistant";
+ return `${role}: ${text}`;
+ })
+ .filter(Boolean)
+ .join("\n\n");
+
+ if (conversationText.trim()) {
+ try {
+ const { text } = await generateText({
+ model: google(GEMINI_FLASH_LITE_MODEL),
+ system: `Generate a very short chat title (2-6 words) that captures the topic. Output ONLY the title, no quotes or punctuation.`,
+ prompt: `Conversation:\n\n${conversationText}\n\nTitle:`,
+ });
+ const generated = text.trim().slice(0, 60);
+ if (generated) title = generated;
+ } catch (err) {
+ console.warn("[threads] title Gemini fallback:", err);
+ const firstUser = messages.find(
+ (m: { role?: string }) => m.role === "user"
+ );
+ const fallback = extractTextFromMessage(firstUser ?? {});
+ if (fallback) {
+ title = fallback.slice(0, 50) + (fallback.length > 50 ? "..." : "");
+ }
+ }
+ }
+ }
+
+ await db
+ .update(chatThreads)
+ .set({ title })
+ .where(eq(chatThreads.id, id));
+
+ return NextResponse.json({ title });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] title error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/threads/[id]/unarchive/route.ts b/src/app/api/threads/[id]/unarchive/route.ts
new file mode 100644
index 00000000..71136b1a
--- /dev/null
+++ b/src/app/api/threads/[id]/unarchive/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db/client";
+import { chatThreads } from "@/lib/db/schema";
+import {
+ requireAuth,
+ verifyWorkspaceAccess,
+} from "@/lib/api/workspace-helpers";
+import { eq } from "drizzle-orm";
+
+/**
+ * POST /api/threads/[id]/unarchive
+ * Unarchive a thread
+ */
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const userId = await requireAuth();
+ const { id } = await params;
+
+ const [thread] = await db
+ .select()
+ .from(chatThreads)
+ .where(eq(chatThreads.id, id))
+ .limit(1);
+
+ if (!thread) {
+ return NextResponse.json({ error: "Thread not found" }, { status: 404 });
+ }
+
+ await verifyWorkspaceAccess(thread.workspaceId, userId, "editor");
+
+ await db
+ .update(chatThreads)
+ .set({
+ isArchived: false,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(chatThreads.id, id));
+
+ return new Response(null, { status: 204 });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] unarchive error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/threads/route.ts b/src/app/api/threads/route.ts
new file mode 100644
index 00000000..63103a14
--- /dev/null
+++ b/src/app/api/threads/route.ts
@@ -0,0 +1,106 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db/client";
+import { chatThreads } from "@/lib/db/schema";
+import {
+ requireAuth,
+ verifyWorkspaceAccess,
+} from "@/lib/api/workspace-helpers";
+import { eq, and, desc } from "drizzle-orm";
+
+/**
+ * GET /api/threads?workspaceId=xxx
+ * List threads for a workspace
+ */
+export async function GET(req: NextRequest) {
+ try {
+ const userId = await requireAuth();
+ const { searchParams } = new URL(req.url);
+ const workspaceId = searchParams.get("workspaceId");
+
+ if (!workspaceId) {
+ return NextResponse.json(
+ { error: "workspaceId is required" },
+ { status: 400 }
+ );
+ }
+
+ await verifyWorkspaceAccess(workspaceId, userId);
+
+ const threads = await db
+ .select({
+ id: chatThreads.id,
+ title: chatThreads.title,
+ isArchived: chatThreads.isArchived,
+ externalId: chatThreads.externalId,
+ })
+ .from(chatThreads)
+ .where(eq(chatThreads.workspaceId, workspaceId))
+ .orderBy(desc(chatThreads.lastMessageAt));
+
+ return NextResponse.json({
+ threads: threads.map((t) => ({
+ remoteId: t.id,
+ status: t.isArchived ? "archived" : "regular",
+ title: t.title ?? undefined,
+ externalId: t.externalId ?? undefined,
+ })),
+ });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] GET error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * POST /api/threads
+ * Create a new thread
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const userId = await requireAuth();
+ const body = await req.json().catch(() => ({}));
+ const { workspaceId, localId, externalId } = body;
+
+ if (!workspaceId) {
+ return NextResponse.json(
+ { error: "workspaceId is required" },
+ { status: 400 }
+ );
+ }
+
+ await verifyWorkspaceAccess(workspaceId, userId, "editor");
+
+ const [inserted] = await db
+ .insert(chatThreads)
+ .values({
+ workspaceId,
+ userId,
+ externalId: externalId ?? undefined,
+ })
+ .returning({ id: chatThreads.id, externalId: chatThreads.externalId });
+
+ if (!inserted) {
+ return NextResponse.json(
+ { error: "Failed to create thread" },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json({
+ id: inserted.id,
+ remoteId: inserted.id,
+ externalId: inserted.externalId ?? undefined,
+ });
+ } catch (error) {
+ if (error instanceof Response) return error;
+ console.error("[threads] POST error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/ai-elements/inline-citation.tsx b/src/components/ai-elements/inline-citation.tsx
index 5a58924c..eee46a23 100644
--- a/src/components/ai-elements/inline-citation.tsx
+++ b/src/components/ai-elements/inline-citation.tsx
@@ -139,7 +139,7 @@ export const InlineCitationSource = ({
type="button"
onClick={onClick}
className={cn(baseClasses, className)}
- {...props}
+ {...(props as ComponentProps<"button">)}
>
{content}
diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
index 6bc81b6f..78567ab1 100644
--- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
+++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
@@ -1,15 +1,17 @@
"use client";
-import { AssistantCloud, AssistantRuntimeProvider } from "@assistant-ui/react";
+import {
+ AssistantRuntimeProvider,
+ unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime,
+} from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk";
import { useMemo, useCallback } from "react";
import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityContext";
import { useUIStore } from "@/lib/stores/ui-store";
-import { useSession } from "@/lib/auth-client";
import { toast } from "sonner";
import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state";
import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context";
-import { SupabaseAttachmentAdapter } from "@/lib/attachments/supabase-attachment-adapter";
+import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter";
interface WorkspaceRuntimeProviderProps {
workspaceId: string;
@@ -23,23 +25,11 @@ export function WorkspaceRuntimeProvider({
workspaceId,
children
}: WorkspaceRuntimeProviderProps) {
- // ... existing hooks
-
-
const selectedModelId = useUIStore((state) => state.selectedModelId);
const activeFolderId = useUIStore((state) => state.activeFolderId);
- /*
- FIX for "Maximum update depth exceeded":
- We select the Set directly because Array.from() inside the selector creates a new reference on every render,
- triggering an infinite update loop. The Set reference is stable until changed.
- */
const selectedCardIdsSet = useUIStore((state) => state.selectedCardIds);
- const { data: session } = useSession();
-
- // Get workspace state to format selected cards context on client
const { state: workspaceState } = useWorkspaceState(workspaceId);
- // Format selected cards context on client side (eliminates server-side DB fetch)
const selectedCardsContext = useMemo(() => {
if (!workspaceState?.items || selectedCardIdsSet.size === 0) {
return "";
@@ -56,39 +46,6 @@ export function WorkspaceRuntimeProvider({
return formatSelectedCardsContext(selectedItems, workspaceState.items);
}, [workspaceState?.items, selectedCardIdsSet]);
- // Create AssistantCloud instance - use anonymous mode for anonymous users
- const cloud = useMemo(() => {
- // If user is anonymous, use Assistant UI's built-in anonymous mode
- if (session?.user?.isAnonymous) {
- return new AssistantCloud({
- baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
- anonymous: true, // Browser-session based user ID
- });
- }
-
- // For authenticated users, use token-based authentication
- return new AssistantCloud({
- baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
- authToken: async () => {
- const response = await fetch("/api/assistant-ui-token", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ workspaceId }),
- });
-
- if (!response.ok) {
- throw new Error("Failed to get auth token");
- }
-
- const { token } = await response.json();
- return token;
- },
- });
- }, [workspaceId, session?.user?.isAnonymous]);
-
- // Error handler for chat runtime errors (timeouts, network issues, etc.)
const handleChatError = useCallback((error: Error) => {
console.error("[Chat Error]", error);
@@ -130,28 +87,32 @@ export function WorkspaceRuntimeProvider({
}
}, []);
- // Create runtime with Assistant Cloud integration
- const runtime = useChatRuntime({
- cloud,
- transport: useMemo(() => {
- const transport = new AssistantChatTransport({
+ const threadListAdapter = useMemo(
+ () => createThreadListAdapter(workspaceId),
+ [workspaceId]
+ );
+
+ const transport = useMemo(
+ () =>
+ new AssistantChatTransport({
api: "/api/chat",
body: {
workspaceId,
modelId: selectedModelId,
activeFolderId,
- selectedCardsContext, // Pre-formatted context (client-side) instead of IDs
- },
- headers: {
- // Headers for static context if needed
+ selectedCardsContext,
},
- });
- return transport;
- }, [workspaceId, selectedModelId, activeFolderId, selectedCardsContext]),
- adapters: {
- attachments: new SupabaseAttachmentAdapter(),
- },
- onError: handleChatError,
+ }),
+ [workspaceId, selectedModelId, activeFolderId, selectedCardsContext]
+ );
+
+ const runtime = useRemoteThreadListRuntime({
+ runtimeHook: () =>
+ useChatRuntime({
+ transport,
+ onError: handleChatError,
+ }),
+ adapter: threadListAdapter,
});
return (
diff --git a/src/hooks/ai/use-citations-from-message.ts b/src/hooks/ai/use-citations-from-message.ts
index 9492409b..b08e9694 100644
--- a/src/hooks/ai/use-citations-from-message.ts
+++ b/src/hooks/ai/use-citations-from-message.ts
@@ -15,14 +15,16 @@ const CITATION_EXTRACTORS = [extractCitationsFromInlineData];
*/
export function useCitationsFromMessage(): Citation[] {
const message = useMessage();
- const messages = useAuiState((s) => (s.thread as { messages?: unknown[] } | undefined)?.messages ?? []);
+ const messages = useAuiState(
+ (s) => (s.thread as unknown as { messages?: unknown[] } | undefined)?.messages ?? []
+ );
const thread = useMemo(() => ({ messages }), [messages]);
return useMemo(() => {
const msg = {
- id: (message as { id?: string }).id,
- role: (message as { role?: string }).role,
- content: (message as { content?: unknown[] }).content,
+ id: (message as unknown as { id?: string }).id,
+ role: (message as unknown as { role?: string }).role,
+ content: (message as unknown as { content?: unknown[] }).content,
};
// Extract from model-generated block
diff --git a/src/lib/chat/aui-v0.ts b/src/lib/chat/aui-v0.ts
new file mode 100644
index 00000000..4a265091
--- /dev/null
+++ b/src/lib/chat/aui-v0.ts
@@ -0,0 +1,124 @@
+import type { ThreadMessage } from "@assistant-ui/react";
+import { INTERNAL } from "@assistant-ui/react";
+
+type StoredMessage = {
+ id: string;
+ parent_id: string | null;
+ format: string;
+ content: unknown;
+ created_at: string;
+};
+
+type AuiV0MessagePart =
+ | { readonly type: "text"; readonly text: string }
+ | { readonly type: "reasoning"; readonly text: string }
+ | {
+ readonly type: "source";
+ readonly sourceType: "url";
+ readonly id: string;
+ readonly url: string;
+ readonly title?: string;
+ }
+ | {
+ readonly type: "tool-call";
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly args?: Record;
+ readonly argsText?: string;
+ readonly result?: unknown;
+ readonly isError?: true;
+ }
+ | { readonly type: "image"; readonly image: string }
+ | {
+ readonly type: "file";
+ readonly data: string;
+ readonly mimeType: string;
+ readonly filename?: string;
+ };
+
+type AuiV0Message = {
+ readonly role: "assistant" | "user" | "system";
+ readonly status?: { type: string; reason?: string };
+ readonly content: readonly AuiV0MessagePart[];
+ readonly metadata: {
+ readonly unstable_state?: unknown;
+ readonly unstable_annotations: readonly unknown[];
+ readonly unstable_data: readonly unknown[];
+ readonly steps: readonly { readonly usage?: { inputTokens?: number; outputTokens?: number } }[];
+ readonly custom: Record;
+ };
+};
+
+export function auiV0Encode(message: ThreadMessage): AuiV0Message {
+ const status =
+ message.status?.type === "running"
+ ? ({ type: "incomplete", reason: "cancelled" } as const)
+ : message.status;
+
+ return {
+ role: message.role,
+ content: message.content.map((part) => {
+ const type = part.type;
+ switch (type) {
+ case "text":
+ return { type: "text", text: part.text };
+ case "reasoning":
+ return { type: "reasoning", text: part.text };
+ case "source":
+ return {
+ type: "source",
+ sourceType: part.sourceType,
+ id: part.id,
+ url: part.url,
+ ...(part.title ? { title: part.title } : undefined),
+ };
+ case "tool-call":
+ return {
+ type: "tool-call",
+ toolCallId: part.toolCallId,
+ toolName: part.toolName,
+ ...(JSON.stringify(part.args) === part.argsText
+ ? { args: part.args }
+ : { argsText: part.argsText }),
+ ...(part.result !== undefined ? { result: part.result } : undefined),
+ ...(part.isError ? { isError: true as const } : undefined),
+ };
+ case "image":
+ return { type: "image", image: part.image };
+ case "file":
+ return {
+ type: "file",
+ data: part.data,
+ mimeType: part.mimeType,
+ ...(part.filename ? { filename: part.filename } : undefined),
+ };
+ default:
+ throw new Error(`Message part type not supported by aui/v0: ${(part as { type: string }).type}`);
+ }
+ }),
+ metadata: message.metadata as AuiV0Message["metadata"],
+ ...(status ? { status } : undefined),
+ };
+}
+
+export function auiV0Decode(stored: StoredMessage): {
+ parentId: string | null;
+ message: ThreadMessage;
+} {
+ const payload = stored.content as AuiV0Message;
+ const like = {
+ id: stored.id,
+ createdAt: new Date(stored.created_at),
+ ...payload,
+ };
+ const message = INTERNAL.fromThreadMessageLike(
+ like as Parameters[0],
+ stored.id,
+ { type: "complete", reason: "unknown" }
+ );
+
+ return {
+ parentId: stored.parent_id,
+ message,
+ };
+}
diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx
new file mode 100644
index 00000000..2197383e
--- /dev/null
+++ b/src/lib/chat/custom-thread-history-adapter.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useMemo } from "react";
+import type {
+ ThreadHistoryAdapter,
+ ExportedMessageRepositoryItem,
+ GenericThreadHistoryAdapter,
+ MessageFormatAdapter,
+ MessageFormatItem,
+ MessageFormatRepository,
+ MessageStorageEntry,
+} from "@assistant-ui/react";
+import { useAui } from "@assistant-ui/react";
+import { auiV0Encode, auiV0Decode } from "./aui-v0";
+
+const FORMAT = "aui/v0";
+
+/**
+ * Matches FormattedCloudPersistence / AssistantCloudThreadHistoryAdapter exactly:
+ * - API returns messages newest-first
+ * - We reverse to get oldest-first (parents before children) for MessageRepository.import()
+ * - headId falls back to messages.at(-1) in import(), which after reverse = newest = active branch tip
+ */
+
+export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter {
+ const aui = useAui();
+
+ return useMemo(() => ({
+ /**
+ * Required by useExternalHistory: the AI SDK runtime calls withFormat(aiSDKV6FormatAdapter)
+ * and uses the returned adapter for append/load. Without this, messages are never persisted.
+ */
+ withFormat(
+ formatAdapter: MessageFormatAdapter
+ ): GenericThreadHistoryAdapter {
+ return {
+ async append(item: MessageFormatItem) {
+ const { remoteId } = await aui.threadListItem().initialize();
+ const messageId = formatAdapter.getId(item.message);
+ const encoded = formatAdapter.encode(item) as TStorageFormat;
+
+ const res = await fetch(`/api/threads/${remoteId}/messages`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messageId,
+ parentId: item.parentId,
+ format: formatAdapter.format,
+ content: encoded,
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(
+ (err as { error?: string }).error || `Failed to save message: ${res.status}`
+ );
+ }
+ },
+ async load(): Promise> {
+ const remoteId = aui.threadListItem().getState().remoteId;
+ if (!remoteId) return { messages: [] };
+
+ const res = await fetch(
+ `/api/threads/${remoteId}/messages?format=${formatAdapter.format}`
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to load messages: ${res.status}`);
+ }
+
+ const { messages } = await res.json();
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return { messages: [] };
+ }
+
+ const result = messages
+ .filter(
+ (m: { format?: string }) => m.format === formatAdapter.format
+ )
+ .map(
+ (m: { id: string; parent_id: string | null; format: string; content: unknown }) =>
+ formatAdapter.decode({
+ id: m.id,
+ parent_id: m.parent_id,
+ format: m.format,
+ content: m.content as TStorageFormat,
+ } as MessageStorageEntry)
+ )
+ .reverse();
+
+ return { messages: result };
+ },
+ };
+ },
+ async append({ parentId, message }: ExportedMessageRepositoryItem) {
+ const { remoteId } = await aui.threadListItem().initialize();
+ const encoded = auiV0Encode(message);
+
+ const res = await fetch(`/api/threads/${remoteId}/messages`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messageId: message.id,
+ parentId,
+ format: FORMAT,
+ content: encoded,
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(
+ (err as { error?: string }).error || `Failed to save message: ${res.status}`
+ );
+ }
+ },
+ async load() {
+ const remoteId = aui.threadListItem().getState().remoteId;
+ if (!remoteId) return { messages: [] };
+
+ const res = await fetch(
+ `/api/threads/${remoteId}/messages?format=${FORMAT}`
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to load messages: ${res.status}`);
+ }
+
+ const { messages } = await res.json();
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return { messages: [] };
+ }
+
+ const result = messages
+ .filter((m: { format?: string }) => m.format === FORMAT)
+ .map(
+ (m: {
+ id: string;
+ parent_id: string | null;
+ format: string;
+ content: unknown;
+ created_at?: string;
+ }) =>
+ auiV0Decode({
+ ...m,
+ created_at: m.created_at ?? new Date().toISOString(),
+ })
+ )
+ .reverse();
+
+ return { messages: result };
+ },
+ }), [aui]);
+}
diff --git a/src/lib/chat/custom-thread-list-adapter.tsx b/src/lib/chat/custom-thread-list-adapter.tsx
new file mode 100644
index 00000000..be2e5eef
--- /dev/null
+++ b/src/lib/chat/custom-thread-list-adapter.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import { type FC, type PropsWithChildren, useMemo } from "react";
+import {
+ type ThreadMessage,
+ type unstable_RemoteThreadListAdapter as RemoteThreadListAdapter,
+ RuntimeAdapterProvider,
+} from "@assistant-ui/react";
+import { createAssistantStream } from "assistant-stream";
+import { useCustomThreadHistoryAdapter } from "./custom-thread-history-adapter";
+import { SupabaseAttachmentAdapter } from "@/lib/attachments/supabase-attachment-adapter";
+
+const attachmentsInstance = new SupabaseAttachmentAdapter();
+
+function CustomThreadListProviderInner({
+ children,
+}: PropsWithChildren) {
+ const history = useCustomThreadHistoryAdapter();
+ const adapters = useMemo(
+ () => ({
+ history,
+ attachments: attachmentsInstance,
+ }),
+ [history]
+ );
+ return (
+
+ {children}
+
+ );
+}
+
+export function createThreadListAdapter(
+ workspaceId: string
+): RemoteThreadListAdapter {
+ const unstable_Provider: FC = function CustomThreadListProvider({
+ children,
+ }) {
+ return {children} ;
+ };
+
+ return {
+ async list() {
+ const res = await fetch(`/api/threads?workspaceId=${workspaceId}`);
+ if (!res.ok) throw new Error(`Failed to list threads: ${res.status}`);
+ const data = await res.json();
+ return {
+ threads: (data.threads ?? []).map((t: { remoteId: string; status?: string; title?: string; externalId?: string }) => ({
+ remoteId: t.remoteId,
+ status: t.status ?? "regular",
+ title: t.title,
+ externalId: t.externalId,
+ })),
+ };
+ },
+
+ async initialize(_localId: string) {
+ const res = await fetch("/api/threads", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ workspaceId }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error((err as { error?: string }).error ?? `Failed to create thread: ${res.status}`);
+ }
+ const data = await res.json();
+ return {
+ remoteId: data.remoteId ?? data.id,
+ externalId: data.externalId,
+ };
+ },
+
+ async rename(remoteId: string, title: string) {
+ const res = await fetch(`/api/threads/${remoteId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title }),
+ });
+ if (!res.ok) throw new Error(`Failed to rename: ${res.status}`);
+ },
+
+ async archive(remoteId: string) {
+ const res = await fetch(`/api/threads/${remoteId}/archive`, {
+ method: "POST",
+ });
+ if (!res.ok) throw new Error(`Failed to archive: ${res.status}`);
+ },
+
+ async unarchive(remoteId: string) {
+ const res = await fetch(`/api/threads/${remoteId}/unarchive`, {
+ method: "POST",
+ });
+ if (!res.ok) throw new Error(`Failed to unarchive: ${res.status}`);
+ },
+
+ async delete(remoteId: string) {
+ const res = await fetch(`/api/threads/${remoteId}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(`Failed to delete: ${res.status}`);
+ },
+
+ async fetch(remoteId: string) {
+ const res = await fetch(`/api/threads/${remoteId}`);
+ if (!res.ok) throw new Error(`Failed to fetch thread: ${res.status}`);
+ const data = await res.json();
+ return {
+ remoteId: data.remoteId ?? data.id,
+ status: data.status ?? "regular",
+ title: data.title,
+ externalId: data.externalId,
+ };
+ },
+
+ async generateTitle(
+ remoteId: string,
+ messages: readonly ThreadMessage[]
+ ) {
+ return createAssistantStream(async (controller) => {
+ const res = await fetch(`/api/threads/${remoteId}/title`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages }),
+ });
+ if (!res.ok) {
+ controller.appendText("New Chat");
+ return;
+ }
+ const data = await res.json();
+ controller.appendText(data.title ?? "New Chat");
+ });
+ },
+
+ unstable_Provider,
+ };
+}
diff --git a/src/lib/db/relations.ts b/src/lib/db/relations.ts
index b4daca53..b442377d 100644
--- a/src/lib/db/relations.ts
+++ b/src/lib/db/relations.ts
@@ -1,5 +1,11 @@
import { relations } from "drizzle-orm/relations";
-import { workspaces, workspaceSnapshots, workspaceEvents } from "./schema";
+import {
+ workspaces,
+ workspaceSnapshots,
+ workspaceEvents,
+ chatThreads,
+ chatMessages,
+} from "./schema";
// workspace_shares removed - sharing is now fork-based (users import copies)
@@ -20,4 +26,19 @@ export const workspaceEventsRelations = relations(workspaceEvents, ({ one }) =>
fields: [workspaceEvents.workspaceId],
references: [workspaces.id]
}),
+}));
+
+export const chatThreadsRelations = relations(chatThreads, ({ one, many }) => ({
+ workspace: one(workspaces, {
+ fields: [chatThreads.workspaceId],
+ references: [workspaces.id],
+ }),
+ messages: many(chatMessages),
+}));
+
+export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
+ thread: one(chatThreads, {
+ fields: [chatMessages.threadId],
+ references: [chatThreads.id],
+ }),
}));
\ No newline at end of file
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index d7b9bc96..32b96800 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -265,3 +265,73 @@ export const workspaceShareLinks = pgTable("workspace_share_links", {
FROM workspace_collaborators c
WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))` }),
]);
+
+// Chat threads - workspace-scoped conversations
+export const chatThreads = pgTable("chat_threads", {
+ id: uuid().defaultRandom().primaryKey().notNull(),
+ workspaceId: uuid("workspace_id")
+ .notNull()
+ .references(() => workspaces.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ title: text("title"),
+ isArchived: boolean("is_archived").default(false),
+ externalId: text("external_id"),
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "string" })
+ .defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "string" })
+ .defaultNow()
+ .$onUpdate(() => new Date().toISOString()),
+ lastMessageAt: timestamp("last_message_at", {
+ withTimezone: true,
+ mode: "string",
+ }).defaultNow(),
+}, (table) => [
+ index("idx_chat_threads_workspace").using(
+ "btree",
+ table.workspaceId.asc().nullsLast().op("uuid_ops")
+ ),
+ index("idx_chat_threads_user").using(
+ "btree",
+ table.userId.asc().nullsLast().op("text_ops")
+ ),
+ index("idx_chat_threads_last_message").using(
+ "btree",
+ table.workspaceId.asc().nullsLast().op("uuid_ops"),
+ table.lastMessageAt.desc().nullsFirst().op("timestamptz_ops")
+ ),
+ pgPolicy("Users can manage threads in their workspaces", {
+ as: "permissive",
+ for: "all",
+ to: ["authenticated"],
+ using: sql`(EXISTS ( SELECT 1 FROM workspaces w
+ WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))
+ OR (EXISTS ( SELECT 1 FROM workspace_collaborators c
+ WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))`,
+ }),
+]);
+
+// Chat messages - stored per thread
+export const chatMessages = pgTable("chat_messages", {
+ id: uuid().defaultRandom().primaryKey().notNull(),
+ threadId: uuid("thread_id")
+ .notNull()
+ .references(() => chatThreads.id, { onDelete: "cascade" }),
+ messageId: text("message_id").notNull(),
+ parentId: text("parent_id"),
+ format: text("format").notNull(),
+ content: jsonb().notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "string" })
+ .defaultNow(),
+}, (table) => [
+ index("idx_chat_messages_thread").using(
+ "btree",
+ table.threadId.asc().nullsLast().op("uuid_ops")
+ ),
+ index("idx_chat_messages_thread_created").using(
+ "btree",
+ table.threadId.asc().nullsLast().op("uuid_ops"),
+ table.createdAt.asc().nullsLast().op("timestamptz_ops")
+ ),
+]);
diff --git a/tsconfig.json b/tsconfig.json
index b575f7da..26bd994a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -36,6 +36,7 @@
".next/dev/types/**/*.ts"
],
"exclude": [
- "node_modules"
+ "node_modules",
+ "assistant-ui-main"
]
}
From 1c125a9032dcbf114737aee8d68c694cde94edc1 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 20 Feb 2026 20:34:53 -0500
Subject: [PATCH 05/56] fix: thread adapter
---
src/app/api/threads/[id]/messages/route.ts | 8 +-
src/lib/chat/aui-v0.ts | 124 ---------
.../chat/custom-thread-history-adapter.tsx | 246 +++++++++---------
3 files changed, 127 insertions(+), 251 deletions(-)
delete mode 100644 src/lib/chat/aui-v0.ts
diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts
index b9a0e535..4e7d62dd 100644
--- a/src/app/api/threads/[id]/messages/route.ts
+++ b/src/app/api/threads/[id]/messages/route.ts
@@ -24,8 +24,8 @@ async function getThreadAndVerify(id: string, userId: string) {
}
/**
- * GET /api/threads/[id]/messages?format=aui/v0
- * Load messages for a thread
+ * GET /api/threads/[id]/messages?format=ai-sdk/v6
+ * Load messages for a thread. Format filter is strict (ai-sdk/v6 only).
*/
export async function GET(
req: NextRequest,
@@ -35,7 +35,7 @@ export async function GET(
const userId = await requireAuth();
const { id } = await params;
const { searchParams } = new URL(req.url);
- const format = searchParams.get("format") ?? "aui/v0";
+ const format = searchParams.get("format") ?? "ai-sdk/v6";
await getThreadAndVerify(id, userId);
@@ -46,7 +46,7 @@ export async function GET(
.orderBy(desc(chatMessages.createdAt));
const messages = rows
- .filter((r) => format === "aui/v0" || r.format === format)
+ .filter((r) => r.format === format)
.map((r) => ({
id: r.messageId,
parent_id: r.parentId,
diff --git a/src/lib/chat/aui-v0.ts b/src/lib/chat/aui-v0.ts
deleted file mode 100644
index 4a265091..00000000
--- a/src/lib/chat/aui-v0.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import type { ThreadMessage } from "@assistant-ui/react";
-import { INTERNAL } from "@assistant-ui/react";
-
-type StoredMessage = {
- id: string;
- parent_id: string | null;
- format: string;
- content: unknown;
- created_at: string;
-};
-
-type AuiV0MessagePart =
- | { readonly type: "text"; readonly text: string }
- | { readonly type: "reasoning"; readonly text: string }
- | {
- readonly type: "source";
- readonly sourceType: "url";
- readonly id: string;
- readonly url: string;
- readonly title?: string;
- }
- | {
- readonly type: "tool-call";
- readonly toolCallId: string;
- readonly toolName: string;
- readonly args?: Record;
- readonly argsText?: string;
- readonly result?: unknown;
- readonly isError?: true;
- }
- | { readonly type: "image"; readonly image: string }
- | {
- readonly type: "file";
- readonly data: string;
- readonly mimeType: string;
- readonly filename?: string;
- };
-
-type AuiV0Message = {
- readonly role: "assistant" | "user" | "system";
- readonly status?: { type: string; reason?: string };
- readonly content: readonly AuiV0MessagePart[];
- readonly metadata: {
- readonly unstable_state?: unknown;
- readonly unstable_annotations: readonly unknown[];
- readonly unstable_data: readonly unknown[];
- readonly steps: readonly { readonly usage?: { inputTokens?: number; outputTokens?: number } }[];
- readonly custom: Record;
- };
-};
-
-export function auiV0Encode(message: ThreadMessage): AuiV0Message {
- const status =
- message.status?.type === "running"
- ? ({ type: "incomplete", reason: "cancelled" } as const)
- : message.status;
-
- return {
- role: message.role,
- content: message.content.map((part) => {
- const type = part.type;
- switch (type) {
- case "text":
- return { type: "text", text: part.text };
- case "reasoning":
- return { type: "reasoning", text: part.text };
- case "source":
- return {
- type: "source",
- sourceType: part.sourceType,
- id: part.id,
- url: part.url,
- ...(part.title ? { title: part.title } : undefined),
- };
- case "tool-call":
- return {
- type: "tool-call",
- toolCallId: part.toolCallId,
- toolName: part.toolName,
- ...(JSON.stringify(part.args) === part.argsText
- ? { args: part.args }
- : { argsText: part.argsText }),
- ...(part.result !== undefined ? { result: part.result } : undefined),
- ...(part.isError ? { isError: true as const } : undefined),
- };
- case "image":
- return { type: "image", image: part.image };
- case "file":
- return {
- type: "file",
- data: part.data,
- mimeType: part.mimeType,
- ...(part.filename ? { filename: part.filename } : undefined),
- };
- default:
- throw new Error(`Message part type not supported by aui/v0: ${(part as { type: string }).type}`);
- }
- }),
- metadata: message.metadata as AuiV0Message["metadata"],
- ...(status ? { status } : undefined),
- };
-}
-
-export function auiV0Decode(stored: StoredMessage): {
- parentId: string | null;
- message: ThreadMessage;
-} {
- const payload = stored.content as AuiV0Message;
- const like = {
- id: stored.id,
- createdAt: new Date(stored.created_at),
- ...payload,
- };
- const message = INTERNAL.fromThreadMessageLike(
- like as Parameters[0],
- stored.id,
- { type: "complete", reason: "unknown" }
- );
-
- return {
- parentId: stored.parent_id,
- message,
- };
-}
diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx
index 2197383e..3928fa77 100644
--- a/src/lib/chat/custom-thread-history-adapter.tsx
+++ b/src/lib/chat/custom-thread-history-adapter.tsx
@@ -3,7 +3,6 @@
import { useMemo } from "react";
import type {
ThreadHistoryAdapter,
- ExportedMessageRepositoryItem,
GenericThreadHistoryAdapter,
MessageFormatAdapter,
MessageFormatItem,
@@ -11,143 +10,144 @@ import type {
MessageStorageEntry,
} from "@assistant-ui/react";
import { useAui } from "@assistant-ui/react";
-import { auiV0Encode, auiV0Decode } from "./aui-v0";
-
-const FORMAT = "aui/v0";
/**
- * Matches FormattedCloudPersistence / AssistantCloudThreadHistoryAdapter exactly:
- * - API returns messages newest-first
- * - We reverse to get oldest-first (parents before children) for MessageRepository.import()
- * - headId falls back to messages.at(-1) in import(), which after reverse = newest = active branch tip
+ * Sorts messages so parents always come before their children.
+ * Required for MessageRepository.import() which expects parent to exist before child.
+ * Uses topological sort with created_at + id as tiebreakers for stable ordering.
*/
+function sortParentsBeforeChildren<
+ T extends { parentId: string | null; message: { id: string } }
+>(items: T[], getCreatedAt: (item: T) => string): T[] {
+ const output: T[] = [];
+ const added = new Set();
+
+ const getReady = () =>
+ items.filter(
+ (m) =>
+ !added.has(m.message.id) &&
+ (m.parentId === null || added.has(m.parentId))
+ );
+
+ while (output.length < items.length) {
+ const ready = getReady();
+ if (ready.length === 0) {
+ // Orphan or circular ref: add remaining by created_at to avoid infinite loop
+ const remaining = items.filter((m) => !added.has(m.message.id));
+ remaining.sort((a, b) => {
+ const tA = new Date(getCreatedAt(a)).getTime();
+ const tB = new Date(getCreatedAt(b)).getTime();
+ return tA - tB || a.message.id.localeCompare(b.message.id);
+ });
+ output.push(...remaining);
+ break;
+ }
+ ready.sort((a, b) => {
+ const tA = new Date(getCreatedAt(a)).getTime();
+ const tB = new Date(getCreatedAt(b)).getTime();
+ return tA - tB || a.message.id.localeCompare(b.message.id);
+ });
+ const next = ready[0]!;
+ output.push(next);
+ added.add(next.message.id);
+ }
+
+ return output;
+}
+/**
+ * AI SDK–only thread history adapter.
+ * Uses withFormat(aiSDKV6FormatAdapter) for persistence via useExternalHistory.
+ */
export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter {
const aui = useAui();
- return useMemo(() => ({
- /**
- * Required by useExternalHistory: the AI SDK runtime calls withFormat(aiSDKV6FormatAdapter)
- * and uses the returned adapter for append/load. Without this, messages are never persisted.
- */
- withFormat(
- formatAdapter: MessageFormatAdapter
- ): GenericThreadHistoryAdapter {
- return {
- async append(item: MessageFormatItem) {
- const { remoteId } = await aui.threadListItem().initialize();
- const messageId = formatAdapter.getId(item.message);
- const encoded = formatAdapter.encode(item) as TStorageFormat;
-
- const res = await fetch(`/api/threads/${remoteId}/messages`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messageId,
- parentId: item.parentId,
- format: formatAdapter.format,
- content: encoded,
- }),
- });
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(
- (err as { error?: string }).error || `Failed to save message: ${res.status}`
+ return useMemo(
+ () => ({
+ withFormat(
+ formatAdapter: MessageFormatAdapter
+ ): GenericThreadHistoryAdapter {
+ return {
+ async append(item: MessageFormatItem) {
+ const { remoteId } = await aui.threadListItem().initialize();
+ const messageId = formatAdapter.getId(item.message);
+ const encoded = formatAdapter.encode(item) as TStorageFormat;
+
+ const res = await fetch(`/api/threads/${remoteId}/messages`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messageId,
+ parentId: item.parentId,
+ format: formatAdapter.format,
+ content: encoded,
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(
+ (err as { error?: string }).error ||
+ `Failed to save message: ${res.status}`
+ );
+ }
+ },
+ async load(): Promise> {
+ const remoteId = aui.threadListItem().getState().remoteId;
+ if (!remoteId) return { messages: [] };
+
+ const res = await fetch(
+ `/api/threads/${remoteId}/messages?format=${formatAdapter.format}`
);
- }
- },
- async load(): Promise> {
- const remoteId = aui.threadListItem().getState().remoteId;
- if (!remoteId) return { messages: [] };
+ if (!res.ok) {
+ throw new Error(`Failed to load messages: ${res.status}`);
+ }
- const res = await fetch(
- `/api/threads/${remoteId}/messages?format=${formatAdapter.format}`
- );
- if (!res.ok) {
- throw new Error(`Failed to load messages: ${res.status}`);
- }
+ const { messages } = await res.json();
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return { messages: [] };
+ }
- const { messages } = await res.json();
- if (!Array.isArray(messages) || messages.length === 0) {
- return { messages: [] };
- }
-
- const result = messages
- .filter(
+ const filtered = messages.filter(
(m: { format?: string }) => m.format === formatAdapter.format
- )
- .map(
- (m: { id: string; parent_id: string | null; format: string; content: unknown }) =>
- formatAdapter.decode({
+ );
+ const decoded = filtered.map(
+ (m: {
+ id: string;
+ parent_id: string | null;
+ format: string;
+ content: unknown;
+ created_at?: string;
+ }) => {
+ const d = formatAdapter.decode({
id: m.id,
parent_id: m.parent_id,
format: m.format,
content: m.content as TStorageFormat,
- } as MessageStorageEntry)
- )
- .reverse();
-
- return { messages: result };
- },
- };
- },
- async append({ parentId, message }: ExportedMessageRepositoryItem) {
- const { remoteId } = await aui.threadListItem().initialize();
- const encoded = auiV0Encode(message);
-
- const res = await fetch(`/api/threads/${remoteId}/messages`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messageId: message.id,
- parentId,
- format: FORMAT,
- content: encoded,
- }),
- });
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(
- (err as { error?: string }).error || `Failed to save message: ${res.status}`
- );
- }
- },
- async load() {
- const remoteId = aui.threadListItem().getState().remoteId;
- if (!remoteId) return { messages: [] };
-
- const res = await fetch(
- `/api/threads/${remoteId}/messages?format=${FORMAT}`
- );
- if (!res.ok) {
- throw new Error(`Failed to load messages: ${res.status}`);
- }
-
- const { messages } = await res.json();
- if (!Array.isArray(messages) || messages.length === 0) {
- return { messages: [] };
- }
+ } as MessageStorageEntry);
+ return {
+ ...d,
+ created_at: m.created_at ?? new Date().toISOString(),
+ };
+ }
+ );
- const result = messages
- .filter((m: { format?: string }) => m.format === FORMAT)
- .map(
- (m: {
- id: string;
- parent_id: string | null;
- format: string;
- content: unknown;
- created_at?: string;
- }) =>
- auiV0Decode({
- ...m,
- created_at: m.created_at ?? new Date().toISOString(),
- })
- )
- .reverse();
+ // Topological sort: parents before children for MessageRepository.import()
+ const sorted = sortParentsBeforeChildren(
+ decoded,
+ (d) => d.created_at ?? new Date().toISOString()
+ );
- return { messages: result };
- },
- }), [aui]);
+ return {
+ messages: sorted.map(({ parentId, message }) => ({
+ parentId,
+ message,
+ })),
+ };
+ },
+ };
+ },
+ }),
+ [aui]
+ );
}
From 8df461ee8f6442c3a17d3b474933850af6389c57 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 20 Feb 2026 22:23:04 -0500
Subject: [PATCH 06/56] feat: show ui for added context
---
.../assistant-ui/WorkspaceRuntimeProvider.tsx | 2 +
src/components/assistant-ui/thread.tsx | 2 +
src/components/chat/MessageContextBadges.tsx | 72 +++++++++++++++++++
src/lib/chat/toCreateMessageWithContext.ts | 62 ++++++++++++++++
4 files changed, 138 insertions(+)
create mode 100644 src/components/chat/MessageContextBadges.tsx
create mode 100644 src/lib/chat/toCreateMessageWithContext.ts
diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
index 78567ab1..ba89adfc 100644
--- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
+++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
@@ -12,6 +12,7 @@ import { toast } from "sonner";
import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state";
import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context";
import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter";
+import { toCreateMessageWithContext } from "@/lib/chat/toCreateMessageWithContext";
interface WorkspaceRuntimeProviderProps {
workspaceId: string;
@@ -111,6 +112,7 @@ export function WorkspaceRuntimeProvider({
useChatRuntime({
transport,
onError: handleChatError,
+ toCreateMessage: toCreateMessageWithContext,
}),
adapter: threadListAdapter,
});
diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx
index cafa29cd..55f8c1ad 100644
--- a/src/components/assistant-ui/thread.tsx
+++ b/src/components/assistant-ui/thread.tsx
@@ -96,6 +96,7 @@ import { ToolGroup } from "@/components/assistant-ui/tool-group";
import type { Item, PdfData } from "@/lib/workspace-state/types";
import { CardContextDisplay } from "@/components/chat/CardContextDisplay";
import { ReplyContextDisplay } from "@/components/chat/ReplyContextDisplay";
+import { MessageContextBadges } from "@/components/chat/MessageContextBadges";
import { MentionMenu } from "@/components/chat/MentionMenu";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { useUIStore, selectReplySelections, selectSelectedCardIdsArray, selectBlockNoteSelection } from "@/lib/stores/ui-store";
@@ -1382,6 +1383,7 @@ const UserMessage: FC = () => {
+
+ text.length <= max ? text : text.slice(0, max).trim() + "...";
+
+/**
+ * Renders persisted context badges (BlockNote selection, reply)
+ * from message.metadata.custom when viewing user messages in history.
+ */
+function MessageContextBadgesImpl() {
+ const message = useAuiState((s) => s.message);
+ if (!message || message.role !== "user") return null;
+
+ const custom = (message.metadata as { custom?: MessageCustomMetadata } | undefined)?.custom;
+ if (!custom) return null;
+
+ const { blockNoteSelection, replySelections } = custom;
+ const hasAny = blockNoteSelection || (replySelections && replySelections.length > 0);
+ if (!hasAny) return null;
+
+ return (
+
+ {blockNoteSelection && (
+
+
+
+ From "{blockNoteSelection.cardName}": {truncate(blockNoteSelection.text, 40)}
+
+
+ )}
+ {replySelections?.map((sel, i) => (
+
+
+
+ {truncate(sel.text, 40)}
+
+
+ ))}
+
+ );
+}
+
+export const MessageContextBadges = memo(MessageContextBadgesImpl);
diff --git a/src/lib/chat/toCreateMessageWithContext.ts b/src/lib/chat/toCreateMessageWithContext.ts
new file mode 100644
index 00000000..9be2a672
--- /dev/null
+++ b/src/lib/chat/toCreateMessageWithContext.ts
@@ -0,0 +1,62 @@
+import type { AppendMessage } from "@assistant-ui/react";
+import {
+ CreateUIMessage,
+ UIDataTypes,
+ UIMessage,
+ UIMessagePart,
+ UITools,
+} from "ai";
+
+/**
+ * Custom toCreateMessage that merges runConfig (BlockNote selection, reply, selected cards)
+ * into the UIMessage metadata so it persists and can be rendered when viewing message history.
+ * Based on @assistant-ui/react-ai-sdk toCreateMessage with runConfig merged into metadata.
+ */
+export function toCreateMessageWithContext<
+ UI_MESSAGE extends UIMessage = UIMessage,
+>(message: AppendMessage): CreateUIMessage {
+ const inputParts = [
+ ...message.content.filter((c) => c.type !== "file"),
+ ...(message.attachments?.flatMap((a) =>
+ a.content.map((c) => ({
+ ...c,
+ filename: a.name,
+ })),
+ ) ?? []),
+ ];
+
+ const parts = inputParts.map((part): UIMessagePart => {
+ switch (part.type) {
+ case "text":
+ return { type: "text", text: part.text };
+ case "image":
+ return {
+ type: "file",
+ url: part.image,
+ ...(part.filename && { filename: part.filename }),
+ mediaType: "image/png",
+ };
+ case "file":
+ return {
+ type: "file",
+ url: part.data,
+ mediaType: part.mimeType,
+ ...(part.filename && { filename: part.filename }),
+ };
+ default:
+ throw new Error(`Unsupported part type: ${(part as { type: string }).type}`);
+ }
+ });
+
+ const runConfig = message.runConfig as Record | undefined;
+ const metadata = {
+ ...message.metadata,
+ ...(runConfig && Object.keys(runConfig).length > 0 ? runConfig : {}),
+ };
+
+ return {
+ role: message.role,
+ parts,
+ metadata,
+ } as CreateUIMessage;
+}
From 15eaf88ce54dda366a1b2016f9a63d453a9e661a Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 20 Feb 2026 23:07:11 -0500
Subject: [PATCH 07/56] feat: remove old context
---
src/app/api/chat/route.ts | 10 +++++-
.../FlashcardWorkspaceCard.tsx | 4 ---
.../workspace-canvas/FolderCard.tsx | 6 +---
.../workspace-canvas/WorkspaceCard.tsx | 4 ---
src/contexts/AssistantAvailabilityContext.tsx | 2 +-
src/hooks/ai/use-card-context-provider.ts | 33 -------------------
6 files changed, 11 insertions(+), 48 deletions(-)
delete mode 100644 src/hooks/ai/use-card-context-provider.ts
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 34b1b3d6..9392bdc3 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -1,5 +1,5 @@
import { gateway } from "ai";
-import { streamText, smoothStream, convertToModelMessages, stepCountIs, wrapLanguageModel, tool } from "ai";
+import { streamText, smoothStream, convertToModelMessages, pruneMessages, stepCountIs, wrapLanguageModel, tool } from "ai";
import { devToolsMiddleware } from "@ai-sdk/devtools";
import { PostHog } from "posthog-node";
import { withTracing } from "@posthog/ai";
@@ -203,6 +203,14 @@ export async function POST(req: Request) {
throw convertError;
}
+ // Prune older reasoning and tool calls to save context
+ convertedMessages = pruneMessages({
+ messages: convertedMessages,
+ reasoning: "before-last-message",
+ toolCalls: "before-last-5-messages",
+ emptyMessages: "remove",
+ });
+
// Process messages in single pass: extract URLs and clean markers
const { urlContextUrls, cleanedMessages } = processMessages(convertedMessages);
diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx
index 838f7d23..a822d5e1 100644
--- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx
+++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx
@@ -13,7 +13,6 @@ import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store";
import { plainTextToBlocks, type Block } from "@/components/editor/BlockNoteEditor";
import { BlockNotePreview, PreviewBlock } from "@/components/editor/BlockNotePreview";
import { generateItemId } from "@/lib/workspace-state/item-helpers";
-import { useCardContextProvider } from "@/hooks/ai/use-card-context-provider";
import {
DropdownMenu,
DropdownMenuContent,
@@ -167,9 +166,6 @@ export function FlashcardWorkspaceCard({
onOpenModal,
onMoveItem,
}: FlashcardWorkspaceCardProps) {
- // Register this card's minimal context (title, id, type) with the assistant
- useCardContextProvider(item);
-
// Subscribe directly to this card's selection state from the store
// This prevents full grid re-renders when selection changes
const isSelected = useUIStore(
diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx
index 5bff8e66..54c94abe 100644
--- a/src/components/workspace-canvas/FolderCard.tsx
+++ b/src/components/workspace-canvas/FolderCard.tsx
@@ -10,7 +10,6 @@ import { getCardColorCSS, getCardAccentColor, SWATCHES_COLOR_GROUPS, type CardCo
import { useTheme } from "next-themes";
import { useUIStore } from "@/lib/stores/ui-store";
-import { useCardContextProvider } from "@/hooks/ai/use-card-context-provider";
import {
DropdownMenu,
DropdownMenuContent,
@@ -91,10 +90,7 @@ function FolderCardComponent({
const isSelected = useUIStore(
(state) => state.selectedCardIds.has(item.id)
);
- const onToggleSelection = useUIStore((state) => state.toggleCardSelection);
-
- // Register folder context with assistant
- useCardContextProvider(item);
+ const onToggleSelection = useUIStore((state) => state.toggleCardSelection);
// Track drag movement to prevent opening folder after drag
const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx
index 57898681..bf5b1ab3 100644
--- a/src/components/workspace-canvas/WorkspaceCard.tsx
+++ b/src/components/workspace-canvas/WorkspaceCard.tsx
@@ -27,7 +27,6 @@ import "react-quizlet-flashcard/dist/index.css";
import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
-import { useCardContextProvider } from "@/hooks/ai/use-card-context-provider";
import { useElementSize } from "@/hooks/use-element-size";
import { useIsVisible } from "@/hooks/use-is-visible";
import { extractYouTubeVideoId, extractYouTubePlaylistId } from "@/lib/utils/youtube-url";
@@ -228,9 +227,6 @@ function WorkspaceCard({
const viewMode = useUIStore((state) => state.viewMode);
const splitWithItem = useUIStore((state) => state.splitWithItem);
- // Register this card's minimal context (title, id, type) with the assistant
- useCardContextProvider(item);
-
// No dynamic calculations needed - just overflow hidden
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
diff --git a/src/contexts/AssistantAvailabilityContext.tsx b/src/contexts/AssistantAvailabilityContext.tsx
index 58744a26..d3d807da 100644
--- a/src/contexts/AssistantAvailabilityContext.tsx
+++ b/src/contexts/AssistantAvailabilityContext.tsx
@@ -4,7 +4,7 @@ import { createContext, useContext, type ReactNode } from "react";
/**
* Context to indicate whether the AI assistant is available
- * Used by hooks like useCardContextProvider to skip AI-related operations
+ * Used by hooks that need to skip AI-related operations
* when not inside an AssistantProvider (e.g., in guest mode)
*/
interface AssistantAvailabilityContextType {
diff --git a/src/hooks/ai/use-card-context-provider.ts b/src/hooks/ai/use-card-context-provider.ts
deleted file mode 100644
index 9765f7b4..00000000
--- a/src/hooks/ai/use-card-context-provider.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useAssistantInstructions } from "@assistant-ui/react";
-import { useEffect, useMemo } from "react";
-import type { Item } from "@/lib/workspace-state/types";
-import { useIsAssistantAvailable } from "@/contexts/AssistantAvailabilityContext";
-
-/**
- * Hook that registers minimal context for an individual card
- * Each card registers its own context (title, id, type) using modelContext API
- * Automatically updates when card properties change and cleans up on unmount
- *
- * Note: Gracefully handles missing AssistantProvider (e.g., in guest mode)
- * by checking useIsAssistantAvailable() first and becoming a no-op
- */
-export function useCardContextProvider(item: Item) {
- const isAssistantAvailable = useIsAssistantAvailable();
-
- // Format minimal card context - memoized to avoid recalculation
- // Only include type and title, not ID (tools will handle ID lookup by title)
- const cardContext = useMemo(() => {
- return ` `;
- }, [item.name, item.type]);
-
- // Use the high-level hook to register instructions
- // This automatically handles cleanup and updates
- // We disable it if the assistant isn't available (e.g. guest mode)
- useAssistantInstructions({
- instruction: cardContext,
- disabled: !isAssistantAvailable,
- });
-}
-
-// Re-export for components that need more control
-export { useIsAssistantAvailable };
From a72da653d43b6619be0600e3db8f2738916b9ab4 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 20 Feb 2026 23:52:55 -0500
Subject: [PATCH 08/56] fix: more features
---
src/app/api/workspaces/[id]/events/route.ts | 33 +++++
.../assistant-ui/GrepWorkspaceToolUI.tsx | 86 ++++++++++++
.../assistant-ui/ReadWorkspaceToolUI.tsx | 75 ++++++++++
.../assistant-ui/WorkspaceRuntimeProvider.tsx | 4 +-
src/components/assistant-ui/thread.tsx | 5 +-
.../workspace/use-workspace-operations.ts | 60 ++++++--
src/lib/ai/tools/grep-workspace.ts | 130 ++++++++++++++++++
src/lib/ai/tools/index.ts | 4 +
src/lib/ai/tools/read-workspace.ts | 96 +++++++++++++
src/lib/ai/tools/workspace-search-utils.ts | 114 +++++++++++++++
src/lib/ai/tools/workspace-tools.ts | 8 +-
src/lib/ai/workers/workspace-worker.ts | 36 +++++
.../chat/custom-thread-history-adapter.tsx | 24 +++-
src/lib/utils/format-workspace-context.ts | 64 +++++++--
src/lib/workspace/unique-name.ts | 33 +++++
15 files changed, 741 insertions(+), 31 deletions(-)
create mode 100644 src/components/assistant-ui/GrepWorkspaceToolUI.tsx
create mode 100644 src/components/assistant-ui/ReadWorkspaceToolUI.tsx
create mode 100644 src/lib/ai/tools/grep-workspace.ts
create mode 100644 src/lib/ai/tools/read-workspace.ts
create mode 100644 src/lib/ai/tools/workspace-search-utils.ts
create mode 100644 src/lib/workspace/unique-name.ts
diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts
index bf80ab52..945b3b77 100644
--- a/src/app/api/workspaces/[id]/events/route.ts
+++ b/src/app/api/workspaces/[id]/events/route.ts
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events";
import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager";
+import { loadWorkspaceState } from "@/lib/workspace/state-loader";
+import { hasDuplicateName } from "@/lib/workspace/unique-name";
import { db, workspaceEvents } from "@/lib/db/client";
import { eq, gt, asc, sql, and } from "drizzle-orm";
import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers";
@@ -232,6 +234,37 @@ async function handlePOST(
await verifyWorkspaceAccess(id, userId, 'editor');
timings.workspaceCheck = Date.now() - workspaceCheckStart;
+ // Validate unique name for ITEM_CREATED and ITEM_UPDATED (when name changes)
+ if (event.type === "ITEM_CREATED") {
+ const item = event.payload?.item;
+ if (item?.name != null && item?.type) {
+ const state = await loadWorkspaceState(id);
+ if (hasDuplicateName(state.items, item.name, item.type, item.folderId ?? null)) {
+ return NextResponse.json(
+ { error: `A ${item.type} named "${item.name}" already exists in this folder` },
+ { status: 400 }
+ );
+ }
+ }
+ }
+ if (event.type === "ITEM_UPDATED" && event.payload?.changes?.name != null) {
+ const itemId = event.payload?.id;
+ const newName = event.payload.changes.name;
+ if (itemId && newName) {
+ const state = await loadWorkspaceState(id);
+ const existingItem = state.items.find((i: { id: string }) => i.id === itemId);
+ if (existingItem) {
+ const newFolderId = event.payload.changes.folderId ?? existingItem.folderId ?? null;
+ if (hasDuplicateName(state.items, newName, existingItem.type, newFolderId, itemId)) {
+ return NextResponse.json(
+ { error: `A ${existingItem.type} named "${newName}" already exists in this folder` },
+ { status: 400 }
+ );
+ }
+ }
+ }
+ }
+
// Use the append function to handle versioning and conflicts
const appendStart = Date.now();
const result = await db.execute(sql`
diff --git a/src/components/assistant-ui/GrepWorkspaceToolUI.tsx b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx
new file mode 100644
index 00000000..57d43d5a
--- /dev/null
+++ b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { Search } from "lucide-react";
+import { makeAssistantToolUI } from "@assistant-ui/react";
+import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
+import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
+import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell";
+
+type GrepArgs = { pattern: string; include?: string; path?: string };
+type GrepResult = { success: boolean; matches?: number; output?: string; message?: string };
+
+function GrepArgsSummary({ args }: { args?: GrepArgs }) {
+ if (!args?.pattern) return null;
+ const parts = [`pattern: "${args.pattern}"`];
+ if (args.include) parts.push(`type: ${args.include}`);
+ if (args.path) parts.push(`path: ${args.path}`);
+ return (
+
+ {parts.join(" · ")}
+
+ );
+}
+
+export const GrepWorkspaceToolUI = makeAssistantToolUI({
+ toolName: "grepWorkspace",
+ render: ({ args, status, result }) => {
+ let content: React.ReactNode = null;
+
+ if (status.type === "running") {
+ content = (
+
+
+
+
+ );
+ } else if (status.type === "complete" && result) {
+ if (!result.success && result.message) {
+ content = (
+
+
+
+
+ );
+ } else if (result.success && result.output) {
+ content = (
+
+
+
+
+ {result.matches != null && (
+
+ {result.matches} match
+ {result.matches !== 1 ? "es" : ""}
+
+ )}
+
+
+ {result.output}
+
+
+ );
+ }
+ } else if (status.type === "incomplete" && status.reason === "error") {
+ content = (
+
+
+
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ },
+});
diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
new file mode 100644
index 00000000..52522f86
--- /dev/null
+++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { FileText } from "lucide-react";
+import { makeAssistantToolUI } from "@assistant-ui/react";
+import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown";
+import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
+import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
+import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell";
+
+type ReadArgs = { path?: string; itemName?: string };
+type ReadResult = {
+ success: boolean;
+ itemName?: string;
+ type?: string;
+ path?: string;
+ content?: string;
+ message?: string;
+};
+
+export const ReadWorkspaceToolUI = makeAssistantToolUI({
+ toolName: "readWorkspace",
+ render: ({ args, status, result }) => {
+ let content: React.ReactNode = null;
+
+ if (status.type === "running") {
+ const label = args?.path
+ ? `Reading ${args.path}`
+ : args?.itemName
+ ? `Reading "${args.itemName}"`
+ : "Reading workspace item...";
+ content = ;
+ } else if (status.type === "complete" && result) {
+ if (!result.success && result.message) {
+ content = (
+
+ );
+ } else if (result.success && result.content) {
+ content = (
+
+
+
+
+ {result.path ?? result.itemName}
+ {result.type && (
+
+ {result.type}
+
+ )}
+
+
+
+ {result.content}
+
+
+ );
+ }
+ } else if (status.type === "incomplete" && status.reason === "error") {
+ content = (
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ },
+});
diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
index ba89adfc..e2d8739d 100644
--- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
+++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx
@@ -10,7 +10,7 @@ import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityCont
import { useUIStore } from "@/lib/stores/ui-store";
import { toast } from "sonner";
import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state";
-import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context";
+import { formatSelectedCardsMetadata } from "@/lib/utils/format-workspace-context";
import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter";
import { toCreateMessageWithContext } from "@/lib/chat/toCreateMessageWithContext";
@@ -44,7 +44,7 @@ export function WorkspaceRuntimeProvider({
return "";
}
- return formatSelectedCardsContext(selectedItems, workspaceState.items);
+ return formatSelectedCardsMetadata(selectedItems, workspaceState.items);
}, [workspaceState?.items, selectedCardIdsSet]);
const handleChatError = useCallback((error: Error) => {
diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx
index 55f8c1ad..82eb4453 100644
--- a/src/components/assistant-ui/thread.tsx
+++ b/src/components/assistant-ui/thread.tsx
@@ -75,6 +75,8 @@ import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI";
import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI";
import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI";
import { UpdatePdfContentToolUI } from "@/components/assistant-ui/UpdatePdfContentToolUI";
+import { GrepWorkspaceToolUI } from "@/components/assistant-ui/GrepWorkspaceToolUI";
+import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI";
import { DeleteCardToolUI } from "@/components/assistant-ui/DeleteCardToolUI";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@@ -110,7 +112,6 @@ import { useCreateCardFromMessage } from "@/hooks/ai/use-create-card-from-messag
import { extractUrls, createUrlFile } from "@/lib/attachments/url-utils";
import { filterItems } from "@/lib/workspace-state/search";
import { useSession } from "@/lib/auth-client";
-import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context";
import { focusComposerInput } from "@/lib/utils/composer-utils";
import { SpeechToTextButton } from "@/components/assistant-ui/SpeechToTextButton";
@@ -198,6 +199,8 @@ export const Thread: FC = ({ items = [] }) => {
{/* */}
+
+
{
// Validate type is a valid CardType
@@ -167,6 +179,15 @@ export function useWorkspaceOperations(
}
const id = generateItemId();
+ const finalName = name || `New ${validType.charAt(0).toUpperCase() + validType.slice(1)}`;
+ const folderId = activeFolderId ?? null;
+
+ // Check duplicate against existing + already-created in this batch
+ const allItemsSoFar = [...currentState.items, ...itemsSoFar];
+ if (hasDuplicateName(allItemsSoFar, finalName, validType, folderId)) {
+ logger.warn(`🔧 [CREATE-ITEMS] Skipping duplicate: ${finalName} (${validType})`);
+ return null;
+ }
// Merge default data with initial data
const baseData = defaultDataFor(validType);
@@ -199,17 +220,28 @@ export function useWorkspaceOperations(
});
}
- return {
+ const newItem: Item = {
id,
type: validType,
- name: name || `New ${validType.charAt(0).toUpperCase() + validType.slice(1)}`,
+ name: finalName,
subtitle: "",
data: mergedData as ItemData,
color: getRandomCardColor(), // Assign random color to new cards
folderId: activeFolderId ?? undefined, // Auto-assign to active folder
layout,
};
- });
+ itemsSoFar.push(newItem);
+ return newItem;
+ }).filter((item): item is Item => item !== null);
+
+ if (createdItems.length === 0) {
+ toast.error("All items were skipped (duplicate names in folder)");
+ return [];
+ }
+
+ if (createdItems.length < items.length) {
+ toast.warning(`${items.length - createdItems.length} item(s) skipped due to duplicate names`);
+ }
// Create single batch event with all items
const event = createEvent("BULK_ITEMS_CREATED", { items: createdItems }, userId, userName);
@@ -223,7 +255,7 @@ export function useWorkspaceOperations(
// Return array of created item IDs
return createdItems.map(item => item.id);
},
- [mutation, userId, userName, workspaceId]
+ [mutation, userId, userName, workspaceId, currentState.items]
);
const updateItem = useCallback(
@@ -244,9 +276,21 @@ export function useWorkspaceOperations(
const finalChanges = pendingItemChangesRef.current.get(id);
if (finalChanges) {
const item = currentState.items.find(i => i.id === id);
- const name = (finalChanges as Partial- ).name ?? item?.name;
+ const newName = (finalChanges as Partial
- ).name ?? item?.name;
+ const newType = (finalChanges as Partial
- ).type ?? item?.type;
+ const folderId = ((finalChanges as Partial
- ).folderId ?? item?.folderId) ?? null;
+
+ if (newName && newType && "name" in finalChanges) {
+ if (hasDuplicateName(currentState.items, newName, newType, folderId, id)) {
+ toast.error(`A ${newType} named "${newName}" already exists in this folder`);
+ pendingItemChangesRef.current.delete(id);
+ updateItemDebounceRef.current.delete(id);
+ return;
+ }
+ }
+
logger.debug("⏱️ [DEBOUNCE] updateItem firing after 500ms:", { id, changes: finalChanges, source });
- const event = createEvent("ITEM_UPDATED", { id, changes: finalChanges, source, name }, userId, userName);
+ const event = createEvent("ITEM_UPDATED", { id, changes: finalChanges, source, name: newName }, userId, userName);
mutation.mutate(event);
// Clean up
pendingItemChangesRef.current.delete(id);
diff --git a/src/lib/ai/tools/grep-workspace.ts b/src/lib/ai/tools/grep-workspace.ts
new file mode 100644
index 00000000..67e80a8c
--- /dev/null
+++ b/src/lib/ai/tools/grep-workspace.ts
@@ -0,0 +1,130 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import { loadStateForTool } from "./tool-utils";
+import { extractSearchableText } from "./workspace-search-utils";
+import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs";
+import type { WorkspaceToolContext } from "./workspace-tools";
+import type { Item } from "@/lib/workspace-state/types";
+
+const MAX_LINE_LENGTH = 2000;
+const MAX_MATCHES = 100;
+
+function buildRegex(pattern: string): RegExp {
+ const hasRegexChars = /[.*+?^${}()|[\]\\]/.test(pattern);
+ if (hasRegexChars) {
+ try {
+ return new RegExp(pattern, "gi");
+ } catch {
+ return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
+ }
+ }
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return new RegExp(escaped, "gi");
+}
+
+export function createGrepWorkspaceTool(ctx: WorkspaceToolContext) {
+ return tool({
+ description:
+ "Search for a pattern in workspace item content (notes, flashcards, PDFs, quizzes, audio transcripts). Use when you need to find where a term or phrase appears across the user's materials.",
+ inputSchema: zodSchema(
+ z.object({
+ pattern: z.string().describe("The regex or plain text pattern to search for"),
+ include: z
+ .string()
+ .optional()
+ .describe('Filter by item type, e.g. "note", "flashcard", "pdf"'),
+ path: z
+ .string()
+ .optional()
+ .describe("Virtual path prefix to scope search, e.g. Physics/ for items under Physics folder"),
+ })
+ ),
+ execute: async ({ pattern, include, path: pathPrefix }) => {
+ if (!pattern?.trim()) {
+ return { success: false, message: "pattern is required", matches: 0, output: "" };
+ }
+
+ const accessResult = await loadStateForTool(ctx);
+ if (!accessResult.success) return accessResult;
+
+ const { state } = accessResult;
+ let items: Item[] = state.items.filter((i) => i.type !== "folder");
+
+ if (include) {
+ const type = include.toLowerCase().trim();
+ items = items.filter((i) => i.type === type);
+ }
+
+ const normalizedPrefix = pathPrefix?.trim().replace(/\/+$/, "");
+ if (normalizedPrefix) {
+ items = items.filter((item) => {
+ const vp = getVirtualPath(item, state.items);
+ return vp.startsWith(normalizedPrefix) || vp.startsWith(normalizedPrefix + "/");
+ });
+ }
+
+ const regex = buildRegex(pattern);
+ const matches: { path: string; itemName: string; itemType: string; lineNum: number; lineText: string }[] = [];
+
+ for (const item of items) {
+ const text = extractSearchableText(item, state.items);
+ if (!text) continue;
+
+ const lines = text.split(/\r?\n/);
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (regex.test(line)) {
+ regex.lastIndex = 0;
+ const truncated =
+ line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line;
+ matches.push({
+ path: getVirtualPath(item, state.items),
+ itemName: item.name,
+ itemType: item.type,
+ lineNum: i + 1,
+ lineText: truncated,
+ });
+ }
+ }
+ }
+
+ const truncated = matches.length > MAX_MATCHES;
+ const finalMatches = truncated ? matches.slice(0, MAX_MATCHES) : matches;
+
+ if (finalMatches.length === 0) {
+ return {
+ success: true,
+ matches: 0,
+ truncated: false,
+ output: `No matches found for "${pattern}"${normalizedPrefix ? ` in ${normalizedPrefix}` : ""}`,
+ };
+ }
+
+ const outputLines: string[] = [
+ `Found ${matches.length} matches${truncated ? ` (showing first ${MAX_MATCHES})` : ""}`,
+ ];
+ let currentPath = "";
+ for (const m of finalMatches) {
+ if (currentPath !== m.path) {
+ if (currentPath) outputLines.push("");
+ currentPath = m.path;
+ outputLines.push(`${m.path}:`);
+ }
+ outputLines.push(` Line ${m.lineNum}: ${m.lineText}`);
+ }
+ if (truncated) {
+ outputLines.push("");
+ outputLines.push(
+ `(Results truncated: showing ${MAX_MATCHES} of ${matches.length} matches. Consider using a more specific path or pattern.)`
+ );
+ }
+
+ return {
+ success: true,
+ matches: matches.length,
+ truncated,
+ output: outputLines.join("\n"),
+ };
+ },
+ });
+}
diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts
index 08e0751d..ad3df2fb 100644
--- a/src/lib/ai/tools/index.ts
+++ b/src/lib/ai/tools/index.ts
@@ -21,6 +21,8 @@ import { createUpdatePdfContentTool } from "./pdf-tools";
import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools";
import { createSearchImagesTool, createAddImageTool } from "./image-tools";
import { createWebSearchTool } from "./web-search";
+import { createGrepWorkspaceTool } from "./grep-workspace";
+import { createReadWorkspaceTool } from "./read-workspace";
import { logger } from "@/lib/utils/logger";
export interface ChatToolsConfig {
@@ -57,6 +59,8 @@ export function createChatTools(config: ChatToolsConfig): Record
{
// Search & code execution
webSearch: createWebSearchTool(),
executeCode: createExecuteCodeTool(),
+ grepWorkspace: createGrepWorkspaceTool(ctx),
+ readWorkspace: createReadWorkspaceTool(ctx),
// Workspace operations
createNote: createNoteTool(ctx),
diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts
new file mode 100644
index 00000000..7fb03f5e
--- /dev/null
+++ b/src/lib/ai/tools/read-workspace.ts
@@ -0,0 +1,96 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import { loadStateForTool } from "./tool-utils";
+import { fuzzyMatchItem } from "./tool-utils";
+import { resolveItemByPath } from "./workspace-search-utils";
+import { formatItemContent } from "@/lib/utils/format-workspace-context";
+import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs";
+import type { WorkspaceToolContext } from "./workspace-tools";
+
+export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
+ return tool({
+ description:
+ "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Use path when items share the same name. Use when you need the complete content of a specific card to answer questions or perform updates.",
+ inputSchema: zodSchema(
+ z.object({
+ path: z
+ .string()
+ .optional()
+ .describe(
+ "Virtual path (e.g. Physics/notes/Thermodynamics.md) — unambiguous when duplicates exist"
+ ),
+ itemName: z
+ .string()
+ .optional()
+ .describe(
+ "Name for fuzzy match — use when path unknown; if multiple items share the name, use path instead"
+ ),
+ })
+ ),
+ execute: async ({ path, itemName }) => {
+ if (!path?.trim() && !itemName?.trim()) {
+ return {
+ success: false,
+ message: "Either path or itemName is required",
+ };
+ }
+
+ const accessResult = await loadStateForTool(ctx);
+ if (!accessResult.success) return accessResult;
+
+ const { state } = accessResult;
+ const items = state.items;
+
+ let item = null;
+
+ if (path?.trim()) {
+ item = resolveItemByPath(items, path.trim());
+ }
+
+ if (!item && itemName?.trim()) {
+ const exactMatches = items.filter(
+ (i) =>
+ i.type !== "folder" &&
+ i.name.toLowerCase().trim() === itemName.toLowerCase().trim()
+ );
+ if (exactMatches.length > 1) {
+ const paths = exactMatches.map((m) => getVirtualPath(m, items)).join(", ");
+ return {
+ success: false,
+ message: `Multiple items named "${itemName}". Use path to disambiguate: ${paths}`,
+ };
+ }
+ item = fuzzyMatchItem(items, itemName.trim());
+ }
+
+ if (!item) {
+ const contentItems = items.filter((i) => i.type !== "folder");
+ const sample = contentItems.slice(0, 5).map((i) => getVirtualPath(i, items)).join(", ");
+ return {
+ success: false,
+ message: `Item not found${itemName ? `: "${itemName}"` : ` at path: ${path}`}. ${
+ sample ? `Example paths: ${sample}` : "Workspace may be empty. Use grep to search."
+ }`,
+ };
+ }
+
+ if (item.type === "folder") {
+ return {
+ success: false,
+ message: "Folders have no readable content. Use path to a note, flashcard, PDF, or quiz.",
+ };
+ }
+
+ const content = formatItemContent(item);
+ const vpath = getVirtualPath(item, items);
+
+ return {
+ success: true,
+ itemName: item.name,
+ type: item.type,
+ path: vpath,
+ content,
+ };
+ },
+ });
+}
diff --git a/src/lib/ai/tools/workspace-search-utils.ts b/src/lib/ai/tools/workspace-search-utils.ts
new file mode 100644
index 00000000..5233e3b0
--- /dev/null
+++ b/src/lib/ai/tools/workspace-search-utils.ts
@@ -0,0 +1,114 @@
+/**
+ * Shared utilities for workspace grep and read tools.
+ */
+
+import type { Item, NoteData, PdfData, FlashcardData, FlashcardItem, QuizData, AudioData } from "@/lib/workspace-state/types";
+import { serializeBlockNote } from "@/lib/utils/serialize-blocknote";
+import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs";
+import { type Block } from "@/components/editor/BlockNoteEditor";
+
+/**
+ * Extract plain text from an item for searching (grep).
+ * Returns empty string for types with no searchable content.
+ */
+export function extractSearchableText(item: Item, items: Item[]): string {
+ switch (item.type) {
+ case "note": {
+ const data = item.data as NoteData;
+ if (data.blockContent) {
+ return serializeBlockNote(data.blockContent as Block[]);
+ }
+ return data.field1 ?? "";
+ }
+ case "flashcard": {
+ const data = item.data as FlashcardData;
+ const cards: FlashcardItem[] =
+ data.cards?.length
+ ? data.cards
+ : data.front || data.back
+ ? [{ id: "legacy", front: data.front ?? "", back: data.back ?? "", frontBlocks: data.frontBlocks, backBlocks: data.backBlocks }]
+ : [];
+ return cards
+ .map((c) => {
+ const front = c.frontBlocks ? serializeBlockNote(c.frontBlocks as Block[]) : c.front;
+ const back = c.backBlocks ? serializeBlockNote(c.backBlocks as Block[]) : c.back;
+ return `${front}\n${back}`;
+ })
+ .join("\n\n");
+ }
+ case "pdf": {
+ const data = item.data as PdfData;
+ return data.textContent ?? "";
+ }
+ case "quiz": {
+ const data = item.data as QuizData;
+ const questions = data.questions ?? [];
+ return questions
+ .map(
+ (q) =>
+ `${q.questionText}\n${q.options?.join("\n") ?? ""}\n${q.explanation ?? ""}`
+ )
+ .join("\n\n");
+ }
+ case "audio": {
+ const data = item.data as AudioData;
+ const parts: string[] = [];
+ if (data.transcript) parts.push(data.transcript);
+ if (data.segments?.length) {
+ parts.push(
+ data.segments
+ .map((s) => `${s.content}${s.translation ? ` (${s.translation})` : ""}`)
+ .join("\n")
+ );
+ }
+ return parts.join("\n");
+ }
+ case "image":
+ case "youtube":
+ case "folder":
+ return item.name;
+ default:
+ return "";
+ }
+}
+
+/**
+ * Resolve an item by virtual path.
+ * Path format: "Physics/notes/Thermodynamics.md" or "notes/My Note.md"
+ */
+export function resolveItemByPath(items: Item[], pathInput: string): Item | null {
+ const normalized = pathInput.trim().replace(/\/+/g, "/").replace(/^\//, "");
+ if (!normalized) return null;
+
+ // Try exact match on getVirtualPath first
+ const contentItems = items.filter((i) => i.type !== "folder");
+ const exact = contentItems.find((item) => getVirtualPath(item, items) === normalized);
+ if (exact) return exact;
+
+ // Try path without extension (user might omit .md etc.)
+ const withoutExt = normalized.replace(/\.[^.]+$/, "");
+ const byPathNoExt = contentItems.find((item) => {
+ const vp = getVirtualPath(item, items);
+ return vp.replace(/\.[^.]+$/, "") === withoutExt || vp === normalized;
+ });
+ if (byPathNoExt) return byPathNoExt;
+
+ // Try matching last segment as filename (e.g. "Thermodynamics.md" -> item named "Thermodynamics")
+ const segments = normalized.split("/").filter(Boolean);
+ const filename = segments[segments.length - 1];
+ const nameWithoutExt = filename.replace(/\.[^.]+$/, "");
+
+ const candidates = contentItems.filter((item) => {
+ const vp = getVirtualPath(item, items);
+ return vp.endsWith(filename) || vp.endsWith(nameWithoutExt + ".md") || vp.endsWith(nameWithoutExt + ".pdf");
+ });
+
+ if (candidates.length === 1) return candidates[0];
+ if (candidates.length > 1) {
+ // Prefer path that matches most segments
+ const best = candidates.find((item) => getVirtualPath(item, items) === normalized);
+ return best ?? candidates[0];
+ }
+
+ return null;
+}
diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts
index 76d3f657..fbd7465a 100644
--- a/src/lib/ai/tools/workspace-tools.ts
+++ b/src/lib/ai/tools/workspace-tools.ts
@@ -3,7 +3,6 @@ import { z } from "zod";
import { logger } from "@/lib/utils/logger";
import { workspaceWorker } from "@/lib/ai/workers";
import { loadWorkspaceState } from "@/lib/workspace/state-loader";
-import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context";
import type { Item } from "@/lib/workspace-state/types";
import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils";
@@ -308,9 +307,6 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) {
}
}
- // Format the selected cards context
- const context = formatSelectedCardsContext(selectedItems, state.items);
-
const addedCount = selectedItems.length;
const notFoundCount = cardTitles.length - addedCount;
@@ -318,13 +314,12 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) {
if (notFoundCount > 0) {
message += ` (${notFoundCount} not found)`;
}
- message += `: ${selectedItems.map(item => item.name).join(", ")}`;
+ message += `: ${selectedItems.map(item => item.name).join(", ")}. Use grepWorkspace or readWorkspace to fetch content when needed.`;
return {
success: true,
message,
addedCount,
- context, // Return formatted context so AI has immediate access
cardTitles: cardTitles,
};
} catch (error: any) {
@@ -332,7 +327,6 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) {
return {
success: false,
message: `Failed to load workspace state: ${error?.message || String(error)}`,
- context: "",
};
}
},
diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts
index 2515e78f..35b48dff 100644
--- a/src/lib/ai/workers/workspace-worker.ts
+++ b/src/lib/ai/workers/workspace-worker.ts
@@ -11,6 +11,7 @@ import type { Item, NoteData, PdfData, QuizData, QuizQuestion } from "@/lib/work
import { markdownToBlocks } from "@/lib/editor/markdown-to-blocks";
import { executeWorkspaceOperation } from "./common";
import { loadWorkspaceState } from "@/lib/workspace/state-loader";
+import { hasDuplicateName } from "@/lib/workspace/unique-name";
import type { WorkspaceEvent } from "@/lib/workspace/events";
/** Create params for a single item (used by create and bulkCreate). Exported for autogen. */
@@ -282,6 +283,13 @@ export async function workspaceWorker(
// Handle different actions
if (action === "create") {
const item = await buildItemFromCreateParams(params);
+ const currentState = await loadWorkspaceState(params.workspaceId);
+ if (hasDuplicateName(currentState.items, item.name, item.type, item.folderId ?? null)) {
+ return {
+ success: false,
+ message: `A ${item.type} named "${item.name}" already exists in this folder`,
+ };
+ }
const event = createEvent("ITEM_CREATED", { id: item.id, item }, userId);
// For create operations, retry on version conflicts since creates are independent
@@ -502,6 +510,16 @@ export async function workspaceWorker(
const currentState = await loadWorkspaceState(params.workspaceId);
const existingItem = currentState.items.find((i: any) => i.id === params.itemId);
const itemName = (changes as Partial- ).name ?? existingItem?.name;
+ const newFolderId = (changes as Partial
- ).folderId ?? existingItem?.folderId ?? null;
+
+ if (itemName && existingItem) {
+ if (hasDuplicateName(currentState.items, itemName, existingItem.type, newFolderId, params.itemId)) {
+ return {
+ success: false,
+ message: `A ${existingItem.type} named "${itemName}" already exists in this folder`,
+ };
+ }
+ }
logger.time("📝 [UPDATE-NOTE] Event creation");
const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: 'agent', name: itemName }, userId);
@@ -614,6 +632,12 @@ export async function workspaceWorker(
// Handle title update if provided
if (params.title) {
logger.debug("🎴 [UPDATE-FLASHCARD] Updating title:", params.title);
+ if (hasDuplicateName(currentState.items, params.title, existingItem.type, existingItem.folderId ?? null, params.itemId)) {
+ return {
+ success: false,
+ message: `A ${existingItem.type} named "${params.title}" already exists in this folder`,
+ };
+ }
changes.name = params.title;
}
@@ -708,6 +732,12 @@ export async function workspaceWorker(
// Handle title update if provided
if (params.title) {
logger.debug("🎯 [UPDATE-QUIZ] Updating title:", params.title);
+ if (hasDuplicateName(currentState.items, params.title, existingItem.type, existingItem.folderId ?? null, params.itemId)) {
+ return {
+ success: false,
+ message: `A ${existingItem.type} named "${params.title}" already exists in this folder`,
+ };
+ }
changes.name = params.title;
}
@@ -806,6 +836,12 @@ export async function workspaceWorker(
const changes: Partial
- = { data: updatedData };
if (params.title) {
+ if (hasDuplicateName(currentState.items, params.title, existingItem.type, existingItem.folderId ?? null, params.itemId)) {
+ return {
+ success: false,
+ message: `A ${existingItem.type} named "${params.title}" already exists in this folder`,
+ };
+ }
changes.name = params.title;
}
diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx
index 3928fa77..2f6a7d80 100644
--- a/src/lib/chat/custom-thread-history-adapter.tsx
+++ b/src/lib/chat/custom-thread-history-adapter.tsx
@@ -58,12 +58,19 @@ function sortParentsBeforeChildren<
/**
* AI SDK–only thread history adapter.
* Uses withFormat(aiSDKV6FormatAdapter) for persistence via useExternalHistory.
+ * Base load/append are stubs since ExternalStoreRuntime uses withFormat for persistence.
*/
export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter {
const aui = useAui();
return useMemo
(
() => ({
+ async load() {
+ return { messages: [] };
+ },
+ async append() {
+ // No-op: ExternalStoreRuntime uses withFormat for persistence
+ },
withFormat(
formatAdapter: MessageFormatAdapter
): GenericThreadHistoryAdapter {
@@ -111,7 +118,10 @@ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter {
const filtered = messages.filter(
(m: { format?: string }) => m.format === formatAdapter.format
);
- const decoded = filtered.map(
+ type DecodedItem = MessageFormatItem & {
+ created_at: string;
+ };
+ const decoded: DecodedItem[] = filtered.map(
(m: {
id: string;
parent_id: string | null;
@@ -133,10 +143,16 @@ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter {
);
// Topological sort: parents before children for MessageRepository.import()
+ // formatAdapter.decode returns messages with id (ai-sdk/v6 and similar formats)
+ type SortableItem = {
+ parentId: string | null;
+ message: { id: string };
+ created_at: string;
+ };
const sorted = sortParentsBeforeChildren(
- decoded,
- (d) => d.created_at ?? new Date().toISOString()
- );
+ decoded as SortableItem[],
+ (d) => d.created_at
+ ) as DecodedItem[];
return {
messages: sorted.map(({ parentId, message }) => ({
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index a60fc077..de524956 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -91,9 +91,9 @@ Your knowledge cutoff date is January 2025.
WORKSPACE (virtual file system):
${formatVirtualWorkspaceFS(state)}
-When users say "this", they may mean information in the section. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCard tool.
+When users say "this", they may mean the selected cards. Selected cards context provides paths and metadata only — use grepWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool.
-When answering questions about selected cards or content in , rely only on the facts directly mentioned in that context. Do not invent or assume information not present. If the answer is not in the context, say so.
+When answering questions about selected cards, use grepWorkspace to search or readWorkspace to read the item. Rely only on facts from the content you fetch. Do not invent or assume information not present.
@@ -425,8 +425,49 @@ function formatRichContentSection(richContent: RichContent): string {
* Formats a single selected card with FULL content (no truncation)
*/
/**
- * Formats selected cards context for the assistant
- * Used when cards are added to the context drawer
+ * Formats selected cards as metadata only (paths, names, types).
+ * Use when the AI has grep/read tools — it fetches content on demand.
+ */
+export function formatSelectedCardsMetadata(selectedItems: Item[], allItems?: Item[]): string {
+ if (selectedItems.length === 0) {
+ return `
+No cards selected.
+ `;
+ }
+
+ let effectiveItems: Item[] = [];
+ const processedIds = new Set();
+
+ const processItem = (item: Item) => {
+ if (processedIds.has(item.id)) return;
+ processedIds.add(item.id);
+
+ if (item.type === "folder") {
+ effectiveItems.push(item);
+ if (allItems) {
+ const children = allItems.filter((child) => child.folderId === item.id);
+ children.forEach((child) => processItem(child));
+ }
+ } else {
+ effectiveItems.push(item);
+ }
+ };
+
+ selectedItems.forEach((item) => processItem(item));
+
+ const contentItems = effectiveItems.filter((i) => i.type !== "folder");
+ const entries = contentItems.map((item) => formatItemMetadata(item, allItems ?? effectiveItems));
+
+ return `
+SELECTED CARDS (${contentItems.length}) — paths and metadata. Use grepWorkspace or readWorkspace to fetch content when needed.
+
+${entries.join("\n")}
+ `;
+}
+
+/**
+ * Formats selected cards context for the assistant (FULL content).
+ * Used when cards are added to the context drawer — prefer formatSelectedCardsMetadata when grep/read tools exist.
*/
export function formatSelectedCardsContext(selectedItems: Item[], allItems?: Item[]): string {
if (selectedItems.length === 0) {
@@ -474,11 +515,15 @@ No cards selected.
* Formats a single selected card with FULL content (no truncation)
*/
function formatSelectedCardFull(item: Item, index: number): string {
- const lines = [
- ``
- ];
+ return formatItemContent(item);
+}
+
+/**
+ * Format full content of a single item. Used by read tool and formatSelectedCardFull.
+ */
+export function formatItemContent(item: Item): string {
+ const lines = [``];
- // Add type-specific details with FULL content
switch (item.type) {
case "note":
lines.push(...formatNoteDetailsFull(item.data as NoteData));
@@ -501,10 +546,11 @@ function formatSelectedCardFull(item: Item, index: number): string {
case "audio":
lines.push(...formatAudioDetailsFull(item.data as AudioData));
break;
+ default:
+ break;
}
lines.push(` `);
-
return lines.join("\n");
}
diff --git a/src/lib/workspace/unique-name.ts b/src/lib/workspace/unique-name.ts
new file mode 100644
index 00000000..61b5af07
--- /dev/null
+++ b/src/lib/workspace/unique-name.ts
@@ -0,0 +1,33 @@
+import type { Item } from "@/lib/workspace-state/types";
+
+/**
+ * Check if a name+type already exists among siblings (same folder).
+ * Returns true if there would be a duplicate.
+ *
+ * @param items - All workspace items
+ * @param name - Proposed name (case-insensitive check)
+ * @param type - Item type
+ * @param folderId - Parent folder (null = root)
+ * @param excludeItemId - When updating, exclude this item from the check
+ */
+export function hasDuplicateName(
+ items: Item[],
+ name: string,
+ type: Item["type"],
+ folderId: string | null,
+ excludeItemId?: string
+): boolean {
+ const normalized = name.trim().toLowerCase();
+ if (!normalized) return false;
+
+ const siblings = items.filter((i) => {
+ if (i.type === "folder") return false;
+ if (excludeItemId && i.id === excludeItemId) return false;
+ const sameFolder =
+ (folderId == null && i.folderId == null) ||
+ (folderId != null && i.folderId === folderId);
+ return sameFolder && i.type === type && i.name.trim().toLowerCase() === normalized;
+ });
+
+ return siblings.length > 0;
+}
From c7934a559d133617afa7cdf301c1fb545d6412c9 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 00:29:54 -0500
Subject: [PATCH 09/56] feat: thread loading skeleton
---
.env.example | 4 ---
.gitignore | 2 ++
src/components/assistant-ui/thread.tsx | 40 +++++++++++++++++++++++++-
3 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/.env.example b/.env.example
index ef1e9309..a2454351 100644
--- a/.env.example
+++ b/.env.example
@@ -34,10 +34,6 @@ SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Google AI
GOOGLE_GENERATIVE_AI_API_KEY=AIza...
-# Assistant UI
-NEXT_PUBLIC_ASSISTANT_BASE_URL=https://...
-ASSISTANT_API_KEY=sk_aui_...
-
# Firecrawl Web Scraping (Optional)
# Get your API key from firecrawl.dev
FIRECRAWL_API_KEY=fc_...
diff --git a/.gitignore b/.gitignore
index 36202e82..8a8d6139 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
# dependencies
/node_modules
+.pnpm-store
/.pnp
.pnp.*
.yarn/*
@@ -22,6 +23,7 @@
# misc
.DS_Store
+assistant-ui-main/
*.pem
# debug
diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx
index 82eb4453..ebee6cef 100644
--- a/src/components/assistant-ui/thread.tsx
+++ b/src/components/assistant-ui/thread.tsx
@@ -46,6 +46,7 @@ import * as m from "motion/react-m";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuContent,
@@ -213,7 +214,10 @@ export const Thread: FC = ({ items = [] }) => {
autoScroll={false}
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll px-4"
>
- thread.isEmpty}>
+ thread.isLoading}>
+
+
+ thread.isEmpty && !thread.isLoading}>
@@ -256,6 +260,40 @@ const ThreadScrollToBottom: FC = () => {
);
};
+const ThreadLoadingSkeleton: FC = () => {
+ return (
+
+ {/* User message skeleton (right-aligned) */}
+
+ {/* Assistant message skeleton (left-aligned) */}
+
+
+
+
+
+ {/* User message skeleton */}
+
+
+
+ {/* Assistant message skeleton - taller */}
+
+
+
+
+
+
+
+ );
+};
+
const ThreadWelcome: FC = () => {
return (
From 2c914ad5f110671b7f7322c8622591d3b283d792 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 01:11:57 -0500
Subject: [PATCH 10/56] more changes
---
.../assistant-ui/GrepWorkspaceToolUI.tsx | 86 --------------
.../assistant-ui/ReadWorkspaceToolUI.tsx | 37 +++---
.../assistant-ui/SearchWorkspaceToolUI.tsx | 109 ++++++++++++++++++
src/components/assistant-ui/thread.tsx | 4 +-
src/lib/ai/tools/index.ts | 4 +-
src/lib/ai/tools/read-workspace.ts | 2 +-
...{grep-workspace.ts => search-workspace.ts} | 6 +-
src/lib/ai/tools/workspace-tools.ts | 2 +-
src/lib/utils/format-workspace-context.ts | 6 +-
9 files changed, 140 insertions(+), 116 deletions(-)
delete mode 100644 src/components/assistant-ui/GrepWorkspaceToolUI.tsx
create mode 100644 src/components/assistant-ui/SearchWorkspaceToolUI.tsx
rename src/lib/ai/tools/{grep-workspace.ts => search-workspace.ts} (92%)
diff --git a/src/components/assistant-ui/GrepWorkspaceToolUI.tsx b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx
deleted file mode 100644
index 57d43d5a..00000000
--- a/src/components/assistant-ui/GrepWorkspaceToolUI.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-"use client";
-
-import { Search } from "lucide-react";
-import { makeAssistantToolUI } from "@assistant-ui/react";
-import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
-import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
-import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell";
-
-type GrepArgs = { pattern: string; include?: string; path?: string };
-type GrepResult = { success: boolean; matches?: number; output?: string; message?: string };
-
-function GrepArgsSummary({ args }: { args?: GrepArgs }) {
- if (!args?.pattern) return null;
- const parts = [`pattern: "${args.pattern}"`];
- if (args.include) parts.push(`type: ${args.include}`);
- if (args.path) parts.push(`path: ${args.path}`);
- return (
-
- {parts.join(" · ")}
-
- );
-}
-
-export const GrepWorkspaceToolUI = makeAssistantToolUI
({
- toolName: "grepWorkspace",
- render: ({ args, status, result }) => {
- let content: React.ReactNode = null;
-
- if (status.type === "running") {
- content = (
-
-
-
-
- );
- } else if (status.type === "complete" && result) {
- if (!result.success && result.message) {
- content = (
-
-
-
-
- );
- } else if (result.success && result.output) {
- content = (
-
-
-
-
- {result.matches != null && (
-
- {result.matches} match
- {result.matches !== 1 ? "es" : ""}
-
- )}
-
-
- {result.output}
-
-
- );
- }
- } else if (status.type === "incomplete" && status.reason === "error") {
- content = (
-
-
-
-
- );
- }
-
- return (
-
- {content}
-
- );
- },
-});
diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
index 52522f86..289327da 100644
--- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
+++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
@@ -2,7 +2,6 @@
import { FileText } from "lucide-react";
import { makeAssistantToolUI } from "@assistant-ui/react";
-import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown";
import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell";
@@ -17,6 +16,10 @@ type ReadResult = {
message?: string;
};
+function stripExtension(s: string): string {
+ return s.replace(/\.[^.]+$/, "");
+}
+
export const ReadWorkspaceToolUI = makeAssistantToolUI({
toolName: "readWorkspace",
render: ({ args, status, result }) => {
@@ -24,7 +27,7 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({
if (status.type === "running") {
const label = args?.path
- ? `Reading ${args.path}`
+ ? `Reading ${stripExtension(args.path)}`
: args?.itemName
? `Reading "${args.itemName}"`
: "Reading workspace item...";
@@ -37,23 +40,21 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({
message={result.message}
/>
);
- } else if (result.success && result.content) {
+ } else if (result.success) {
content = (
-
-
-
-
- {result.path ?? result.itemName}
- {result.type && (
-
- {result.type}
-
- )}
-
-
-
- {result.content}
-
+
+
+
+ Read -{" "}
+ {result.path
+ ? stripExtension(result.path)
+ : result.itemName}
+ {result.type && (
+
+ {result.type}
+
+ )}
+
);
}
diff --git a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx
new file mode 100644
index 00000000..253ab5de
--- /dev/null
+++ b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { Search } from "lucide-react";
+import { makeAssistantToolUI } from "@assistant-ui/react";
+import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
+import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
+import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell";
+
+type GrepArgs = { pattern: string; include?: string; path?: string };
+type GrepResult = { success: boolean; matches?: number; output?: string; message?: string };
+
+const MAX_EXCERPTS = 5;
+const MAX_EXCERPT_LEN = 80;
+
+function stripExtension(s: string): string {
+ return s.replace(/\.[^.]+$/, "");
+}
+
+function parseExcerpts(output: string): { path: string; excerpt: string }[] {
+ const items: { path: string; excerpt: string }[] = [];
+ const lines = output.split("\n");
+ let currentPath = "";
+
+ for (const line of lines) {
+ if (line.match(/^Found \d+ matches/) || line === "" || line.startsWith("(Results truncated")) continue;
+ if (line.endsWith(":") && !line.startsWith(" ")) {
+ currentPath = stripExtension(line.slice(0, -1).trim());
+ } else if (line.startsWith(" Line ") && currentPath) {
+ const match = line.match(/^ Line \d+: (.+)$/);
+ const excerpt = match ? match[1].trim() : line.replace(/^ Line \d+: /, "").trim();
+ const truncated = excerpt.length > MAX_EXCERPT_LEN ? excerpt.slice(0, MAX_EXCERPT_LEN) + "…" : excerpt;
+ items.push({ path: currentPath, excerpt: truncated });
+ if (items.length >= MAX_EXCERPTS) break;
+ }
+ }
+ return items;
+}
+
+export const SearchWorkspaceToolUI = makeAssistantToolUI
({
+ toolName: "searchWorkspace",
+ render: ({ status, result }) => {
+ let content: React.ReactNode = null;
+
+ if (status.type === "running") {
+ content = ;
+ } else if (status.type === "complete" && result) {
+ if (!result.success && result.message) {
+ content = (
+
+ );
+ } else if (result.success && result.output) {
+ const isNoMatches = result.output.startsWith("No matches found");
+ const excerpts = isNoMatches ? [] : parseExcerpts(result.output);
+
+ content = (
+
+
+
+
+ Search -{" "}
+ {result.matches != null
+ ? `${result.matches} match${result.matches !== 1 ? "es" : ""}`
+ : "completed"}
+
+
+ {excerpts.length > 0 && (
+
+ {excerpts.map((item, i) => (
+
+ {item.path}
+ {item.excerpt}
+
+ ))}
+
+ )}
+
+ );
+ } else if (result.success) {
+ content = (
+
+
+
+ Search -{" "}
+ {result.matches != null
+ ? `${result.matches} match${result.matches !== 1 ? "es" : ""}`
+ : "completed"}
+
+
+ );
+ }
+ } else if (status.type === "incomplete" && status.reason === "error") {
+ content = (
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ },
+});
diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx
index ebee6cef..54baa535 100644
--- a/src/components/assistant-ui/thread.tsx
+++ b/src/components/assistant-ui/thread.tsx
@@ -76,7 +76,7 @@ import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI";
import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI";
import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI";
import { UpdatePdfContentToolUI } from "@/components/assistant-ui/UpdatePdfContentToolUI";
-import { GrepWorkspaceToolUI } from "@/components/assistant-ui/GrepWorkspaceToolUI";
+import { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI";
import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI";
import { DeleteCardToolUI } from "@/components/assistant-ui/DeleteCardToolUI";
@@ -200,7 +200,7 @@ export const Thread: FC = ({ items = [] }) => {
{/* */}
-
+
{
// Search & code execution
webSearch: createWebSearchTool(),
executeCode: createExecuteCodeTool(),
- grepWorkspace: createGrepWorkspaceTool(ctx),
+ searchWorkspace: createSearchWorkspaceTool(ctx),
readWorkspace: createReadWorkspaceTool(ctx),
// Workspace operations
diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts
index 7fb03f5e..d2eb4d8f 100644
--- a/src/lib/ai/tools/read-workspace.ts
+++ b/src/lib/ai/tools/read-workspace.ts
@@ -69,7 +69,7 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
return {
success: false,
message: `Item not found${itemName ? `: "${itemName}"` : ` at path: ${path}`}. ${
- sample ? `Example paths: ${sample}` : "Workspace may be empty. Use grep to search."
+ sample ? `Example paths: ${sample}` : "Workspace may be empty. Use searchWorkspace to search."
}`,
};
}
diff --git a/src/lib/ai/tools/grep-workspace.ts b/src/lib/ai/tools/search-workspace.ts
similarity index 92%
rename from src/lib/ai/tools/grep-workspace.ts
rename to src/lib/ai/tools/search-workspace.ts
index 67e80a8c..ee4a6143 100644
--- a/src/lib/ai/tools/grep-workspace.ts
+++ b/src/lib/ai/tools/search-workspace.ts
@@ -22,13 +22,13 @@ function buildRegex(pattern: string): RegExp {
return new RegExp(escaped, "gi");
}
-export function createGrepWorkspaceTool(ctx: WorkspaceToolContext) {
+export function createSearchWorkspaceTool(ctx: WorkspaceToolContext) {
return tool({
description:
- "Search for a pattern in workspace item content (notes, flashcards, PDFs, quizzes, audio transcripts). Use when you need to find where a term or phrase appears across the user's materials.",
+ "Grep-like text search across workspace content (notes, flashcards, PDFs, quizzes, audio transcripts). Use when you need to find where a term or phrase appears. Pass the search pattern as `pattern` (plain text or regex).",
inputSchema: zodSchema(
z.object({
- pattern: z.string().describe("The regex or plain text pattern to search for"),
+ pattern: z.string().describe("Search pattern (plain text or regex) — the term/phrase to find in workspace content"),
include: z
.string()
.optional()
diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts
index fbd7465a..29675632 100644
--- a/src/lib/ai/tools/workspace-tools.ts
+++ b/src/lib/ai/tools/workspace-tools.ts
@@ -314,7 +314,7 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) {
if (notFoundCount > 0) {
message += ` (${notFoundCount} not found)`;
}
- message += `: ${selectedItems.map(item => item.name).join(", ")}. Use grepWorkspace or readWorkspace to fetch content when needed.`;
+ message += `: ${selectedItems.map(item => item.name).join(", ")}. Use searchWorkspace or readWorkspace to fetch content when needed.`;
return {
success: true,
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index de524956..3bb4e874 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -91,9 +91,9 @@ Your knowledge cutoff date is January 2025.
WORKSPACE (virtual file system):
${formatVirtualWorkspaceFS(state)}
-When users say "this", they may mean the selected cards. Selected cards context provides paths and metadata only — use grepWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool.
+When users say "this", they may mean the selected cards. Selected cards context provides paths and metadata only — use searchWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool.
-When answering questions about selected cards, use grepWorkspace to search or readWorkspace to read the item. Rely only on facts from the content you fetch. Do not invent or assume information not present.
+When answering questions about selected cards, use searchWorkspace to search or readWorkspace to read the item. Rely only on facts from the content you fetch. Do not invent or assume information not present.
@@ -459,7 +459,7 @@ No cards selected.
const entries = contentItems.map((item) => formatItemMetadata(item, allItems ?? effectiveItems));
return `
-SELECTED CARDS (${contentItems.length}) — paths and metadata. Use grepWorkspace or readWorkspace to fetch content when needed.
+SELECTED CARDS (${contentItems.length}) — paths and metadata. Use searchWorkspace or readWorkspace to fetch content when needed.
${entries.join("\n")}
`;
From edd3e11e21df3969c491ece9f366519be4d39039 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 01:37:30 -0500
Subject: [PATCH 11/56] feat: diff based updating
---
package.json | 2 +
src/app/api/deep-research/finalize/route.ts | 3 +-
.../assistant-ui/ReadWorkspaceToolUI.tsx | 34 +-
.../assistant-ui/UpdateNoteToolUI.tsx | 109 +--
src/components/assistant-ui/diff-viewer.tsx | 625 ++++++++++++++++++
src/lib/ai/tools/workspace-tools.ts | 84 ++-
src/lib/ai/workers/workspace-worker.ts | 81 ++-
src/lib/utils/edit-replace.ts | 478 ++++++++++++++
src/lib/utils/format-workspace-context.ts | 33 +-
9 files changed, 1341 insertions(+), 108 deletions(-)
create mode 100644 src/components/assistant-ui/diff-viewer.tsx
create mode 100644 src/lib/utils/edit-replace.ts
diff --git a/package.json b/package.json
index 3fb7ddfe..7cf2549e 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
+ "diff": "^8.0.3",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1",
"embla-carousel-react": "^8.6.0",
@@ -113,6 +114,7 @@
"motion": "^12.34.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
+ "parse-diff": "^0.11.1",
"postgres": "^3.4.7",
"posthog-js": "^1.345.0",
"posthog-node": "^5.24.14",
diff --git a/src/app/api/deep-research/finalize/route.ts b/src/app/api/deep-research/finalize/route.ts
index a5f988dd..9112b88d 100644
--- a/src/app/api/deep-research/finalize/route.ts
+++ b/src/app/api/deep-research/finalize/route.ts
@@ -46,7 +46,8 @@ export async function POST(req: NextRequest) {
const result = await workspaceWorker("update", {
workspaceId,
itemId,
- content: report, // This will be converted via markdownToBlocks
+ oldString: "",
+ newString: report,
});
if (!result.success) {
diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
index 289327da..8616e889 100644
--- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
+++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
@@ -1,6 +1,6 @@
"use client";
-import { FileText } from "lucide-react";
+import { Eye } from "lucide-react";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
@@ -36,32 +36,34 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({
if (!result.success && result.message) {
content = (
);
} else if (result.success) {
content = (
-
-
-
- Read -{" "}
- {result.path
- ? stripExtension(result.path)
- : result.itemName}
- {result.type && (
-
- {result.type}
-
- )}
-
+
+
+
+
+ Read -{" "}
+ {result.path
+ ? stripExtension(result.path)
+ : result.itemName}
+ {result.type && (
+
+ {result.type}
+
+ )}
+
+
);
}
} else if (status.type === "incomplete" && status.reason === "error") {
content = (
);
diff --git a/src/components/assistant-ui/UpdateNoteToolUI.tsx b/src/components/assistant-ui/UpdateNoteToolUI.tsx
index a32b67db..140e71f4 100644
--- a/src/components/assistant-ui/UpdateNoteToolUI.tsx
+++ b/src/components/assistant-ui/UpdateNoteToolUI.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
-import { useEffect, useMemo } from "react";
+import { useMemo } from "react";
import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useOptimisticToolUpdate } from "@/hooks/ai/use-optimistic-tool-update";
@@ -14,14 +14,20 @@ import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item";
import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell";
+import { DiffViewer } from "@/components/assistant-ui/diff-viewer";
import type { WorkspaceResult } from "@/lib/ai/tool-result-schemas";
import { parseWorkspaceResult } from "@/lib/ai/tool-result-schemas";
-type UpdateNoteArgs = { noteName: string; content: string };
+type UpdateNoteArgs = { noteName: string; oldString: string; newString: string; replaceAll?: boolean };
+
+interface UpdateNoteResult extends WorkspaceResult {
+ diff?: string;
+ filediff?: { additions: number; deletions: number };
+}
interface UpdateNoteReceiptProps {
args: UpdateNoteArgs;
- result: WorkspaceResult;
+ result: UpdateNoteResult;
status: any;
}
@@ -45,52 +51,67 @@ const UpdateNoteReceipt = ({ args, result, status }: UpdateNoteReceiptProps) =>
}
};
+ const hasDiff = result.diff && result.diff.trim().length > 0;
+
return (
-
-
-
- {status?.type === "complete" ? (
-
- ) : (
-
- )}
-
-
-
- {status?.type === "complete" ? (card?.name || "Card Updated") : "Update Cancelled"}
-
- {status?.type === "complete" && (
-
- Note updated
+
+
+
+
+ {status?.type === "complete" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {status?.type === "complete" ? (card?.name || "Card Updated") : "Update Cancelled"}
+ {status?.type === "complete" && (
+ Note updated
+ )}
+
+
+
+
+ {status?.type === "complete" && result.itemId && (
+ {
+ e.stopPropagation();
+ handleViewCard();
+ }}
+ >
+
+ View
+
)}
-
- {status?.type === "complete" && result.itemId && (
- {
- e.stopPropagation();
- handleViewCard();
- }}
- >
-
- View
-
- )}
-
+ {hasDiff && (
+
+ )}
);
};
@@ -117,7 +138,7 @@ export const UpdateNoteToolUI = makeAssistantToolUI ;
+ content = ;
} else if (status.type === "running") {
content = ;
} else if (status.type === "complete" && parsed && !parsed.success) {
diff --git a/src/components/assistant-ui/diff-viewer.tsx b/src/components/assistant-ui/diff-viewer.tsx
new file mode 100644
index 00000000..336ce988
--- /dev/null
+++ b/src/components/assistant-ui/diff-viewer.tsx
@@ -0,0 +1,625 @@
+"use client";
+
+import type { ComponentProps } from "react";
+import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown";
+import { cva, type VariantProps } from "class-variance-authority";
+import { diffLines } from "diff";
+import parseDiff from "parse-diff";
+import { useMemo } from "react";
+import { cn } from "@/lib/utils";
+import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown";
+
+type DiffLineType = "add" | "del" | "normal";
+
+interface ParsedLine {
+ type: DiffLineType;
+ content: string;
+ oldLineNumber?: number;
+ newLineNumber?: number;
+}
+
+interface ParsedFile {
+ oldName?: string | undefined;
+ newName?: string | undefined;
+ lines: ParsedLine[];
+ additions: number;
+ deletions: number;
+}
+
+interface SplitLinePair {
+ left: ParsedLine | null;
+ right: ParsedLine | null;
+}
+
+function parsePatch(patch: string): ParsedFile[] {
+ const files = parseDiff(patch);
+ return files.map((file) => {
+ const lines: ParsedLine[] = [];
+ let additions = 0;
+ let deletions = 0;
+ for (const chunk of file.chunks) {
+ let oldLine = chunk.oldStart;
+ let newLine = chunk.newStart;
+ for (const change of chunk.changes) {
+ if (change.type === "add") {
+ additions++;
+ lines.push({
+ type: "add",
+ content: change.content.slice(1),
+ newLineNumber: newLine++,
+ });
+ } else if (change.type === "del") {
+ deletions++;
+ lines.push({
+ type: "del",
+ content: change.content.slice(1),
+ oldLineNumber: oldLine++,
+ });
+ } else {
+ lines.push({
+ type: "normal",
+ content: change.content.slice(1),
+ oldLineNumber: oldLine++,
+ newLineNumber: newLine++,
+ });
+ }
+ }
+ }
+ return {
+ oldName: file.from,
+ newName: file.to,
+ lines,
+ additions,
+ deletions,
+ };
+ });
+}
+
+function computeDiff(
+ oldContent: string,
+ newContent: string,
+): { lines: ParsedLine[]; additions: number; deletions: number } {
+ const changes = diffLines(oldContent, newContent);
+ const lines: ParsedLine[] = [];
+ let oldLine = 1;
+ let newLine = 1;
+ let additions = 0;
+ let deletions = 0;
+
+ for (const change of changes) {
+ const contentLines = change.value.replace(/\n$/, "").split("\n");
+ for (const content of contentLines) {
+ if (change.added) {
+ additions++;
+ lines.push({ type: "add", content, newLineNumber: newLine++ });
+ } else if (change.removed) {
+ deletions++;
+ lines.push({ type: "del", content, oldLineNumber: oldLine++ });
+ } else {
+ lines.push({
+ type: "normal",
+ content,
+ oldLineNumber: oldLine++,
+ newLineNumber: newLine++,
+ });
+ }
+ }
+ }
+ return { lines, additions, deletions };
+}
+
+function pairLinesForSplit(lines: ParsedLine[]): SplitLinePair[] {
+ const pairs: SplitLinePair[] = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i]!;
+ if (line.type === "normal") {
+ pairs.push({ left: line, right: line });
+ i++;
+ } else if (line.type === "del") {
+ const deletions: ParsedLine[] = [];
+ while (i < lines.length && lines[i]!.type === "del") {
+ deletions.push(lines[i]!);
+ i++;
+ }
+ const additions: ParsedLine[] = [];
+ while (i < lines.length && lines[i]!.type === "add") {
+ additions.push(lines[i]!);
+ i++;
+ }
+ const maxLen = Math.max(deletions.length, additions.length);
+ for (let j = 0; j < maxLen; j++) {
+ pairs.push({
+ left: deletions[j] ?? null,
+ right: additions[j] ?? null,
+ });
+ }
+ } else {
+ pairs.push({ left: null, right: line });
+ i++;
+ }
+ }
+ return pairs;
+}
+
+const diffViewerVariants = cva(
+ "aui-diff-viewer overflow-hidden rounded-lg font-sans text-sm",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background",
+ ghost: "bg-transparent",
+ muted: "border border-muted-foreground/20 bg-muted",
+ },
+ size: {
+ xs: "text-[11px]",
+ sm: "text-xs",
+ default: "text-sm",
+ lg: "text-base",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+const diffLineVariants = cva("flex", {
+ variants: {
+ type: {
+ add: "bg-[var(--diff-add-bg,_rgba(46,160,67,0.15))]",
+ del: "bg-[var(--diff-del-bg,_rgba(248,81,73,0.15))]",
+ normal: "",
+ empty: "",
+ },
+ },
+ defaultVariants: {
+ type: "normal",
+ },
+});
+
+const diffLineTextVariants = cva("", {
+ variants: {
+ type: {
+ add: "text-[var(--diff-add-text,_#1a7f37)] dark:text-[var(--diff-add-text-dark,_#3fb950)]",
+ del: "text-[var(--diff-del-text,_#cf222e)] dark:text-[var(--diff-del-text-dark,_#f85149)]",
+ normal: "",
+ empty: "",
+ },
+ },
+ defaultVariants: {
+ type: "normal",
+ },
+});
+
+function getFileExtension(filename?: string): string {
+ const ext = filename?.split(".").pop()?.toLowerCase();
+ if (!ext) return "";
+ return ext.toUpperCase();
+}
+
+function DiffViewerFileBadge({ filename }: { filename?: string | undefined }) {
+ const ext = getFileExtension(filename);
+ if (!ext) return null;
+
+ return (
+
+ {ext}
+
+ );
+}
+
+function DiffViewerStats({
+ additions,
+ deletions,
+}: {
+ additions: number;
+ deletions: number;
+}) {
+ return (
+
+ +{additions}
+ -{deletions}
+
+ );
+}
+
+function DiffViewerFile({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DiffViewerContent({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+interface DiffViewerHeaderProps extends ComponentProps<"div"> {
+ oldName?: string | undefined;
+ newName?: string | undefined;
+ additions?: number;
+ deletions?: number;
+ showIcon?: boolean;
+ showStats?: boolean;
+}
+
+function DiffViewerHeader({
+ oldName,
+ newName,
+ additions = 0,
+ deletions = 0,
+ showIcon = true,
+ showStats = true,
+ className,
+ ...props
+}: DiffViewerHeaderProps) {
+ if (!oldName && !newName) return null;
+
+ const displayName = newName || oldName;
+
+ return (
+
+ {showIcon && }
+
+ {oldName && newName && oldName !== newName ? (
+ <>
+ {oldName}
+ {" → "}
+
+ {newName}
+
+ >
+ ) : (
+ displayName
+ )}
+
+ {showStats && (additions > 0 || deletions > 0) && (
+
+ )}
+
+ );
+}
+
+interface DiffViewerLineProps extends ComponentProps<"div"> {
+ line: ParsedLine;
+ showLineNumbers?: boolean;
+}
+
+function DiffViewerLine({
+ line,
+ showLineNumbers = true,
+ className,
+ ...props
+}: DiffViewerLineProps) {
+ const indicator = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
+
+ return (
+
+ {showLineNumbers && (
+
+ {line.type === "del"
+ ? line.oldLineNumber
+ : line.type === "add"
+ ? line.newLineNumber
+ : line.oldLineNumber}
+
+ )}
+
+ {indicator}
+
+
+ {line.content}
+
+
+ );
+}
+
+interface DiffViewerSplitLineProps extends ComponentProps<"div"> {
+ pair: SplitLinePair;
+ showLineNumbers?: boolean;
+}
+
+function DiffViewerSplitLine({
+ pair,
+ showLineNumbers = true,
+ className,
+ ...props
+}: DiffViewerSplitLineProps) {
+ const { left, right } = pair;
+
+ return (
+
+
+ {showLineNumbers && (
+
+ {left?.oldLineNumber ?? ""}
+
+ )}
+
+ {left ? (left.type === "del" ? "-" : " ") : ""}
+
+
+ {left?.content ?? ""}
+
+
+
+ {showLineNumbers && (
+
+ {right?.newLineNumber ?? ""}
+
+ )}
+
+ {right ? (right.type === "add" ? "+" : " ") : ""}
+
+
+ {right?.content ?? ""}
+
+
+
+ );
+}
+
+export type DiffViewerProps = Partial &
+ VariantProps & {
+ patch?: string;
+ oldFile?: { content: string; name?: string };
+ newFile?: { content: string; name?: string };
+ viewMode?: "split" | "unified";
+ showLineNumbers?: boolean;
+ showIcon?: boolean;
+ showStats?: boolean;
+ /** Render content as markdown (math, code, etc.) via Streamdown */
+ renderMarkdown?: boolean;
+ className?: string;
+ };
+
+/** Group consecutive lines of same type into chunks (for markdown rendering) */
+function groupLinesByType(lines: ParsedLine[]): Array<{ type: DiffLineType; content: string }> {
+ const chunks: Array<{ type: DiffLineType; content: string }> = [];
+ if (lines.length === 0) return chunks;
+
+ let current: { type: DiffLineType; lines: string[] } = {
+ type: lines[0].type,
+ lines: [lines[0].content],
+ };
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i];
+ if (line.type === current.type) {
+ current.lines.push(line.content);
+ } else {
+ chunks.push({ type: current.type, content: current.lines.join("\n") });
+ current = { type: line.type, lines: [line.content] };
+ }
+ }
+ chunks.push({ type: current.type, content: current.lines.join("\n") });
+ return chunks;
+}
+
+function DiffViewer({
+ code,
+ patch,
+ oldFile,
+ newFile,
+ viewMode = "unified",
+ showLineNumbers = true,
+ showIcon = true,
+ showStats = true,
+ renderMarkdown = false,
+ variant,
+ size,
+ className,
+}: DiffViewerProps) {
+ const diffPatch = patch ?? code;
+
+ const parsedFiles = useMemo(() => {
+ if (diffPatch) {
+ return parsePatch(diffPatch);
+ }
+ if (oldFile && newFile) {
+ const { lines, additions, deletions } = computeDiff(
+ oldFile.content,
+ newFile.content,
+ );
+ return [
+ {
+ oldName: oldFile.name,
+ newName: newFile.name,
+ lines,
+ additions,
+ deletions,
+ },
+ ];
+ }
+ return [];
+ }, [diffPatch, oldFile, newFile]);
+
+ if (parsedFiles.length === 0) {
+ return (
+
+ No diff content provided
+
+ );
+ }
+
+ return (
+
+ {parsedFiles.map((file, fileIndex) => (
+
+
+
+ {renderMarkdown && viewMode === "unified" ? (
+ groupLinesByType(file.lines).map((chunk, chunkIndex) => {
+ const chunkLabels = {
+ add: "Added",
+ del: "Removed",
+ normal: "Unchanged",
+ } as const;
+ const label = chunkLabels[chunk.type];
+ return (
+
+
+ {label}
+
+
+ {chunk.content}
+
+
+ );
+ })
+ ) : viewMode === "split" ? (
+ pairLinesForSplit(file.lines).map((pair, pairIndex) => (
+
+ ))
+ ) : (
+ file.lines.map((line, lineIndex) => (
+
+ ))
+ )}
+
+
+ ))}
+
+ );
+}
+
+DiffViewer.displayName = "DiffViewer";
+
+export type { ParsedLine, ParsedFile, SplitLinePair };
+
+export {
+ DiffViewer,
+ DiffViewerFile,
+ DiffViewerHeader,
+ DiffViewerContent,
+ DiffViewerLine,
+ DiffViewerSplitLine,
+ DiffViewerFileBadge,
+ DiffViewerStats,
+ diffViewerVariants,
+ diffLineVariants,
+ diffLineTextVariants,
+ parsePatch,
+ computeDiff,
+};
diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts
index 29675632..0de4f982 100644
--- a/src/lib/ai/tools/workspace-tools.ts
+++ b/src/lib/ai/tools/workspace-tools.ts
@@ -68,28 +68,48 @@ export function createNoteTool(ctx: WorkspaceToolContext) {
/**
* Create the updateNote tool
+ * Cline convention: oldString='' = full rewrite, oldString!='' = targeted edit
*/
export function createUpdateNoteTool(ctx: WorkspaceToolContext) {
return tool({
- description: "Update the content and/or title of an existing note.",
+ description:
+ "Update a note. Full rewrite: oldString='', newString=entire note content. Targeted edit: oldString=exact text to find (from readWorkspace), newString=replacement. Include enough context in oldString to make it unique.",
inputSchema: zodSchema(
- z.object({
- noteName: z.string().describe("The name of the note to update (will be matched using fuzzy search)"),
- content: z.string().describe("The full note body ONLY (do not include the title as a header)."),
- title: z.string().optional().describe("New title for the note. If not provided, the existing title will be preserved."),
- sources: z.array(
- z.object({
- title: z.string().describe("Title of the source page"),
- url: z.string().describe("URL of the source"),
- favicon: z.string().optional().describe("Optional favicon URL"),
- })
- ).optional().describe("Optional sources from web search or user-provided URLs"),
- }).passthrough()
+ z
+ .object({
+ noteName: z.string().describe("The name of the note to update (will be matched using fuzzy search)"),
+ oldString: z
+ .string()
+ .describe(
+ "Text to find. Use empty string '' for full rewrite; otherwise exact text from readWorkspace for targeted edit."
+ ),
+ newString: z
+ .string()
+ .describe("Replacement text (entire note if oldString is empty)"),
+ replaceAll: z.boolean().optional().default(false),
+ title: z.string().optional().describe("New title for the note. If not provided, the existing title will be preserved."),
+ sources: z
+ .array(
+ z.object({
+ title: z.string().describe("Title of the source page"),
+ url: z.string().describe("URL of the source"),
+ favicon: z.string().optional().describe("Optional favicon URL"),
+ })
+ )
+ .optional()
+ .describe("Optional sources from web search or user-provided URLs"),
+ })
+ .passthrough()
),
- execute: async (input: { noteName: string; content: string; title?: string; sources?: Array<{ title: string; url: string; favicon?: string }> }) => {
- const noteName = input.noteName;
- const content = input.content;
- const title = input.title;
+ execute: async (input: {
+ noteName: string;
+ oldString: string;
+ newString: string;
+ replaceAll?: boolean;
+ title?: string;
+ sources?: Array<{ title: string; url: string; favicon?: string }>;
+ }) => {
+ const { noteName, oldString, newString, replaceAll, title } = input;
if (!noteName) {
return {
@@ -98,10 +118,24 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) {
};
}
- if (content === undefined || content === null) {
+ if (oldString === undefined || oldString === null) {
+ return {
+ success: false,
+ message: "oldString is required. Use '' for full rewrite.",
+ };
+ }
+
+ if (newString === undefined || newString === null) {
return {
success: false,
- message: "Content is required.",
+ message: "newString is required.",
+ };
+ }
+
+ if (oldString === newString) {
+ return {
+ success: false,
+ message: "No changes to apply: oldString and newString are identical.",
};
}
@@ -113,22 +147,19 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) {
}
try {
- // Load workspace state (security is enforced by workspace-worker)
const accessResult = await loadStateForTool(ctx);
if (!accessResult.success) {
return accessResult;
}
const { state } = accessResult;
-
- // Fuzzy match the note by name
const matchedNote = fuzzyMatchItem(state.items, noteName, "note");
if (!matchedNote) {
const availableNotes = getAvailableItemsList(state.items, "note");
return {
success: false,
- message: `Could not find note "${noteName}". ${availableNotes ? `Available notes: ${availableNotes}` : 'No notes found in workspace.'}`,
+ message: `Could not find note "${noteName}". ${availableNotes ? `Available notes: ${availableNotes}` : "No notes found in workspace."}`,
};
}
@@ -141,8 +172,11 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) {
const workerResult = await workspaceWorker("update", {
workspaceId: ctx.workspaceId,
itemId: matchedNote.id,
- content: content,
- title: title,
+ itemName: matchedNote.name,
+ oldString,
+ newString,
+ replaceAll,
+ title,
sources: input.sources,
});
diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts
index 35b48dff..e6d5ca28 100644
--- a/src/lib/ai/workers/workspace-worker.ts
+++ b/src/lib/ai/workers/workspace-worker.ts
@@ -1,4 +1,5 @@
import { headers } from "next/headers";
+import { createPatch, diffLines } from "diff";
import { auth } from "@/lib/auth";
import { db, workspaces } from "@/lib/db/client";
import { workspaceCollaborators } from "@/lib/db/schema";
@@ -13,6 +14,8 @@ import { executeWorkspaceOperation } from "./common";
import { loadWorkspaceState } from "@/lib/workspace/state-loader";
import { hasDuplicateName } from "@/lib/workspace/unique-name";
import type { WorkspaceEvent } from "@/lib/workspace/events";
+import { replace as applyReplace, trimDiff, normalizeLineEndings } from "@/lib/utils/edit-replace";
+import { getNoteContentAsMarkdown } from "@/lib/utils/format-workspace-context";
/** Create params for a single item (used by create and bulkCreate). Exported for autogen. */
export type CreateItemParams = {
@@ -183,8 +186,14 @@ export async function workspaceWorker(
/** For bulkCreate: array of create params (no workspaceId). Items are built and appended as one BULK_ITEMS_CREATED event. */
items?: CreateItemParams[];
title?: string;
- content?: string; // For notes
+ content?: string; // For create
+ /** Cline convention: oldString+newString. oldString='' = full rewrite, else targeted edit (update only) */
+ oldString?: string;
+ newString?: string;
+ replaceAll?: boolean;
itemId?: string;
+ /** Display name for diff header (e.g. note title) */
+ itemName?: string;
itemType?: "note" | "flashcard" | "quiz" | "youtube" | "image" | "audio" | "pdf"; // Defaults to "note" if undefined
pdfData?: {
@@ -471,18 +480,61 @@ export async function workspaceWorker(
changes.name = titleStr;
}
- // Update content if provided (allow empty string to clear content)
- if (params.content !== undefined) {
- const contentStr = typeof params.content === 'string' ? params.content : String(params.content);
+ // Update content: Cline convention (oldString + newString)
+ let contentOld = "";
+ let contentNew = "";
+ let diffOutput = "";
+ let filediffAdditions = 0;
+ let filediffDeletions = 0;
+
+ if (params.oldString !== undefined && params.newString !== undefined) {
+ const oldStr = typeof params.oldString === "string" ? params.oldString : String(params.oldString);
+ const newStr = typeof params.newString === "string" ? params.newString : String(params.newString);
+ const replaceAll = !!params.replaceAll;
+
+ if (oldStr === newStr) {
+ throw new Error("No changes to apply: oldString and newString are identical.");
+ }
+
+ if (oldStr === "") {
+ // Full rewrite: newString is entire note content
+ contentOld = "";
+ contentNew = newStr;
+ } else {
+ // Targeted edit: load note, find and replace
+ const currentState = await loadWorkspaceState(params.workspaceId);
+ const existingItem = currentState.items.find((i: Item) => i.id === params.itemId);
+ if (!existingItem) {
+ throw new Error(`Note not found with ID: ${params.itemId}`);
+ }
+ if (existingItem.type !== "note") {
+ throw new Error(`Item "${existingItem.name}" is not a note (type: ${existingItem.type})`);
+ }
+ contentOld = getNoteContentAsMarkdown(existingItem.data as NoteData);
+ contentNew = applyReplace(contentOld, oldStr, newStr, replaceAll);
+ }
logger.time("📝 [UPDATE-NOTE] markdownToBlocks conversion");
- const blockContent = await markdownToBlocks(contentStr);
+ const blockContent = await markdownToBlocks(contentNew);
logger.timeEnd("📝 [UPDATE-NOTE] markdownToBlocks conversion");
changes.data = {
- field1: contentStr,
- blockContent: blockContent,
+ field1: contentNew,
+ blockContent,
} as NoteData;
+
+ const patchName = params.itemName ?? "note";
+ diffOutput = trimDiff(
+ createPatch(
+ patchName,
+ normalizeLineEndings(contentOld),
+ normalizeLineEndings(contentNew)
+ )
+ );
+ for (const change of diffLines(contentOld, contentNew)) {
+ if (change.added) filediffAdditions += change.count || 0;
+ if (change.removed) filediffDeletions += change.count || 0;
+ }
}
// Update sources if provided
@@ -564,13 +616,26 @@ export async function workspaceWorker(
logger.group("✅ [UPDATE-NOTE] Update completed successfully", true);
logger.groupEnd();
- return {
+ const result: {
+ success: boolean;
+ itemId?: string;
+ message: string;
+ event?: WorkspaceEvent;
+ version?: number;
+ diff?: string;
+ filediff?: { additions: number; deletions: number };
+ } = {
success: true,
itemId: params.itemId,
message: `Updated note successfully`,
event,
version: appendResult.version,
};
+ if (diffOutput) {
+ result.diff = diffOutput;
+ result.filediff = { additions: filediffAdditions, deletions: filediffDeletions };
+ }
+ return result;
} catch (error: any) {
logger.group("❌ [UPDATE-NOTE] Error during update operation", false);
logger.error("Error type:", error?.constructor?.name || typeof error);
diff --git a/src/lib/utils/edit-replace.ts b/src/lib/utils/edit-replace.ts
new file mode 100644
index 00000000..ece52819
--- /dev/null
+++ b/src/lib/utils/edit-replace.ts
@@ -0,0 +1,478 @@
+/**
+ * Robust edit/replace utilities for note editing.
+ * Approaches sourced from Cline and Gemini CLI:
+ * - https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
+ * - https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
+ * - https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
+ */
+
+export function normalizeLineEndings(text: string): string {
+ return text.replaceAll("\r\n", "\n");
+}
+
+export type Replacer = (content: string, find: string) => Generator;
+
+// Similarity thresholds for block anchor fallback matching
+const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0;
+const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3;
+
+function levenshtein(a: string, b: string): number {
+ if (a === "" || b === "") {
+ return Math.max(a.length, b.length);
+ }
+ const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
+ Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
+ );
+
+ for (let i = 1; i <= a.length; i++) {
+ for (let j = 1; j <= b.length; j++) {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
+ }
+ }
+ return matrix[a.length][b.length];
+}
+
+export const SimpleReplacer: Replacer = function* (_content, find) {
+ yield find;
+};
+
+export const LineTrimmedReplacer: Replacer = function* (content, find) {
+ const originalLines = content.split("\n");
+ const searchLines = find.split("\n");
+
+ if (searchLines[searchLines.length - 1] === "") {
+ searchLines.pop();
+ }
+
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
+ let matches = true;
+
+ for (let j = 0; j < searchLines.length; j++) {
+ const originalTrimmed = originalLines[i + j].trim();
+ const searchTrimmed = searchLines[j].trim();
+
+ if (originalTrimmed !== searchTrimmed) {
+ matches = false;
+ break;
+ }
+ }
+
+ if (matches) {
+ let matchStartIndex = 0;
+ for (let k = 0; k < i; k++) {
+ matchStartIndex += originalLines[k].length + 1;
+ }
+
+ let matchEndIndex = matchStartIndex;
+ for (let k = 0; k < searchLines.length; k++) {
+ matchEndIndex += originalLines[i + k].length;
+ if (k < searchLines.length - 1) {
+ matchEndIndex += 1;
+ }
+ }
+
+ yield content.substring(matchStartIndex, matchEndIndex);
+ }
+ }
+};
+
+export const BlockAnchorReplacer: Replacer = function* (content, find) {
+ const originalLines = content.split("\n");
+ const searchLines = find.split("\n");
+
+ if (searchLines.length < 3) {
+ return;
+ }
+
+ if (searchLines[searchLines.length - 1] === "") {
+ searchLines.pop();
+ }
+
+ const firstLineSearch = searchLines[0].trim();
+ const lastLineSearch = searchLines[searchLines.length - 1].trim();
+ const searchBlockSize = searchLines.length;
+
+ const candidates: Array<{ startLine: number; endLine: number }> = [];
+ for (let i = 0; i < originalLines.length; i++) {
+ if (originalLines[i].trim() !== firstLineSearch) {
+ continue;
+ }
+
+ for (let j = i + 2; j < originalLines.length; j++) {
+ if (originalLines[j].trim() === lastLineSearch) {
+ candidates.push({ startLine: i, endLine: j });
+ break;
+ }
+ }
+ }
+
+ if (candidates.length === 0) {
+ return;
+ }
+
+ if (candidates.length === 1) {
+ const { startLine, endLine } = candidates[0];
+ const actualBlockSize = endLine - startLine + 1;
+
+ let similarity = 0;
+ const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
+
+ if (linesToCheck > 0) {
+ for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
+ const originalLine = originalLines[startLine + j].trim();
+ const searchLine = searchLines[j].trim();
+ const maxLen = Math.max(originalLine.length, searchLine.length);
+ if (maxLen === 0) {
+ continue;
+ }
+ const distance = levenshtein(originalLine, searchLine);
+ similarity += (1 - distance / maxLen) / linesToCheck;
+
+ if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
+ break;
+ }
+ }
+ } else {
+ similarity = 1.0;
+ }
+
+ if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
+ let matchStartIndex = 0;
+ for (let k = 0; k < startLine; k++) {
+ matchStartIndex += originalLines[k].length + 1;
+ }
+ let matchEndIndex = matchStartIndex;
+ for (let k = startLine; k <= endLine; k++) {
+ matchEndIndex += originalLines[k].length;
+ if (k < endLine) {
+ matchEndIndex += 1;
+ }
+ }
+ yield content.substring(matchStartIndex, matchEndIndex);
+ }
+ return;
+ }
+
+ let bestMatch: { startLine: number; endLine: number } | null = null;
+ let maxSimilarity = -1;
+
+ for (const candidate of candidates) {
+ const { startLine, endLine } = candidate;
+ const actualBlockSize = endLine - startLine + 1;
+
+ let similarity = 0;
+ const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
+
+ if (linesToCheck > 0) {
+ for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
+ const originalLine = originalLines[startLine + j].trim();
+ const searchLine = searchLines[j].trim();
+ const maxLen = Math.max(originalLine.length, searchLine.length);
+ if (maxLen === 0) {
+ continue;
+ }
+ const distance = levenshtein(originalLine, searchLine);
+ similarity += 1 - distance / maxLen;
+ }
+ similarity /= linesToCheck;
+ } else {
+ similarity = 1.0;
+ }
+
+ if (similarity > maxSimilarity) {
+ maxSimilarity = similarity;
+ bestMatch = candidate;
+ }
+ }
+
+ if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
+ const { startLine, endLine } = bestMatch;
+ let matchStartIndex = 0;
+ for (let k = 0; k < startLine; k++) {
+ matchStartIndex += originalLines[k].length + 1;
+ }
+ let matchEndIndex = matchStartIndex;
+ for (let k = startLine; k <= endLine; k++) {
+ matchEndIndex += originalLines[k].length;
+ if (k < endLine) {
+ matchEndIndex += 1;
+ }
+ }
+ yield content.substring(matchStartIndex, matchEndIndex);
+ }
+};
+
+export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
+ const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
+ const normalizedFind = normalizeWhitespace(find);
+
+ const lines = content.split("\n");
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (normalizeWhitespace(line) === normalizedFind) {
+ yield line;
+ } else {
+ const normalizedLine = normalizeWhitespace(line);
+ if (normalizedLine.includes(normalizedFind)) {
+ const words = find.trim().split(/\s+/);
+ if (words.length > 0) {
+ const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+");
+ try {
+ const regex = new RegExp(pattern);
+ const match = line.match(regex);
+ if (match) {
+ yield match[0];
+ }
+ } catch {
+ // Invalid regex pattern, skip
+ }
+ }
+ }
+ }
+ }
+
+ const findLines = find.split("\n");
+ if (findLines.length > 1) {
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
+ const block = lines.slice(i, i + findLines.length);
+ if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
+ yield block.join("\n");
+ }
+ }
+ }
+};
+
+export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
+ const removeIndentation = (text: string) => {
+ const lines = text.split("\n");
+ const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
+ if (nonEmptyLines.length === 0) return text;
+
+ const minIndent = Math.min(
+ ...nonEmptyLines.map((line) => {
+ const match = line.match(/^(\s*)/);
+ return match ? match[1].length : 0;
+ })
+ );
+
+ return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n");
+ };
+
+ const normalizedFind = removeIndentation(find);
+ const contentLines = content.split("\n");
+ const findLines = find.split("\n");
+
+ for (let i = 0; i <= contentLines.length - findLines.length; i++) {
+ const block = contentLines.slice(i, i + findLines.length).join("\n");
+ if (removeIndentation(block) === normalizedFind) {
+ yield block;
+ }
+ }
+};
+
+export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
+ const unescapeString = (str: string): string => {
+ return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar: string) => {
+ switch (capturedChar) {
+ case "n":
+ return "\n";
+ case "t":
+ return "\t";
+ case "r":
+ return "\r";
+ case "'":
+ return "'";
+ case '"':
+ return '"';
+ case "`":
+ return "`";
+ case "\\":
+ return "\\";
+ case "\n":
+ return "\n";
+ case "$":
+ return "$";
+ default:
+ return match;
+ }
+ });
+ };
+
+ const unescapedFind = unescapeString(find);
+
+ if (content.includes(unescapedFind)) {
+ yield unescapedFind;
+ }
+
+ const lines = content.split("\n");
+ const findLines = unescapedFind.split("\n");
+
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
+ const block = lines.slice(i, i + findLines.length).join("\n");
+ const unescapedBlock = unescapeString(block);
+
+ if (unescapedBlock === unescapedFind) {
+ yield block;
+ }
+ }
+};
+
+export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
+ let startIndex = 0;
+
+ while (true) {
+ const index = content.indexOf(find, startIndex);
+ if (index === -1) break;
+
+ yield find;
+ startIndex = index + find.length;
+ }
+};
+
+export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
+ const trimmedFind = find.trim();
+
+ if (trimmedFind === find) {
+ return;
+ }
+
+ if (content.includes(trimmedFind)) {
+ yield trimmedFind;
+ }
+
+ const lines = content.split("\n");
+ const findLines = find.split("\n");
+
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
+ const block = lines.slice(i, i + findLines.length).join("\n");
+
+ if (block.trim() === trimmedFind) {
+ yield block;
+ }
+ }
+};
+
+export const ContextAwareReplacer: Replacer = function* (content, find) {
+ const findLines = find.split("\n");
+ if (findLines.length < 3) {
+ return;
+ }
+
+ if (findLines[findLines.length - 1] === "") {
+ findLines.pop();
+ }
+
+ const contentLines = content.split("\n");
+ const firstLine = findLines[0].trim();
+ const lastLine = findLines[findLines.length - 1].trim();
+
+ for (let i = 0; i < contentLines.length; i++) {
+ if (contentLines[i].trim() !== firstLine) continue;
+
+ for (let j = i + 2; j < contentLines.length; j++) {
+ if (contentLines[j].trim() === lastLine) {
+ const blockLines = contentLines.slice(i, j + 1);
+ const block = blockLines.join("\n");
+
+ if (blockLines.length === findLines.length) {
+ let matchingLines = 0;
+ let totalNonEmptyLines = 0;
+
+ for (let k = 1; k < blockLines.length - 1; k++) {
+ const blockLine = blockLines[k].trim();
+ const findLine = findLines[k].trim();
+
+ if (blockLine.length > 0 || findLine.length > 0) {
+ totalNonEmptyLines++;
+ if (blockLine === findLine) {
+ matchingLines++;
+ }
+ }
+ }
+
+ if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
+ yield block;
+ break;
+ }
+ }
+ break;
+ }
+ }
+ }
+};
+
+export function trimDiff(diff: string): string {
+ const lines = diff.split("\n");
+ const contentLines = lines.filter(
+ (line) =>
+ (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
+ !line.startsWith("---") &&
+ !line.startsWith("+++")
+ );
+
+ if (contentLines.length === 0) return diff;
+
+ let min = Infinity;
+ for (const line of contentLines) {
+ const content = line.slice(1);
+ if (content.trim().length > 0) {
+ const match = content.match(/^(\s*)/);
+ if (match) min = Math.min(min, match[1].length);
+ }
+ }
+ if (min === Infinity || min === 0) return diff;
+ const trimmedLines = lines.map((line) => {
+ if (
+ (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
+ !line.startsWith("---") &&
+ !line.startsWith("+++")
+ ) {
+ const prefix = line[0];
+ const content = line.slice(1);
+ return prefix + content.slice(min);
+ }
+ return line;
+ });
+
+ return trimmedLines.join("\n");
+}
+
+export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
+ if (oldString === newString) {
+ throw new Error("No changes to apply: oldString and newString are identical.");
+ }
+
+ let notFound = true;
+
+ for (const replacer of [
+ SimpleReplacer,
+ LineTrimmedReplacer,
+ BlockAnchorReplacer,
+ WhitespaceNormalizedReplacer,
+ IndentationFlexibleReplacer,
+ EscapeNormalizedReplacer,
+ TrimmedBoundaryReplacer,
+ ContextAwareReplacer,
+ MultiOccurrenceReplacer,
+ ]) {
+ for (const search of replacer(content, oldString)) {
+ const index = content.indexOf(search);
+ if (index === -1) continue;
+ notFound = false;
+ if (replaceAll) {
+ return content.replaceAll(search, newString);
+ }
+ const lastIndex = content.lastIndexOf(search);
+ if (index !== lastIndex) continue;
+ return content.substring(0, index) + newString + content.substring(index + search.length);
+ }
+ }
+
+ if (notFound) {
+ throw new Error(
+ "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings."
+ );
+ }
+ throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.");
+}
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index 3bb4e874..022c0a10 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -124,6 +124,11 @@ Rules:
- Never make up or hallucinate URLs
- Include article dates in responses when available
+NOTE EDITING (updateNote, Cline convention):
+- Full rewrite: oldString="", newString=entire note content.
+- Targeted edit: readWorkspace first, then oldString=exact text to find, newString=replacement. Include enough context in oldString to make it unique.
+- oldString must match exactly including whitespace, or use enough surrounding lines for uniqueness.
+
INLINE CITATIONS (optional):
Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source.
@@ -555,26 +560,26 @@ export function formatItemContent(item: Item): string {
}
/**
- * Formats note details with FULL content (no truncation)
+ * Extracts the note content as markdown. Uses same source as readWorkspace
+ * (blockContent serialized, or field1 fallback) so edits match what the AI sees.
*/
-function formatNoteDetailsFull(data: NoteData): string[] {
- const lines: string[] = [];
-
- // OPTIMIZED: Prioritize blockContent for rich markdown serialization
+export function getNoteContentAsMarkdown(data: NoteData): string {
if (data.blockContent) {
- // Use the markdown serializer to preserve structure and formatting
const content = serializeBlockNote(data.blockContent as Block[]);
- if (content) {
- lines.push(` - Content:\n${content}`);
- return lines; // Return early if successful
- }
+ if (content) return content;
}
+ return data.field1 ?? "";
+}
- // Fallback to field1 (plain text) if blockContent is missing or empty
- if (data.field1) {
- lines.push(` - Content: ${data.field1}`);
+/**
+ * Formats note details with FULL content (no truncation)
+ */
+function formatNoteDetailsFull(data: NoteData): string[] {
+ const lines: string[] = [];
+ const content = getNoteContentAsMarkdown(data);
+ if (content) {
+ lines.push(` - Content:\n${content}`);
}
-
return lines;
}
From dfd06a41dbd0b0f5d0199353faf9018abc23051e Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 10:58:51 -0500
Subject: [PATCH 12/56] feat: enforce read before write
---
drizzle/0003_elite_harrier.sql | 17 +
drizzle/meta/0003_snapshot.json | 1918 +++++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/app/api/chat/route.ts | 3 +
src/lib/ai/tools/index.ts | 2 +
src/lib/ai/tools/read-workspace.ts | 29 +-
src/lib/ai/tools/workspace-tools.ts | 27 +-
src/lib/db/relations.ts | 9 +
src/lib/db/schema.ts | 34 +
src/lib/db/workspace-item-reads.ts | 79 +
src/lib/utils/format-workspace-context.ts | 9 +-
src/lib/utils/thread-id.ts | 16 +
12 files changed, 2144 insertions(+), 6 deletions(-)
create mode 100644 drizzle/0003_elite_harrier.sql
create mode 100644 drizzle/meta/0003_snapshot.json
create mode 100644 src/lib/db/workspace-item-reads.ts
create mode 100644 src/lib/utils/thread-id.ts
diff --git a/drizzle/0003_elite_harrier.sql b/drizzle/0003_elite_harrier.sql
new file mode 100644
index 00000000..36963ed3
--- /dev/null
+++ b/drizzle/0003_elite_harrier.sql
@@ -0,0 +1,17 @@
+CREATE TABLE "workspace_item_reads" (
+ "thread_id" uuid NOT NULL,
+ "item_id" text NOT NULL,
+ "last_modified" bigint NOT NULL,
+ "read_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "workspace_item_reads_thread_item_key" UNIQUE("thread_id","item_id")
+);
+--> statement-breakpoint
+ALTER TABLE "workspace_item_reads" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "workspace_item_reads" ADD CONSTRAINT "workspace_item_reads_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "idx_workspace_item_reads_thread_item" ON "workspace_item_reads" USING btree ("thread_id" uuid_ops,"item_id" text_ops);--> statement-breakpoint
+CREATE POLICY "Users can manage reads for threads in their workspaces" ON "workspace_item_reads" AS PERMISSIVE FOR ALL TO "authenticated" USING ((EXISTS ( SELECT 1 FROM chat_threads ct
+ JOIN workspaces w ON w.id = ct.workspace_id
+ WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))
+ OR (EXISTS ( SELECT 1 FROM chat_threads ct
+ JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id
+ WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));
\ No newline at end of file
diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json
new file mode 100644
index 00000000..f74c0463
--- /dev/null
+++ b/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,1918 @@
+{
+ "id": "4ff977d6-4f70-49fe-a07e-c39bf93e134e",
+ "prevId": "208b0e93-f789-49c6-abc8-e9328aab88b5",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_account_user_id": {
+ "name": "idx_account_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_messages": {
+ "name": "chat_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_chat_messages_thread": {
+ "name": "idx_chat_messages_thread",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_messages_thread_created": {
+ "name": "idx_chat_messages_thread_created",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_messages_thread_id_chat_threads_id_fk": {
+ "name": "chat_messages_thread_id_chat_threads_id_fk",
+ "tableFrom": "chat_messages",
+ "tableTo": "chat_threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_threads": {
+ "name": "chat_threads",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_chat_threads_workspace": {
+ "name": "idx_chat_threads_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_threads_user": {
+ "name": "idx_chat_threads_user",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_threads_last_message": {
+ "name": "idx_chat_threads_last_message",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "last_message_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_threads_workspace_id_workspaces_id_fk": {
+ "name": "chat_threads_workspace_id_workspaces_id_fk",
+ "tableFrom": "chat_threads",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_threads_user_id_user_id_fk": {
+ "name": "chat_threads_user_id_user_id_fk",
+ "tableFrom": "chat_threads",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "Users can manage threads in their workspaces": {
+ "name": "Users can manage threads in their workspaces",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_session_user_id": {
+ "name": "idx_session_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_session_token": {
+ "name": "idx_session_token",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_anonymous": {
+ "name": "is_anonymous",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_user_email": {
+ "name": "idx_user_email",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_profiles": {
+ "name": "user_profiles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "onboarding_completed": {
+ "name": "onboarding_completed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_user_profiles_user_id": {
+ "name": "idx_user_profiles_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_profiles_user_id_key": {
+ "name": "user_profiles_user_id_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {
+ "Users can insert their own profile": {
+ "name": "Users can insert their own profile",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "authenticated"
+ ],
+ "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)"
+ },
+ "Users can update their own profile": {
+ "name": "Users can update their own profile",
+ "as": "PERMISSIVE",
+ "for": "UPDATE",
+ "to": [
+ "authenticated"
+ ]
+ },
+ "Users can view their own profile": {
+ "name": "Users can view their own profile",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "authenticated"
+ ]
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_verification_identifier": {
+ "name": "idx_verification_identifier",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_collaborators": {
+ "name": "workspace_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_level": {
+ "name": "permission_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'editor'"
+ },
+ "invite_token": {
+ "name": "invite_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_workspace_collaborators_lookup": {
+ "name": "idx_workspace_collaborators_lookup",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_collaborators_workspace": {
+ "name": "idx_workspace_collaborators_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_collaborators_last_opened_at": {
+ "name": "idx_workspace_collaborators_last_opened_at",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "last_opened_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_collaborators_workspace_id_fkey": {
+ "name": "workspace_collaborators_workspace_id_fkey",
+ "tableFrom": "workspace_collaborators",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_collaborators_invite_token_unique": {
+ "name": "workspace_collaborators_invite_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "invite_token"
+ ]
+ },
+ "workspace_collaborators_workspace_user_unique": {
+ "name": "workspace_collaborators_workspace_user_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "workspace_id",
+ "user_id"
+ ]
+ }
+ },
+ "policies": {
+ "Owners can manage collaborators": {
+ "name": "Owners can manage collaborators",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))"
+ },
+ "Collaborators can view their access": {
+ "name": "Collaborators can view their access",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(user_id = (auth.jwt() ->> 'sub'::text))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_events": {
+ "name": "workspace_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "user_name": {
+ "name": "user_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_workspace_events_event_id": {
+ "name": "idx_workspace_events_event_id",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_events_timestamp": {
+ "name": "idx_workspace_events_timestamp",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "timestamp",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "int8_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_events_user_name": {
+ "name": "idx_workspace_events_user_name",
+ "columns": [
+ {
+ "expression": "user_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_events_workspace": {
+ "name": "idx_workspace_events_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "int4_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_events_workspace_id_fkey": {
+ "name": "workspace_events_workspace_id_fkey",
+ "tableFrom": "workspace_events",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_events_event_id_key": {
+ "name": "workspace_events_event_id_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "event_id"
+ ]
+ }
+ },
+ "policies": {
+ "Users can insert workspace events they have write access to": {
+ "name": "Users can insert workspace events they have write access to",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "public"
+ ],
+ "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))"
+ },
+ "Users can read workspace events they have access to": {
+ "name": "Users can read workspace events they have access to",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ],
+ "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_invites": {
+ "name": "workspace_invites",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_level": {
+ "name": "permission_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'editor'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_workspace_invites_token": {
+ "name": "idx_workspace_invites_token",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_invites_email": {
+ "name": "idx_workspace_invites_email",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_invites_workspace": {
+ "name": "idx_workspace_invites_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_invites_workspace_id_fkey": {
+ "name": "workspace_invites_workspace_id_fkey",
+ "tableFrom": "workspace_invites",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_invites_token_key": {
+ "name": "workspace_invites_token_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {
+ "Public can view invite by token": {
+ "name": "Public can view invite by token",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ],
+ "using": "true"
+ },
+ "Users can insert invites for workspaces they own/edit": {
+ "name": "Users can insert invites for workspaces they own/edit",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "authenticated"
+ ],
+ "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_item_reads": {
+ "name": "workspace_item_reads",
+ "schema": "",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_modified": {
+ "name": "last_modified",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_workspace_item_reads_thread_item": {
+ "name": "idx_workspace_item_reads_thread_item",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_item_reads_thread_id_chat_threads_id_fk": {
+ "name": "workspace_item_reads_thread_id_chat_threads_id_fk",
+ "tableFrom": "workspace_item_reads",
+ "tableTo": "chat_threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_item_reads_thread_item_key": {
+ "name": "workspace_item_reads_thread_item_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "thread_id",
+ "item_id"
+ ]
+ }
+ },
+ "policies": {
+ "Users can manage reads for threads in their workspaces": {
+ "name": "Users can manage reads for threads in their workspaces",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(EXISTS ( SELECT 1 FROM chat_threads ct\n JOIN workspaces w ON w.id = ct.workspace_id\n WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM chat_threads ct\n JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id\n WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_share_links": {
+ "name": "workspace_share_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_level": {
+ "name": "permission_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'editor'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_workspace_share_links_token": {
+ "name": "idx_workspace_share_links_token",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_share_links_workspace": {
+ "name": "idx_workspace_share_links_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_share_links_workspace_id_fkey": {
+ "name": "workspace_share_links_workspace_id_fkey",
+ "tableFrom": "workspace_share_links",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_share_links_token_key": {
+ "name": "workspace_share_links_token_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ },
+ "workspace_share_links_workspace_key": {
+ "name": "workspace_share_links_workspace_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "workspace_id"
+ ]
+ }
+ },
+ "policies": {
+ "Public can view share link by token": {
+ "name": "Public can view share link by token",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ],
+ "using": "true"
+ },
+ "Owners and editors can manage share links": {
+ "name": "Owners and editors can manage share links",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_snapshots": {
+ "name": "workspace_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot_version": {
+ "name": "snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_count": {
+ "name": "event_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_workspace_snapshots_version": {
+ "name": "idx_workspace_snapshots_version",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ },
+ {
+ "expression": "snapshot_version",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "int4_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspace_snapshots_workspace": {
+ "name": "idx_workspace_snapshots_workspace",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "uuid_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_snapshots_workspace_id_fkey": {
+ "name": "workspace_snapshots_workspace_id_fkey",
+ "tableFrom": "workspace_snapshots",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_snapshots_workspace_id_snapshot_version_key": {
+ "name": "workspace_snapshots_workspace_id_snapshot_version_key",
+ "nullsNotDistinct": false,
+ "columns": [
+ "workspace_id",
+ "snapshot_version"
+ ]
+ }
+ },
+ "policies": {
+ "Service role can insert workspace snapshots": {
+ "name": "Service role can insert workspace snapshots",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "public"
+ ],
+ "withCheck": "true"
+ },
+ "Users can read workspace snapshots they have access to": {
+ "name": "Users can read workspace snapshots they have access to",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "public"
+ ]
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspaces": {
+ "name": "workspaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'blank'"
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_workspaces_created_at": {
+ "name": "idx_workspaces_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_slug": {
+ "name": "idx_workspaces_slug",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_user_id": {
+ "name": "idx_workspaces_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_user_slug": {
+ "name": "idx_workspaces_user_slug",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_user_sort_order": {
+ "name": "idx_workspaces_user_sort_order",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "text_ops"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "int4_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_workspaces_last_opened_at": {
+ "name": "idx_workspaces_last_opened_at",
+ "columns": [
+ {
+ "expression": "last_opened_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "first",
+ "opclass": "timestamptz_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "Users can delete their own workspaces": {
+ "name": "Users can delete their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "DELETE",
+ "to": [
+ "authenticated"
+ ],
+ "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)"
+ },
+ "Users can insert their own workspaces": {
+ "name": "Users can insert their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "INSERT",
+ "to": [
+ "authenticated"
+ ]
+ },
+ "Users can update their own workspaces": {
+ "name": "Users can update their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "UPDATE",
+ "to": [
+ "authenticated"
+ ]
+ },
+ "Users can view their own workspaces": {
+ "name": "Users can view their own workspaces",
+ "as": "PERMISSIVE",
+ "for": "SELECT",
+ "to": [
+ "authenticated"
+ ]
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 222c9751..e0009c66 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1771631691687,
"tag": "0002_deep_shadowcat",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1771686859689,
+ "tag": "0003_elite_harrier",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 9392bdc3..7d920fb3 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -190,6 +190,8 @@ export async function POST(req: Request) {
const system = body.system || "";
workspaceId = extractWorkspaceId(body);
activeFolderId = body.activeFolderId;
+ // AssistantChatTransport passes thread remoteId as body.id (see assistant-ui react-ai-sdk)
+ const threadId = body.id ?? body.threadId ?? null;
// Convert messages
let convertedMessages;
@@ -261,6 +263,7 @@ export async function POST(req: Request) {
workspaceId,
userId,
activeFolderId,
+ threadId,
clientTools: body.tools,
enableDeepResearch: false,
});
diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts
index 2f180aa9..e0a79b4e 100644
--- a/src/lib/ai/tools/index.ts
+++ b/src/lib/ai/tools/index.ts
@@ -29,6 +29,7 @@ export interface ChatToolsConfig {
workspaceId: string | null;
userId: string | null;
activeFolderId?: string;
+ threadId?: string | null;
clientTools?: Record;
enableDeepResearch?: boolean;
}
@@ -41,6 +42,7 @@ export function createChatTools(config: ChatToolsConfig): Record {
workspaceId: config.workspaceId,
userId: config.userId,
activeFolderId: config.activeFolderId,
+ threadId: config.threadId ?? null,
};
// Safeguard frontendTools
diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts
index d2eb4d8f..68546642 100644
--- a/src/lib/ai/tools/read-workspace.ts
+++ b/src/lib/ai/tools/read-workspace.ts
@@ -5,12 +5,15 @@ import { fuzzyMatchItem } from "./tool-utils";
import { resolveItemByPath } from "./workspace-search-utils";
import { formatItemContent } from "@/lib/utils/format-workspace-context";
import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs";
+import { recordWorkspaceItemRead } from "@/lib/db/workspace-item-reads";
+import { logger } from "@/lib/utils/logger";
+import { isValidThreadIdForDb } from "@/lib/utils/thread-id";
import type { WorkspaceToolContext } from "./workspace-tools";
export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
return tool({
description:
- "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Use path when items share the same name. Use when you need the complete content of a specific card to answer questions or perform updates.",
+ "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. REQUIRED before targeted updateNote edits — the edit tool will error otherwise. Use path when items share the same name. When editing notes, use exact text from the Content section only (not the wrapper).",
inputSchema: zodSchema(
z.object({
path: z
@@ -84,6 +87,30 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
const content = formatItemContent(item);
const vpath = getVirtualPath(item, items);
+ // Record read for read-before-write enforcement (targeted edits)
+ // Skip when threadId is a placeholder (e.g. DEFAULT_THREAD_ID before thread is created)
+ logger.debug("[readWorkspace] Recording read:", {
+ threadId: ctx.threadId,
+ threadIdType: typeof ctx.threadId,
+ isValidForDb: isValidThreadIdForDb(ctx.threadId),
+ itemId: item.id,
+ lastModified: item.lastModified ?? 0,
+ });
+ if (isValidThreadIdForDb(ctx.threadId)) {
+ try {
+ await recordWorkspaceItemRead(
+ ctx.threadId,
+ item.id,
+ item.lastModified ?? 0
+ );
+ logger.debug("[readWorkspace] Recorded read successfully");
+ } catch (err) {
+ logger.warn("[readWorkspace] Failed to record read:", err);
+ }
+ } else {
+ logger.debug("[readWorkspace] Skipping record (invalid threadId for DB)");
+ }
+
return {
success: true,
itemName: item.name,
diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts
index 0de4f982..9960c3f8 100644
--- a/src/lib/ai/tools/workspace-tools.ts
+++ b/src/lib/ai/tools/workspace-tools.ts
@@ -5,11 +5,14 @@ import { workspaceWorker } from "@/lib/ai/workers";
import { loadWorkspaceState } from "@/lib/workspace/state-loader";
import type { Item } from "@/lib/workspace-state/types";
import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils";
+import { assertWorkspaceItemRead } from "@/lib/db/workspace-item-reads";
+import { isValidThreadIdForDb } from "@/lib/utils/thread-id";
export interface WorkspaceToolContext {
workspaceId: string | null;
userId: string | null;
activeFolderId?: string;
+ threadId?: string | null;
}
/**
@@ -73,7 +76,7 @@ export function createNoteTool(ctx: WorkspaceToolContext) {
export function createUpdateNoteTool(ctx: WorkspaceToolContext) {
return tool({
description:
- "Update a note. Full rewrite: oldString='', newString=entire note content. Targeted edit: oldString=exact text to find (from readWorkspace), newString=replacement. Include enough context in oldString to make it unique.",
+ "Update a note. You MUST use readWorkspace at least once before targeted edits — the tool will error otherwise. Full rewrite: oldString='', newString=entire note. Targeted edit: readWorkspace first, then oldString=exact text from the Content section (never include wrapper), newString=replacement. Preserve exact whitespace/indentation. Fails if oldString not found or matches multiple times — include more context or use replaceAll.",
inputSchema: zodSchema(
z
.object({
@@ -81,12 +84,12 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) {
oldString: z
.string()
.describe(
- "Text to find. Use empty string '' for full rewrite; otherwise exact text from readWorkspace for targeted edit."
+ "Text to find. Use '' for full rewrite. For targeted edit: exact text from readWorkspace Content section — match exactly including whitespace. Include enough context to make it unique, or use replaceAll to change every instance."
),
newString: z
.string()
.describe("Replacement text (entire note if oldString is empty)"),
- replaceAll: z.boolean().optional().default(false),
+ replaceAll: z.boolean().optional().default(false).describe("Replace every occurrence of oldString; use for renaming or changing repeated text."),
title: z.string().optional().describe("New title for the note. If not provided, the existing title will be preserved."),
sources: z
.array(
@@ -169,6 +172,24 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) {
matchedId: matchedNote.id,
});
+ // Read-before-write: for targeted edits, assert item was read
+ // Skip assert when threadId is a placeholder (e.g. DEFAULT_THREAD_ID before thread exists)
+ const isTargetedEdit = oldString.trim().length > 0;
+ if (isTargetedEdit && isValidThreadIdForDb(ctx.threadId)) {
+ const currentLastModified = matchedNote.lastModified ?? 0;
+ const assert = await assertWorkspaceItemRead(
+ ctx.threadId,
+ matchedNote.id,
+ currentLastModified
+ );
+ if (!assert.ok) {
+ return {
+ success: false,
+ message: assert.message,
+ };
+ }
+ }
+
const workerResult = await workspaceWorker("update", {
workspaceId: ctx.workspaceId,
itemId: matchedNote.id,
diff --git a/src/lib/db/relations.ts b/src/lib/db/relations.ts
index b442377d..1bf2a106 100644
--- a/src/lib/db/relations.ts
+++ b/src/lib/db/relations.ts
@@ -5,6 +5,7 @@ import {
workspaceEvents,
chatThreads,
chatMessages,
+ workspaceItemReads,
} from "./schema";
// workspace_shares removed - sharing is now fork-based (users import copies)
@@ -34,6 +35,14 @@ export const chatThreadsRelations = relations(chatThreads, ({ one, many }) => ({
references: [workspaces.id],
}),
messages: many(chatMessages),
+ workspaceItemReads: many(workspaceItemReads),
+}));
+
+export const workspaceItemReadsRelations = relations(workspaceItemReads, ({ one }) => ({
+ thread: one(chatThreads, {
+ fields: [workspaceItemReads.threadId],
+ references: [chatThreads.id],
+ }),
}));
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 32b96800..96c15e7b 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -335,3 +335,37 @@ export const chatMessages = pgTable("chat_messages", {
table.createdAt.asc().nullsLast().op("timestamptz_ops")
),
]);
+
+// Read-before-write tracking for workspace items (FileTime pattern)
+export const workspaceItemReads = pgTable(
+ "workspace_item_reads",
+ {
+ threadId: uuid("thread_id")
+ .notNull()
+ .references(() => chatThreads.id, { onDelete: "cascade" }),
+ itemId: text("item_id").notNull(),
+ lastModified: bigint("last_modified", { mode: "number" }).notNull(),
+ readAt: timestamp("read_at", { withTimezone: true, mode: "string" })
+ .defaultNow()
+ .notNull(),
+ },
+ (table) => [
+ unique("workspace_item_reads_thread_item_key").on(table.threadId, table.itemId),
+ index("idx_workspace_item_reads_thread_item").using(
+ "btree",
+ table.threadId.asc().nullsLast().op("uuid_ops"),
+ table.itemId.asc().nullsLast().op("text_ops")
+ ),
+ pgPolicy("Users can manage reads for threads in their workspaces", {
+ as: "permissive",
+ for: "all",
+ to: ["authenticated"],
+ using: sql`(EXISTS ( SELECT 1 FROM chat_threads ct
+ JOIN workspaces w ON w.id = ct.workspace_id
+ WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))
+ OR (EXISTS ( SELECT 1 FROM chat_threads ct
+ JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id
+ WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))`,
+ }),
+ ]
+);
diff --git a/src/lib/db/workspace-item-reads.ts b/src/lib/db/workspace-item-reads.ts
new file mode 100644
index 00000000..2711f199
--- /dev/null
+++ b/src/lib/db/workspace-item-reads.ts
@@ -0,0 +1,79 @@
+/**
+ * Read-before-write tracking for workspace items (FileTime pattern).
+ * Records when a thread reads an item; asserts before targeted edits.
+ */
+
+import { db, workspaceItemReads } from "@/lib/db/client";
+import { eq, and } from "drizzle-orm";
+import { logger } from "@/lib/utils/logger";
+
+/**
+ * Record that a thread has read an item at the given lastModified.
+ * Upserts: if thread+item exists, updates lastModified and readAt.
+ */
+export async function recordWorkspaceItemRead(
+ threadId: string,
+ itemId: string,
+ lastModified: number
+): Promise {
+ logger.debug("[workspace_item_reads] Writing to DB:", {
+ threadId,
+ itemId,
+ lastModified,
+ op: "upsert",
+ });
+ await db
+ .insert(workspaceItemReads)
+ .values({
+ threadId,
+ itemId,
+ lastModified,
+ })
+ .onConflictDoUpdate({
+ target: [workspaceItemReads.threadId, workspaceItemReads.itemId],
+ set: {
+ lastModified,
+ readAt: new Date().toISOString(),
+ },
+ });
+}
+
+/**
+ * Assert that the thread has read the item and the lastModified matches.
+ * Used before targeted edits (oldString !== '').
+ * @returns true if assertion passes
+ * @throws never - returns an error object on failure
+ */
+export async function assertWorkspaceItemRead(
+ threadId: string,
+ itemId: string,
+ currentLastModified: number
+): Promise<{ ok: true } | { ok: false; message: string }> {
+ const [row] = await db
+ .select()
+ .from(workspaceItemReads)
+ .where(
+ and(
+ eq(workspaceItemReads.threadId, threadId),
+ eq(workspaceItemReads.itemId, itemId)
+ )
+ )
+ .limit(1);
+
+ if (!row) {
+ return {
+ ok: false,
+ message:
+ "Read required before targeted edit. Use readWorkspace to read the note first, then retry updateNote with exact text from the content.",
+ };
+ }
+
+ if (row.lastModified !== currentLastModified) {
+ return {
+ ok: false,
+ message: `Note was modified since last read. Please use readWorkspace to get the latest content, then retry the edit.`,
+ };
+ }
+
+ return { ok: true };
+}
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index 022c0a10..beea00e2 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -125,9 +125,14 @@ Rules:
- Include article dates in responses when available
NOTE EDITING (updateNote, Cline convention):
+- You MUST use readWorkspace at least once before a targeted edit. The tool will error if you edit without reading.
- Full rewrite: oldString="", newString=entire note content.
-- Targeted edit: readWorkspace first, then oldString=exact text to find, newString=replacement. Include enough context in oldString to make it unique.
-- oldString must match exactly including whitespace, or use enough surrounding lines for uniqueness.
+- Targeted edit: readWorkspace first, then oldString=exact text to find (from the Content section only), newString=replacement. Extract oldString from the note body — never include the wrapper or " - Content:" prefix.
+- When editing from readWorkspace output, preserve exact indentation and whitespace. Match the text as it appears in the Content section.
+- The edit will FAIL if oldString is not found with "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings."
+- The edit will FAIL if oldString matches multiple times with "Found multiple matches for oldString. Provide more surrounding context to make the match unique." Use more surrounding lines in oldString or use replaceAll to change every instance.
+- Use replaceAll for replacing/renaming across the entire note (e.g., rename a term).
+- Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked.
INLINE CITATIONS (optional):
Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source.
diff --git a/src/lib/utils/thread-id.ts b/src/lib/utils/thread-id.ts
new file mode 100644
index 00000000..de64fa10
--- /dev/null
+++ b/src/lib/utils/thread-id.ts
@@ -0,0 +1,16 @@
+/**
+ * Thread ID validation for read-before-write enforcement.
+ *
+ * assistant-ui uses "DEFAULT_THREAD_ID" (from ExternalStoreThreadListRuntimeCore)
+ * as a placeholder before a thread is persisted. The real UUID is set when:
+ * - User sends first message -> adapter.initialize() POSTs to /api/threads -> returns remoteId
+ * - AssistantChatTransport uses: id = (await mainItem.initialize())?.remoteId ?? options.id
+ *
+ * Until then, body.id can be "DEFAULT_THREAD_ID", which is not a valid UUID and will fail
+ * DB inserts (thread_id references chat_threads.id). We skip recording/assert when invalid.
+ */
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function isValidThreadIdForDb(threadId: string | null | undefined): threadId is string {
+ return !!threadId && UUID_REGEX.test(threadId);
+}
From ebf991386c6126ada3cf78c5d3cb27988e49a086 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 11:01:58 -0500
Subject: [PATCH 13/56] fix: pdf highlight color
---
src/components/pdf/AppPdfViewer.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx
index 68c568dd..325e6a5f 100644
--- a/src/components/pdf/AppPdfViewer.tsx
+++ b/src/components/pdf/AppPdfViewer.tsx
@@ -970,6 +970,7 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName,
(
)}
From 494302384881d69711abb9146cfcb54161472128 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 12:09:26 -0500
Subject: [PATCH 14/56] feat: durable recordings
---
.gitignore | 1 +
next.config.ts | 3 +-
package.json | 1 +
src/app/api/audio/process/route.ts | 171 ++++--------------
src/app/api/audio/process/status/route.ts | 69 +++++++
.../workspace-canvas/AudioCardContent.tsx | 26 +--
.../WorkspaceCanvasDropzone.tsx | 19 +-
.../workspace-canvas/WorkspaceHeader.tsx | 24 +--
.../workspace-canvas/WorkspaceSection.tsx | 21 +--
src/lib/audio/poll-audio-processing.ts | 40 ++++
src/workflows/audio-transcribe/index.ts | 21 +++
.../steps/download-and-upload.ts | 52 ++++++
src/workflows/audio-transcribe/steps/index.ts | 2 +
.../audio-transcribe/steps/transcribe.ts | 96 ++++++++++
14 files changed, 360 insertions(+), 186 deletions(-)
create mode 100644 src/app/api/audio/process/status/route.ts
create mode 100644 src/lib/audio/poll-audio-processing.ts
create mode 100644 src/workflows/audio-transcribe/index.ts
create mode 100644 src/workflows/audio-transcribe/steps/download-and-upload.ts
create mode 100644 src/workflows/audio-transcribe/steps/index.ts
create mode 100644 src/workflows/audio-transcribe/steps/transcribe.ts
diff --git a/.gitignore b/.gitignore
index 8a8d6139..81dc4e73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@
# misc
.DS_Store
+tmp/
assistant-ui-main/
*.pem
diff --git a/next.config.ts b/next.config.ts
index 938792cc..f8861d2d 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
+import { withWorkflow } from "workflow/next";
const nextConfig: NextConfig = {
reactCompiler: true,
@@ -37,4 +38,4 @@ const nextConfig: NextConfig = {
},
};
-export default nextConfig;
+export default withWorkflow(nextConfig);
diff --git a/package.json b/package.json
index 7cf2549e..0549d1ea 100644
--- a/package.json
+++ b/package.json
@@ -144,6 +144,7 @@
"streamdown": "^2.2.0",
"tailwind-merge": "^3.4.0",
"tw-shimmer": "^0.4.6",
+ "workflow": "4.1.0-beta.60",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
diff --git a/src/app/api/audio/process/route.ts b/src/app/api/audio/process/route.ts
index d1cfbf48..d1a111c3 100644
--- a/src/app/api/audio/process/route.ts
+++ b/src/app/api/audio/process/route.ts
@@ -1,25 +1,18 @@
-import {
- GoogleGenAI,
- Type,
- createPartFromUri,
- createUserContent,
-} from "@google/genai";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
+import { start } from "workflow/api";
+import { audioTranscribeWorkflow } from "@/workflows/audio-transcribe";
export const dynamic = "force-dynamic";
-const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
-
/**
* POST /api/audio/process
- * Receives an audio file URL, downloads it, uploads to Gemini Files API,
- * and returns a structured transcript + summary.
+ * Receives an audio file URL, runs a durable workflow to download, upload to Gemini,
+ * and transcribe. Returns structured transcript + summary.
*/
export async function POST(req: NextRequest) {
try {
- // Auth check
const session = await auth.api.getSession({
headers: await headers(),
});
@@ -27,7 +20,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- if (!apiKey) {
+ if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
return NextResponse.json(
{ error: "GOOGLE_GENERATIVE_AI_API_KEY is not set" },
{ status: 500 }
@@ -35,7 +28,7 @@ export async function POST(req: NextRequest) {
}
const body = await req.json();
- const { fileUrl, filename, mimeType } = body;
+ const { fileUrl, filename, mimeType, itemId } = body;
if (!fileUrl) {
return NextResponse.json(
@@ -44,25 +37,35 @@ export async function POST(req: NextRequest) {
);
}
+ if (!itemId || typeof itemId !== "string") {
+ return NextResponse.json(
+ { error: "itemId is required for polling" },
+ { status: 400 }
+ );
+ }
+
// Validate URL origin to prevent SSRF
- const allowedHosts = [
- process.env.NEXT_PUBLIC_SUPABASE_URL
- ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname
- : null,
- ].filter(Boolean) as string[];
+ const allowedHosts: string[] = [];
+ if (process.env.NEXT_PUBLIC_SUPABASE_URL) {
+ allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname);
+ }
+ if (process.env.NEXT_PUBLIC_APP_URL) {
+ allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname);
+ }
+ allowedHosts.push("localhost"); // local storage in dev
let parsedUrl: URL;
try {
parsedUrl = new URL(fileUrl);
} catch {
- return NextResponse.json(
- { error: "Invalid fileUrl" },
- { status: 400 }
- );
+ return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 });
}
if (
- !allowedHosts.some((host) => parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`))
+ !allowedHosts.some(
+ (host) =>
+ parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`)
+ )
) {
return NextResponse.json(
{ error: "fileUrl origin is not allowed" },
@@ -70,123 +73,25 @@ export async function POST(req: NextRequest) {
);
}
- // Determine MIME type
const audioMimeType = mimeType || guessMimeType(filename || fileUrl);
- // Download the audio file from storage
- const audioResponse = await fetch(fileUrl, { redirect: "error" });
- if (!audioResponse.ok) {
- return NextResponse.json(
- { error: "Failed to download audio" },
- { status: 500 }
- );
- }
-
- // Enforce a 200 MB size limit before buffering into memory
- const MAX_AUDIO_SIZE = 200 * 1024 * 1024;
- const contentLength = Number(audioResponse.headers.get("content-length") || "0");
- if (contentLength > MAX_AUDIO_SIZE) {
- return NextResponse.json(
- { error: "Audio file exceeds the 200 MB size limit" },
- { status: 400 }
- );
- }
-
- const audioBuffer = await audioResponse.arrayBuffer();
- if (audioBuffer.byteLength > MAX_AUDIO_SIZE) {
- return NextResponse.json(
- { error: "Audio file exceeds the 200 MB size limit" },
- { status: 400 }
- );
- }
-
- const client = new GoogleGenAI({ apiKey });
-
- // Upload audio to Gemini Files API (supports up to 2 GB, avoids 20 MB inline limit)
- const audioBlob = new Blob([audioBuffer], { type: audioMimeType });
- const uploadedFile = await client.files.upload({
- file: audioBlob,
- config: { mimeType: audioMimeType },
- });
-
- if (!uploadedFile.uri || !uploadedFile.mimeType) {
- return NextResponse.json(
- { error: "Failed to upload audio to Gemini" },
- { status: 500 }
- );
- }
-
- const prompt = `Process this audio file and generate a detailed transcription and summary.
-
-Requirements:
-1. Provide a comprehensive summary of the entire audio content.
-2. Identify distinct speakers (e.g., Speaker 1, Speaker 2, or names if context allows).
-3. Provide accurate timestamps for each segment (Format: MM:SS).
-4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`;
-
- const response = await client.models.generateContent({
- model: "gemini-2.5-flash-lite",
- contents: createUserContent([
- createPartFromUri(uploadedFile.uri, uploadedFile.mimeType),
- prompt,
- ]),
- config: {
- responseMimeType: "application/json",
- responseSchema: {
- type: Type.OBJECT,
- properties: {
- summary: {
- type: Type.STRING,
- description: "A concise summary of the audio content.",
- },
- duration: {
- type: Type.NUMBER,
- description: "Total duration of the audio in seconds.",
- },
- segments: {
- type: Type.ARRAY,
- description:
- "List of transcribed segments with speaker and timestamp.",
- items: {
- type: Type.OBJECT,
- properties: {
- speaker: { type: Type.STRING },
- timestamp: { type: Type.STRING },
- content: { type: Type.STRING },
- },
- required: [
- "speaker",
- "timestamp",
- "content",
- ],
- },
- },
- },
- required: ["summary", "segments"],
- },
- },
- });
-
- const resultText = response.text;
- if (!resultText) {
- return NextResponse.json(
- { error: "No response from Gemini" },
- { status: 500 }
- );
- }
-
- const result = JSON.parse(resultText);
+ // Start durable workflow; return immediately for client to poll
+ const run = await start(audioTranscribeWorkflow, [
+ fileUrl,
+ audioMimeType,
+ ]);
return NextResponse.json({
- success: true,
- summary: result.summary,
- segments: result.segments,
- duration: typeof result.duration === "number" && result.duration > 0 ? result.duration : undefined,
+ runId: run.runId,
+ itemId,
});
} catch (error: unknown) {
console.error("[AUDIO_PROCESS] Error:", error);
return NextResponse.json(
- { error: "Failed to process audio" },
+ {
+ error:
+ error instanceof Error ? error.message : "Failed to process audio",
+ },
{ status: 500 }
);
}
@@ -202,5 +107,5 @@ function guessMimeType(filenameOrUrl: string): string {
if (lower.endsWith(".aiff")) return "audio/aiff";
if (lower.endsWith(".webm")) return "audio/webm";
if (lower.endsWith(".m4a")) return "audio/mp4";
- return "audio/mp3"; // Default fallback
+ return "audio/mp3";
}
diff --git a/src/app/api/audio/process/status/route.ts b/src/app/api/audio/process/status/route.ts
new file mode 100644
index 00000000..a03d4d7a
--- /dev/null
+++ b/src/app/api/audio/process/status/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { headers } from "next/headers";
+import { getRun } from "workflow/api";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/audio/process/status?runId=xxx
+ * Poll workflow status. Returns { status, result? } when completed or { status, error? } when failed.
+ */
+export async function GET(req: NextRequest) {
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const runId = req.nextUrl.searchParams.get("runId");
+ if (!runId) {
+ return NextResponse.json(
+ { error: "runId is required" },
+ { status: 400 }
+ );
+ }
+
+ const run = getRun(runId);
+ const status = await run.status;
+
+ if (status === "completed") {
+ const result = await run.returnValue;
+ return NextResponse.json({
+ status: "completed",
+ result: {
+ summary: result.summary,
+ segments: result.segments,
+ duration: result.duration,
+ },
+ });
+ }
+
+ if (status === "failed") {
+ let errorMessage = "Processing failed";
+ try {
+ await run.returnValue;
+ } catch (err) {
+ errorMessage =
+ err instanceof Error ? err.message : String(err ?? "Processing failed");
+ }
+ return NextResponse.json({
+ status: "failed",
+ error: errorMessage,
+ });
+ }
+
+ return NextResponse.json({ status: "running" });
+ } catch (error: unknown) {
+ console.error("[AUDIO_PROCESS_STATUS] Error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Failed to get status",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/workspace-canvas/AudioCardContent.tsx b/src/components/workspace-canvas/AudioCardContent.tsx
index b9de6580..bfe8da50 100644
--- a/src/components/workspace-canvas/AudioCardContent.tsx
+++ b/src/components/workspace-canvas/AudioCardContent.tsx
@@ -80,22 +80,22 @@ export function AudioCardContent({ item, isCompact = false, isScrollLocked = fal
fileUrl: audioData.fileUrl,
filename: audioData.filename,
mimeType: audioData.mimeType || "audio/webm",
+ itemId: item.id,
}),
})
.then((res) => res.json())
- .then((result) => {
- window.dispatchEvent(
- new CustomEvent("audio-processing-complete", {
- detail: result.success
- ? {
- itemId: item.id,
- summary: result.summary,
- segments: result.segments,
- duration: result.duration,
- }
- : { itemId: item.id, error: result.error || "Processing failed" },
- })
- );
+ .then((data) => {
+ if (data.runId && data.itemId) {
+ import("@/lib/audio/poll-audio-processing").then(({ pollAudioProcessing }) =>
+ pollAudioProcessing(data.runId, data.itemId)
+ );
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("audio-processing-complete", {
+ detail: { itemId: item.id, error: data.error || "Processing failed" },
+ })
+ );
+ }
})
.catch((err) => {
window.dispatchEvent(
diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
index af82924b..359155b1 100644
--- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
+++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
@@ -331,17 +331,22 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
fileUrl: result.fileUrl,
filename: result.filename,
mimeType: result.originalFile.type || 'audio/mpeg',
+ itemId,
}),
})
.then(res => res.json())
.then(data => {
- window.dispatchEvent(
- new CustomEvent('audio-processing-complete', {
- detail: data.success
- ? { itemId, summary: data.summary, segments: data.segments, duration: data.duration }
- : { itemId, error: data.error || 'Processing failed' },
- })
- );
+ if (data.runId && data.itemId) {
+ import('@/lib/audio/poll-audio-processing').then(({ pollAudioProcessing }) =>
+ pollAudioProcessing(data.runId, data.itemId)
+ );
+ } else {
+ window.dispatchEvent(
+ new CustomEvent('audio-processing-complete', {
+ detail: { itemId, error: data.error || 'Processing failed' },
+ })
+ );
+ }
})
.catch(err => {
window.dispatchEvent(
diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx
index 78733e4d..f494d2b4 100644
--- a/src/components/workspace-canvas/WorkspaceHeader.tsx
+++ b/src/components/workspace-canvas/WorkspaceHeader.tsx
@@ -369,7 +369,7 @@ export function WorkspaceHeader({
toast.dismiss(loadingToastId);
toast.success("Audio uploaded — analyzing with Gemini...");
- // Kick off Gemini processing in the background
+ // Kick off durable workflow and poll for completion
fetch("/api/audio/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -377,29 +377,19 @@ export function WorkspaceHeader({
fileUrl,
filename: file.name,
mimeType: file.type || "audio/webm",
+ itemId,
}),
})
.then((res) => res.json())
- .then((result) => {
- if (result.success) {
- // Dispatch a custom event to update the audio card data
- window.dispatchEvent(
- new CustomEvent("audio-processing-complete", {
- detail: {
- itemId,
- summary: result.summary,
- segments: result.segments,
- duration: result.duration,
- },
- })
+ .then((data) => {
+ if (data.runId && data.itemId) {
+ import("@/lib/audio/poll-audio-processing").then(({ pollAudioProcessing }) =>
+ pollAudioProcessing(data.runId, data.itemId)
);
} else {
window.dispatchEvent(
new CustomEvent("audio-processing-complete", {
- detail: {
- itemId,
- error: result.error || "Processing failed",
- },
+ detail: { itemId, error: data.error || "Processing failed" },
})
);
}
diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx
index 4eab3256..c1589d2a 100644
--- a/src/components/workspace-canvas/WorkspaceSection.tsx
+++ b/src/components/workspace-canvas/WorkspaceSection.tsx
@@ -549,28 +549,19 @@ export function WorkspaceSection({
fileUrl,
filename: file.name,
mimeType: file.type || "audio/webm",
+ itemId,
}),
})
.then((res) => res.json())
- .then((result) => {
- if (result.success) {
- window.dispatchEvent(
- new CustomEvent("audio-processing-complete", {
- detail: {
- itemId,
- summary: result.summary,
- segments: result.segments,
- duration: result.duration,
- },
- })
+ .then((data) => {
+ if (data.runId && data.itemId) {
+ import("@/lib/audio/poll-audio-processing").then(({ pollAudioProcessing }) =>
+ pollAudioProcessing(data.runId, data.itemId)
);
} else {
window.dispatchEvent(
new CustomEvent("audio-processing-complete", {
- detail: {
- itemId,
- error: result.error || "Processing failed",
- },
+ detail: { itemId, error: data.error || "Processing failed" },
})
);
}
diff --git a/src/lib/audio/poll-audio-processing.ts b/src/lib/audio/poll-audio-processing.ts
new file mode 100644
index 00000000..ad1e0d67
--- /dev/null
+++ b/src/lib/audio/poll-audio-processing.ts
@@ -0,0 +1,40 @@
+const POLL_INTERVAL_MS = 10_000;
+
+/**
+ * Polls the audio processing status endpoint until the workflow completes or fails.
+ * Dispatches audio-processing-complete when done.
+ */
+export async function pollAudioProcessing(runId: string, itemId: string): Promise {
+ while (true) {
+ const res = await fetch(`/api/audio/process/status?runId=${encodeURIComponent(runId)}`);
+ const data = await res.json();
+
+ if (data.status === "completed") {
+ window.dispatchEvent(
+ new CustomEvent("audio-processing-complete", {
+ detail: {
+ itemId,
+ summary: data.result.summary,
+ segments: data.result.segments,
+ duration: data.result.duration,
+ },
+ })
+ );
+ return;
+ }
+
+ if (data.status === "failed") {
+ window.dispatchEvent(
+ new CustomEvent("audio-processing-complete", {
+ detail: {
+ itemId,
+ error: data.error || "Processing failed",
+ },
+ })
+ );
+ return;
+ }
+
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
+ }
+}
diff --git a/src/workflows/audio-transcribe/index.ts b/src/workflows/audio-transcribe/index.ts
new file mode 100644
index 00000000..eb773319
--- /dev/null
+++ b/src/workflows/audio-transcribe/index.ts
@@ -0,0 +1,21 @@
+import { downloadAndUploadToGemini, transcribeWithGemini } from "./steps";
+
+/**
+ * Durable workflow for audio transcription.
+ * Steps are retriable and survive restarts.
+ *
+ * @param fileUrl - URL of the audio file (must be from allowed hosts)
+ * @param mimeType - MIME type of the audio
+ */
+export async function audioTranscribeWorkflow(fileUrl: string, mimeType: string) {
+ "use workflow";
+
+ const { fileUri, mimeType: geminiMimeType } = await downloadAndUploadToGemini(
+ fileUrl,
+ mimeType
+ );
+
+ const result = await transcribeWithGemini(fileUri, geminiMimeType);
+
+ return result;
+}
diff --git a/src/workflows/audio-transcribe/steps/download-and-upload.ts b/src/workflows/audio-transcribe/steps/download-and-upload.ts
new file mode 100644
index 00000000..e5b3369d
--- /dev/null
+++ b/src/workflows/audio-transcribe/steps/download-and-upload.ts
@@ -0,0 +1,52 @@
+import {
+ GoogleGenAI,
+} from "@google/genai";
+
+const MAX_AUDIO_SIZE = 200 * 1024 * 1024;
+
+/**
+ * Step: Download audio from URL and upload to Gemini Files API.
+ * Returns the file URI for use in transcription.
+ */
+export async function downloadAndUploadToGemini(
+ fileUrl: string,
+ mimeType: string
+): Promise<{ fileUri: string; mimeType: string }> {
+ "use step";
+
+ const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
+ if (!apiKey) {
+ throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set");
+ }
+
+ const audioResponse = await fetch(fileUrl, { redirect: "error" });
+ if (!audioResponse.ok) {
+ throw new Error("Failed to download audio");
+ }
+
+ const contentLength = Number(audioResponse.headers.get("content-length") || "0");
+ if (contentLength > MAX_AUDIO_SIZE) {
+ throw new Error("Audio file exceeds the 200 MB size limit");
+ }
+
+ const audioBuffer = await audioResponse.arrayBuffer();
+ if (audioBuffer.byteLength > MAX_AUDIO_SIZE) {
+ throw new Error("Audio file exceeds the 200 MB size limit");
+ }
+
+ const client = new GoogleGenAI({ apiKey });
+ const audioBlob = new Blob([audioBuffer], { type: mimeType });
+ const uploadedFile = await client.files.upload({
+ file: audioBlob,
+ config: { mimeType },
+ });
+
+ if (!uploadedFile.uri || !uploadedFile.mimeType) {
+ throw new Error("Failed to upload audio to Gemini");
+ }
+
+ return {
+ fileUri: uploadedFile.uri,
+ mimeType: uploadedFile.mimeType,
+ };
+}
diff --git a/src/workflows/audio-transcribe/steps/index.ts b/src/workflows/audio-transcribe/steps/index.ts
new file mode 100644
index 00000000..6cc18b1a
--- /dev/null
+++ b/src/workflows/audio-transcribe/steps/index.ts
@@ -0,0 +1,2 @@
+export { downloadAndUploadToGemini } from "./download-and-upload";
+export { transcribeWithGemini, type TranscribeResult } from "./transcribe";
diff --git a/src/workflows/audio-transcribe/steps/transcribe.ts b/src/workflows/audio-transcribe/steps/transcribe.ts
new file mode 100644
index 00000000..c1766d3c
--- /dev/null
+++ b/src/workflows/audio-transcribe/steps/transcribe.ts
@@ -0,0 +1,96 @@
+import {
+ GoogleGenAI,
+ Type,
+ createPartFromUri,
+ createUserContent,
+} from "@google/genai";
+
+export interface TranscribeResult {
+ summary: string;
+ segments: Array<{ speaker: string; timestamp: string; content: string }>;
+ duration?: number;
+}
+
+/**
+ * Step: Call Gemini to transcribe audio and generate summary + segments.
+ */
+export async function transcribeWithGemini(
+ fileUri: string,
+ mimeType: string
+): Promise {
+ "use step";
+
+ const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
+ if (!apiKey) {
+ throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set");
+ }
+
+ const client = new GoogleGenAI({ apiKey });
+
+ const prompt = `Process this audio file and generate a detailed transcription and summary.
+
+Requirements:
+1. Provide a comprehensive summary of the entire audio content.
+2. Identify distinct speakers (e.g., Speaker 1, Speaker 2, or names if context allows).
+3. Provide accurate timestamps for each segment (Format: MM:SS).
+4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`;
+
+ const response = await client.models.generateContent({
+ model: "gemini-2.5-flash-lite",
+ contents: createUserContent([
+ createPartFromUri(fileUri, mimeType),
+ prompt,
+ ]),
+ config: {
+ responseMimeType: "application/json",
+ responseSchema: {
+ type: Type.OBJECT,
+ properties: {
+ summary: {
+ type: Type.STRING,
+ description: "A concise summary of the audio content.",
+ },
+ duration: {
+ type: Type.NUMBER,
+ description: "Total duration of the audio in seconds.",
+ },
+ segments: {
+ type: Type.ARRAY,
+ description:
+ "List of transcribed segments with speaker and timestamp.",
+ items: {
+ type: Type.OBJECT,
+ properties: {
+ speaker: { type: Type.STRING },
+ timestamp: { type: Type.STRING },
+ content: { type: Type.STRING },
+ },
+ required: ["speaker", "timestamp", "content"],
+ },
+ },
+ },
+ required: ["summary", "segments"],
+ },
+ },
+ });
+
+ const resultText = response.text;
+ if (!resultText) {
+ throw new Error("No response from Gemini");
+ }
+
+ const result = JSON.parse(resultText) as {
+ summary: string;
+ segments: Array<{ speaker: string; timestamp: string; content: string }>;
+ duration?: number;
+ };
+
+ return {
+ summary: result.summary,
+ segments: result.segments,
+ duration:
+ typeof result.duration === "number" && result.duration > 0
+ ? result.duration
+ : undefined,
+ };
+}
From 29234fc8f6f364a4b795e9fe4dbc196f5a640af4 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 13:51:49 -0500
Subject: [PATCH 15/56] fix: citations
---
.../ai-elements/inline-citation.tsx | 29 ++++++
src/components/assistant-ui/markdown-text.tsx | 99 ++++++++-----------
src/hooks/ai/use-citations-from-message.ts | 43 --------
src/lib/ai/citations/extract-from-inline.ts | 47 ---------
src/lib/ai/citations/types.ts | 17 ----
src/lib/utils/format-workspace-context.ts | 32 +++---
src/lib/utils/preprocess-latex.ts | 59 +++++++----
7 files changed, 124 insertions(+), 202 deletions(-)
delete mode 100644 src/hooks/ai/use-citations-from-message.ts
delete mode 100644 src/lib/ai/citations/extract-from-inline.ts
delete mode 100644 src/lib/ai/citations/types.ts
diff --git a/src/components/ai-elements/inline-citation.tsx b/src/components/ai-elements/inline-citation.tsx
index eee46a23..4f5af2cb 100644
--- a/src/components/ai-elements/inline-citation.tsx
+++ b/src/components/ai-elements/inline-citation.tsx
@@ -1,6 +1,7 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
+import { ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
@@ -169,3 +170,31 @@ export const InlineCitationQuote = ({
{children}
);
+
+function extractDomain(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return url;
+ }
+}
+
+/**
+ * SurfSense-style URL citation: clickable badge with domain, opens in new tab.
+ * Used for [citation:https://...] refs.
+ */
+export function UrlCitation({ url }: { url: string }) {
+ const domain = extractDomain(url);
+ return (
+
+
+ {domain}
+
+ );
+}
diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx
index db3bc9cf..884a865f 100644
--- a/src/components/assistant-ui/markdown-text.tsx
+++ b/src/components/assistant-ui/markdown-text.tsx
@@ -8,12 +8,10 @@ import { createMathPlugin } from "@streamdown/math";
import { useMessagePartText, useAuiState } from "@assistant-ui/react";
import {
Children,
- createContext,
isValidElement,
memo,
useRef,
useEffect,
- useContext,
type ReactNode,
} from "react";
import type {
@@ -28,24 +26,23 @@ import {
InlineCitationCardBody,
InlineCitationSource,
InlineCitationQuote,
+ UrlCitation,
} from "@/components/ai-elements/inline-citation";
import { MarkdownLink } from "@/components/ui/markdown-link";
-import { useCitationsFromMessage } from "@/hooks/ai/use-citations-from-message";
import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item";
import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state";
import { useUIStore } from "@/lib/stores/ui-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
-import type { Citation } from "@/lib/ai/citations/types";
+import { getCitationUrl } from "@/lib/utils/preprocess-latex";
import { preprocessLatex } from "@/lib/utils/preprocess-latex";
import { cn } from "@/lib/utils";
const math = createMathPlugin({ singleDollarTextMath: true });
const code = createCodePlugin({ themes: ["one-dark-pro", "one-dark-pro"] });
-const CitationContext = createContext([]);
-/** Recursively extract all text from children (handles nested elements from markdown parsing). */
-function extractAllText(children: ReactNode): string {
- if (typeof children === "string") return children;
+/** Extract raw text from citation element children (handles nested elements). */
+function extractCitationText(children: ReactNode): string {
+ if (typeof children === "string") return children.trim();
if (children == null) return "";
const arr = Children.toArray(children);
return arr
@@ -53,58 +50,54 @@ function extractAllText(children: ReactNode): string {
if (typeof child === "string") return child;
if (isValidElement(child)) {
const nested = (child.props as { children?: ReactNode }).children;
- if (nested != null) return extractAllText(nested);
+ if (nested != null) return extractCitationText(nested);
}
return "";
})
- .join("");
-}
-
-/** Parse "N | quote" or legacy "N" from citation element content. */
-function parseCitationContent(children: ReactNode): { number: string; quote?: string } {
- const text = extractAllText(children).trim();
- if (!text) return { number: "1" };
- // New format: "N | quote" — same source can have different quotes per use
- const pipeIdx = text.indexOf(" | ");
- if (pipeIdx !== -1) {
- return {
- number: text.slice(0, pipeIdx).trim() || "1",
- quote: text.slice(pipeIdx + 3).trim() || undefined,
- };
- }
- // Legacy: just "N"
- return { number: text };
+ .join("")
+ .trim();
}
+/**
+ * SurfSense-style citation renderer.
+ * Content is the ref itself: urlciteN (URL), or Title, or Title|quote (workspace).
+ */
const CitationRenderer = memo(
({ children }: { children?: ReactNode }) => {
- const citations = useContext(CitationContext);
- const { number: idStr, quote: instanceQuote } = parseCitationContent(children);
- const index = parseInt(idStr, 10);
- const citation = !isNaN(index) && index >= 1 ? citations[index - 1] : null;
- // Prefer instance-level quote (new format); fall back to source quote (legacy)
- const quote = instanceQuote ?? citation?.quote;
- const effectiveCitation = citation ?? {
- number: idStr,
- title: `Source ${idStr}`,
- };
+ const ref = extractCitationText(children);
+ if (!ref) return null;
+
+ // URL placeholder (from preprocess): render as direct link
+ const url = getCitationUrl(ref);
+ if (url) {
+ return ;
+ }
+
+ // Direct URL (in case preprocess didn't run, e.g. edge case)
+ if (ref.startsWith("http://") || ref.startsWith("https://")) {
+ return ;
+ }
+
+ // Workspace citation: Title or Title|quote
+ const pipeIdx = ref.indexOf(" | ");
+ const title = pipeIdx !== -1 ? ref.slice(0, pipeIdx).trim() : ref;
+ const quote = pipeIdx !== -1 ? ref.slice(pipeIdx + 3).trim() : undefined;
const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { state: workspaceState } = useWorkspaceState(workspaceId);
const navigateToItem = useNavigateToItem();
const setOpenModalItemId = useUIStore((s) => s.setOpenModalItemId);
-
const titleNorm = (s: string) => s.trim().toLowerCase();
const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery);
+
const handleWorkspaceItemClick = () => {
- if (!workspaceState?.items || !effectiveCitation.title) return;
+ if (!workspaceState?.items || !title) return;
const item = workspaceState.items.find(
(i) =>
(i.type === "note" || i.type === "pdf") &&
- titleNorm(i.name) === titleNorm(effectiveCitation.title)
+ titleNorm(i.name) === titleNorm(title)
);
if (!item) return;
- // Set highlight query first (works when item is already open)
if (quote?.trim()) {
setCitationHighlightQuery({ itemId: item.id, query: quote.trim() });
}
@@ -112,29 +105,24 @@ const CitationRenderer = memo(
setOpenModalItemId(item.id);
};
- const hasWorkspaceItem =
- !effectiveCitation.url &&
- workspaceState?.items?.some(
- (i) =>
- (i.type === "note" || i.type === "pdf") &&
- titleNorm(i.name) === titleNorm(effectiveCitation.title)
- );
+ const hasWorkspaceItem = workspaceState?.items?.some(
+ (i) =>
+ (i.type === "note" || i.type === "pdf") &&
+ titleNorm(i.name) === titleNorm(title)
+ );
return (
20 ? "…" : "")}
/>
{quote && {quote} }
@@ -149,7 +137,6 @@ CitationRenderer.displayName = "CitationRenderer";
const MarkdownTextImpl = () => {
// Get the text content from assistant-ui context
const { text } = useMessagePartText();
- const citations = useCitationsFromMessage();
// Get thread and message ID for unique key per message
const threadId = useAuiState(({ threads }) => (threads as any)?.mainThreadId);
@@ -231,7 +218,6 @@ const MarkdownTextImpl = () => {
return (
-
{
>
{preprocessLatex(text)}
-
);
};
diff --git a/src/hooks/ai/use-citations-from-message.ts b/src/hooks/ai/use-citations-from-message.ts
deleted file mode 100644
index b08e9694..00000000
--- a/src/hooks/ai/use-citations-from-message.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-
-import { useMessage, useAuiState } from "@assistant-ui/react";
-import { useMemo } from "react";
-import type { Citation } from "@/lib/ai/citations/types";
-import { extractCitationsFromInlineData } from "@/lib/ai/citations/extract-from-inline";
-
-/** Extracts citations from the model-generated ... block in message text. */
-const CITATION_EXTRACTORS = [extractCitationsFromInlineData];
-
-/**
- * Extracts citations from the current message.
- * Citations come from the optional model-generated block appended to the message.
- * Returns a stable array; citations are 1-indexed by number.
- */
-export function useCitationsFromMessage(): Citation[] {
- const message = useMessage();
- const messages = useAuiState(
- (s) => (s.thread as unknown as { messages?: unknown[] } | undefined)?.messages ?? []
- );
- const thread = useMemo(() => ({ messages }), [messages]);
-
- return useMemo(() => {
- const msg = {
- id: (message as unknown as { id?: string }).id,
- role: (message as unknown as { role?: string }).role,
- content: (message as unknown as { content?: unknown[] }).content,
- };
-
- // Extract from model-generated block
- for (const extract of CITATION_EXTRACTORS) {
- const citations = extract(msg, thread);
- if (citations.length > 0) {
- return citations.map((c, i) => ({
- ...c,
- number: String(i + 1),
- }));
- }
- }
-
- return [];
- }, [message, thread]);
-}
diff --git a/src/lib/ai/citations/extract-from-inline.ts b/src/lib/ai/citations/extract-from-inline.ts
deleted file mode 100644
index db36f49e..00000000
--- a/src/lib/ai/citations/extract-from-inline.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Citation } from "./types";
-
-const CITATIONS_BLOCK_REGEX = /([\s\S]*?)<\/citations>/i;
-
-/**
- * Parses the optional model-generated ... block from message text.
- * The model outputs sources at the beginning (no quotes — quotes are per-instance in inline elements).
- */
-export function extractCitationsFromInlineData(
- message: { id?: string; role?: string; content?: unknown[] },
- _thread?: { messages?: unknown[] }
-): Citation[] {
- const content = message?.content;
- if (!Array.isArray(content)) return [];
-
- const textParts = content
- .filter((p): p is { type: string; text?: string } => (p as any)?.type === "text" && typeof (p as any).text === "string")
- .map((p) => (p as { text: string }).text);
-
- const fullText = textParts.join("\n");
- const match = fullText.match(CITATIONS_BLOCK_REGEX);
- if (!match?.[1]) return [];
-
- let parsed: unknown;
- try {
- parsed = JSON.parse(match[1].trim());
- } catch {
- return [];
- }
-
- if (!Array.isArray(parsed)) return [];
-
- const citations: Citation[] = [];
- for (const item of parsed) {
- const c = item as Record;
- const number = String(c?.number ?? "");
- const title = String(c?.title ?? "Source");
- const url = typeof c?.url === "string" ? c.url : undefined;
- const quote = typeof c?.quote === "string" ? c.quote : undefined;
-
- if (number) {
- citations.push({ number, title, url, quote });
- }
- }
-
- return citations;
-}
diff --git a/src/lib/ai/citations/types.ts b/src/lib/ai/citations/types.ts
deleted file mode 100644
index cf7d3849..00000000
--- a/src/lib/ai/citations/types.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Generic citation type for inline citations.
- * Source-agnostic: can represent web search, URL context, workspace content, etc.
- * Quote is per-instance (in inline element), not per-source.
- */
-export type Citation = {
- number: string; // "1", "2", ... (1-based index for display)
- title: string;
- url?: string; // optional — workspace items may not have URLs
- quote?: string; // optional at source level; use instance quote from inline element
-};
-
-/** Extractor function: takes a message (and optional thread) and returns citations */
-export type CitationExtractor = (
- message: { id?: string; role?: string; content?: unknown[] },
- thread?: { messages?: unknown[] }
-) => Citation[];
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index beea00e2..fdb5999b 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -135,29 +135,25 @@ NOTE EDITING (updateNote, Cline convention):
- Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked.
INLINE CITATIONS (optional):
-Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source.
+Use inline brackets only — no separate block. Format: [citation:REF] where REF is one of:
-Sources block (no quotes — quotes go in each inline use):
-[{"number":"1","title":"Source title","url":"https://example.com"}]
+- Web URL: [citation:https://example.com/article] — for web sources
+- Workspace note: [citation:Note Title] — use exact note title from workspace
+- Workspace + quote: [citation:Note Title | exact excerpt] — pipe with spaces before quote; only when you have the exact text
-For workspace items: omit url. Example:
-[{"number":"1","title":"My Calculus Notes"}]
+Examples:
+- [citation:https://en.wikipedia.org/wiki/Supply_chain]
+- [citation:My Calculus Notes]
+- [citation:My Calculus Notes | The derivative of x^2 is 2x]
-Inline format: N or N | exact excerpt
-- Without quote: 1 — use when you cannot cite an exact excerpt.
-- With quote: 1 | exact excerpt from source — use only when you have the exact text from the source. Use pipe with spaces to separate.
+NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source. If unsure, use [citation:Title] without a quote. Never fabricate or paraphrase.
-NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source (tool response, context, or workspace content). If unsure or you do not have the exact text, use N without a quote. Never fabricate or paraphrase quotes.
+When quoting: Use ONLY plain text — no math, code blocks, or special formatting. Use surrounding prose or omit the quote instead.
-When quoting: Use ONLY plain text — no math ($$...$$), code blocks, or special formatting. Use surrounding prose or omit the quote instead.
-
-CRITICAL — Punctuation placement: End the sentence/clause with the period or comma BEFORE the citation. The citation always comes after the punctuation, never before it.
-Correct: "...flow of goods and services." 1 | comprehensive administration of the flow
-Correct: "demand forecasting." 1
-Wrong: "...flow of goods and services" 1 . (do NOT put the period after the citation)
-- Sources block: number, title, url (for web) or omit (workspace)
-- Inline: N (no quote) or N | quote (quote optional; only when you have exact text)
-Omit the block entirely if you have no citations. You may invent credible source metadata when not from a tool.
+CRITICAL — Punctuation: Put the period or comma BEFORE the citation. The citation always comes after the punctuation.
+Correct: "...flow of goods and services." [citation:Source Title | comprehensive administration]
+Correct: "demand forecasting." [citation:Source Title]
+Wrong: "...flow of goods and services" [citation:Source Title]. (do NOT put the period after the citation)
diff --git a/src/lib/utils/preprocess-latex.ts b/src/lib/utils/preprocess-latex.ts
index 5ffbf1a3..1efc7f14 100644
--- a/src/lib/utils/preprocess-latex.ts
+++ b/src/lib/utils/preprocess-latex.ts
@@ -2,32 +2,51 @@
* Preprocesses markdown content to normalize LaTeX delimiters for Streamdown/remark-math.
*
* Handles:
- * 0. Strips optional model-generated ... block (for display only; extracted separately)
+ * 0. Converts SurfSense-style [citation:X] to X (inline-only, no block)
* 1. Protects currency values ($19.99, $5, $1,000) from being parsed as math
* 2. Converts \(...\) → $...$ and \[...\] → $$...$$ (remark-math doesn't support these)
*
* Preserves code blocks (``` and inline `) so their contents are never modified.
*/
-// Match complete citations block at start (model generates sources first so they're ready during streaming)
-const CITATIONS_BLOCK_AT_START_REGEX = /^\s*[\s\S]*?<\/citations>\s*/i;
-// Fallback: match complete block at end if model still outputs there (backwards compatible)
-const CITATIONS_BLOCK_AT_END_REGEX = /[\s\S]*?<\/citations>\s*$/i;
-// During streaming: incomplete block at start (... without ) — hide from to end so user never sees raw JSON
-const CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX = /^\s*[\s\S]*$/i;
+// Storage for URL citations replaced during preprocess to avoid GFM autolink interference.
+// Populated in preprocessCitations, consumed by CitationRenderer.
+let _pendingUrlCitations = new Map();
+let _urlCiteIdx = 0;
-/** Strips the optional ... block from markdown so it doesn't render. Also hides in-progress block during streaming. */
-export function stripCitationsBlock(markdown: string): string {
+/** Get a stored URL by placeholder key (urlcite0, urlcite1, etc.). */
+export function getCitationUrl(placeholder: string): string | undefined {
+ return _pendingUrlCitations.get(placeholder);
+}
+
+/**
+ * Converts [citation:X] to X .
+ * For URLs: replaces with placeholder to avoid GFM autolinks; stores URL in _pendingUrlCitations.
+ * Supports: [citation:https://...], [citation:Title], [citation:Title|quote]
+ */
+function preprocessCitations(markdown: string): string {
if (!markdown) return markdown;
- let out = markdown;
- // 1. Strip complete block at start (or in-progress block at start — hide raw JSON during streaming)
- if (CITATIONS_BLOCK_AT_START_REGEX.test(out)) {
- out = out.replace(CITATIONS_BLOCK_AT_START_REGEX, "").trimStart();
- } else if (CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX.test(out)) {
- out = out.replace(CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX, "").trimStart();
- }
- // 2. Strip complete block at end
- out = out.replace(CITATIONS_BLOCK_AT_END_REGEX, "").trimEnd();
+ _pendingUrlCitations = new Map();
+ _urlCiteIdx = 0;
+
+ // Replace URL citations with placeholders BEFORE markdown parsing
+ // GFM autolinks would otherwise convert https://... into , breaking our pattern
+ let out = markdown.replace(
+ /\[citation:\s*(https?:\/\/[^\]\u200B]+)\s*\]/g,
+ (_, url: string) => {
+ const key = `urlcite${_urlCiteIdx++}`;
+ _pendingUrlCitations.set(key, url.trim());
+ return `${key} `;
+ }
+ );
+
+ // Replace remaining [citation:Title] or [citation:Title|quote] with ...
+ // Content can be: workspace title (spaces ok), or title|quote
+ out = out.replace(/\[citation:\s*([^\]]+)\s*\]/g, (_, content: string) => {
+ const trimmed = content.trim();
+ return trimmed ? `${trimmed} ` : "";
+ });
+
return out;
}
@@ -38,8 +57,8 @@ const CURRENCY_REGEX = /(?X (SurfSense-style inline)
+ markdown = preprocessCitations(markdown);
// 1. Protect code blocks and inline code from modification
const preserved: string[] = [];
From 788678560699665fa53e67b1985fb8e6bed10d9a Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 21 Feb 2026 17:42:16 -0500
Subject: [PATCH 16/56] feat: pdf ocr
---
.env.example | 6 +
package.json | 1 +
src/app/api/pdf/ocr/route.ts | 136 ++++++++++++
src/app/api/pdf/upload-and-ocr/route.ts | 165 +++++++++++++++
src/app/dashboard/page.tsx | 70 ++++---
.../assistant-ui/FileProcessingToolUI.tsx | 29 ++-
.../assistant-ui/UpdatePdfContentToolUI.tsx | 8 -
src/components/assistant-ui/thread.tsx | 2 -
.../WorkspaceCanvasDropzone.tsx | 152 +++++++++-----
.../workspace-canvas/WorkspaceSection.tsx | 63 +++---
src/lib/ai/tools/index.ts | 4 -
src/lib/ai/tools/pdf-tools.ts | 86 --------
src/lib/ai/tools/process-files.ts | 191 ++++++++++++++++-
src/lib/ai/tools/read-workspace.ts | 46 ++++-
src/lib/ai/tools/workspace-search-utils.ts | 3 +
src/lib/ai/workers/workspace-worker.ts | 15 +-
src/lib/pdf/azure-ocr.ts | 194 ++++++++++++++++++
src/lib/utils/format-workspace-context.ts | 88 ++++++--
src/lib/workspace-state/search.ts | 7 +-
src/lib/workspace-state/types.ts | 11 +
20 files changed, 1037 insertions(+), 240 deletions(-)
create mode 100644 src/app/api/pdf/ocr/route.ts
create mode 100644 src/app/api/pdf/upload-and-ocr/route.ts
delete mode 100644 src/components/assistant-ui/UpdatePdfContentToolUI.tsx
delete mode 100644 src/lib/ai/tools/pdf-tools.ts
create mode 100644 src/lib/pdf/azure-ocr.ts
diff --git a/.env.example b/.env.example
index a2454351..a114bc06 100644
--- a/.env.example
+++ b/.env.example
@@ -46,3 +46,9 @@ FIRECRAWL_API_KEY=fc_...
# - direct-only (Force Direct Fetch only)
SCRAPING_MODE=hybrid
+# Azure Document AI (Mistral OCR) - for PDF upload OCR and scripts/ocr-pdf-from-url.sh
+AZURE_DOCUMENT_AI_API_KEY=your-api-key-from-azure-deployment
+AZURE_DOCUMENT_AI_ENDPOINT=https://chakrabortyurjit-7873-resource.services.ai.azure.com/providers/mistral/azure/ocr
+# Optional: AZURE_DOCUMENT_AI_MODEL (default: mistral-document-ai-2512)
+# Optional: OCR_INCLUDE_IMAGES=false to skip extracting images (smaller OCR; pdfImageRefs won't work)
+
diff --git a/package.json b/package.json
index 0549d1ea..6a6369fb 100644
--- a/package.json
+++ b/package.json
@@ -115,6 +115,7 @@
"next": "16.1.6",
"next-themes": "^0.4.6",
"parse-diff": "^0.11.1",
+ "pdf-lib": "^1.17.1",
"postgres": "^3.4.7",
"posthog-js": "^1.345.0",
"posthog-node": "^5.24.14",
diff --git a/src/app/api/pdf/ocr/route.ts b/src/app/api/pdf/ocr/route.ts
new file mode 100644
index 00000000..6b7d4432
--- /dev/null
+++ b/src/app/api/pdf/ocr/route.ts
@@ -0,0 +1,136 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { headers } from "next/headers";
+import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr";
+import { logger } from "@/lib/utils/logger";
+
+export const dynamic = "force-dynamic";
+const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
+
+/**
+ * POST /api/pdf/ocr
+ * Receives a PDF file URL (Supabase or local), fetches it, runs Azure Mistral Document AI OCR,
+ * returns extracted text and page data.
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await req.json();
+ const { fileUrl } = body;
+
+ if (!fileUrl || typeof fileUrl !== "string") {
+ return NextResponse.json(
+ { error: "fileUrl is required" },
+ { status: 400 }
+ );
+ }
+
+ logger.info("[PDF_OCR] Route fired", {
+ fileUrl: fileUrl.slice(0, 80) + (fileUrl.length > 80 ? "…" : ""),
+ userId: session.user?.id,
+ });
+
+ // Validate URL origin to prevent SSRF
+ const allowedHosts: string[] = [];
+ if (process.env.NEXT_PUBLIC_SUPABASE_URL) {
+ allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname);
+ }
+ if (process.env.NEXT_PUBLIC_APP_URL) {
+ allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname);
+ }
+ allowedHosts.push("localhost", "127.0.0.1");
+
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(fileUrl);
+ } catch {
+ return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 });
+ }
+
+ if (
+ !allowedHosts.some(
+ (host) =>
+ parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`)
+ )
+ ) {
+ return NextResponse.json(
+ { error: "fileUrl origin is not allowed" },
+ { status: 400 }
+ );
+ }
+
+ const res = await fetch(fileUrl);
+ if (!res.ok) {
+ return NextResponse.json(
+ { error: `Failed to fetch PDF: ${res.status} ${res.statusText}` },
+ { status: 400 }
+ );
+ }
+
+ const contentType = res.headers.get("content-type") ?? "";
+ if (
+ !contentType.includes("application/pdf") &&
+ !fileUrl.toLowerCase().includes(".pdf")
+ ) {
+ return NextResponse.json(
+ { error: "URL does not point to a PDF file" },
+ { status: 400 }
+ );
+ }
+
+ const contentLength = res.headers.get("content-length");
+ if (contentLength && parseInt(contentLength, 10) > MAX_PDF_SIZE_BYTES) {
+ return NextResponse.json(
+ {
+ error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`,
+ },
+ { status: 400 }
+ );
+ }
+
+ const arrayBuffer = await res.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ logger.debug("[PDF_OCR] Fetched PDF", {
+ sizeBytes: buffer.length,
+ sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
+ });
+
+ if (buffer.length > MAX_PDF_SIZE_BYTES) {
+ return NextResponse.json(
+ {
+ error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`,
+ },
+ { status: 400 }
+ );
+ }
+
+ const result = await ocrPdfFromBuffer(buffer);
+
+ logger.info("[PDF_OCR] OCR complete", {
+ pageCount: result.pages.length,
+ textContentLength: result.textContent.length,
+ textContentPreview: result.textContent.slice(0, 100) + (result.textContent.length > 100 ? "…" : ""),
+ });
+
+ return NextResponse.json({
+ textContent: result.textContent,
+ ocrPages: result.pages,
+ });
+ } catch (error: unknown) {
+ logger.error("[PDF_OCR] Error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "OCR processing failed",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/pdf/upload-and-ocr/route.ts b/src/app/api/pdf/upload-and-ocr/route.ts
new file mode 100644
index 00000000..10e2be32
--- /dev/null
+++ b/src/app/api/pdf/upload-and-ocr/route.ts
@@ -0,0 +1,165 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { headers } from "next/headers";
+import { createClient } from "@supabase/supabase-js";
+import { writeFile, mkdir } from "fs/promises";
+import { join } from "path";
+import { existsSync } from "fs";
+import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr";
+import { logger } from "@/lib/utils/logger";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 120; // OCR can be slow for large PDFs
+const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
+
+const getStorageType = (): "supabase" | "local" => {
+ const storageType = process.env.STORAGE_TYPE || "supabase";
+ return storageType === "local" ? "local" : "supabase";
+};
+
+async function saveFileLocally(buffer: Buffer, filename: string): Promise {
+ const uploadsDir = process.env.UPLOADS_DIR || join(process.cwd(), "uploads");
+ if (!existsSync(uploadsDir)) {
+ await mkdir(uploadsDir, { recursive: true });
+ }
+ const filePath = join(uploadsDir, filename);
+ await writeFile(filePath, buffer);
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
+ return `${baseUrl}/api/files/${filename}`;
+}
+
+/**
+ * POST /api/pdf/upload-and-ocr
+ * Accepts a PDF file via multipart form, uploads to storage, runs OCR on the buffer,
+ * returns fileUrl + OCR result. Single request — no fetch-back from storage.
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const formData = await req.formData();
+ const file = formData.get("file") as File | null;
+
+ if (!file) {
+ return NextResponse.json(
+ { error: "No file provided" },
+ { status: 400 }
+ );
+ }
+
+ if (
+ file.type !== "application/pdf" &&
+ !file.name.toLowerCase().endsWith(".pdf")
+ ) {
+ return NextResponse.json(
+ { error: "File must be a PDF" },
+ { status: 400 }
+ );
+ }
+
+ if (file.size > MAX_PDF_SIZE_BYTES) {
+ return NextResponse.json(
+ {
+ error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`,
+ },
+ { status: 400 }
+ );
+ }
+
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ const timestamp = Date.now();
+ const random = Math.random().toString(36).substring(2, 15);
+ const sanitizedName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
+ const filename = `${timestamp}-${random}-${sanitizedName}`;
+
+ const storageType = getStorageType();
+ let fileUrl: string;
+
+ if (storageType === "local") {
+ fileUrl = await saveFileLocally(buffer, filename);
+ } else {
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+
+ if (!supabaseUrl || !serviceRoleKey) {
+ return NextResponse.json(
+ { error: "Server configuration error: Supabase not configured" },
+ { status: 500 }
+ );
+ }
+
+ const supabase = createClient(supabaseUrl, serviceRoleKey, {
+ auth: { autoRefreshToken: false, persistSession: false },
+ });
+
+ const { error } = await supabase.storage
+ .from("file-upload")
+ .upload(filename, buffer, {
+ cacheControl: "3600",
+ upsert: false,
+ contentType: "application/pdf",
+ });
+
+ if (error) {
+ logger.error("[PDF_UPLOAD_OCR] Supabase upload failed:", error);
+ return NextResponse.json(
+ { error: `Failed to upload: ${error.message}` },
+ { status: 500 }
+ );
+ }
+
+ const { data: urlData } = supabase.storage
+ .from("file-upload")
+ .getPublicUrl(filename);
+ fileUrl = urlData.publicUrl;
+ }
+
+ logger.info("[PDF_UPLOAD_OCR] Upload complete, running OCR", {
+ filename,
+ sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
+ });
+
+ let ocrResult: Awaited>;
+ let ocrStatus: "complete" | "failed" = "complete";
+ let ocrError: string | undefined;
+
+ try {
+ ocrResult = await ocrPdfFromBuffer(buffer);
+ logger.info("[PDF_UPLOAD_OCR] OCR complete", {
+ pageCount: ocrResult.pages.length,
+ textContentLength: ocrResult.textContent.length,
+ });
+ } catch (err) {
+ ocrStatus = "failed";
+ ocrError = err instanceof Error ? err.message : "OCR failed";
+ logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError);
+ ocrResult = { pages: [], textContent: "" };
+ }
+
+ return NextResponse.json({
+ fileUrl,
+ filename: file.name,
+ fileSize: file.size,
+ textContent: ocrResult.textContent,
+ ocrPages: ocrResult.pages,
+ ocrStatus,
+ ...(ocrError && { ocrError }),
+ });
+ } catch (error: unknown) {
+ logger.error("[PDF_UPLOAD_OCR] Error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Upload and OCR failed",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index ca9a6d5f..cd39c4f5 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -43,7 +43,6 @@ import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-in
import { InviteGuard } from "@/components/workspace/InviteGuard";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
-import { uploadFileDirect } from "@/lib/uploads/client-upload";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
import { useFolderUrl } from "@/hooks/ui/use-folder-url";
@@ -344,31 +343,50 @@ function DashboardContent({
return;
}
- const uploadPromises = unprotectedFiles.map(async (file) => {
- const { url: fileUrl, filename } = await uploadFileDirect(file);
-
- return {
- fileUrl,
- filename: filename || file.name,
- fileSize: file.size,
- name: file.name.replace(/\.pdf$/i, ""),
- };
- });
-
- const uploadResults = await Promise.all(uploadPromises);
- const pdfCardDefinitions = uploadResults.map((result) => {
- const pdfData: Partial = {
- fileUrl: result.fileUrl,
- filename: result.filename,
- fileSize: result.fileSize,
- };
-
- return {
- type: "pdf" as const,
- name: result.name,
- initialData: pdfData,
- };
- });
+ const ocrToastId = toast.loading(
+ `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`,
+ { style: { color: "#fff" } }
+ );
+
+ const pdfCardDefinitions = (
+ await Promise.all(
+ unprotectedFiles.map(async (file) => {
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ const res = await fetch("/api/pdf/upload-and-ocr", {
+ method: "POST",
+ body: formData,
+ });
+ const json = await res.json();
+ if (!res.ok || json.error) {
+ throw new Error(json.error || "Upload and OCR failed");
+ }
+ const pdfData: Partial = {
+ fileUrl: json.fileUrl,
+ filename: json.filename,
+ fileSize: json.fileSize,
+ textContent: json.textContent,
+ ocrPages: json.ocrPages,
+ ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? "complete" : "failed"),
+ ...(json.ocrError && { ocrError: json.ocrError }),
+ };
+ return {
+ type: "pdf" as const,
+ name: file.name.replace(/\.pdf$/i, ""),
+ initialData: pdfData,
+ };
+ } catch (err) {
+ toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`);
+ return null;
+ }
+ })
+ )
+ ).filter((r): r is NonNullable => r !== null);
+
+ toast.dismiss(ocrToastId);
+
+ if (pdfCardDefinitions.length === 0) return;
// Create all PDF cards and navigate to the first one
const createdIds = operations.createItems(pdfCardDefinitions);
diff --git a/src/components/assistant-ui/FileProcessingToolUI.tsx b/src/components/assistant-ui/FileProcessingToolUI.tsx
index 69e7d1f1..f8bd7880 100644
--- a/src/components/assistant-ui/FileProcessingToolUI.tsx
+++ b/src/components/assistant-ui/FileProcessingToolUI.tsx
@@ -221,6 +221,7 @@ function getFileType(url: string): { type: 'video' | 'pdf' | 'image' | 'document
export const FileProcessingToolUI = makeAssistantToolUI<{
urls?: string[];
fileNames?: string[];
+ pdfImageRefs?: Array<{ pdfName: string; imageId: string }>;
instruction?: string;
forceReprocess?: boolean;
}, string>({
@@ -243,17 +244,21 @@ export const FileProcessingToolUI = makeAssistantToolUI<{
// Parse args
let urls: string[] = [];
+ let pdfImageRefs: Array<{ pdfName: string; imageId: string }> = [];
let instruction: string | undefined;
// Use the actual tool parameters
if (args?.urls && Array.isArray(args.urls)) {
urls = args.urls;
}
+ if (args?.pdfImageRefs && Array.isArray(args.pdfImageRefs)) {
+ pdfImageRefs = args.pdfImageRefs;
+ }
if (args?.instruction) {
instruction = args.instruction;
}
- const fileCount = urls.length;
+ const fileCount = urls.length + (args?.fileNames?.length ?? 0) + pdfImageRefs.length;
// Debug parsed data
if (typeof window !== 'undefined') {
@@ -302,7 +307,7 @@ export const FileProcessingToolUI = makeAssistantToolUI<{
const fileInfo = getFileType(url);
const filename = url.split('/').pop() || url;
return (
-
)}
diff --git a/src/components/assistant-ui/UpdatePdfContentToolUI.tsx b/src/components/assistant-ui/UpdatePdfContentToolUI.tsx
deleted file mode 100644
index 1c158f00..00000000
--- a/src/components/assistant-ui/UpdatePdfContentToolUI.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-"use client";
-
-import { makeAssistantToolUI } from "@assistant-ui/react";
-
-export const UpdatePdfContentToolUI = makeAssistantToolUI({
- toolName: "updatePdfContent",
- render: () => null,
-});
diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx
index 54baa535..3014f456 100644
--- a/src/components/assistant-ui/thread.tsx
+++ b/src/components/assistant-ui/thread.tsx
@@ -75,7 +75,6 @@ import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI";
// import { DeepResearchToolUI } from "@/components/assistant-ui/DeepResearchToolUI";
import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI";
import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI";
-import { UpdatePdfContentToolUI } from "@/components/assistant-ui/UpdatePdfContentToolUI";
import { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI";
import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI";
@@ -199,7 +198,6 @@ export const Thread: FC = ({ items = [] }) => {
{/* */}
-
{
- try {
- const { url, filename } = await uploadFileToStorage(file);
- return {
- fileUrl: url,
- filename: file.name,
- fileSize: file.size,
- name: file.name.replace(/\.pdf$/i, ''), // Remove .pdf extension for card name
- originalFile: file,
- };
- } catch (error) {
- console.error("Failed to upload file:", error);
- // Remove from processing set on error so it can be retried
- const fileKey = getFileKey(file);
- processingFilesRef.current.delete(fileKey);
- return null;
- }
- });
-
- const uploadResults = await Promise.all(uploadPromises);
-
- // Filter out any null results (files that couldn't be processed)
- const validResults = uploadResults.filter((result): result is NonNullable => result !== null);
-
- // Dismiss loading toast
- toast.dismiss(loadingToastId);
+ // Separate PDFs from other files — PDFs use upload-and-ocr (single request)
+ const pdfFiles = filteredFiles.filter((f) => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
+ const nonPdfFiles = filteredFiles.filter((f) => f.type !== 'application/pdf' && !f.name.toLowerCase().endsWith('.pdf'));
+
+ // PDFs: upload + OCR in one request per file
+ const pdfResults: Array<{
+ fileUrl: string;
+ filename: string;
+ fileSize: number;
+ name: string;
+ pdfData: Partial;
+ }> = [];
+ if (pdfFiles.length > 0) {
+ const pdfToastId = toast.loading(
+ `Uploading and extracting text from ${pdfFiles.length} PDF${pdfFiles.length > 1 ? 's' : ''}...`,
+ { style: { color: '#fff' } }
+ );
- if (validResults.length > 0) {
- // Separate files by type using the original file reference (avoids index misalignment)
- const pdfResults: typeof validResults = [];
- const imageResults: typeof validResults = [];
- const audioResults: typeof validResults = [];
-
- validResults.forEach((result) => {
- const fileType = result.originalFile.type;
- if (fileType === 'application/pdf') {
- pdfResults.push(result);
- } else if (fileType.startsWith('audio/')) {
- audioResults.push(result);
- } else {
- imageResults.push(result);
+ const pdfPromises = pdfFiles.map(async (file) => {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch('/api/pdf/upload-and-ocr', {
+ method: 'POST',
+ body: formData,
+ });
+ const json = await res.json();
+ if (!res.ok || json.error) {
+ throw new Error(json.error || 'Upload and OCR failed');
+ }
+ return {
+ fileUrl: json.fileUrl,
+ filename: json.filename,
+ fileSize: json.fileSize,
+ name: file.name.replace(/\.pdf$/i, ''),
+ pdfData: {
+ fileUrl: json.fileUrl,
+ filename: json.filename,
+ fileSize: json.fileSize,
+ textContent: json.textContent,
+ ocrPages: json.ocrPages,
+ ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'),
+ ...(json.ocrError && { ocrError: json.ocrError }),
+ } as Partial,
+ };
+ } catch (err) {
+ const fileKey = getFileKey(file);
+ processingFilesRef.current.delete(fileKey);
+ console.error('PDF upload-and-OCR failed:', err);
+ return null;
}
});
- // Create PDF cards
- if (pdfResults.length > 0) {
- const pdfCardDefinitions = pdfResults.map((result) => {
- const pdfData: Partial = {
- fileUrl: result.fileUrl,
- filename: result.filename,
- fileSize: result.fileSize,
- };
+ const results = await Promise.all(pdfPromises);
+ pdfResults.push(...results.filter((r): r is NonNullable => r !== null));
+ toast.dismiss(pdfToastId);
+ }
+ // Non-PDFs: upload only
+ const nonPdfResults: Array<{
+ fileUrl: string;
+ filename: string;
+ fileSize: number;
+ name: string;
+ originalFile: File;
+ }> = [];
+ if (nonPdfFiles.length > 0) {
+ const uploadPromises = nonPdfFiles.map(async (file) => {
+ try {
+ const { url, filename } = await uploadFileToStorage(file);
return {
- type: 'pdf' as const,
- name: result.name,
- initialData: pdfData,
+ fileUrl: url,
+ filename: file.name,
+ fileSize: file.size,
+ name: file.name.replace(/\.pdf$/i, ''),
+ originalFile: file,
};
- });
+ } catch (error) {
+ console.error('Failed to upload file:', error);
+ const fileKey = getFileKey(file);
+ processingFilesRef.current.delete(fileKey);
+ return null;
+ }
+ });
+ const results = await Promise.all(uploadPromises);
+ nonPdfResults.push(...results.filter((r): r is NonNullable => r !== null));
+ }
+ const validResults = [...pdfResults, ...nonPdfResults];
+ if (validResults.length > 0) {
+ const imageResults: typeof nonPdfResults = [];
+ const audioResults: typeof nonPdfResults = [];
+ nonPdfResults.forEach((r) => {
+ if (r.originalFile.type.startsWith('audio/')) audioResults.push(r);
+ else imageResults.push(r);
+ });
+
+ // Create PDF cards (OCR data already included)
+ if (pdfResults.length > 0) {
+ const pdfCardDefinitions = pdfResults.map((r) => ({
+ type: 'pdf' as const,
+ name: r.name,
+ initialData: r.pdfData,
+ }));
const pdfCreatedIds = operations.createItems(pdfCardDefinitions);
- // Use shared hook to handle navigation/selection for PDFs
handleCreatedItems(pdfCreatedIds);
}
+ toast.dismiss(loadingToastId);
+
// Create image cards with aspect ratio detection
if (imageResults.length > 0) {
// Helper to get image dimensions
diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx
index c1589d2a..2115d284 100644
--- a/src/components/workspace-canvas/WorkspaceSection.tsx
+++ b/src/components/workspace-canvas/WorkspaceSection.tsx
@@ -461,38 +461,49 @@ export function WorkspaceSection({
return;
}
- // Upload all PDFs first
- const uploadPromises = unprotectedFiles.map(async (file) => {
- const { url: fileUrl, filename } = await uploadFileDirect(file);
-
- return {
- fileUrl,
- filename: filename || file.name,
- fileSize: file.size,
- name: file.name.replace(/\.pdf$/i, ''),
- };
- });
-
- const uploadResults = await Promise.all(uploadPromises);
-
- // Filter out any null results (files that couldn't be processed)
- const validResults = uploadResults.filter((result): result is NonNullable => result !== null);
-
- if (validResults.length > 0) {
- // Collect all PDF card data and create in a single batch event
- const pdfCardDefinitions = validResults.map((result) => {
+ const ocrToastId = toast.loading(
+ `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? 's' : ''}...`,
+ { style: { color: '#fff' } }
+ );
+
+ const uploadAndOcrPromises = unprotectedFiles.map(async (file) => {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch('/api/pdf/upload-and-ocr', {
+ method: 'POST',
+ body: formData,
+ });
+ const json = await res.json();
+ if (!res.ok || json.error) {
+ throw new Error(json.error || 'Upload and OCR failed');
+ }
const pdfData: Partial = {
- fileUrl: result.fileUrl,
- filename: result.filename,
- fileSize: result.fileSize,
+ fileUrl: json.fileUrl,
+ filename: json.filename,
+ fileSize: json.fileSize,
+ textContent: json.textContent,
+ ocrPages: json.ocrPages,
+ ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'),
+ ...(json.ocrError && { ocrError: json.ocrError }),
};
-
return {
type: 'pdf' as const,
- name: result.name,
+ name: file.name.replace(/\.pdf$/i, ''),
initialData: pdfData,
};
- });
+ } catch (err) {
+ toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
+ return null;
+ }
+ });
+
+ const pdfCardDefinitions = (await Promise.all(uploadAndOcrPromises))
+ .filter((r): r is NonNullable => r !== null);
+
+ toast.dismiss(ocrToastId);
+
+ if (pdfCardDefinitions.length > 0) {
// Create all PDF cards atomically in a single event
const createdIds = operations.createItems(pdfCardDefinitions);
diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts
index e0a79b4e..28f8c383 100644
--- a/src/lib/ai/tools/index.ts
+++ b/src/lib/ai/tools/index.ts
@@ -17,7 +17,6 @@ import {
import { createFlashcardsTool, createUpdateFlashcardsTool } from "./flashcard-tools";
import { createQuizTool, createUpdateQuizTool } from "./quiz-tools";
import { createDeepResearchTool } from "./deep-research";
-import { createUpdatePdfContentTool } from "./pdf-tools";
import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools";
import { createSearchImagesTool, createAddImageTool } from "./image-tools";
import { createWebSearchTool } from "./web-search";
@@ -71,9 +70,6 @@ export function createChatTools(config: ChatToolsConfig): Record {
deleteItem: createDeleteItemTool(ctx),
selectCards: createSelectCardsTool(ctx),
- // PDF content caching
- updatePdfContent: createUpdatePdfContentTool(ctx),
-
// Flashcards
createFlashcards: createFlashcardsTool(ctx),
updateFlashcards: createUpdateFlashcardsTool(ctx),
diff --git a/src/lib/ai/tools/pdf-tools.ts b/src/lib/ai/tools/pdf-tools.ts
deleted file mode 100644
index 5ecf09c0..00000000
--- a/src/lib/ai/tools/pdf-tools.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { tool, zodSchema } from "ai";
-import { z } from "zod";
-import { logger } from "@/lib/utils/logger";
-import { workspaceWorker } from "@/lib/ai/workers";
-import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils";
-import type { WorkspaceToolContext } from "./workspace-tools";
-import type { PdfData } from "@/lib/workspace-state/types";
-
-/**
- * Create the updatePdfContent tool
- * Allows the agent to cache extracted text content on a PDF workspace item
- * without re-processing the file through Gemini.
- *
- * Primary use case: after Gemini reads a PDF inline (via composer file attachment),
- * the agent calls this to persist its understanding so future interactions
- * can reference the content without reprocessing.
- */
-export function createUpdatePdfContentTool(ctx: WorkspaceToolContext) {
- return tool({
- description: "Cache/update the extracted text content of a PDF in the workspace. Use this after you've read a PDF (e.g. from a file attachment) to save your understanding so it doesn't need to be reprocessed later. Also used by processFiles to auto-cache results.",
- inputSchema: zodSchema(
- z.object({
- pdfName: z.string().describe("The name of the PDF item in the workspace (fuzzy matched)"),
- textContent: z.string().describe("The extracted/summarized text content of the PDF to cache"),
- title: z.string().optional().describe("Optional new title for the PDF item"),
- })
- ),
- execute: async ({ pdfName, textContent, title }) => {
- if (!pdfName) {
- return { success: false, message: "PDF name is required." };
- }
- if (!textContent) {
- return { success: false, message: "Text content is required." };
- }
- if (!ctx.workspaceId) {
- return { success: false, message: "No workspace context available" };
- }
-
- try {
- const accessResult = await loadStateForTool(ctx);
- if (!accessResult.success) {
- return accessResult;
- }
-
- const { state } = accessResult;
- const matchedPdf = fuzzyMatchItem(state.items, pdfName, "pdf");
-
- if (!matchedPdf) {
- const availablePdfs = getAvailableItemsList(state.items, "pdf");
- return {
- success: false,
- message: `Could not find PDF "${pdfName}". ${availablePdfs ? `Available PDFs: ${availablePdfs}` : 'No PDFs found in workspace.'}`,
- };
- }
-
- logger.debug("📄 [UPDATE-PDF-CONTENT] Found PDF via fuzzy match:", {
- searchedName: pdfName,
- matchedName: matchedPdf.name,
- matchedId: matchedPdf.id,
- });
-
- const result = await workspaceWorker("updatePdfContent", {
- workspaceId: ctx.workspaceId,
- itemId: matchedPdf.id,
- pdfTextContent: textContent,
- title,
- });
-
- if (result.success) {
- return {
- ...result,
- pdfName: matchedPdf.name,
- };
- }
-
- return result;
- } catch (error) {
- logger.error("Error updating PDF content:", error);
- return {
- success: false,
- message: `Error updating PDF content: ${error instanceof Error ? error.message : String(error)}`,
- };
- }
- },
- });
-}
diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts
index 327bc721..24fd9a88 100644
--- a/src/lib/ai/tools/process-files.ts
+++ b/src/lib/ai/tools/process-files.ts
@@ -5,10 +5,12 @@ import { logger } from "@/lib/utils/logger";
import { readFile } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
+import { headers } from "next/headers";
import { loadStateForTool, fuzzyMatchItem } from "./tool-utils";
import type { WorkspaceToolContext } from "./workspace-tools";
import type { Item, PdfData } from "@/lib/workspace-state/types";
import { workspaceWorker } from "@/lib/ai/workers";
+import { formatOcrPagesAsMarkdown } from "@/lib/utils/format-workspace-context";
type FileInfo = { fileUrl: string; filename: string; mediaType: string };
@@ -218,6 +220,118 @@ async function processSupabaseFiles(
return batchAnalysis;
}
+/**
+ * Run OCR on a PDF via the /api/pdf/ocr endpoint.
+ * Reuses the same upload+extract logic as workspace dropzone/upload flows.
+ * Returns extracted text and pages, or null on failure.
+ */
+async function runOcrForPdfUrl(fileUrl: string): Promise<{
+ textContent: string;
+ ocrPages: PdfData["ocrPages"];
+} | null> {
+ const baseUrl =
+ process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
+ let cookie: string | undefined;
+ try {
+ const headersList = await headers();
+ cookie = headersList.get("cookie") ?? undefined;
+ } catch {
+ // No request context (e.g. background job)
+ }
+
+ const res = await fetch(`${baseUrl}/api/pdf/ocr`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(cookie && { cookie }),
+ },
+ body: JSON.stringify({ fileUrl }),
+ });
+
+ const json = await res.json();
+ if (!res.ok || json.error || !json.textContent) {
+ logger.warn("📁 [FILE_TOOL] OCR failed for PDF:", {
+ url: fileUrl.slice(0, 80),
+ error: json.error || res.statusText,
+ });
+ return null;
+ }
+
+ return {
+ textContent: json.textContent,
+ ocrPages: json.ocrPages ?? undefined,
+ };
+}
+
+type PdfImageRef = { pdfName: string; imageId: string };
+
+/**
+ * Process images extracted from PDF OCR (resolve placeholder refs like img-0.jpeg to base64).
+ */
+async function processPdfImages(
+ pdfImageRefs: PdfImageRef[],
+ stateItems: Item[],
+ instruction?: string
+): Promise {
+ const fileInfos: Array<{ filename: string; mediaType: string; data: string }> = [];
+
+ for (const ref of pdfImageRefs) {
+ const pdfItem = fuzzyMatchItem(stateItems, ref.pdfName);
+ if (!pdfItem || pdfItem.type !== "pdf") continue;
+
+ const pdfData = pdfItem.data as PdfData;
+ const ocrPages = pdfData.ocrPages ?? [];
+
+ for (const page of ocrPages) {
+ const images = (page.images ?? []) as Array<{ id?: string; image_base64?: string; imageBase64?: string }>;
+ const img = images.find((i) => i.id === ref.imageId);
+ if (!img) continue;
+
+ const base64 = img.image_base64 ?? img.imageBase64;
+ if (!base64 || typeof base64 !== "string") continue;
+
+ const dataUrl =
+ base64.startsWith("data:") ? base64 : `data:image/png;base64,${base64}`;
+ const mediaType = ref.imageId.toLowerCase().match(/\.(jpe?g|png|gif|webp)$/)
+ ? (ref.imageId.endsWith(".png") ? "image/png"
+ : ref.imageId.match(/\.jpe?g$/i) ? "image/jpeg"
+ : ref.imageId.endsWith(".gif") ? "image/gif"
+ : "image/webp")
+ : "image/png";
+
+ fileInfos.push({ filename: ref.imageId, mediaType, data: dataUrl });
+ break;
+ }
+ }
+
+ if (fileInfos.length === 0) {
+ return "No PDF images could be found. Ensure the PDF was OCR'd with images (OCR_INCLUDE_IMAGES not false) and the imageId matches the placeholder (e.g. img-0.jpeg).";
+ }
+
+ const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename} (from PDF)`).join("\n");
+ const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos);
+ const batchPrompt = instruction
+ ? `Analyze the following ${fileInfos.length} image(s) extracted from PDFs:\n${fileListText}\n\n${instruction}\n\n${outputFormat}`
+ : `Analyze the following ${fileInfos.length} image(s) extracted from PDFs:\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`;
+
+ const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [
+ { type: "text", text: batchPrompt },
+ ...fileInfos.map((f) => ({
+ type: "file" as const,
+ data: f.data,
+ mediaType: f.mediaType,
+ filename: f.filename,
+ })),
+ ];
+
+ const { text: batchAnalysis } = await generateText({
+ model: google("gemini-2.5-flash-lite"),
+ messages: [{ role: "user", content: messageContent }],
+ });
+
+ return batchAnalysis;
+}
+
/**
* Process a YouTube video using Gemini's native video support
*/
@@ -255,16 +369,20 @@ async function processYouTubeVideo(
*/
export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
return tool({
- description: "Process and analyze files including PDFs, images, documents, and videos. Handles local file URLs (/api/files/...), Supabase storage URLs (files uploaded to your workspace), and YouTube videos. Files are downloaded and analyzed directly by Gemini. You can provide custom instructions for what to extract or focus on. Use this for file URLs, video URLs, OR by providing the names of files/videos existing in the workspace (fuzzy matched). If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache and re-analyze with a custom instruction.",
+ description: "Process and analyze files including PDFs, images, documents, and videos. Handles local file URLs (/api/files/...), Supabase storage URLs (files uploaded to your workspace), YouTube videos, and images extracted from PDFs (use pdfImageRefs for placeholders like img-0.jpeg from readWorkspace). Use processFiles for file URLs, video URLs, workspace file names (fuzzy matched), OR pdfImageRefs to analyze images inside PDFs. For workspace PDFs without OCR content (ocr=none), processFiles runs OCR first and caches the result; if OCR fails it falls back to Gemini via the file URL. If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache.",
inputSchema: zodSchema(
z.object({
urls: z.array(z.string()).optional().describe("Array of file/video URLs to process (Supabase storage URLs, local /api/files/ URLs, or YouTube URLs)"),
fileNames: z.array(z.string()).optional().describe("Array of workspace item names to look up via fuzzy match (e.g. 'Annual Report')"),
+ pdfImageRefs: z.array(z.object({
+ pdfName: z.string().describe("Name of the PDF workspace item (fuzzy matched)"),
+ imageId: z.string().describe("Image placeholder ID from OCR output (e.g. img-0.jpeg)"),
+ })).optional().describe("Images extracted from PDFs during OCR — map placeholder names to base64 for analysis"),
instruction: z.string().optional().describe("Custom instruction for what to extract or focus on during analysis"),
forceReprocess: z.boolean().optional().describe("Set to true to bypass cached PDF content and re-analyze the file"),
})
),
- execute: async ({ urls, fileNames: fileNamesInput, instruction, forceReprocess: forceReprocessInput }) => {
+ execute: async ({ urls, fileNames: fileNamesInput, pdfImageRefs, instruction, forceReprocess: forceReprocessInput }) => {
let urlList = urls || [];
const fileNames = fileNamesInput || [];
const forceReprocess = forceReprocessInput === true;
@@ -290,17 +408,42 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
if (matchedItem.type === 'pdf') {
const pdfData = matchedItem.data as PdfData;
- // Check for cached text content first (skip if forceReprocess)
- if (pdfData.textContent && !forceReprocess) {
- logger.debug(`📁 [FILE_TOOL] Using cached text content for "${name}" (${pdfData.textContent.length} chars)`);
- cachedResults.push(`**${matchedItem.name}** (cached):\n\n${pdfData.textContent}`);
+ // Use cached content only when we have actual OCR (ocrPages); textContent alone may be from Gemini/summary
+ if (pdfData.ocrPages?.length && pdfData.textContent && !forceReprocess) {
+ const formatted = formatOcrPagesAsMarkdown(pdfData.ocrPages);
+ logger.debug(`📁 [FILE_TOOL] Using cached OCR content for "${name}" (${formatted.length} chars)`);
+ cachedResults.push(`**${matchedItem.name}** (cached):\n\n${formatted}`);
continue; // Skip adding to urlList — no reprocessing needed
}
if (pdfData.fileUrl) {
+ // Try OCR first (reuses upload+extract logic); fall back to Supabase URL if OCR fails
+ const ocrResult = await runOcrForPdfUrl(pdfData.fileUrl);
+ if (ocrResult) {
+ const formatted = ocrResult.ocrPages?.length
+ ? formatOcrPagesAsMarkdown(ocrResult.ocrPages)
+ : ocrResult.textContent;
+ logger.debug(`📁 [FILE_TOOL] OCR extracted content for "${name}" (${formatted.length} chars)`);
+ cachedResults.push(`**${matchedItem.name}** (OCR):\n\n${formatted}`);
+ // Persist OCR result so future calls use cached content
+ try {
+ await workspaceWorker("updatePdfContent", {
+ workspaceId: ctx.workspaceId!,
+ itemId: matchedItem.id,
+ pdfTextContent: ocrResult.textContent,
+ pdfOcrPages: ocrResult.ocrPages,
+ pdfOcrStatus: "complete",
+ });
+ logger.debug(`📁 [FILE_TOOL] Persisted OCR content for PDF "${matchedItem.name}"`);
+ } catch (cacheErr) {
+ logger.warn(`📁 [FILE_TOOL] Failed to persist OCR for "${matchedItem.name}":`, cacheErr);
+ }
+ continue; // Don't add to urlList
+ }
+ // OCR failed — fall back to Supabase URL (Gemini)
urlList.push(pdfData.fileUrl);
matchedPdfItems.set(pdfData.fileUrl, matchedItem);
- logger.debug(`📁 [FILE_TOOL] Resolved file name "${name}" to URL: ${pdfData.fileUrl}`);
+ logger.debug(`📁 [FILE_TOOL] Resolved file name "${name}" to URL (Supabase fallback): ${pdfData.fileUrl}`);
} else {
notFoundData.push(`Item "${name}" found but has no file URL.`);
}
@@ -331,8 +474,27 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
}
}
- // If all requested files had cached content, return early
- if (cachedResults.length > 0 && urlList.length === 0) {
+ // Handle PDF image refs (placeholders like img-0.jpeg from OCR)
+ const pdfImageResults: string[] = [];
+ if (pdfImageRefs && pdfImageRefs.length > 0 && ctx?.workspaceId) {
+ try {
+ const accessResult = await loadStateForTool(ctx);
+ if (accessResult.success) {
+ const result = await processPdfImages(
+ pdfImageRefs,
+ accessResult.state.items,
+ instruction
+ );
+ pdfImageResults.push(result);
+ }
+ } catch (e) {
+ logger.error("📁 [FILE_TOOL] Error processing PDF images:", e);
+ pdfImageResults.push(`Error processing PDF images: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ }
+
+ // If all requested files had cached content and no other work, return early
+ if (cachedResults.length > 0 && urlList.length === 0 && pdfImageResults.length === 0) {
return cachedResults.join('\n\n---\n\n');
}
@@ -340,8 +502,13 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
return "Error: 'urls' must be an array.";
}
+ // If only pdfImageRefs were provided and we processed them, return those
+ if (urlList.length === 0 && pdfImageResults.length > 0) {
+ return pdfImageResults.join("\n\n---\n\n");
+ }
+
if (urlList.length === 0) {
- return "No file URLs provided (and no file names could be resolved).";
+ return "No file URLs provided (and no file names or pdfImageRefs could be resolved).";
}
if (urlList.length > 20) {
@@ -432,6 +599,10 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
fileResults.unshift(...cachedResults);
}
+ if (pdfImageResults.length > 0) {
+ fileResults.push(...pdfImageResults);
+ }
+
if (fileResults.length === 0) {
return "No files were successfully processed";
}
diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts
index 68546642..d5f7ecb6 100644
--- a/src/lib/ai/tools/read-workspace.ts
+++ b/src/lib/ai/tools/read-workspace.ts
@@ -10,10 +10,14 @@ import { logger } from "@/lib/utils/logger";
import { isValidThreadIdForDb } from "@/lib/utils/thread-id";
import type { WorkspaceToolContext } from "./workspace-tools";
+const DEFAULT_LIMIT = 500;
+const MAX_LIMIT = 2000;
+const MAX_LINE_LENGTH = 2000;
+
export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
return tool({
description:
- "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. REQUIRED before targeted updateNote edits — the edit tool will error otherwise. Use path when items share the same name. When editing notes, use exact text from the Content section only (not the wrapper).",
+ "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. REQUIRED before targeted updateNote edits — the tool will error otherwise.",
inputSchema: zodSchema(
z.object({
path: z
@@ -28,9 +32,22 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
.describe(
"Name for fuzzy match — use when path unknown; if multiple items share the name, use path instead"
),
+ lineStart: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("1-based line number to start from (default 1). Use with limit for pagination."),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(MAX_LIMIT)
+ .optional()
+ .describe(`Max lines to return (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}). Use with lineStart for pagination.`),
})
),
- execute: async ({ path, itemName }) => {
+ execute: async ({ path, itemName, lineStart = 1, limit = DEFAULT_LIMIT }) => {
if (!path?.trim() && !itemName?.trim()) {
return {
success: false,
@@ -84,7 +101,25 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
};
}
- const content = formatItemContent(item);
+ const fullContent = formatItemContent(item);
+ const allLines = fullContent.split(/\r?\n/);
+ const totalLines = allLines.length;
+ const startIdx = Math.max(0, lineStart - 1);
+ const cappedLimit = Math.min(limit, MAX_LIMIT);
+ const slice = allLines.slice(startIdx, startIdx + cappedLimit);
+ const content = slice
+ .map((line, i) => {
+ const lineNum = startIdx + 1 + i;
+ const truncated =
+ line.length > MAX_LINE_LENGTH
+ ? line.substring(0, MAX_LINE_LENGTH) + "..."
+ : line;
+ return `${lineNum}: ${truncated}`;
+ })
+ .join("\n");
+ const lineEnd = startIdx + slice.length;
+ const hasMore = lineEnd < totalLines;
+
const vpath = getVirtualPath(item, items);
// Record read for read-before-write enforcement (targeted edits)
@@ -117,6 +152,11 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
type: item.type,
path: vpath,
content,
+ totalLines,
+ lineStart: startIdx + 1,
+ lineEnd,
+ hasMore,
+ ...(hasMore && { nextLineStart: lineEnd + 1 }),
};
},
});
diff --git a/src/lib/ai/tools/workspace-search-utils.ts b/src/lib/ai/tools/workspace-search-utils.ts
index 5233e3b0..d6534cec 100644
--- a/src/lib/ai/tools/workspace-search-utils.ts
+++ b/src/lib/ai/tools/workspace-search-utils.ts
@@ -38,6 +38,9 @@ export function extractSearchableText(item: Item, items: Item[]): string {
}
case "pdf": {
const data = item.data as PdfData;
+ if (data.ocrPages?.length) {
+ return data.ocrPages.map((p) => p.markdown).filter(Boolean).join("\n\n");
+ }
return data.textContent ?? "";
}
case "quiz": {
diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts
index e6d5ca28..eed05d42 100644
--- a/src/lib/ai/workers/workspace-worker.ts
+++ b/src/lib/ai/workers/workspace-worker.ts
@@ -202,6 +202,8 @@ export async function workspaceWorker(
fileSize?: number;
};
pdfTextContent?: string; // For caching extracted PDF text content
+ pdfOcrPages?: PdfData["ocrPages"]; // Full OCR page data from Azure Document AI
+ pdfOcrStatus?: "complete" | "failed"; // OCR run status
flashcardData?: {
cards?: { front: string; back: string }[]; // For creating flashcards
cardsToAdd?: { front: string; back: string }[]; // For updating flashcards (appending)
@@ -879,8 +881,8 @@ export async function workspaceWorker(
if (!params.itemId) {
throw new Error("Item ID required for PDF content update");
}
- if (!params.pdfTextContent) {
- throw new Error("Text content required for PDF content update");
+ if (!params.pdfTextContent && !params.pdfOcrPages?.length) {
+ throw new Error("Text content or OCR pages required for PDF content update");
}
const currentState = await loadWorkspaceState(params.workspaceId);
@@ -893,9 +895,16 @@ export async function workspaceWorker(
}
const existingData = existingItem.data as PdfData;
+ const textContent =
+ params.pdfTextContent ??
+ (params.pdfOcrPages?.length
+ ? params.pdfOcrPages.map((p) => p.markdown ?? "").filter(Boolean).join("\n\n")
+ : undefined);
const updatedData: PdfData = {
...existingData,
- textContent: params.pdfTextContent,
+ ...(textContent != null && { textContent }),
+ ...(params.pdfOcrPages != null && { ocrPages: params.pdfOcrPages }),
+ ...(params.pdfOcrStatus != null && { ocrStatus: params.pdfOcrStatus }),
};
const changes: Partial- = { data: updatedData };
diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts
new file mode 100644
index 00000000..9bbe6d0d
--- /dev/null
+++ b/src/lib/pdf/azure-ocr.ts
@@ -0,0 +1,194 @@
+/**
+ * Azure Mistral Document AI OCR for PDFs.
+ * Splits large PDFs into 30-page batches (30 MB cap), processes in parallel, merges results.
+ */
+
+import { PDFDocument } from "pdf-lib";
+
+const MAX_PAGES_PER_BATCH = 30;
+const MAX_BATCH_SIZE_BYTES = 30 * 1024 * 1024; // 30 MB
+const DEFAULT_MODEL = "mistral-document-ai-2512";
+
+/** Rich OCR page data from Azure Document AI (stored in PdfData.ocrPages). Dimensions omitted. */
+export interface OcrPage {
+ index: number;
+ markdown: string;
+ images?: unknown[];
+ footer?: string | null;
+ header?: string | null;
+ hyperlinks?: unknown[];
+ tables?: unknown[];
+}
+
+export interface OcrResult {
+ pages: OcrPage[];
+ textContent: string;
+}
+
+/** Azure OCR API response schema (pages array from Document AI) */
+interface AzureOcrResponsePage {
+ index?: number;
+ markdown?: string;
+ images?: unknown[];
+ footer?: string | null;
+ header?: string | null;
+ hyperlinks?: unknown[];
+ tables?: unknown[];
+ dimensions?: { dpi?: number; height?: number; width?: number };
+}
+
+interface AzureOcrResponse {
+ pages?: AzureOcrResponsePage[];
+ [key: string]: unknown;
+}
+
+/**
+ * Call Azure Document AI OCR endpoint with a base64-encoded PDF chunk.
+ */
+async function ocrChunk(base64Pdf: string): Promise
{
+ const apiKey = process.env.AZURE_DOCUMENT_AI_API_KEY;
+ const endpoint = process.env.AZURE_DOCUMENT_AI_ENDPOINT;
+ const model =
+ process.env.AZURE_DOCUMENT_AI_MODEL ?? DEFAULT_MODEL;
+
+ if (!apiKey || !endpoint) {
+ throw new Error(
+ "AZURE_DOCUMENT_AI_API_KEY and AZURE_DOCUMENT_AI_ENDPOINT must be set"
+ );
+ }
+
+ const documentUrl = `data:application/pdf;base64,${base64Pdf}`;
+ const includeImages = process.env.OCR_INCLUDE_IMAGES !== "false";
+ const body = {
+ model,
+ document: {
+ type: "document_url",
+ document_name: "chunk",
+ document_url: documentUrl,
+ },
+ include_image_base64: includeImages,
+ table_format: "markdown",
+ };
+
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const errText = await res.text();
+ throw new Error(`Azure OCR failed (${res.status}): ${errText}`);
+ }
+
+ const json = (await res.json()) as AzureOcrResponse;
+ const rawPages = json.pages ?? [];
+
+ return rawPages.map((p, i) => {
+ const page: OcrPage = {
+ index: p.index ?? i,
+ markdown: p.markdown ?? "",
+ };
+ if (p.images?.length) page.images = p.images;
+ if (p.footer) page.footer = p.footer;
+ if (p.header) page.header = p.header;
+ if (p.hyperlinks?.length) page.hyperlinks = p.hyperlinks;
+ if (p.tables?.length) page.tables = p.tables;
+ return page;
+ });
+}
+
+/**
+ * Split a PDF buffer into page chunks. Respects 30 pages and ~30 MB per batch.
+ */
+function getChunkRanges(
+ pageCount: number,
+ buffer: Buffer,
+ maxPages: number
+): number[][] {
+ if (pageCount <= maxPages) {
+ return [[0, pageCount - 1]];
+ }
+
+ const ranges: number[][] = [];
+ const avgBytesPerPage = buffer.length / pageCount;
+ const maxPagesForSize = Math.max(
+ 1,
+ Math.floor(MAX_BATCH_SIZE_BYTES / avgBytesPerPage)
+ );
+ const effectiveMaxPages = Math.min(maxPages, maxPagesForSize);
+
+ for (let start = 0; start < pageCount; start += effectiveMaxPages) {
+ const end = Math.min(start + effectiveMaxPages, pageCount) - 1;
+ ranges.push([start, end]);
+ }
+ return ranges;
+}
+
+/**
+ * Extract a page range from a PDF as a new PDF buffer (base64).
+ */
+async function extractChunkAsBase64(
+ sourceDoc: PDFDocument,
+ startIndex: number,
+ endIndex: number
+): Promise {
+ const indices: number[] = [];
+ for (let i = startIndex; i <= endIndex; i++) indices.push(i);
+
+ const chunkDoc = await PDFDocument.create();
+ const copiedPages = await chunkDoc.copyPages(sourceDoc, indices);
+ for (const page of copiedPages) chunkDoc.addPage(page);
+
+ const bytes = await chunkDoc.save();
+ return Buffer.from(bytes).toString("base64");
+}
+
+/**
+ * Run OCR on a PDF buffer. Splits into batches if >30 pages or chunk >30 MB.
+ */
+export async function ocrPdfFromBuffer(
+ buffer: Buffer,
+ options?: { maxPagesPerBatch?: number }
+): Promise {
+ const maxPages = options?.maxPagesPerBatch ?? MAX_PAGES_PER_BATCH;
+
+ const doc = await PDFDocument.load(buffer);
+ const pageCount = doc.getPageCount();
+
+ if (pageCount === 0) {
+ return { pages: [], textContent: "" };
+ }
+
+ const ranges = getChunkRanges(pageCount, buffer, maxPages);
+
+ const chunkPromises = ranges.map(async ([start, end]) => {
+ const base64 = await extractChunkAsBase64(doc, start, end);
+ const pages = await ocrChunk(base64);
+ return { start, end, pages };
+ });
+
+ const chunkResults = await Promise.all(chunkPromises);
+
+ const allPages: OcrPage[] = [];
+ let globalIndex = 0;
+ for (const { pages } of chunkResults) {
+ for (const p of pages) {
+ allPages.push({
+ ...p,
+ index: globalIndex,
+ });
+ globalIndex++;
+ }
+ }
+
+ const textContent = allPages
+ .map((p) => p.markdown)
+ .filter(Boolean)
+ .join("\n\n");
+
+ return { pages: allPages, textContent };
+}
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index fdb5999b..e5db5b76 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -15,7 +15,10 @@ function formatItemMetadata(item: Item, items: Item[]): string {
case "pdf": {
const d = item.data as PdfData;
if (d?.filename) parts.push(`filename=${d.filename}`);
- if (d?.textContent) parts.push("hasContent=true");
+ // OCR indicator: complete only when we have ocrPages (actual OCR ran)
+ if (d?.ocrStatus === "failed") parts.push("ocr=failed");
+ else if (d?.ocrPages?.length) parts.push("ocr=complete");
+ else parts.push("ocr=none");
break;
}
case "flashcard": {
@@ -57,7 +60,7 @@ Workspace is empty. Reference items by name when created.
);
return `
-Paths and metadata. Reference items by path or name. Use processFiles or selected cards for content.
+Paths and metadata. Use readWorkspace for items with ocr=complete; use processFiles for PDFs with ocr=none.
${entries.join("\n")}
`;
@@ -109,7 +112,9 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data
Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content.
If uncertain about accuracy, prefer to search.
-PDF ATTACHMENTS: When the user uploads a PDF as a file attachment and there's a matching PDF item in the workspace, call updatePdfContent with a comprehensive summary of the PDF's content to cache it. This avoids reprocessing the file later.
+PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed.
+
+PDF IMAGES: When readWorkspace shows image placeholders like , use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image.
YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search.
@@ -141,6 +146,8 @@ Use inline brackets only — no separate block. Format: [citation:REF] where REF
- Workspace note: [citation:Note Title] — use exact note title from workspace
- Workspace + quote: [citation:Note Title | exact excerpt] — pipe with spaces before quote; only when you have the exact text
+Do NOT use page numbers (e.g. p. 3, page 5) in citations. Use the actual quoted text instead.
+
Examples:
- [citation:https://en.wikipedia.org/wiki/Supply_chain]
- [citation:My Calculus Notes]
@@ -528,7 +535,7 @@ function formatSelectedCardFull(item: Item, index: number): string {
* Format full content of a single item. Used by read tool and formatSelectedCardFull.
*/
export function formatItemContent(item: Item): string {
- const lines = [``];
+ const lines: string[] = [];
switch (item.type) {
case "note":
@@ -556,7 +563,6 @@ export function formatItemContent(item: Item): string {
break;
}
- lines.push(` `);
return lines.join("\n");
}
@@ -584,10 +590,57 @@ function formatNoteDetailsFull(data: NoteData): string[] {
return lines;
}
+/**
+ * Formats OCR pages as markdown matching readWorkspace output.
+ * Exported for processFiles to return OCR content in the same format.
+ */
+export function formatOcrPagesAsMarkdown(ocrPages: PdfData["ocrPages"]): string {
+ if (!ocrPages?.length) return "";
+ const lines: string[] = [`OCR Pages (${ocrPages.length}):`];
+ for (const page of ocrPages) {
+ const pageNum = page.index + 1;
+ lines.push(`--- Page ${pageNum} ---`);
+ if (page.header) lines.push(`Header: ${page.header}`);
+ const rawMd = page.markdown ?? "";
+ const md = replaceOcrPlaceholders(
+ rawMd,
+ page.tables as Array<{ id?: string; content?: string }> | undefined
+ );
+ for (const line of md.split(/\r?\n/)) lines.push(line);
+ if (page.footer) lines.push(`Footer: ${page.footer}`);
+ lines.push("");
+ }
+ return lines.join("\n").trimEnd();
+}
+
+/** Replaces table placeholders [id](id) with actual table content. Images stay as placeholders; use processFiles with pdfImageRefs to fetch. */
+function replaceOcrPlaceholders(
+ markdown: string,
+ tables?: Array<{ id?: string; content?: string }>
+): string {
+ let out = markdown;
+ for (const tbl of tables ?? []) {
+ const id = tbl.id;
+ const content = tbl.content;
+ if (!id || !content) continue;
+ out = out.replace(
+ new RegExp(`\\[${escapeRegex(id)}\\]\\(${escapeRegex(id)}\\)`, "g"),
+ `\n\n[Table ${id}]\n${content}\n\n`
+ );
+ }
+ return out;
+}
+
+function escapeRegex(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
/**
* Formats PDF details with FULL content
- * If cached textContent is available, include it so the agent can reason about the PDF
+ * If cached textContent/ocrPages are available, include them so the agent can reason about the PDF
* without needing to call processFiles.
+ * OCR pages output markdown as proper lines (one line per line) instead of JSON blobs.
+ * Image and table placeholders are mapped to actual content when available.
*/
function formatPdfDetailsFull(data: PdfData): string[] {
const lines: string[] = [];
@@ -600,12 +653,23 @@ function formatPdfDetailsFull(data: PdfData): string[] {
lines.push(` - URL: ${data.fileUrl}`);
}
- if (data.fileSize) {
- const sizeMB = (data.fileSize / (1024 * 1024)).toFixed(2);
- lines.push(` - Size: ${sizeMB} MB`);
- }
-
- if (data.textContent) {
+ if (data.ocrPages?.length) {
+ lines.push(` - OCR Pages (${data.ocrPages.length}):`);
+ for (const page of data.ocrPages) {
+ const pageNum = page.index + 1;
+ lines.push(` --- Page ${pageNum} ---`);
+ if (page.header) lines.push(` Header: ${page.header}`);
+ const rawMd = page.markdown ?? "";
+ const md = replaceOcrPlaceholders(
+ rawMd,
+ page.tables as Array<{ id?: string; content?: string }> | undefined
+ );
+ for (const line of md.split(/\r?\n/)) {
+ lines.push(` ${line}`);
+ }
+ if (page.footer) lines.push(` Footer: ${page.footer}`);
+ }
+ } else if (data.textContent) {
lines.push(` - Extracted Content:\n${data.textContent}`);
} else {
lines.push(` - (Content not yet extracted — use processFiles or upload the PDF to extract)`);
diff --git a/src/lib/workspace-state/search.ts b/src/lib/workspace-state/search.ts
index 00ec4bfa..317ad08f 100644
--- a/src/lib/workspace-state/search.ts
+++ b/src/lib/workspace-state/search.ts
@@ -1,4 +1,4 @@
-import type { Item, NoteData } from "./types";
+import type { Item, NoteData, PdfData } from "./types";
/**
* Extracts searchable text from an item's data field
@@ -13,9 +13,8 @@ function getSearchableDataText(item: Item): string {
}
case "pdf": {
- // PDF cards don't have searchable text content in the data field
- // The filename is already indexed via item.name
- return "";
+ const pdfData = data as PdfData;
+ return pdfData.textContent ?? "";
}
default:
diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts
index d6f9323d..bddf3982 100644
--- a/src/lib/workspace-state/types.ts
+++ b/src/lib/workspace-state/types.ts
@@ -31,6 +31,17 @@ export interface PdfData {
filename: string; // original filename
fileSize?: number; // optional file size in bytes
textContent?: string; // cached extracted text content (avoids reprocessing)
+ ocrStatus?: "complete" | "failed";
+ ocrError?: string;
+ ocrPages?: Array<{
+ index: number;
+ markdown: string;
+ images?: unknown[];
+ footer?: string | null;
+ header?: string | null;
+ hyperlinks?: unknown[];
+ tables?: unknown[];
+ }>;
}
export interface FlashcardItem {
From 23ff5674fad346603581e7e0dd82bc988c0ec189 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 12:29:07 -0500
Subject: [PATCH 17/56] fix
---
src/app/api/pdf/upload-and-ocr/route.ts | 86 ++++++++++---------------
1 file changed, 35 insertions(+), 51 deletions(-)
diff --git a/src/app/api/pdf/upload-and-ocr/route.ts b/src/app/api/pdf/upload-and-ocr/route.ts
index 10e2be32..bf546ebc 100644
--- a/src/app/api/pdf/upload-and-ocr/route.ts
+++ b/src/app/api/pdf/upload-and-ocr/route.ts
@@ -80,67 +80,51 @@ export async function POST(req: NextRequest) {
const filename = `${timestamp}-${random}-${sanitizedName}`;
const storageType = getStorageType();
- let fileUrl: string;
- if (storageType === "local") {
- fileUrl = await saveFileLocally(buffer, filename);
- } else {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
-
- if (!supabaseUrl || !serviceRoleKey) {
- return NextResponse.json(
- { error: "Server configuration error: Supabase not configured" },
- { status: 500 }
- );
- }
-
- const supabase = createClient(supabaseUrl, serviceRoleKey, {
- auth: { autoRefreshToken: false, persistSession: false },
- });
-
- const { error } = await supabase.storage
- .from("file-upload")
- .upload(filename, buffer, {
- cacheControl: "3600",
- upsert: false,
- contentType: "application/pdf",
- });
-
- if (error) {
- logger.error("[PDF_UPLOAD_OCR] Supabase upload failed:", error);
- return NextResponse.json(
- { error: `Failed to upload: ${error.message}` },
- { status: 500 }
- );
- }
-
- const { data: urlData } = supabase.storage
- .from("file-upload")
- .getPublicUrl(filename);
- fileUrl = urlData.publicUrl;
- }
-
- logger.info("[PDF_UPLOAD_OCR] Upload complete, running OCR", {
- filename,
- sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
- });
+ // Run upload and OCR in parallel (we have the buffer for both)
+ const [uploadResult, ocrResultOrError] = await Promise.all([
+ storageType === "local"
+ ? saveFileLocally(buffer, filename)
+ : (async () => {
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!supabaseUrl || !serviceRoleKey) {
+ throw new Error("Server configuration error: Supabase not configured");
+ }
+ const supabase = createClient(supabaseUrl, serviceRoleKey, {
+ auth: { autoRefreshToken: false, persistSession: false },
+ });
+ const { error } = await supabase.storage
+ .from("file-upload")
+ .upload(filename, buffer, {
+ cacheControl: "3600",
+ upsert: false,
+ contentType: "application/pdf",
+ });
+ if (error) throw new Error(error.message);
+ const { data } = supabase.storage.from("file-upload").getPublicUrl(filename);
+ return data.publicUrl;
+ })(),
+ ocrPdfFromBuffer(buffer).catch((err) => err),
+ ]);
+
+ const fileUrl = uploadResult;
let ocrResult: Awaited>;
let ocrStatus: "complete" | "failed" = "complete";
let ocrError: string | undefined;
- try {
- ocrResult = await ocrPdfFromBuffer(buffer);
+ if (ocrResultOrError instanceof Error) {
+ ocrStatus = "failed";
+ ocrError = ocrResultOrError.message;
+ ocrResult = { pages: [], textContent: "" };
+ logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError);
+ } else {
+ ocrResult = ocrResultOrError;
logger.info("[PDF_UPLOAD_OCR] OCR complete", {
pageCount: ocrResult.pages.length,
textContentLength: ocrResult.textContent.length,
});
- } catch (err) {
- ocrStatus = "failed";
- ocrError = err instanceof Error ? err.message : "OCR failed";
- logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError);
- ocrResult = { pages: [], textContent: "" };
}
return NextResponse.json({
From 810908afdba7e649521e8e9a12d0acb3728881e9 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 13:17:30 -0500
Subject: [PATCH 18/56] fix: types
---
src/app/api/audio/process/status/route.ts | 6 +++++-
src/lib/ai/workers/workspace-worker.ts | 5 +++--
tsconfig.json | 3 ++-
3 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/src/app/api/audio/process/status/route.ts b/src/app/api/audio/process/status/route.ts
index a03d4d7a..26827b27 100644
--- a/src/app/api/audio/process/status/route.ts
+++ b/src/app/api/audio/process/status/route.ts
@@ -30,7 +30,11 @@ export async function GET(req: NextRequest) {
const status = await run.status;
if (status === "completed") {
- const result = await run.returnValue;
+ const result = (await run.returnValue) as {
+ summary: string;
+ segments: Array<{ speaker: string; timestamp: string; content: string }>;
+ duration?: number;
+ };
return NextResponse.json({
status: "completed",
result: {
diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts
index eed05d42..f6cde0e1 100644
--- a/src/lib/ai/workers/workspace-worker.ts
+++ b/src/lib/ai/workers/workspace-worker.ts
@@ -948,15 +948,16 @@ export async function workspaceWorker(
throw new Error("Workspace was modified by another user, please try again");
}
+ const contentLen = textContent?.length ?? 0;
logger.info("📄 [WORKSPACE-WORKER] Updated PDF text content:", {
itemId: params.itemId,
- contentLength: params.pdfTextContent.length,
+ contentLength: contentLen,
});
return {
success: true,
itemId: params.itemId,
- message: `Cached text content for PDF "${existingItem.name}" (${params.pdfTextContent.length} chars)`,
+ message: `Cached text content for PDF "${existingItem.name}" (${contentLen} chars)`,
event,
version: appendResult.version,
};
diff --git a/tsconfig.json b/tsconfig.json
index 26bd994a..ead11e1f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -37,6 +37,7 @@
],
"exclude": [
"node_modules",
- "assistant-ui-main"
+ "assistant-ui-main",
+ "tmp"
]
}
From eecc672670477bc71695a40458fb9b04c36adccc Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 17:07:00 -0500
Subject: [PATCH 19/56] fix: upload
---
.env.example | 2 +-
src/app/api/pdf/ocr/route.ts | 14 +-
src/app/api/pdf/upload-and-ocr/route.ts | 149 ------------------
src/app/dashboard/page.tsx | 14 +-
.../assistant-ui/ReadWorkspaceToolUI.tsx | 23 ++-
.../WorkspaceCanvasDropzone.tsx | 18 +--
.../workspace-canvas/WorkspaceSection.tsx | 14 +-
src/lib/ai/tools/read-workspace.ts | 33 +++-
src/lib/pdf/azure-ocr.ts | 126 ++++++++++-----
src/lib/uploads/client-upload.ts | 34 +++-
src/lib/uploads/pdf-upload-with-ocr.ts | 76 +++++++++
src/lib/utils/format-workspace-context.ts | 51 +++++-
12 files changed, 316 insertions(+), 238 deletions(-)
delete mode 100644 src/app/api/pdf/upload-and-ocr/route.ts
create mode 100644 src/lib/uploads/pdf-upload-with-ocr.ts
diff --git a/.env.example b/.env.example
index a114bc06..c0a996ca 100644
--- a/.env.example
+++ b/.env.example
@@ -48,7 +48,7 @@ SCRAPING_MODE=hybrid
# Azure Document AI (Mistral OCR) - for PDF upload OCR and scripts/ocr-pdf-from-url.sh
AZURE_DOCUMENT_AI_API_KEY=your-api-key-from-azure-deployment
-AZURE_DOCUMENT_AI_ENDPOINT=https://chakrabortyurjit-7873-resource.services.ai.azure.com/providers/mistral/azure/ocr
+# Required: AZURE_DOCUMENT_AI_ENDPOINT=your-ocr-endpoint-url
# Optional: AZURE_DOCUMENT_AI_MODEL (default: mistral-document-ai-2512)
# Optional: OCR_INCLUDE_IMAGES=false to skip extracting images (smaller OCR; pdfImageRefs won't work)
diff --git a/src/app/api/pdf/ocr/route.ts b/src/app/api/pdf/ocr/route.ts
index 6b7d4432..5b5b7b87 100644
--- a/src/app/api/pdf/ocr/route.ts
+++ b/src/app/api/pdf/ocr/route.ts
@@ -31,6 +31,7 @@ export async function POST(req: NextRequest) {
);
}
+ const t0 = Date.now();
logger.info("[PDF_OCR] Route fired", {
fileUrl: fileUrl.slice(0, 80) + (fileUrl.length > 80 ? "…" : ""),
userId: session.user?.id,
@@ -65,7 +66,11 @@ export async function POST(req: NextRequest) {
);
}
+ const tFetch = Date.now();
const res = await fetch(fileUrl);
+ logger.info("[PDF_OCR] Fetched from storage", {
+ ms: Date.now() - tFetch,
+ });
if (!res.ok) {
return NextResponse.json(
{ error: `Failed to fetch PDF: ${res.status} ${res.statusText}` },
@@ -97,9 +102,10 @@ export async function POST(req: NextRequest) {
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
- logger.debug("[PDF_OCR] Fetched PDF", {
+ logger.info("[PDF_OCR] PDF buffered", {
sizeBytes: buffer.length,
sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
+ fetchMs: Date.now() - tFetch,
});
if (buffer.length > MAX_PDF_SIZE_BYTES) {
@@ -111,12 +117,16 @@ export async function POST(req: NextRequest) {
);
}
+ const tOcr = Date.now();
const result = await ocrPdfFromBuffer(buffer);
+ const ocrMs = Date.now() - tOcr;
+ const totalMs = Date.now() - t0;
logger.info("[PDF_OCR] OCR complete", {
pageCount: result.pages.length,
textContentLength: result.textContent.length,
- textContentPreview: result.textContent.slice(0, 100) + (result.textContent.length > 100 ? "…" : ""),
+ ocrMs,
+ totalMs,
});
return NextResponse.json({
diff --git a/src/app/api/pdf/upload-and-ocr/route.ts b/src/app/api/pdf/upload-and-ocr/route.ts
deleted file mode 100644
index bf546ebc..00000000
--- a/src/app/api/pdf/upload-and-ocr/route.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
-import { headers } from "next/headers";
-import { createClient } from "@supabase/supabase-js";
-import { writeFile, mkdir } from "fs/promises";
-import { join } from "path";
-import { existsSync } from "fs";
-import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr";
-import { logger } from "@/lib/utils/logger";
-
-export const dynamic = "force-dynamic";
-export const maxDuration = 120; // OCR can be slow for large PDFs
-const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
-
-const getStorageType = (): "supabase" | "local" => {
- const storageType = process.env.STORAGE_TYPE || "supabase";
- return storageType === "local" ? "local" : "supabase";
-};
-
-async function saveFileLocally(buffer: Buffer, filename: string): Promise {
- const uploadsDir = process.env.UPLOADS_DIR || join(process.cwd(), "uploads");
- if (!existsSync(uploadsDir)) {
- await mkdir(uploadsDir, { recursive: true });
- }
- const filePath = join(uploadsDir, filename);
- await writeFile(filePath, buffer);
- const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
- return `${baseUrl}/api/files/${filename}`;
-}
-
-/**
- * POST /api/pdf/upload-and-ocr
- * Accepts a PDF file via multipart form, uploads to storage, runs OCR on the buffer,
- * returns fileUrl + OCR result. Single request — no fetch-back from storage.
- */
-export async function POST(req: NextRequest) {
- try {
- const session = await auth.api.getSession({
- headers: await headers(),
- });
- if (!session) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const formData = await req.formData();
- const file = formData.get("file") as File | null;
-
- if (!file) {
- return NextResponse.json(
- { error: "No file provided" },
- { status: 400 }
- );
- }
-
- if (
- file.type !== "application/pdf" &&
- !file.name.toLowerCase().endsWith(".pdf")
- ) {
- return NextResponse.json(
- { error: "File must be a PDF" },
- { status: 400 }
- );
- }
-
- if (file.size > MAX_PDF_SIZE_BYTES) {
- return NextResponse.json(
- {
- error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`,
- },
- { status: 400 }
- );
- }
-
- const arrayBuffer = await file.arrayBuffer();
- const buffer = Buffer.from(arrayBuffer);
-
- const timestamp = Date.now();
- const random = Math.random().toString(36).substring(2, 15);
- const sanitizedName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
- const filename = `${timestamp}-${random}-${sanitizedName}`;
-
- const storageType = getStorageType();
-
- // Run upload and OCR in parallel (we have the buffer for both)
- const [uploadResult, ocrResultOrError] = await Promise.all([
- storageType === "local"
- ? saveFileLocally(buffer, filename)
- : (async () => {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
- if (!supabaseUrl || !serviceRoleKey) {
- throw new Error("Server configuration error: Supabase not configured");
- }
- const supabase = createClient(supabaseUrl, serviceRoleKey, {
- auth: { autoRefreshToken: false, persistSession: false },
- });
- const { error } = await supabase.storage
- .from("file-upload")
- .upload(filename, buffer, {
- cacheControl: "3600",
- upsert: false,
- contentType: "application/pdf",
- });
- if (error) throw new Error(error.message);
- const { data } = supabase.storage.from("file-upload").getPublicUrl(filename);
- return data.publicUrl;
- })(),
- ocrPdfFromBuffer(buffer).catch((err) => err),
- ]);
-
- const fileUrl = uploadResult;
-
- let ocrResult: Awaited>;
- let ocrStatus: "complete" | "failed" = "complete";
- let ocrError: string | undefined;
-
- if (ocrResultOrError instanceof Error) {
- ocrStatus = "failed";
- ocrError = ocrResultOrError.message;
- ocrResult = { pages: [], textContent: "" };
- logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError);
- } else {
- ocrResult = ocrResultOrError;
- logger.info("[PDF_UPLOAD_OCR] OCR complete", {
- pageCount: ocrResult.pages.length,
- textContentLength: ocrResult.textContent.length,
- });
- }
-
- return NextResponse.json({
- fileUrl,
- filename: file.name,
- fileSize: file.size,
- textContent: ocrResult.textContent,
- ocrPages: ocrResult.pages,
- ocrStatus,
- ...(ocrError && { ocrError }),
- });
- } catch (error: unknown) {
- logger.error("[PDF_UPLOAD_OCR] Error:", error);
- return NextResponse.json(
- {
- error:
- error instanceof Error ? error.message : "Upload and OCR failed",
- },
- { status: 500 }
- );
- }
-}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index cd39c4f5..8377fb79 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -44,6 +44,7 @@ import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-in
import { InviteGuard } from "@/components/workspace/InviteGuard";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
+import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
import { useFolderUrl } from "@/hooks/ui/use-folder-url";
import { OPEN_RECORD_PARAM } from "@/components/modals/RecordWorkspaceDialog";
@@ -352,23 +353,14 @@ function DashboardContent({
await Promise.all(
unprotectedFiles.map(async (file) => {
try {
- const formData = new FormData();
- formData.append("file", file);
- const res = await fetch("/api/pdf/upload-and-ocr", {
- method: "POST",
- body: formData,
- });
- const json = await res.json();
- if (!res.ok || json.error) {
- throw new Error(json.error || "Upload and OCR failed");
- }
+ const json = await uploadPdfAndRunOcr(file);
const pdfData: Partial = {
fileUrl: json.fileUrl,
filename: json.filename,
fileSize: json.fileSize,
textContent: json.textContent,
ocrPages: json.ocrPages,
- ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? "complete" : "failed"),
+ ocrStatus: json.ocrStatus,
...(json.ocrError && { ocrError: json.ocrError }),
};
return {
diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
index 8616e889..9ae75971 100644
--- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
+++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx
@@ -6,7 +6,7 @@ import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell";
import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell";
-type ReadArgs = { path?: string; itemName?: string };
+type ReadArgs = { path?: string; itemName?: string; pageStart?: number; pageEnd?: number };
type ReadResult = {
success: boolean;
itemName?: string;
@@ -14,6 +14,8 @@ type ReadResult = {
path?: string;
content?: string;
message?: string;
+ totalPages?: number;
+ pageRange?: { start?: number; end?: number };
};
function stripExtension(s: string): string {
@@ -26,10 +28,18 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({
let content: React.ReactNode = null;
if (status.type === "running") {
+ const pageInfo =
+ args?.pageStart != null && args?.pageEnd != null
+ ? ` (pages ${args.pageStart}-${args.pageEnd})`
+ : args?.pageStart != null
+ ? ` (from page ${args.pageStart})`
+ : args?.pageEnd != null
+ ? ` (to page ${args.pageEnd})`
+ : "";
const label = args?.path
- ? `Reading ${stripExtension(args.path)}`
+ ? `Reading ${stripExtension(args.path)}${pageInfo}`
: args?.itemName
- ? `Reading "${args.itemName}"`
+ ? `Reading "${args.itemName}"${pageInfo}`
: "Reading workspace item...";
content = ;
} else if (status.type === "complete" && result) {
@@ -41,6 +51,12 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({
/>
);
} else if (result.success) {
+ const pageLabel =
+ result.pageRange?.start != null && result.pageRange?.end != null
+ ? ` pages ${result.pageRange.start}-${result.pageRange.end}`
+ : result.totalPages != null
+ ? ` (${result.totalPages} pages)`
+ : "";
content = (
@@ -50,6 +66,7 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI
({
{result.path
? stripExtension(result.path)
: result.itemName}
+ {pageLabel}
{result.type && (
{result.type}
diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
index 8e06ed25..9ab4f53d 100644
--- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
+++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
@@ -11,6 +11,7 @@ import type { PdfData, ImageData, AudioData } from "@/lib/workspace-state/types"
import { getBestFrameForRatio, type GridFrame } from "@/lib/workspace-state/aspect-ratios";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
import { uploadFileDirect } from "@/lib/uploads/client-upload";
+import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
@@ -142,11 +143,11 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
);
try {
- // Separate PDFs from other files — PDFs use upload-and-ocr (single request)
+ // Separate PDFs from other files — PDFs use direct upload + OCR from URL
const pdfFiles = filteredFiles.filter((f) => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
const nonPdfFiles = filteredFiles.filter((f) => f.type !== 'application/pdf' && !f.name.toLowerCase().endsWith('.pdf'));
- // PDFs: upload + OCR in one request per file
+ // PDFs: direct upload to Supabase + OCR from URL (bypasses 10MB body limit)
const pdfResults: Array<{
fileUrl: string;
filename: string;
@@ -162,16 +163,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
const pdfPromises = pdfFiles.map(async (file) => {
try {
- const formData = new FormData();
- formData.append('file', file);
- const res = await fetch('/api/pdf/upload-and-ocr', {
- method: 'POST',
- body: formData,
- });
- const json = await res.json();
- if (!res.ok || json.error) {
- throw new Error(json.error || 'Upload and OCR failed');
- }
+ const json = await uploadPdfAndRunOcr(file);
return {
fileUrl: json.fileUrl,
filename: json.filename,
@@ -183,7 +175,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
fileSize: json.fileSize,
textContent: json.textContent,
ocrPages: json.ocrPages,
- ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'),
+ ocrStatus: json.ocrStatus,
...(json.ocrError && { ocrError: json.ocrError }),
} as Partial,
};
diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx
index 2115d284..6c230107 100644
--- a/src/components/workspace-canvas/WorkspaceSection.tsx
+++ b/src/components/workspace-canvas/WorkspaceSection.tsx
@@ -15,6 +15,7 @@ import { useSession } from "@/lib/auth-client";
import { LoginGate } from "@/components/workspace/LoginGate";
import { AccessDenied } from "@/components/workspace/AccessDenied";
import { uploadFileDirect } from "@/lib/uploads/client-upload";
+import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
@@ -468,23 +469,14 @@ export function WorkspaceSection({
const uploadAndOcrPromises = unprotectedFiles.map(async (file) => {
try {
- const formData = new FormData();
- formData.append('file', file);
- const res = await fetch('/api/pdf/upload-and-ocr', {
- method: 'POST',
- body: formData,
- });
- const json = await res.json();
- if (!res.ok || json.error) {
- throw new Error(json.error || 'Upload and OCR failed');
- }
+ const json = await uploadPdfAndRunOcr(file);
const pdfData: Partial = {
fileUrl: json.fileUrl,
filename: json.filename,
fileSize: json.fileSize,
textContent: json.textContent,
ocrPages: json.ocrPages,
- ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'),
+ ocrStatus: json.ocrStatus,
...(json.ocrError && { ocrError: json.ocrError }),
};
return {
diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts
index d5f7ecb6..3b158839 100644
--- a/src/lib/ai/tools/read-workspace.ts
+++ b/src/lib/ai/tools/read-workspace.ts
@@ -17,7 +17,7 @@ const MAX_LINE_LENGTH = 2000;
export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
return tool({
description:
- "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. REQUIRED before targeted updateNote edits — the tool will error otherwise.",
+ "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. For PDFs: use pageStart and pageEnd (1-indexed) to read only specific pages — e.g. pageStart=5, pageEnd=10 reads pages 5–10. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. REQUIRED before targeted updateNote edits — the tool will error otherwise.",
inputSchema: zodSchema(
z.object({
path: z
@@ -45,9 +45,21 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
.max(MAX_LIMIT)
.optional()
.describe(`Max lines to return (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}). Use with lineStart for pagination.`),
+ pageStart: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("For PDFs only: 1-indexed start page (e.g. 5 for page 5). Use with pageEnd to read a page range."),
+ pageEnd: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("For PDFs only: 1-indexed end page inclusive (e.g. 10 for pages 5–10). Use with pageStart."),
})
),
- execute: async ({ path, itemName, lineStart = 1, limit = DEFAULT_LIMIT }) => {
+ execute: async ({ path, itemName, lineStart = 1, limit = DEFAULT_LIMIT, pageStart, pageEnd }) => {
if (!path?.trim() && !itemName?.trim()) {
return {
success: false,
@@ -101,7 +113,12 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
};
}
- const fullContent = formatItemContent(item);
+ const pdfPageRange =
+ item.type === "pdf" && (pageStart != null || pageEnd != null)
+ ? { pageStart, pageEnd }
+ : undefined;
+
+ const fullContent = formatItemContent(item, pdfPageRange);
const allLines = fullContent.split(/\r?\n/);
const totalLines = allLines.length;
const startIdx = Math.max(0, lineStart - 1);
@@ -157,6 +174,16 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) {
lineEnd,
hasMore,
...(hasMore && { nextLineStart: lineEnd + 1 }),
+ ...(item.type === "pdf" &&
+ Array.isArray((item.data as { ocrPages?: unknown[] })?.ocrPages) && {
+ totalPages: (item.data as { ocrPages: unknown[] }).ocrPages.length,
+ ...(pdfPageRange && {
+ pageRange: {
+ start: pdfPageRange.pageStart,
+ end: pdfPageRange.pageEnd,
+ },
+ }),
+ }),
};
},
});
diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts
index 9bbe6d0d..3d507d4d 100644
--- a/src/lib/pdf/azure-ocr.ts
+++ b/src/lib/pdf/azure-ocr.ts
@@ -1,12 +1,15 @@
/**
* Azure Mistral Document AI OCR for PDFs.
- * Splits large PDFs into 30-page batches (30 MB cap), processes in parallel, merges results.
+ * Splits large PDFs into 10-page batches (30 MB cap), processes in parallel, merges results.
*/
import { PDFDocument } from "pdf-lib";
+import { logger } from "@/lib/utils/logger";
-const MAX_PAGES_PER_BATCH = 30;
+const MAX_PAGES_PER_BATCH = 10;
const MAX_BATCH_SIZE_BYTES = 30 * 1024 * 1024; // 30 MB
+/** Max concurrent OCR requests to Azure; prevents 408 timeouts from request flooding */
+const MAX_CONCURRENT_OCR = 5;
const DEFAULT_MODEL = "mistral-document-ai-2512";
/** Rich OCR page data from Azure Document AI (stored in PdfData.ocrPages). Dimensions omitted. */
@@ -67,42 +70,55 @@ async function ocrChunk(base64Pdf: string): Promise {
document_url: documentUrl,
},
include_image_base64: includeImages,
- table_format: "markdown",
+ table_format: null,
};
- const res = await fetch(endpoint, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${apiKey}`,
- },
- body: JSON.stringify(body),
- });
+ const maxRetries = 1; // Retry 408 (timeout) and 429 (rate limit) once
+ let lastError: Error | null = null;
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (res.ok) {
+ const json = (await res.json()) as AzureOcrResponse;
+ const rawPages = json.pages ?? [];
+
+ return rawPages.map((p, i) => {
+ const page: OcrPage = {
+ index: p.index ?? i,
+ markdown: p.markdown ?? "",
+ };
+ if (p.images?.length) page.images = p.images;
+ if (p.footer) page.footer = p.footer;
+ if (p.header) page.header = p.header;
+ if (p.hyperlinks?.length) page.hyperlinks = p.hyperlinks;
+ if (p.tables?.length) page.tables = p.tables;
+ return page;
+ });
+ }
- if (!res.ok) {
const errText = await res.text();
- throw new Error(`Azure OCR failed (${res.status}): ${errText}`);
+ lastError = new Error(`Azure OCR failed (${res.status}): ${errText}`);
+
+ if ((res.status === 408 || res.status === 429) && attempt < maxRetries) {
+ const delayMs = 3000 * (attempt + 1);
+ logger.warn("[PDF_OCR_AZURE] Retry after", res.status, { attempt, delayMs });
+ await new Promise((r) => setTimeout(r, delayMs));
+ } else {
+ throw lastError;
+ }
}
-
- const json = (await res.json()) as AzureOcrResponse;
- const rawPages = json.pages ?? [];
-
- return rawPages.map((p, i) => {
- const page: OcrPage = {
- index: p.index ?? i,
- markdown: p.markdown ?? "",
- };
- if (p.images?.length) page.images = p.images;
- if (p.footer) page.footer = p.footer;
- if (p.header) page.header = p.header;
- if (p.hyperlinks?.length) page.hyperlinks = p.hyperlinks;
- if (p.tables?.length) page.tables = p.tables;
- return page;
- });
+ throw lastError;
}
/**
- * Split a PDF buffer into page chunks. Respects 30 pages and ~30 MB per batch.
+ * Split a PDF buffer into page chunks. Respects 10 pages and ~30 MB per batch.
*/
function getChunkRanges(
pageCount: number,
@@ -148,30 +164,60 @@ async function extractChunkAsBase64(
}
/**
- * Run OCR on a PDF buffer. Splits into batches if >30 pages or chunk >30 MB.
+ * Run OCR on a PDF buffer. Splits into batches if >10 pages or chunk >30 MB.
*/
export async function ocrPdfFromBuffer(
buffer: Buffer,
options?: { maxPagesPerBatch?: number }
): Promise {
+ const t0 = Date.now();
const maxPages = options?.maxPagesPerBatch ?? MAX_PAGES_PER_BATCH;
const doc = await PDFDocument.load(buffer);
const pageCount = doc.getPageCount();
+ logger.info("[PDF_OCR_AZURE] Loaded PDF", {
+ pageCount,
+ sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
+ loadMs: Date.now() - t0,
+ });
if (pageCount === 0) {
return { pages: [], textContent: "" };
}
const ranges = getChunkRanges(pageCount, buffer, maxPages);
-
- const chunkPromises = ranges.map(async ([start, end]) => {
- const base64 = await extractChunkAsBase64(doc, start, end);
- const pages = await ocrChunk(base64);
- return { start, end, pages };
+ logger.info("[PDF_OCR_AZURE] Chunk plan", {
+ chunkCount: ranges.length,
+ concurrency: MAX_CONCURRENT_OCR,
+ ranges: ranges.map(([s, e]) => `${s}-${e}`),
});
- const chunkResults = await Promise.all(chunkPromises);
+ // Process in batches of MAX_CONCURRENT_OCR to avoid 408 timeouts from flooding Azure
+ const chunkResults: Array<{ start: number; end: number; pages: OcrPage[] }> = [];
+ for (let i = 0; i < ranges.length; i += MAX_CONCURRENT_OCR) {
+ const batch = ranges.slice(i, i + MAX_CONCURRENT_OCR);
+ const batchResults = await Promise.all(
+ batch.map(async ([start, end]) => {
+ const tChunk = Date.now();
+ const base64 = await extractChunkAsBase64(doc, start, end);
+ const extractMs = Date.now() - tChunk;
+ const pages = await ocrChunk(base64);
+ const ocrChunkMs = Date.now() - tChunk;
+ logger.debug("[PDF_OCR_AZURE] Chunk done", {
+ range: `${start}-${end}`,
+ pages: pages.length,
+ extractMs,
+ ocrMs: ocrChunkMs - extractMs,
+ });
+ return { start, end, pages };
+ })
+ );
+ chunkResults.push(...batchResults);
+ }
+ logger.info("[PDF_OCR_AZURE] All chunks complete", {
+ totalChunks: chunkResults.length,
+ chunkMs: Date.now() - t0,
+ });
const allPages: OcrPage[] = [];
let globalIndex = 0;
@@ -190,5 +236,11 @@ export async function ocrPdfFromBuffer(
.filter(Boolean)
.join("\n\n");
+ logger.info("[PDF_OCR_AZURE] Merge complete", {
+ pageCount: allPages.length,
+ textContentLength: textContent.length,
+ totalMs: Date.now() - t0,
+ });
+
return { pages: allPages, textContent };
}
diff --git a/src/lib/uploads/client-upload.ts b/src/lib/uploads/client-upload.ts
index 385812e7..27aa31c2 100644
--- a/src/lib/uploads/client-upload.ts
+++ b/src/lib/uploads/client-upload.ts
@@ -17,11 +17,22 @@ interface UploadResult {
filename: string;
}
+export interface UploadFileDirectOptions {
+ /** Enable timing and step logs for debugging (e.g. PDF upload flow) */
+ log?: boolean;
+}
+
/**
* Upload a file directly to storage, bypassing the serverless function body limit.
* Works for both Supabase (direct upload) and local storage (fallback to API route).
*/
-export async function uploadFileDirect(file: File): Promise {
+export async function uploadFileDirect(
+ file: File,
+ options?: UploadFileDirectOptions
+): Promise {
+ const log = options?.log ?? false;
+ const t0 = log ? performance.now() : 0;
+
if (file.size > MAX_FILE_SIZE_BYTES) {
throw new Error(
`File size exceeds ${MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`
@@ -46,14 +57,25 @@ export async function uploadFileDirect(file: File): Promise {
}
const urlData = await urlResponse.json();
+ if (log) {
+ console.info(
+ `[PDF_UPLOAD] Get signed URL: ${(performance.now() - t0).toFixed(0)}ms`
+ );
+ }
// Local storage mode: fall back to /api/upload-file
if (urlData.mode === "local") {
- return uploadViaApiRoute(file);
+ const result = await uploadViaApiRoute(file);
+ if (log) {
+ const t = performance.now() - t0;
+ console.info(`[PDF_UPLOAD] Local fallback upload: ${t.toFixed(0)}ms`);
+ }
+ return result;
}
// Step 2: Upload file directly to Supabase using the signed URL
const { signedUrl, token, publicUrl, path } = urlData;
+ const tPut = log ? performance.now() : 0;
const uploadResponse = await fetch(signedUrl, {
method: "PUT",
@@ -74,6 +96,14 @@ export async function uploadFileDirect(file: File): Promise {
);
}
+ if (log) {
+ const t = performance.now() - tPut;
+ const total = performance.now() - t0;
+ console.info(
+ `[PDF_UPLOAD] Direct upload to storage: ${t.toFixed(0)}ms | total upload: ${total.toFixed(0)}ms`
+ );
+ }
+
return {
url: publicUrl,
filename: path,
diff --git a/src/lib/uploads/pdf-upload-with-ocr.ts b/src/lib/uploads/pdf-upload-with-ocr.ts
new file mode 100644
index 00000000..26741c8a
--- /dev/null
+++ b/src/lib/uploads/pdf-upload-with-ocr.ts
@@ -0,0 +1,76 @@
+/**
+ * PDF upload + OCR flow using direct Supabase upload.
+ * Uploads to storage first, then runs OCR via /api/pdf/ocr (bypasses 10MB body limit).
+ */
+
+import type { OcrPage } from "@/lib/pdf/azure-ocr";
+import { uploadFileDirect } from "./client-upload";
+
+export interface PdfUploadWithOcrResult {
+ fileUrl: string;
+ filename: string;
+ fileSize: number;
+ textContent: string;
+ ocrPages: OcrPage[];
+ ocrStatus: "complete" | "failed";
+ ocrError?: string;
+}
+
+/**
+ * Upload a PDF to storage (Supabase or local) and run OCR.
+ * Uses direct upload to bypass Next.js body size limits.
+ */
+export async function uploadPdfAndRunOcr(
+ file: File
+): Promise {
+ const t0 = performance.now();
+ console.info(
+ `[PDF_UPLOAD_OCR] Start: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`
+ );
+
+ const { url: fileUrl, filename } = await uploadFileDirect(file, { log: true });
+ const uploadMs = performance.now() - t0;
+ console.info(`[PDF_UPLOAD_OCR] Upload complete: ${uploadMs.toFixed(0)}ms`);
+
+ const tOcr = performance.now();
+ const ocrRes = await fetch("/api/pdf/ocr", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fileUrl }),
+ });
+
+ const ocrJson = await ocrRes.json();
+ const ocrMs = performance.now() - tOcr;
+ const totalMs = performance.now() - t0;
+ console.info(
+ `[PDF_UPLOAD_OCR] OCR request: ${ocrMs.toFixed(0)}ms | total: ${totalMs.toFixed(0)}ms`
+ );
+
+ if (!ocrRes.ok) {
+ console.warn(
+ `[PDF_UPLOAD_OCR] OCR failed (${totalMs.toFixed(0)}ms):`,
+ ocrJson.error
+ );
+ return {
+ fileUrl,
+ filename: filename || file.name,
+ fileSize: file.size,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed",
+ ocrError: ocrJson.error || "OCR failed",
+ };
+ }
+
+ console.info(
+ `[PDF_UPLOAD_OCR] Done: ${file.name} | ${ocrJson.ocrPages?.length ?? 0} pages | ${totalMs.toFixed(0)}ms total`
+ );
+ return {
+ fileUrl,
+ filename: filename || file.name,
+ fileSize: file.size,
+ textContent: ocrJson.textContent ?? "",
+ ocrPages: ocrJson.ocrPages ?? [],
+ ocrStatus: "complete",
+ };
+}
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index e5db5b76..372875af 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -112,7 +112,7 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data
Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content.
If uncertain about accuracy, prefer to search.
-PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed.
+PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use pageStart and pageEnd (1-indexed) to read specific pages only — e.g. pageStart=5, pageEnd=10 for pages 5–10. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed.
PDF IMAGES: When readWorkspace shows image placeholders like , use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image.
@@ -531,10 +531,21 @@ function formatSelectedCardFull(item: Item, index: number): string {
return formatItemContent(item);
}
+export interface FormatItemContentOptions {
+ /** For PDFs: 1-indexed start page (inclusive) */
+ pageStart?: number;
+ /** For PDFs: 1-indexed end page (inclusive) */
+ pageEnd?: number;
+}
+
/**
* Format full content of a single item. Used by read tool and formatSelectedCardFull.
+ * For PDFs, pass pageStart/pageEnd to read only specific pages.
*/
-export function formatItemContent(item: Item): string {
+export function formatItemContent(
+ item: Item,
+ options?: FormatItemContentOptions
+): string {
const lines: string[] = [];
switch (item.type) {
@@ -542,7 +553,13 @@ export function formatItemContent(item: Item): string {
lines.push(...formatNoteDetailsFull(item.data as NoteData));
break;
case "pdf":
- lines.push(...formatPdfDetailsFull(item.data as PdfData));
+ lines.push(
+ ...formatPdfDetailsFull(
+ item.data as PdfData,
+ options?.pageStart,
+ options?.pageEnd
+ )
+ );
break;
case "flashcard":
lines.push(...formatFlashcardDetailsFull(item.data as FlashcardData));
@@ -641,8 +658,13 @@ function escapeRegex(s: string): string {
* without needing to call processFiles.
* OCR pages output markdown as proper lines (one line per line) instead of JSON blobs.
* Image and table placeholders are mapped to actual content when available.
+ * Optionally filter by pageStart/pageEnd (1-indexed, inclusive).
*/
-function formatPdfDetailsFull(data: PdfData): string[] {
+function formatPdfDetailsFull(
+ data: PdfData,
+ pageStart?: number,
+ pageEnd?: number
+): string[] {
const lines: string[] = [];
if (data.filename) {
@@ -654,8 +676,25 @@ function formatPdfDetailsFull(data: PdfData): string[] {
}
if (data.ocrPages?.length) {
- lines.push(` - OCR Pages (${data.ocrPages.length}):`);
- for (const page of data.ocrPages) {
+ let pagesToShow = data.ocrPages;
+ if (pageStart != null || pageEnd != null) {
+ const startIdx = pageStart != null ? Math.max(0, pageStart - 1) : 0;
+ const endIdx =
+ pageEnd != null
+ ? Math.min(data.ocrPages.length - 1, pageEnd - 1)
+ : data.ocrPages.length - 1;
+ pagesToShow = data.ocrPages.filter(
+ (p) => p.index >= startIdx && p.index <= endIdx
+ );
+ if (pagesToShow.length > 0) {
+ lines.push(
+ ` - OCR Pages ${pageStart ?? 1}-${pageEnd ?? data.ocrPages.length} (${pagesToShow.length} of ${data.ocrPages.length}):`
+ );
+ }
+ } else {
+ lines.push(` - OCR Pages (${data.ocrPages.length}):`);
+ }
+ for (const page of pagesToShow) {
const pageNum = page.index + 1;
lines.push(` --- Page ${pageNum} ---`);
if (page.header) lines.push(` Header: ${page.header}`);
From fa26f550975b6045a6d4c4063f9800aeb1bb6b3f Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 19:26:17 -0500
Subject: [PATCH 20/56] more fixes
---
src/app/api/pdf/ocr/route.ts | 19 +--
src/app/api/pdf/ocr/start/route.ts | 85 ++++++++++
src/app/api/pdf/ocr/status/route.ts | 75 +++++++++
src/app/dashboard/page.tsx | 105 ++++++++----
src/components/assistant-ui/attachment.tsx | 2 +-
src/components/assistant-ui/markdown-text.tsx | 48 +++++-
src/components/modals/UploadDialog.tsx | 15 +-
src/components/pdf/AppPdfViewer.tsx | 105 +++++++-----
.../WorkspaceCanvasDropzone.tsx | 77 ++++++---
.../workspace-canvas/WorkspaceCard.tsx | 30 +++-
.../workspace-canvas/WorkspaceContent.tsx | 25 +++
.../workspace-canvas/WorkspaceSection.tsx | 116 +++++++++----
src/hooks/workspace/use-workspace-mutation.ts | 17 +-
.../workspace/use-workspace-operations.ts | 40 ++++-
src/lib/ai/tools/process-files.ts | 157 +++---------------
src/lib/pdf/azure-ocr.ts | 115 +++++++++----
src/lib/pdf/poll-pdf-ocr.ts | 45 +++++
src/lib/stores/ui-store.ts | 4 +-
src/lib/uploads/pdf-upload-with-ocr.ts | 67 +++++---
src/lib/utils/format-workspace-context.ts | 48 +++---
src/lib/utils/preprocess-latex.ts | 26 +--
src/lib/workspace-state/types.ts | 2 +-
src/workflows/pdf-ocr/index.ts | 66 ++++++++
src/workflows/pdf-ocr/steps/fetch-and-ocr.ts | 62 +++++++
src/workflows/pdf-ocr/steps/fetch-pdf.ts | 39 +++++
src/workflows/pdf-ocr/steps/index.ts | 4 +
src/workflows/pdf-ocr/steps/ocr-chunk.ts | 16 ++
.../pdf-ocr/steps/prepare-ocr-chunks.ts | 18 ++
28 files changed, 1028 insertions(+), 400 deletions(-)
create mode 100644 src/app/api/pdf/ocr/start/route.ts
create mode 100644 src/app/api/pdf/ocr/status/route.ts
create mode 100644 src/lib/pdf/poll-pdf-ocr.ts
create mode 100644 src/workflows/pdf-ocr/index.ts
create mode 100644 src/workflows/pdf-ocr/steps/fetch-and-ocr.ts
create mode 100644 src/workflows/pdf-ocr/steps/fetch-pdf.ts
create mode 100644 src/workflows/pdf-ocr/steps/index.ts
create mode 100644 src/workflows/pdf-ocr/steps/ocr-chunk.ts
create mode 100644 src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts
diff --git a/src/app/api/pdf/ocr/route.ts b/src/app/api/pdf/ocr/route.ts
index 5b5b7b87..11b06b86 100644
--- a/src/app/api/pdf/ocr/route.ts
+++ b/src/app/api/pdf/ocr/route.ts
@@ -32,10 +32,6 @@ export async function POST(req: NextRequest) {
}
const t0 = Date.now();
- logger.info("[PDF_OCR] Route fired", {
- fileUrl: fileUrl.slice(0, 80) + (fileUrl.length > 80 ? "…" : ""),
- userId: session.user?.id,
- });
// Validate URL origin to prevent SSRF
const allowedHosts: string[] = [];
@@ -66,11 +62,7 @@ export async function POST(req: NextRequest) {
);
}
- const tFetch = Date.now();
const res = await fetch(fileUrl);
- logger.info("[PDF_OCR] Fetched from storage", {
- ms: Date.now() - tFetch,
- });
if (!res.ok) {
return NextResponse.json(
{ error: `Failed to fetch PDF: ${res.status} ${res.statusText}` },
@@ -102,12 +94,6 @@ export async function POST(req: NextRequest) {
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
- logger.info("[PDF_OCR] PDF buffered", {
- sizeBytes: buffer.length,
- sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
- fetchMs: Date.now() - tFetch,
- });
-
if (buffer.length > MAX_PDF_SIZE_BYTES) {
return NextResponse.json(
{
@@ -119,13 +105,10 @@ export async function POST(req: NextRequest) {
const tOcr = Date.now();
const result = await ocrPdfFromBuffer(buffer);
- const ocrMs = Date.now() - tOcr;
const totalMs = Date.now() - t0;
- logger.info("[PDF_OCR] OCR complete", {
+ logger.info("[PDF_OCR] Complete", {
pageCount: result.pages.length,
- textContentLength: result.textContent.length,
- ocrMs,
totalMs,
});
diff --git a/src/app/api/pdf/ocr/start/route.ts b/src/app/api/pdf/ocr/start/route.ts
new file mode 100644
index 00000000..4d88d567
--- /dev/null
+++ b/src/app/api/pdf/ocr/start/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { headers } from "next/headers";
+import { start } from "workflow/api";
+import { pdfOcrWorkflow } from "@/workflows/pdf-ocr";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * POST /api/pdf/ocr/start
+ * Validates URL, starts durable PDF OCR workflow, returns runId and itemId for polling.
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await req.json();
+ const { fileUrl, itemId } = body;
+
+ if (!fileUrl || typeof fileUrl !== "string") {
+ return NextResponse.json(
+ { error: "fileUrl is required" },
+ { status: 400 }
+ );
+ }
+
+ if (!itemId || typeof itemId !== "string") {
+ return NextResponse.json(
+ { error: "itemId is required for polling" },
+ { status: 400 }
+ );
+ }
+
+ // Validate URL origin to prevent SSRF
+ const allowedHosts: string[] = [];
+ if (process.env.NEXT_PUBLIC_SUPABASE_URL) {
+ allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname);
+ }
+ if (process.env.NEXT_PUBLIC_APP_URL) {
+ allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname);
+ }
+ allowedHosts.push("localhost", "127.0.0.1");
+
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(fileUrl);
+ } catch {
+ return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 });
+ }
+
+ if (
+ !allowedHosts.some(
+ (host) =>
+ parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`)
+ )
+ ) {
+ return NextResponse.json(
+ { error: "fileUrl origin is not allowed" },
+ { status: 400 }
+ );
+ }
+
+ // Start durable workflow; return immediately for client to poll
+ const run = await start(pdfOcrWorkflow, [fileUrl]);
+
+ return NextResponse.json({
+ runId: run.runId,
+ itemId,
+ });
+ } catch (error: unknown) {
+ console.error("[PDF_OCR_START] Error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Failed to start PDF OCR",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/pdf/ocr/status/route.ts b/src/app/api/pdf/ocr/status/route.ts
new file mode 100644
index 00000000..124a3e69
--- /dev/null
+++ b/src/app/api/pdf/ocr/status/route.ts
@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { headers } from "next/headers";
+import { getRun } from "workflow/api";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/pdf/ocr/status?runId=xxx
+ * Poll workflow status. Returns { status, result? } when completed or { status, error? } when failed.
+ */
+export async function GET(req: NextRequest) {
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const runId = req.nextUrl.searchParams.get("runId");
+ if (!runId) {
+ return NextResponse.json(
+ { error: "runId is required" },
+ { status: 400 }
+ );
+ }
+
+ const run = getRun(runId);
+ const status = await run.status;
+
+ if (status === "completed") {
+ const result = (await run.returnValue) as {
+ textContent: string;
+ ocrPages: Array<{
+ index: number;
+ markdown: string;
+ [key: string]: unknown;
+ }>;
+ };
+ return NextResponse.json({
+ status: "completed",
+ result: {
+ textContent: result.textContent ?? "",
+ ocrPages: result.ocrPages ?? [],
+ },
+ });
+ }
+
+ if (status === "failed") {
+ let errorMessage = "OCR failed";
+ try {
+ await run.returnValue;
+ } catch (err) {
+ errorMessage =
+ err instanceof Error ? err.message : String(err ?? "OCR failed");
+ }
+ return NextResponse.json({
+ status: "failed",
+ error: errorMessage,
+ });
+ }
+
+ return NextResponse.json({ status: "running" });
+ } catch (error: unknown) {
+ console.error("[PDF_OCR_STATUS] Error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Failed to get status",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 8377fb79..ab953405 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -44,7 +44,7 @@ import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-in
import { InviteGuard } from "@/components/workspace/InviteGuard";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
-import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr";
+import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
import { useFolderUrl } from "@/hooks/ui/use-folder-url";
import { OPEN_RECORD_PARAM } from "@/components/modals/RecordWorkspaceDialog";
@@ -344,45 +344,86 @@ function DashboardContent({
return;
}
- const ocrToastId = toast.loading(
- `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`,
+ const uploadToastId = toast.loading(
+ `Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`,
{ style: { color: "#fff" } }
);
- const pdfCardDefinitions = (
- await Promise.all(
- unprotectedFiles.map(async (file) => {
- try {
- const json = await uploadPdfAndRunOcr(file);
- const pdfData: Partial = {
- fileUrl: json.fileUrl,
- filename: json.filename,
- fileSize: json.fileSize,
- textContent: json.textContent,
- ocrPages: json.ocrPages,
- ocrStatus: json.ocrStatus,
- ...(json.ocrError && { ocrError: json.ocrError }),
- };
- return {
- type: "pdf" as const,
- name: file.name.replace(/\.pdf$/i, ""),
- initialData: pdfData,
- };
- } catch (err) {
- toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`);
- return null;
- }
- })
- )
- ).filter((r): r is NonNullable => r !== null);
+ const uploadResults = await Promise.all(
+ unprotectedFiles.map(async (file) => {
+ try {
+ const { url, filename, fileSize } = await uploadPdfToStorage(file);
+ return { file, fileUrl: url, filename, fileSize };
+ } catch (err) {
+ toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`);
+ return null;
+ }
+ })
+ );
- toast.dismiss(ocrToastId);
+ toast.dismiss(uploadToastId);
- if (pdfCardDefinitions.length === 0) return;
+ const validUploads = uploadResults.filter((r): r is NonNullable => r !== null);
+ if (validUploads.length === 0) return;
+
+ const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({
+ type: "pdf" as const,
+ name: file.name.replace(/\.pdf$/i, ""),
+ initialData: {
+ fileUrl,
+ filename,
+ fileSize,
+ ocrStatus: "processing" as const,
+ ocrPages: [],
+ } as Partial,
+ }));
- // Create all PDF cards and navigate to the first one
const createdIds = operations.createItems(pdfCardDefinitions);
handleCreatedItems(createdIds);
+
+ // Run OCR via workflow; poller dispatches pdf-processing-complete
+ validUploads.forEach((r, i) => {
+ const itemId = createdIds[i];
+ if (!itemId) return;
+ fetch("/api/pdf/ocr/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fileUrl: r.fileUrl, itemId }),
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.runId && data.itemId) {
+ import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) =>
+ pollPdfOcr(data.runId, data.itemId)
+ );
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed" as const,
+ ocrError: data.error || "Failed to start OCR",
+ },
+ })
+ );
+ }
+ })
+ .catch((err) => {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed" as const,
+ ocrError: err.message || "Failed to start OCR",
+ },
+ })
+ );
+ });
+ });
},
[operations, currentWorkspaceId, handleCreatedItems]
);
diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx
index e4d6032c..08322cd4 100644
--- a/src/components/assistant-ui/attachment.tsx
+++ b/src/components/assistant-ui/attachment.tsx
@@ -467,7 +467,7 @@ export const ComposerAddAttachment: FC = () => {
if (!files || files.length === 0) return;
const MAX_FILES = 10;
- const MAX_FILE_SIZE_MB = 10;
+ const MAX_FILE_SIZE_MB = 50;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const fileArray = Array.from(files);
diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx
index 884a865f..96953428 100644
--- a/src/components/assistant-ui/markdown-text.tsx
+++ b/src/components/assistant-ui/markdown-text.tsx
@@ -40,6 +40,26 @@ import { cn } from "@/lib/utils";
const math = createMathPlugin({ singleDollarTextMath: true });
const code = createCodePlugin({ themes: ["one-dark-pro", "one-dark-pro"] });
+/** Parse page number from citation ref (e.g. "Title | quote | p. 5" or "Title | p. 5"). */
+function parseCitationPage(ref: string): { title: string; quote?: string; pageNumber?: number } {
+ const segments = ref.split(/\s*\|\s*/).map((s) => s.trim()).filter(Boolean);
+ if (segments.length === 0) return { title: "" };
+
+ const last = segments[segments.length - 1];
+ const pageMatch = last.match(/^(?:p\.?\s*)?(\d+)$/i) || last.match(/^page\s*(\d+)$/i);
+ let pageNumber: number | undefined;
+ let title: string;
+ let quote: string | undefined;
+
+ if (pageMatch) {
+ pageNumber = parseInt(pageMatch[1], 10);
+ segments.pop();
+ }
+ title = segments[0] ?? "";
+ quote = segments.length > 1 ? segments.slice(1).join(" | ") : undefined;
+ return { title, quote: quote || undefined, pageNumber };
+}
+
/** Extract raw text from citation element children (handles nested elements). */
function extractCitationText(children: ReactNode): string {
if (typeof children === "string") return children.trim();
@@ -78,10 +98,11 @@ const CitationRenderer = memo(
return ;
}
- // Workspace citation: Title or Title|quote
- const pipeIdx = ref.indexOf(" | ");
- const title = pipeIdx !== -1 ? ref.slice(0, pipeIdx).trim() : ref;
- const quote = pipeIdx !== -1 ? ref.slice(pipeIdx + 3).trim() : undefined;
+ // Workspace citation: Title, Title|quote, or Title|quote|p. 5 (with optional page)
+ const parsed = parseCitationPage(ref);
+ const { title: parsedTitle, quote: parsedQuote, pageNumber } = parsed;
+ const title = parsedTitle;
+ const quote = parsedQuote;
const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { state: workspaceState } = useWorkspaceState(workspaceId);
@@ -98,8 +119,13 @@ const CitationRenderer = memo(
titleNorm(i.name) === titleNorm(title)
);
if (!item) return;
- if (quote?.trim()) {
- setCitationHighlightQuery({ itemId: item.id, query: quote.trim() });
+ // Set citation highlight: for PDFs with page, or when we have a quote to search
+ if (quote?.trim() || (pageNumber != null && item.type === "pdf")) {
+ setCitationHighlightQuery({
+ itemId: item.id,
+ query: quote?.trim() ?? "",
+ ...(pageNumber != null && { pageNumber }),
+ });
}
navigateToItem(item.id, { silent: true });
setOpenModalItemId(item.id);
@@ -116,7 +142,10 @@ const CitationRenderer = memo(
20 ? "…" : "")}
+ fallbackLabel={
+ title.slice(0, 20) + (title.length > 20 ? "…" : "") +
+ (pageNumber != null ? ` · p.${pageNumber}` : "")
+ }
/>
{quote && {quote} }
+ {pageNumber != null && (
+
+ Page {pageNumber}
+
+ )}
diff --git a/src/components/modals/UploadDialog.tsx b/src/components/modals/UploadDialog.tsx
index e1202cfd..e26d6105 100644
--- a/src/components/modals/UploadDialog.tsx
+++ b/src/components/modals/UploadDialog.tsx
@@ -71,17 +71,20 @@ export function UploadDialog({
}
}, [open]);
- // Upload multiple image files
+ // Upload multiple image files in parallel
const uploadImageFiles = useCallback(async (files: File[]) => {
setIsUploading(true);
const toastId = toast.loading(`Uploading ${files.length} image${files.length > 1 ? 's' : ''}...`);
try {
- for (const file of files) {
- const result = await uploadFileDirect(file);
- const simpleName = file.name.split('.').slice(0, -1).join('.') || "Image";
- onImageCreate(result.url, simpleName);
- }
+ const results = await Promise.all(
+ files.map(async (file) => {
+ const result = await uploadFileDirect(file);
+ const simpleName = file.name.split('.').slice(0, -1).join('.') || "Image";
+ return { url: result.url, simpleName };
+ })
+ );
+ results.forEach(({ url, simpleName }) => onImageCreate(url, simpleName));
toast.dismiss(toastId);
toast.success(`${files.length} image${files.length > 1 ? 's' : ''} uploaded successfully`);
diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx
index 325e6a5f..ffda59d5 100644
--- a/src/components/pdf/AppPdfViewer.tsx
+++ b/src/components/pdf/AppPdfViewer.tsx
@@ -695,72 +695,101 @@ const PdfSearchBar = ({ documentId }: { documentId: string }) => {
const PDF_HIGHLIGHT_DURATION_MS = 2500;
-/** Syncs citation highlight query from store: search PDF and scroll to first match */
+/** Syncs citation highlight query from store: search PDF and scroll to first match, or scroll to page if no results */
const PdfCitationHighlightSync = ({ documentId, itemId }: { documentId: string; itemId?: string }) => {
const { state, provides } = useSearch(documentId);
const { provides: scrollCapability } = useScrollCapability();
+ const { state: scrollState } = useScroll(documentId);
const scroll = scrollCapability?.forDocument(documentId);
const triggeredRef = useRef(null);
+ const pageFallbackRef = useRef(null);
const timeoutRef = useRef | undefined>(undefined);
useEffect(() => {
if (!itemId) return;
- const applyHighlight = (query: string) => {
- if (!provides || !query?.trim()) return;
- triggeredRef.current = query;
- provides.searchAllPages(query);
+ const clearCitation = () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = undefined;
+ provides?.stopSearch();
+ useUIStore.getState().setCitationHighlightQuery(null);
+ triggeredRef.current = null;
+ pageFallbackRef.current = null;
+ };
+
+ const applyCitation = (hl: { itemId: string; query: string; pageNumber?: number }) => {
+ const hasQuery = !!hl.query?.trim();
+ const hasPage = hl.pageNumber != null && hl.pageNumber >= 1;
+
+ if (hasQuery) {
+ triggeredRef.current = hl.query.trim();
+ pageFallbackRef.current = hasPage ? hl.pageNumber! : null;
+ provides?.searchAllPages(hl.query.trim());
+ } else if (hasPage && scroll && hl.pageNumber != null) {
+ // No query: scroll directly to page
+ const totalPages = scrollState?.totalPages ?? 1;
+ const page = Math.min(Math.max(1, hl.pageNumber), totalPages);
+ scroll.scrollToPage({ pageNumber: page });
+ pageFallbackRef.current = null;
+ }
if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => {
- provides.stopSearch();
- useUIStore.getState().setCitationHighlightQuery(null);
- triggeredRef.current = null;
- }, PDF_HIGHLIGHT_DURATION_MS);
+ timeoutRef.current = setTimeout(clearCitation, PDF_HIGHLIGHT_DURATION_MS);
};
const unsub = useUIStore.subscribe((storeState) => {
const hl = storeState.citationHighlightQuery;
- if (!hl || hl.itemId !== itemId || !hl.query?.trim()) return;
- applyHighlight(hl.query.trim());
+ if (!hl || hl.itemId !== itemId) return;
+ if (!hl.query?.trim() && !(hl.pageNumber != null && hl.pageNumber >= 1)) return;
+ applyCitation(hl);
});
const hl = useUIStore.getState().citationHighlightQuery;
- if (hl?.itemId === itemId && hl.query?.trim()) {
- applyHighlight(hl.query.trim());
+ if (hl?.itemId === itemId && (hl.query?.trim() || (hl.pageNumber != null && hl.pageNumber >= 1))) {
+ applyCitation(hl);
}
return () => {
unsub();
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ clearCitation();
};
- }, [documentId, itemId, provides]);
+ }, [documentId, itemId, provides, scroll, scrollState?.totalPages]);
- // Scroll to first result when search completes
+ // Scroll to first result when search completes, or fallback to page when no results
useEffect(() => {
if (!scroll || !triggeredRef.current || state.loading) return;
- const results = state.results;
- if (!results?.length) return;
-
- const idx = state.activeResultIndex ?? 0;
- const item = results[idx];
- if (!item) return;
-
- const minCoordinates = item.rects.reduce(
- (min, rect) => ({
- x: Math.min(min.x, rect.origin.x),
- y: Math.min(min.y, rect.origin.y),
- }),
- { x: Infinity, y: Infinity }
- );
+ const results = state.results ?? [];
+ const pageFallback = pageFallbackRef.current;
+
+ if (results.length > 0) {
+ const idx = state.activeResultIndex ?? 0;
+ const item = results[idx];
+ if (item) {
+ const minCoordinates = item.rects.reduce(
+ (min, rect) => ({
+ x: Math.min(min.x, rect.origin.x),
+ y: Math.min(min.y, rect.origin.y),
+ }),
+ { x: Infinity, y: Infinity }
+ );
+ scroll.scrollToPage({
+ pageNumber: item.pageIndex + 1,
+ pageCoordinates: minCoordinates,
+ alignX: 50,
+ alignY: 50,
+ });
+ return;
+ }
+ }
- scroll.scrollToPage({
- pageNumber: item.pageIndex + 1,
- pageCoordinates: minCoordinates,
- alignX: 50,
- alignY: 50,
- });
- }, [scroll, state.results, state.activeResultIndex, state.loading]);
+ // Text search returned no results: fallback to page number if provided
+ if (results.length === 0 && pageFallback != null) {
+ const totalPages = scrollState?.totalPages ?? 1;
+ const page = Math.min(Math.max(1, pageFallback), totalPages);
+ scroll.scrollToPage({ pageNumber: page });
+ pageFallbackRef.current = null;
+ }
+ }, [scroll, state.results, state.activeResultIndex, state.loading, scrollState?.totalPages]);
return null;
};
diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
index 9ab4f53d..c6908e20 100644
--- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
+++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx
@@ -11,7 +11,7 @@ import type { PdfData, ImageData, AudioData } from "@/lib/workspace-state/types"
import { getBestFrameForRatio, type GridFrame } from "@/lib/workspace-state/aspect-ratios";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
import { uploadFileDirect } from "@/lib/uploads/client-upload";
-import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr";
+import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
@@ -147,7 +147,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
const pdfFiles = filteredFiles.filter((f) => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
const nonPdfFiles = filteredFiles.filter((f) => f.type !== 'application/pdf' && !f.name.toLowerCase().endsWith('.pdf'));
- // PDFs: direct upload to Supabase + OCR from URL (bypasses 10MB body limit)
+ // PDFs: upload to storage only (non-blocking), OCR runs in background
const pdfResults: Array<{
fileUrl: string;
filename: string;
@@ -156,40 +156,32 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
pdfData: Partial;
}> = [];
if (pdfFiles.length > 0) {
- const pdfToastId = toast.loading(
- `Uploading and extracting text from ${pdfFiles.length} PDF${pdfFiles.length > 1 ? 's' : ''}...`,
- { style: { color: '#fff' } }
- );
-
const pdfPromises = pdfFiles.map(async (file) => {
try {
- const json = await uploadPdfAndRunOcr(file);
+ const { url, filename, fileSize } = await uploadPdfToStorage(file);
return {
- fileUrl: json.fileUrl,
- filename: json.filename,
- fileSize: json.fileSize,
+ fileUrl: url,
+ filename,
+ fileSize,
name: file.name.replace(/\.pdf$/i, ''),
pdfData: {
- fileUrl: json.fileUrl,
- filename: json.filename,
- fileSize: json.fileSize,
- textContent: json.textContent,
- ocrPages: json.ocrPages,
- ocrStatus: json.ocrStatus,
- ...(json.ocrError && { ocrError: json.ocrError }),
+ fileUrl: url,
+ filename,
+ fileSize,
+ ocrStatus: "processing" as const,
+ ocrPages: [],
} as Partial,
};
} catch (err) {
const fileKey = getFileKey(file);
processingFilesRef.current.delete(fileKey);
- console.error('PDF upload-and-OCR failed:', err);
+ console.error('PDF upload failed:', err);
return null;
}
});
const results = await Promise.all(pdfPromises);
pdfResults.push(...results.filter((r): r is NonNullable => r !== null));
- toast.dismiss(pdfToastId);
}
// Non-PDFs: upload only
@@ -231,7 +223,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
else imageResults.push(r);
});
- // Create PDF cards (OCR data already included)
+ // Create PDF cards (OCR runs in background)
if (pdfResults.length > 0) {
const pdfCardDefinitions = pdfResults.map((r) => ({
type: 'pdf' as const,
@@ -240,6 +232,49 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro
}));
const pdfCreatedIds = operations.createItems(pdfCardDefinitions);
handleCreatedItems(pdfCreatedIds);
+ // Run OCR via workflow; poller dispatches pdf-processing-complete
+ pdfResults.forEach((r, i) => {
+ const itemId = pdfCreatedIds[i];
+ if (!itemId) return;
+ fetch("/api/pdf/ocr/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fileUrl: r.fileUrl, itemId }),
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.runId && data.itemId) {
+ import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) =>
+ pollPdfOcr(data.runId, data.itemId)
+ );
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed" as const,
+ ocrError: data.error || "Failed to start OCR",
+ },
+ })
+ );
+ }
+ })
+ .catch((err) => {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed" as const,
+ ocrError: err.message || "Failed to start OCR",
+ },
+ })
+ );
+ });
+ });
}
toast.dismiss(loadingToastId);
diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx
index bf5b1ab3..0608cbc5 100644
--- a/src/components/workspace-canvas/WorkspaceCard.tsx
+++ b/src/components/workspace-canvas/WorkspaceCard.tsx
@@ -1,6 +1,6 @@
import { QuizContent } from "./QuizContent";
import { ImageCardContent } from "./ImageCardContent";
-import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil, Columns, Link2, PanelRight, SplitSquareHorizontal } from "lucide-react";
+import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil, Columns, Link2, PanelRight, SplitSquareHorizontal, Loader2 } from "lucide-react";
import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi";
import { useCallback, useState, memo, useRef, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";
@@ -815,11 +815,18 @@ function WorkspaceCard({
{/* Subtle type label for narrow cards without preview; hover shows "EXPAND OR CLICK TO VIEW" */}
{(item.type === 'note' || item.type === 'pdf' || item.type === 'quiz' || item.type === 'audio') && !shouldShowPreview && (
-
-
- {item.type === 'note' ? 'Note' : item.type === 'pdf' ? 'PDF' : item.type === 'quiz' ? 'Quiz' : 'Recording'}
+
+
+ {item.type === 'note' ? 'Note' : item.type === 'pdf' ? (
+ (item.data as PdfData)?.ocrStatus === 'processing' ? (
+ <>
+
+ Extracting…
+ >
+ ) : 'PDF'
+ ) : item.type === 'quiz' ? 'Quiz' : 'Recording'}
-
+
Expand or click to view
@@ -837,10 +844,11 @@ function WorkspaceCard({
{/* When scroll is locked, render lightweight placeholder instead of full PDF viewer */}
{!isOpenInPanel && item.type === 'pdf' && shouldShowPreview && (() => {
const pdfData = item.data as PdfData;
+ const isOcrProcessing = pdfData?.ocrStatus === 'processing';
return (
{!isCardVisible ? (
@@ -856,6 +864,16 @@ function WorkspaceCard({
) : (
)}
+ {/* OCR processing indicator overlay */}
+ {isOcrProcessing && (
+
+
+ Extracting…
+
+ )}
);
})()}
diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx
index a92e4c39..224145e9 100644
--- a/src/components/workspace-canvas/WorkspaceContent.tsx
+++ b/src/components/workspace-canvas/WorkspaceContent.tsx
@@ -149,6 +149,31 @@ export default function WorkspaceContent({
onOpenFolder?.(folderId);
}, [setActiveFolderId, onOpenFolder]);
+ // Listen for PDF OCR completion events
+ useEffect(() => {
+ const handlePdfComplete = (e: Event) => {
+ const { itemId, textContent, ocrPages, ocrStatus, ocrError } = (e as CustomEvent).detail;
+ if (!itemId) return;
+
+ const existingData = viewState.items.find((i) => i.id === itemId)?.data ?? {};
+
+ updateItem(itemId, {
+ data: {
+ ...existingData,
+ textContent,
+ ocrPages: ocrPages ?? [],
+ ocrStatus,
+ ...(ocrError && { ocrError }),
+ } as any,
+ });
+ };
+
+ window.addEventListener("pdf-processing-complete", handlePdfComplete);
+ return () => {
+ window.removeEventListener("pdf-processing-complete", handlePdfComplete);
+ };
+ }, [updateItem, viewState.items]);
+
// Listen for audio processing completion events
useEffect(() => {
const handleAudioComplete = (e: Event) => {
diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx
index 6c230107..1b5ee4e0 100644
--- a/src/components/workspace-canvas/WorkspaceSection.tsx
+++ b/src/components/workspace-canvas/WorkspaceSection.tsx
@@ -15,7 +15,7 @@ import { useSession } from "@/lib/auth-client";
import { LoginGate } from "@/components/workspace/LoginGate";
import { AccessDenied } from "@/components/workspace/AccessDenied";
import { uploadFileDirect } from "@/lib/uploads/client-upload";
-import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr";
+import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
@@ -462,47 +462,91 @@ export function WorkspaceSection({
return;
}
- const ocrToastId = toast.loading(
- `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? 's' : ''}...`,
+ const uploadToastId = toast.loading(
+ `Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? 's' : ''}...`,
{ style: { color: '#fff' } }
);
- const uploadAndOcrPromises = unprotectedFiles.map(async (file) => {
- try {
- const json = await uploadPdfAndRunOcr(file);
- const pdfData: Partial = {
- fileUrl: json.fileUrl,
- filename: json.filename,
- fileSize: json.fileSize,
- textContent: json.textContent,
- ocrPages: json.ocrPages,
- ocrStatus: json.ocrStatus,
- ...(json.ocrError && { ocrError: json.ocrError }),
- };
- return {
- type: 'pdf' as const,
- name: file.name.replace(/\.pdf$/i, ''),
- initialData: pdfData,
- };
- } catch (err) {
- toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
- return null;
- }
- });
-
- const pdfCardDefinitions = (await Promise.all(uploadAndOcrPromises))
- .filter((r): r is NonNullable => r !== null);
-
- toast.dismiss(ocrToastId);
+ const uploadResults = await Promise.all(
+ unprotectedFiles.map(async (file) => {
+ try {
+ const { url, filename, fileSize } = await uploadPdfToStorage(file);
+ return {
+ file,
+ fileUrl: url,
+ filename,
+ fileSize,
+ };
+ } catch (err) {
+ toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
+ return null;
+ }
+ })
+ );
- if (pdfCardDefinitions.length > 0) {
+ toast.dismiss(uploadToastId);
- // Create all PDF cards atomically in a single event
- const createdIds = operations.createItems(pdfCardDefinitions);
+ const validUploads = uploadResults.filter((r): r is NonNullable => r !== null);
+ if (validUploads.length === 0) return;
- // Auto-navigate to first created item
- handleCreatedItems(createdIds);
- }
+ const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({
+ type: 'pdf' as const,
+ name: file.name.replace(/\.pdf$/i, ''),
+ initialData: {
+ fileUrl,
+ filename,
+ fileSize,
+ ocrStatus: 'processing' as const,
+ ocrPages: [],
+ } as Partial,
+ }));
+
+ const createdIds = operations.createItems(pdfCardDefinitions);
+ handleCreatedItems(createdIds);
+
+ // Run OCR via workflow; poller dispatches pdf-processing-complete
+ validUploads.forEach((r, i) => {
+ const itemId = createdIds[i];
+ if (!itemId) return;
+ fetch("/api/pdf/ocr/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fileUrl: r.fileUrl, itemId }),
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.runId && data.itemId) {
+ import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) =>
+ pollPdfOcr(data.runId, data.itemId)
+ );
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed" as const,
+ ocrError: data.error || "Failed to start OCR",
+ },
+ })
+ );
+ }
+ })
+ .catch((err) => {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed" as const,
+ ocrError: err.message || "Failed to start OCR",
+ },
+ })
+ );
+ });
+ });
};
diff --git a/src/hooks/workspace/use-workspace-mutation.ts b/src/hooks/workspace/use-workspace-mutation.ts
index 918398a7..02ab4131 100644
--- a/src/hooks/workspace/use-workspace-mutation.ts
+++ b/src/hooks/workspace/use-workspace-mutation.ts
@@ -199,11 +199,18 @@ export function useWorkspaceMutation(workspaceId: string | null, options: Worksp
onSuccess: (data, event, context) => {
if (!workspaceId) return;
- logger.debug("✅ [SUCCESS] Mutation succeeded:", {
- conflict: data.conflict,
- newVersion: data.version,
- eventId: event.id,
- });
+ logger.debug("✅ [SUCCESS] Mutation succeeded:", {
+ conflict: data.conflict,
+ newVersion: data.version,
+ eventId: event.id,
+ });
+ if (event.type === "ITEM_UPDATED" && event.payload?.changes?.data?.ocrStatus) {
+ console.log("[OCR/UPDATE] ITEM_UPDATED persisted:", {
+ itemId: event.payload.id,
+ ocrStatus: event.payload.changes.data.ocrStatus,
+ version: data.version,
+ });
+ }
// Handle conflicts with automatic retry
if (data.conflict) {
diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts
index 4820b58f..5b97f091 100644
--- a/src/hooks/workspace/use-workspace-operations.ts
+++ b/src/hooks/workspace/use-workspace-operations.ts
@@ -370,10 +370,34 @@ export function useWorkspaceOperations(
const timeout = setTimeout(() => {
const finalUpdater = pendingItemDataUpdatersRef.current.get(itemId);
if (finalUpdater) {
- // Get the latest item state when the timeout fires
- const latestItem = currentState.items.find(item => item.id === itemId);
+ // CRITICAL: Read latest state from cache (not currentState closure) so we get
+ // items created after this callback was invoked (e.g. OCR completing after PDF create)
+ let latestItem: Item | undefined;
+ if (workspaceId) {
+ const cacheData = queryClient.getQueryData([
+ "workspace",
+ workspaceId,
+ "events",
+ ]);
+ if (cacheData?.events) {
+ const latestState = replayEvents(cacheData.events, workspaceId, cacheData.snapshot?.state);
+ latestItem = latestState.items.find(item => item.id === itemId);
+ }
+ }
+ if (!latestItem) {
+ latestItem = currentState.items.find(item => item.id === itemId);
+ }
if (!latestItem) {
- logger.warn(`updateItemData: Item ${itemId} not found when applying debounced update`);
+ const cacheData = workspaceId
+ ? queryClient.getQueryData(["workspace", workspaceId, "events"])
+ : null;
+ const itemCount = cacheData?.events
+ ? replayEvents(cacheData.events, workspaceId!, cacheData.snapshot?.state).items.length
+ : 0;
+ logger.warn(`[OCR/UPDATE] updateItemData: Item ${itemId} not found. Item may have been deleted. Cache has ${itemCount} items.`, {
+ itemId,
+ workspaceId,
+ });
pendingItemDataUpdatersRef.current.delete(itemId);
updateItemDataDebounceRef.current.delete(itemId);
return;
@@ -382,10 +406,12 @@ export function useWorkspaceOperations(
// Apply the final updater to get new data
const newData = finalUpdater(latestItem.data);
- logger.debug("⏱️ [DEBOUNCE] updateItemData firing after 500ms:", {
+ const ocrStatus = (newData as { ocrStatus?: string })?.ocrStatus;
+ logger.debug("[OCR/UPDATE] updateItemData firing:", {
itemId,
- hasDataChanges: true,
- dataKeys: Object.keys(newData)
+ itemName: latestItem.name,
+ ocrStatus,
+ dataKeys: Object.keys(newData),
});
// Emit update event with new data - include name for version history display
@@ -404,7 +430,7 @@ export function useWorkspaceOperations(
updateItemDataDebounceRef.current.set(itemId, timeout);
},
- [currentState, mutation, userId, userName]
+ [workspaceId, queryClient, currentState, mutation, userId, userName]
);
diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts
index 24fd9a88..e650fd2d 100644
--- a/src/lib/ai/tools/process-files.ts
+++ b/src/lib/ai/tools/process-files.ts
@@ -2,9 +2,6 @@ import { google } from "@ai-sdk/google";
import { generateText, tool, zodSchema } from "ai";
import { z } from "zod";
import { logger } from "@/lib/utils/logger";
-import { readFile } from "fs/promises";
-import { join } from "path";
-import { existsSync } from "fs";
import { headers } from "next/headers";
import { loadStateForTool, fuzzyMatchItem } from "./tool-utils";
import type { WorkspaceToolContext } from "./workspace-tools";
@@ -86,96 +83,6 @@ function buildFileProcessingPrompt(
return { defaultInstruction, outputFormat };
}
-/**
- * Extract filename from local file URL
- */
-function extractLocalFilename(url: string): string | null {
- // Match: http://localhost:3000/api/files/filename or /api/files/filename
- const match = url.match(/\/api\/files\/(.+?)(?:\?|$)/);
- return match ? decodeURIComponent(match[1]) : null;
-}
-
-/**
- * Process local files by reading from disk and sending as base64 to Gemini
- */
-async function processLocalFiles(
- localUrls: string[],
- instruction?: string
-): Promise {
- const uploadsDir = process.env.UPLOADS_DIR || join(process.cwd(), 'uploads');
- const fileInfos: Array<{ filename: string; mediaType: string; data: string }> = [];
-
- for (const url of localUrls) {
- const filename = extractLocalFilename(url);
- if (!filename) {
- logger.warn(`📁 [FILE_TOOL] Could not extract filename from local URL: ${url}`);
- continue;
- }
-
- // Security: Prevent directory traversal (slash and backslash for Windows)
- if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
- logger.warn(`📁 [FILE_TOOL] Invalid filename detected: ${filename}`);
- continue;
- }
-
- const filePath = join(uploadsDir, filename);
-
- if (!existsSync(filePath)) {
- logger.warn(`📁 [FILE_TOOL] File not found: ${filePath}`);
- continue;
- }
-
- try {
- const fileBuffer = await readFile(filePath);
- const base64Data = fileBuffer.toString('base64');
- const mediaType = getMediaTypeFromUrl(url);
-
- fileInfos.push({
- filename,
- mediaType,
- data: `data:${mediaType};base64,${base64Data}`,
- });
- } catch (error) {
- logger.error(`📁 [FILE_TOOL] Error reading local file ${filePath}:`, error);
- continue;
- }
- }
-
- if (fileInfos.length === 0) {
- return "No local files could be processed";
- }
-
- const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n');
- const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos);
-
- const batchPrompt = instruction
- ? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\n${outputFormat}`
- : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`;
-
- const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [
- { type: "text", text: batchPrompt },
- ...fileInfos.map((f) => ({
- type: "file" as const,
- data: f.data,
- mediaType: f.mediaType,
- filename: f.filename,
- })),
- ];
-
- logger.debug("📁 [FILE_TOOL] Sending batched analysis request for", fileInfos.length, "local files");
-
- const { text: batchAnalysis } = await generateText({
- model: google("gemini-2.5-flash-lite"),
- messages: [{
- role: "user",
- content: messageContent,
- }],
- });
-
- logger.debug("📁 [FILE_TOOL] Successfully analyzed", fileInfos.length, "local files in batch");
- return batchAnalysis;
-}
-
/**
* Process Supabase storage files by sending URLs directly to Gemini
*/
@@ -369,15 +276,15 @@ async function processYouTubeVideo(
*/
export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
return tool({
- description: "Process and analyze files including PDFs, images, documents, and videos. Handles local file URLs (/api/files/...), Supabase storage URLs (files uploaded to your workspace), YouTube videos, and images extracted from PDFs (use pdfImageRefs for placeholders like img-0.jpeg from readWorkspace). Use processFiles for file URLs, video URLs, workspace file names (fuzzy matched), OR pdfImageRefs to analyze images inside PDFs. For workspace PDFs without OCR content (ocr=none), processFiles runs OCR first and caches the result; if OCR fails it falls back to Gemini via the file URL. If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache.",
+ description: "Process and analyze files including PDFs, images, documents, and videos. Handles Supabase storage URLs (files uploaded to your workspace), YouTube videos, and images from PDFs (use pdfImageRefs for placeholders like img-0.jpeg from readWorkspace). Use processFiles for file URLs, video URLs, workspace file names (fuzzy matched), OR pdfImageRefs to analyze images inside PDFs. For workspace PDFs without extracted content, processFiles extracts and caches the result; if extraction fails it falls back to Gemini via the file URL. If a PDF is still extracting, respond that the user should wait. If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache. Cloud storage URLs only.",
inputSchema: zodSchema(
z.object({
- urls: z.array(z.string()).optional().describe("Array of file/video URLs to process (Supabase storage URLs, local /api/files/ URLs, or YouTube URLs)"),
+ urls: z.array(z.string()).optional().describe("Array of file/video URLs to process (Supabase storage URLs or YouTube URLs)"),
fileNames: z.array(z.string()).optional().describe("Array of workspace item names to look up via fuzzy match (e.g. 'Annual Report')"),
pdfImageRefs: z.array(z.object({
pdfName: z.string().describe("Name of the PDF workspace item (fuzzy matched)"),
- imageId: z.string().describe("Image placeholder ID from OCR output (e.g. img-0.jpeg)"),
- })).optional().describe("Images extracted from PDFs during OCR — map placeholder names to base64 for analysis"),
+ imageId: z.string().describe("Image placeholder ID from PDF (e.g. img-0.jpeg)"),
+ })).optional().describe("Images from PDFs — map placeholder names to base64 for analysis"),
instruction: z.string().optional().describe("Custom instruction for what to extract or focus on during analysis"),
forceReprocess: z.boolean().optional().describe("Set to true to bypass cached PDF content and re-analyze the file"),
})
@@ -387,8 +294,6 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
const fileNames = fileNamesInput || [];
const forceReprocess = forceReprocessInput === true;
- // Track matched PDF items for auto-caching after extraction
- const matchedPdfItems: Map = new Map(); // fileUrl -> Item
const cachedResults: string[] = [];
// Resolve file names to URLs using fuzzy matching if context is available
@@ -416,6 +321,13 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
continue; // Skip adding to urlList — no reprocessing needed
}
+ // OCR already running in background from upload — respond instead of Gemini fallback
+ if (pdfData.ocrStatus === "processing") {
+ cachedResults.push(`**${matchedItem.name}**: This PDF is still being extracted. Please wait a moment and try again.`);
+ logger.debug(`📁 [FILE_TOOL] PDF "${name}" still processing — responding with wait message`);
+ continue;
+ }
+
if (pdfData.fileUrl) {
// Try OCR first (reuses upload+extract logic); fall back to Supabase URL if OCR fails
const ocrResult = await runOcrForPdfUrl(pdfData.fileUrl);
@@ -424,7 +336,7 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
? formatOcrPagesAsMarkdown(ocrResult.ocrPages)
: ocrResult.textContent;
logger.debug(`📁 [FILE_TOOL] OCR extracted content for "${name}" (${formatted.length} chars)`);
- cachedResults.push(`**${matchedItem.name}** (OCR):\n\n${formatted}`);
+ cachedResults.push(`**${matchedItem.name}** (extracted):\n\n${formatted}`);
// Persist OCR result so future calls use cached content
try {
await workspaceWorker("updatePdfContent", {
@@ -442,7 +354,6 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
}
// OCR failed — fall back to Supabase URL (Gemini)
urlList.push(pdfData.fileUrl);
- matchedPdfItems.set(pdfData.fileUrl, matchedItem);
logger.debug(`📁 [FILE_TOOL] Resolved file name "${name}" to URL (Supabase fallback): ${pdfData.fileUrl}`);
} else {
notFoundData.push(`Item "${name}" found but has no file URL.`);
@@ -515,28 +426,26 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
return `Too many files (${urlList.length}). Maximum 20 files allowed.`;
}
- // Separate file URLs by type
+ // Separate file URLs by type (cloud only — no local /api/files/)
const supabaseUrls = urlList.filter((url: string) => url.includes('supabase.co/storage'));
- const localUrls = urlList.filter((url: string) => url.includes('/api/files/'));
const youtubeUrls = urlList.filter((url: string) => url.match(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/));
+ const skippedLocal = urlList.filter((url: string) => url.includes('/api/files/'));
+ if (skippedLocal.length > 0) {
+ logger.debug(`📁 [FILE_TOOL] Skipping ${skippedLocal.length} local file URL(s) — cloud storage only`);
+ }
+
+ if (supabaseUrls.length === 0 && youtubeUrls.length === 0) {
+ return skippedLocal.length > 0
+ ? "Local file URLs (/api/files/...) are not supported. Use Supabase storage URLs (files uploaded to your workspace) or YouTube URLs."
+ : "No file URLs provided (and no file names or pdfImageRefs could be resolved).";
+ }
+
const fileResults: string[] = [];
// Process different file types in parallel using Promise.all()
const processingPromises: Promise[] = [];
- // Handle local file URLs (read from disk and send as base64)
- if (localUrls.length > 0) {
- processingPromises.push(
- processLocalFiles(localUrls, instruction)
- .then(result => result)
- .catch(error => {
- logger.error("📁 [FILE_TOOL] Error in local file processing:", error);
- return `Error processing local files: ${error instanceof Error ? error.message : String(error)}`;
- })
- );
- }
-
// Handle Supabase file URLs
if (supabaseUrls.length > 0) {
processingPromises.push(
@@ -576,23 +485,7 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) {
fileResults.push(...results.filter((r): r is string => r !== null));
}
- // Auto-persist extracted content to matched PDF items (fire-and-forget)
- if (matchedPdfItems.size > 0 && ctx?.workspaceId && fileResults.length > 0) {
- const combinedResult = fileResults.join('\n\n---\n\n');
- for (const [fileUrl, item] of matchedPdfItems) {
- try {
- await workspaceWorker("updatePdfContent", {
- workspaceId: ctx.workspaceId,
- itemId: item.id,
- pdfTextContent: combinedResult,
- });
- logger.debug(`📁 [FILE_TOOL] Auto-cached extracted content for PDF "${item.name}" (${combinedResult.length} chars)`);
- } catch (cacheError) {
- // Non-fatal: log but don't fail the tool call
- logger.warn(`📁 [FILE_TOOL] Failed to auto-cache content for PDF "${item.name}":`, cacheError);
- }
- }
- }
+ // Never persist Gemini analysis to PDF items — only Azure OCR (runOcrForPdfUrl) writes ocrPages/textContent
// Prepend cached results if we had a mix of cached + freshly processed
if (cachedResults.length > 0) {
diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts
index 3d507d4d..af2ec395 100644
--- a/src/lib/pdf/azure-ocr.ts
+++ b/src/lib/pdf/azure-ocr.ts
@@ -1,17 +1,29 @@
/**
* Azure Mistral Document AI OCR for PDFs.
- * Splits large PDFs into 10-page batches (30 MB cap), processes in parallel, merges results.
+ * Splits PDFs into page batches (30 MB cap), processes in parallel, merges results.
+ * Chunk size is adaptive: smaller for few-page PDFs (max parallelism), larger for big PDFs (fewer API calls).
*/
import { PDFDocument } from "pdf-lib";
import { logger } from "@/lib/utils/logger";
-const MAX_PAGES_PER_BATCH = 10;
const MAX_BATCH_SIZE_BYTES = 30 * 1024 * 1024; // 30 MB
/** Max concurrent OCR requests to Azure; prevents 408 timeouts from request flooding */
const MAX_CONCURRENT_OCR = 5;
const DEFAULT_MODEL = "mistral-document-ai-2512";
+/**
+ * Choose chunk size based on total page count.
+ * Small PDFs: 1 page per chunk (max parallelism).
+ * Large PDFs: bigger chunks to reduce API calls and rate-limit risk.
+ */
+function getMaxPagesPerChunk(pageCount: number): number {
+ if (pageCount <= 10) return 1;
+ if (pageCount <= 30) return 3;
+ if (pageCount <= 100) return 6;
+ return 12;
+}
+
/** Rich OCR page data from Azure Document AI (stored in PdfData.ocrPages). Dimensions omitted. */
export interface OcrPage {
index: number;
@@ -47,8 +59,9 @@ interface AzureOcrResponse {
/**
* Call Azure Document AI OCR endpoint with a base64-encoded PDF chunk.
+ * Exported for use in workflow steps (single chunk = single step).
*/
-async function ocrChunk(base64Pdf: string): Promise {
+export async function ocrSingleChunk(base64Pdf: string): Promise {
const apiKey = process.env.AZURE_DOCUMENT_AI_API_KEY;
const endpoint = process.env.AZURE_DOCUMENT_AI_ENDPOINT;
const model =
@@ -118,7 +131,7 @@ async function ocrChunk(base64Pdf: string): Promise {
}
/**
- * Split a PDF buffer into page chunks. Respects 10 pages and ~30 MB per batch.
+ * Split a PDF buffer into page chunks. Respects maxPages and ~30 MB per batch.
*/
function getChunkRanges(
pageCount: number,
@@ -144,6 +157,38 @@ function getChunkRanges(
return ranges;
}
+/** Chunk spec for workflow steps - each chunk is a separate OCR step */
+export interface OcrChunkSpec {
+ start: number;
+ end: number;
+ base64: string;
+}
+
+/**
+ * Load PDF, compute chunk ranges, extract each chunk to base64.
+ * Returns chunk specs for workflow steps (avoids passing full PDF to each step).
+ */
+export async function prepareOcrChunks(buffer: Buffer): Promise<{
+ pageCount: number;
+ chunks: OcrChunkSpec[];
+}> {
+ const doc = await PDFDocument.load(buffer);
+ const pageCount = doc.getPageCount();
+ if (pageCount === 0) {
+ return { pageCount: 0, chunks: [] };
+ }
+
+ const maxPages = getMaxPagesPerChunk(pageCount);
+ const ranges = getChunkRanges(pageCount, buffer, maxPages);
+
+ const chunks: OcrChunkSpec[] = [];
+ for (const [start, end] of ranges) {
+ const base64 = await extractChunkAsBase64(doc, start, end);
+ chunks.push({ start, end, base64 });
+ }
+ return { pageCount, chunks };
+}
+
/**
* Extract a page range from a PDF as a new PDF buffer (base64).
*/
@@ -164,60 +209,73 @@ async function extractChunkAsBase64(
}
/**
- * Run OCR on a PDF buffer. Splits into batches if >10 pages or chunk >30 MB.
+ * Run OCR on a PDF buffer. Splits into batches; chunk size adapts to page count.
*/
export async function ocrPdfFromBuffer(
buffer: Buffer,
options?: { maxPagesPerBatch?: number }
): Promise {
const t0 = Date.now();
- const maxPages = options?.maxPagesPerBatch ?? MAX_PAGES_PER_BATCH;
const doc = await PDFDocument.load(buffer);
const pageCount = doc.getPageCount();
- logger.info("[PDF_OCR_AZURE] Loaded PDF", {
- pageCount,
- sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
- loadMs: Date.now() - t0,
- });
if (pageCount === 0) {
return { pages: [], textContent: "" };
}
+ const maxPages =
+ options?.maxPagesPerBatch ?? getMaxPagesPerChunk(pageCount);
const ranges = getChunkRanges(pageCount, buffer, maxPages);
- logger.info("[PDF_OCR_AZURE] Chunk plan", {
- chunkCount: ranges.length,
- concurrency: MAX_CONCURRENT_OCR,
- ranges: ranges.map(([s, e]) => `${s}-${e}`),
+
+ logger.info("[PDF_OCR_AZURE] Start", {
+ pageCount,
+ totalChunks: ranges.length,
+ pagesPerChunk: maxPages,
+ totalBytes: buffer.length,
+ totalBytesMB: (buffer.length / (1024 * 1024)).toFixed(2),
});
// Process in batches of MAX_CONCURRENT_OCR to avoid 408 timeouts from flooding Azure
const chunkResults: Array<{ start: number; end: number; pages: OcrPage[] }> = [];
for (let i = 0; i < ranges.length; i += MAX_CONCURRENT_OCR) {
const batch = ranges.slice(i, i + MAX_CONCURRENT_OCR);
+ const batchIndex = Math.floor(i / MAX_CONCURRENT_OCR) + 1;
+ const totalBatches = Math.ceil(ranges.length / MAX_CONCURRENT_OCR);
+
+ logger.info("[PDF_OCR_AZURE] Batch start", {
+ batch: `${batchIndex}/${totalBatches}`,
+ chunks: batch.map(([s, e]) => `pages ${s}-${e}`),
+ });
+
+ const batchT0 = Date.now();
const batchResults = await Promise.all(
batch.map(async ([start, end]) => {
- const tChunk = Date.now();
const base64 = await extractChunkAsBase64(doc, start, end);
- const extractMs = Date.now() - tChunk;
- const pages = await ocrChunk(base64);
- const ocrChunkMs = Date.now() - tChunk;
+ const chunkSizeKB = (Buffer.byteLength(base64, "utf8") * 3) / 4 / 1024; // approximate raw size
+ logger.debug("[PDF_OCR_AZURE] Chunk request", {
+ pages: `${start}-${end}`,
+ chunkSizeKB: chunkSizeKB.toFixed(0),
+ });
+ const pages = await ocrSingleChunk(base64);
logger.debug("[PDF_OCR_AZURE] Chunk done", {
- range: `${start}-${end}`,
- pages: pages.length,
- extractMs,
- ocrMs: ocrChunkMs - extractMs,
+ pages: `${start}-${end}`,
+ extractedPages: pages.length,
});
return { start, end, pages };
})
);
+ const batchMs = Date.now() - batchT0;
+
+ const batchPages = batchResults.reduce((sum, r) => sum + r.pages.length, 0);
+ logger.info("[PDF_OCR_AZURE] Batch complete", {
+ batch: `${batchIndex}/${totalBatches}`,
+ pagesProcessed: batchPages,
+ ms: batchMs,
+ });
+
chunkResults.push(...batchResults);
}
- logger.info("[PDF_OCR_AZURE] All chunks complete", {
- totalChunks: chunkResults.length,
- chunkMs: Date.now() - t0,
- });
const allPages: OcrPage[] = [];
let globalIndex = 0;
@@ -236,9 +294,8 @@ export async function ocrPdfFromBuffer(
.filter(Boolean)
.join("\n\n");
- logger.info("[PDF_OCR_AZURE] Merge complete", {
+ logger.info("[PDF_OCR_AZURE] Complete", {
pageCount: allPages.length,
- textContentLength: textContent.length,
totalMs: Date.now() - t0,
});
diff --git a/src/lib/pdf/poll-pdf-ocr.ts b/src/lib/pdf/poll-pdf-ocr.ts
new file mode 100644
index 00000000..54788ec2
--- /dev/null
+++ b/src/lib/pdf/poll-pdf-ocr.ts
@@ -0,0 +1,45 @@
+const POLL_INTERVAL_MS = 2000; // 2 seconds
+
+/**
+ * Polls the PDF OCR status endpoint until the workflow completes or fails.
+ * Dispatches pdf-processing-complete when done.
+ */
+export async function pollPdfOcr(runId: string, itemId: string): Promise {
+ while (true) {
+ const res = await fetch(
+ `/api/pdf/ocr/status?runId=${encodeURIComponent(runId)}`
+ );
+ const data = await res.json();
+
+ if (data.status === "completed") {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: data.result?.textContent ?? "",
+ ocrPages: data.result?.ocrPages ?? [],
+ ocrStatus: "complete" as const,
+ },
+ })
+ );
+ return;
+ }
+
+ if (data.status === "failed") {
+ window.dispatchEvent(
+ new CustomEvent("pdf-processing-complete", {
+ detail: {
+ itemId,
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed" as const,
+ ocrError: data.error || "OCR failed",
+ },
+ })
+ );
+ return;
+ }
+
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
+ }
+}
diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts
index ce293577..7873dcd4 100644
--- a/src/lib/stores/ui-store.ts
+++ b/src/lib/stores/ui-store.ts
@@ -57,7 +57,7 @@ interface UIState {
blockNoteSelection: { cardId: string; cardName: string; text: string } | null;
// Citation highlight: when opening note/PDF from citation click, highlight/search this quote
- citationHighlightQuery: { itemId: string; query: string } | null;
+ citationHighlightQuery: { itemId: string; query: string; pageNumber?: number } | null;
// Actions - Chat
setIsChatExpanded: (expanded: boolean) => void;
@@ -130,7 +130,7 @@ interface UIState {
// Actions - BlockNote selection
setBlockNoteSelection: (selection: { cardId: string; cardName: string; text: string } | null) => void;
clearBlockNoteSelection: () => void;
- setCitationHighlightQuery: (query: { itemId: string; query: string } | null) => void;
+ setCitationHighlightQuery: (query: { itemId: string; query: string; pageNumber?: number } | null) => void;
// Utility actions
resetChatState: () => void;
diff --git a/src/lib/uploads/pdf-upload-with-ocr.ts b/src/lib/uploads/pdf-upload-with-ocr.ts
index 26741c8a..9aec7302 100644
--- a/src/lib/uploads/pdf-upload-with-ocr.ts
+++ b/src/lib/uploads/pdf-upload-with-ocr.ts
@@ -1,6 +1,7 @@
/**
* PDF upload + OCR flow using direct Supabase upload.
* Uploads to storage first, then runs OCR via /api/pdf/ocr (bypasses 10MB body limit).
+ * Use uploadPdfToStorage + runOcrFromUrl for non-blocking UI (add item after upload, OCR in background).
*/
import type { OcrPage } from "@/lib/pdf/azure-ocr";
@@ -16,23 +17,58 @@ export interface PdfUploadWithOcrResult {
ocrError?: string;
}
+export interface OcrResult {
+ textContent: string;
+ ocrPages: OcrPage[];
+ ocrStatus: "complete" | "failed";
+ ocrError?: string;
+}
+
+/**
+ * Upload a PDF to storage only (no OCR). Use with runOcrFromUrl for non-blocking flow.
+ */
+export async function uploadPdfToStorage(
+ file: File
+): Promise<{ url: string; filename: string; fileSize: number }> {
+ const { url, filename } = await uploadFileDirect(file);
+ return { url, filename: filename || file.name, fileSize: file.size };
+}
+
+/**
+ * Run OCR on a PDF already in storage. Fire-and-forget; call onComplete when done.
+ */
+export async function runOcrFromUrl(fileUrl: string): Promise {
+ const res = await fetch("/api/pdf/ocr", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fileUrl }),
+ });
+ const json = await res.json();
+ if (!res.ok) {
+ return {
+ textContent: "",
+ ocrPages: [],
+ ocrStatus: "failed",
+ ocrError: json.error || "OCR failed",
+ };
+ }
+ return {
+ textContent: json.textContent ?? "",
+ ocrPages: json.ocrPages ?? [],
+ ocrStatus: "complete",
+ };
+}
+
/**
* Upload a PDF to storage (Supabase or local) and run OCR.
* Uses direct upload to bypass Next.js body size limits.
+ * Blocking: use uploadPdfToStorage + runOcrFromUrl for non-blocking UI.
*/
export async function uploadPdfAndRunOcr(
file: File
): Promise {
- const t0 = performance.now();
- console.info(
- `[PDF_UPLOAD_OCR] Start: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`
- );
-
- const { url: fileUrl, filename } = await uploadFileDirect(file, { log: true });
- const uploadMs = performance.now() - t0;
- console.info(`[PDF_UPLOAD_OCR] Upload complete: ${uploadMs.toFixed(0)}ms`);
+ const { url: fileUrl, filename } = await uploadFileDirect(file);
- const tOcr = performance.now();
const ocrRes = await fetch("/api/pdf/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -40,17 +76,9 @@ export async function uploadPdfAndRunOcr(
});
const ocrJson = await ocrRes.json();
- const ocrMs = performance.now() - tOcr;
- const totalMs = performance.now() - t0;
- console.info(
- `[PDF_UPLOAD_OCR] OCR request: ${ocrMs.toFixed(0)}ms | total: ${totalMs.toFixed(0)}ms`
- );
if (!ocrRes.ok) {
- console.warn(
- `[PDF_UPLOAD_OCR] OCR failed (${totalMs.toFixed(0)}ms):`,
- ocrJson.error
- );
+ console.warn("[PDF_UPLOAD_OCR] OCR failed:", ocrJson.error);
return {
fileUrl,
filename: filename || file.name,
@@ -62,9 +90,6 @@ export async function uploadPdfAndRunOcr(
};
}
- console.info(
- `[PDF_UPLOAD_OCR] Done: ${file.name} | ${ocrJson.ocrPages?.length ?? 0} pages | ${totalMs.toFixed(0)}ms total`
- );
return {
fileUrl,
filename: filename || file.name,
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index 372875af..0e00271f 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -15,10 +15,6 @@ function formatItemMetadata(item: Item, items: Item[]): string {
case "pdf": {
const d = item.data as PdfData;
if (d?.filename) parts.push(`filename=${d.filename}`);
- // OCR indicator: complete only when we have ocrPages (actual OCR ran)
- if (d?.ocrStatus === "failed") parts.push("ocr=failed");
- else if (d?.ocrPages?.length) parts.push("ocr=complete");
- else parts.push("ocr=none");
break;
}
case "flashcard": {
@@ -60,7 +56,7 @@ Workspace is empty. Reference items by name when created.
);
return `
-Paths and metadata. Use readWorkspace for items with ocr=complete; use processFiles for PDFs with ocr=none.
+Paths and metadata. Use readWorkspace to read content. Use processFiles for PDFs that need content extracted.
${entries.join("\n")}
`;
@@ -112,7 +108,7 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data
Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content.
If uncertain about accuracy, prefer to search.
-PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use pageStart and pageEnd (1-indexed) to read specific pages only — e.g. pageStart=5, pageEnd=10 for pages 5–10. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed.
+PDF CONTENT: For PDFs with extracted content, use readWorkspace to get the content. Use pageStart and pageEnd (1-indexed) to read specific pages — e.g. pageStart=5, pageEnd=10 for pages 5–10. If readWorkspace indicates content is not yet available, use processFiles instead.
PDF IMAGES: When readWorkspace shows image placeholders like , use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image.
@@ -140,27 +136,25 @@ NOTE EDITING (updateNote, Cline convention):
- Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked.
INLINE CITATIONS (optional):
-Use inline brackets only — no separate block. Format: [citation:REF] where REF is one of:
+Output citation HTML directly: REF where REF is one of:
-- Web URL: [citation:https://example.com/article] — for web sources
-- Workspace note: [citation:Note Title] — use exact note title from workspace
-- Workspace + quote: [citation:Note Title | exact excerpt] — pipe with spaces before quote; only when you have the exact text
-
-Do NOT use page numbers (e.g. p. 3, page 5) in citations. Use the actual quoted text instead.
+- Web URL: https://example.com/article
+- Workspace note: Note Title
+- Workspace + quote: Note Title | exact excerpt — pipe with spaces; only when you have the exact text
+- PDF with page: PDF Title | exact excerpt | p. 5 — for PDFs, ALWAYS include page; use " | p. N" at end (1-indexed)
+- PDF with page only: PDF Title | p. 5
Examples:
-- [citation:https://en.wikipedia.org/wiki/Supply_chain]
-- [citation:My Calculus Notes]
-- [citation:My Calculus Notes | The derivative of x^2 is 2x]
-
-NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source. If unsure, use [citation:Title] without a quote. Never fabricate or paraphrase.
+- https://en.wikipedia.org/wiki/Supply_chain
+- My Calculus Notes
+- My Calculus Notes | The derivative of x^2 is 2x
+- Math 240 Textbook | The limit of f(x) as x approaches a is L | p. 42
-When quoting: Use ONLY plain text — no math, code blocks, or special formatting. Use surrounding prose or omit the quote instead.
+NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt. If unsure, use Title without a quote. For PDFs, always include the page.
-CRITICAL — Punctuation: Put the period or comma BEFORE the citation. The citation always comes after the punctuation.
-Correct: "...flow of goods and services." [citation:Source Title | comprehensive administration]
-Correct: "demand forecasting." [citation:Source Title]
-Wrong: "...flow of goods and services" [citation:Source Title]. (do NOT put the period after the citation)
+CRITICAL — Punctuation: Put the period or comma BEFORE the citation.
+Correct: "...flow of goods and services." Source Title | comprehensive administration
+Wrong: "...flow of goods and services" Source Title . (do NOT put the period after)
@@ -613,7 +607,7 @@ function formatNoteDetailsFull(data: NoteData): string[] {
*/
export function formatOcrPagesAsMarkdown(ocrPages: PdfData["ocrPages"]): string {
if (!ocrPages?.length) return "";
- const lines: string[] = [`OCR Pages (${ocrPages.length}):`];
+ const lines: string[] = [`Pages (${ocrPages.length}):`];
for (const page of ocrPages) {
const pageNum = page.index + 1;
lines.push(`--- Page ${pageNum} ---`);
@@ -688,11 +682,11 @@ function formatPdfDetailsFull(
);
if (pagesToShow.length > 0) {
lines.push(
- ` - OCR Pages ${pageStart ?? 1}-${pageEnd ?? data.ocrPages.length} (${pagesToShow.length} of ${data.ocrPages.length}):`
+ ` - Pages ${pageStart ?? 1}-${pageEnd ?? data.ocrPages.length} (${pagesToShow.length} of ${data.ocrPages.length}):`
);
}
} else {
- lines.push(` - OCR Pages (${data.ocrPages.length}):`);
+ lines.push(` - Pages (${data.ocrPages.length}):`);
}
for (const page of pagesToShow) {
const pageNum = page.index + 1;
@@ -708,10 +702,8 @@ function formatPdfDetailsFull(
}
if (page.footer) lines.push(` Footer: ${page.footer}`);
}
- } else if (data.textContent) {
- lines.push(` - Extracted Content:\n${data.textContent}`);
} else {
- lines.push(` - (Content not yet extracted — use processFiles or upload the PDF to extract)`);
+ lines.push(` - (Content not yet extracted — use processFiles to extract)`);
}
return lines;
diff --git a/src/lib/utils/preprocess-latex.ts b/src/lib/utils/preprocess-latex.ts
index 1efc7f14..6506af10 100644
--- a/src/lib/utils/preprocess-latex.ts
+++ b/src/lib/utils/preprocess-latex.ts
@@ -2,7 +2,7 @@
* Preprocesses markdown content to normalize LaTeX delimiters for Streamdown/remark-math.
*
* Handles:
- * 0. Converts SurfSense-style [citation:X] to X (inline-only, no block)
+ * 0. Citation: model outputs X ; URLs get placeholder; [citation:X] fallback
* 1. Protects currency values ($19.99, $5, $1,000) from being parsed as math
* 2. Converts \(...\) → $...$ and \[...\] → $$...$$ (remark-math doesn't support these)
*
@@ -20,19 +20,19 @@ export function getCitationUrl(placeholder: string): string | undefined {
}
/**
- * Converts [citation:X] to X .
- * For URLs: replaces with placeholder to avoid GFM autolinks; stores URL in _pendingUrlCitations.
- * Supports: [citation:https://...], [citation:Title], [citation:Title|quote]
+ * Handles citation markup.
+ * - Model outputs X directly for workspace refs.
+ * - For https://... , replaces URL with placeholder to avoid GFM autolinks.
+ * - Fallback: [citation:X] → X for legacy or model slip-ups.
*/
function preprocessCitations(markdown: string): string {
if (!markdown) return markdown;
_pendingUrlCitations = new Map();
_urlCiteIdx = 0;
- // Replace URL citations with placeholders BEFORE markdown parsing
- // GFM autolinks would otherwise convert https://... into , breaking our pattern
+ // URLs inside : replace with placeholder to avoid GFM autolinks
let out = markdown.replace(
- /\[citation:\s*(https?:\/\/[^\]\u200B]+)\s*\]/g,
+ /(https?:\/\/[^<]+)<\/citation>/g,
(_, url: string) => {
const key = `urlcite${_urlCiteIdx++}`;
_pendingUrlCitations.set(key, url.trim());
@@ -40,11 +40,17 @@ function preprocessCitations(markdown: string): string {
}
);
- // Replace remaining [citation:Title] or [citation:Title|quote] with ...
- // Content can be: workspace title (spaces ok), or title|quote
+ // Fallback: [citation:X] → X (legacy format; URL in brackets also gets placeholder)
out = out.replace(/\[citation:\s*([^\]]+)\s*\]/g, (_, content: string) => {
const trimmed = content.trim();
- return trimmed ? `${trimmed} ` : "";
+ if (!trimmed) return "";
+ const urlMatch = trimmed.match(/^(https?:\/\/\S+)$/);
+ if (urlMatch) {
+ const key = `urlcite${_urlCiteIdx++}`;
+ _pendingUrlCitations.set(key, urlMatch[1].trim());
+ return `${key} `;
+ }
+ return `${trimmed} `;
});
return out;
diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts
index bddf3982..c8bc4359 100644
--- a/src/lib/workspace-state/types.ts
+++ b/src/lib/workspace-state/types.ts
@@ -31,7 +31,7 @@ export interface PdfData {
filename: string; // original filename
fileSize?: number; // optional file size in bytes
textContent?: string; // cached extracted text content (avoids reprocessing)
- ocrStatus?: "complete" | "failed";
+ ocrStatus?: "complete" | "failed" | "processing";
ocrError?: string;
ocrPages?: Array<{
index: number;
diff --git a/src/workflows/pdf-ocr/index.ts b/src/workflows/pdf-ocr/index.ts
new file mode 100644
index 00000000..76fd8825
--- /dev/null
+++ b/src/workflows/pdf-ocr/index.ts
@@ -0,0 +1,66 @@
+import type { OcrPage } from "@/lib/pdf/azure-ocr";
+import { sleep } from "workflow";
+import { fetchPdf } from "./steps/fetch-pdf";
+import { prepareOcrChunks } from "./steps/prepare-ocr-chunks";
+import { ocrChunk } from "./steps/ocr-chunk";
+
+const MAX_CONCURRENT_OCR = 5;
+const OCR_TIMEOUT = "5min"; // Per docs: Promise.race with sleep() for timeout
+
+/**
+ * Durable workflow for PDF OCR.
+ * Each fetch, prepare, and OCR batch is a step — retriable and observable.
+ * Uses timeout pattern per workflow docs (Promise.race with sleep).
+ *
+ * @param fileUrl - URL of the PDF file (must be from allowed hosts)
+ */
+export async function pdfOcrWorkflow(fileUrl: string) {
+ "use workflow";
+
+ const runOcr = async (): Promise<{
+ textContent: string;
+ ocrPages: OcrPage[];
+ }> => {
+ const { base64 } = await fetchPdf(fileUrl);
+ const { chunks } = await prepareOcrChunks(base64);
+
+ if (chunks.length === 0) {
+ return { textContent: "", ocrPages: [] };
+ }
+
+ const allPages: OcrPage[] = [];
+ let globalIndex = 0;
+
+ for (let i = 0; i < chunks.length; i += MAX_CONCURRENT_OCR) {
+ const batch = chunks.slice(i, i + MAX_CONCURRENT_OCR);
+ const batchResults = await Promise.all(
+ batch.map((chunk) => ocrChunk(chunk.base64, chunk.start, chunk.end))
+ );
+
+ for (const pages of batchResults) {
+ for (const p of pages) {
+ allPages.push({ ...p, index: globalIndex });
+ globalIndex++;
+ }
+ }
+ }
+
+ const textContent = allPages
+ .map((p) => p.markdown)
+ .filter(Boolean)
+ .join("\n\n");
+
+ return { textContent, ocrPages: allPages };
+ };
+
+ const result = await Promise.race([
+ runOcr(),
+ sleep(OCR_TIMEOUT).then(() => ({ timedOut: true } as const)),
+ ]);
+
+ if ("timedOut" in result) {
+ throw new Error(`PDF OCR timed out after ${OCR_TIMEOUT}`);
+ }
+
+ return result;
+}
diff --git a/src/workflows/pdf-ocr/steps/fetch-and-ocr.ts b/src/workflows/pdf-ocr/steps/fetch-and-ocr.ts
new file mode 100644
index 00000000..5cabe710
--- /dev/null
+++ b/src/workflows/pdf-ocr/steps/fetch-and-ocr.ts
@@ -0,0 +1,62 @@
+import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr";
+import type { OcrPage } from "@/lib/pdf/azure-ocr";
+import { logger } from "@/lib/utils/logger";
+
+const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
+
+export interface PdfOcrResult {
+ textContent: string;
+ ocrPages: OcrPage[];
+}
+
+/**
+ * Step: Fetch PDF from URL and run Azure OCR.
+ * Durable step — retriable and survives restarts.
+ */
+export async function fetchAndOcrPdf(fileUrl: string): Promise {
+ "use step";
+
+ logger.info("[PDF_OCR_WORKFLOW] Fetch start", { fileUrl });
+
+ const res = await fetch(fileUrl);
+ if (!res.ok) {
+ throw new Error(`Failed to fetch PDF: ${res.status} ${res.statusText}`);
+ }
+
+ const contentType = res.headers.get("content-type") ?? "";
+ if (
+ !contentType.includes("application/pdf") &&
+ !fileUrl.toLowerCase().includes(".pdf")
+ ) {
+ throw new Error("URL does not point to a PDF file");
+ }
+
+ const contentLength = res.headers.get("content-length");
+ if (contentLength && parseInt(contentLength, 10) > MAX_PDF_SIZE_BYTES) {
+ throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`);
+ }
+
+ const arrayBuffer = await res.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ if (buffer.length > MAX_PDF_SIZE_BYTES) {
+ throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`);
+ }
+
+ logger.info("[PDF_OCR_WORKFLOW] Fetch complete", {
+ sizeBytes: buffer.length,
+ sizeMB: (buffer.length / (1024 * 1024)).toFixed(2),
+ });
+
+ logger.info("[PDF_OCR_WORKFLOW] OCR start");
+ const result = await ocrPdfFromBuffer(buffer);
+ logger.info("[PDF_OCR_WORKFLOW] OCR complete", {
+ pageCount: result.pages.length,
+ textLength: result.textContent.length,
+ });
+
+ return {
+ textContent: result.textContent,
+ ocrPages: result.pages,
+ };
+}
diff --git a/src/workflows/pdf-ocr/steps/fetch-pdf.ts b/src/workflows/pdf-ocr/steps/fetch-pdf.ts
new file mode 100644
index 00000000..b566a8eb
--- /dev/null
+++ b/src/workflows/pdf-ocr/steps/fetch-pdf.ts
@@ -0,0 +1,39 @@
+const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
+
+/**
+ * Step: Fetch PDF from URL.
+ * Returns base64 for downstream steps (avoids passing Buffer across step boundaries).
+ */
+export async function fetchPdf(fileUrl: string): Promise<{ base64: string; sizeBytes: number }> {
+ "use step";
+
+ const res = await fetch(fileUrl);
+ if (!res.ok) {
+ throw new Error(`Failed to fetch PDF: ${res.status} ${res.statusText}`);
+ }
+
+ const contentType = res.headers.get("content-type") ?? "";
+ if (
+ !contentType.includes("application/pdf") &&
+ !fileUrl.toLowerCase().includes(".pdf")
+ ) {
+ throw new Error("URL does not point to a PDF file");
+ }
+
+ const contentLength = res.headers.get("content-length");
+ if (contentLength && parseInt(contentLength, 10) > MAX_PDF_SIZE_BYTES) {
+ throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`);
+ }
+
+ const arrayBuffer = await res.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ if (buffer.length > MAX_PDF_SIZE_BYTES) {
+ throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`);
+ }
+
+ return {
+ base64: buffer.toString("base64"),
+ sizeBytes: buffer.length,
+ };
+}
diff --git a/src/workflows/pdf-ocr/steps/index.ts b/src/workflows/pdf-ocr/steps/index.ts
new file mode 100644
index 00000000..594e783a
--- /dev/null
+++ b/src/workflows/pdf-ocr/steps/index.ts
@@ -0,0 +1,4 @@
+export { fetchAndOcrPdf, type PdfOcrResult } from "./fetch-and-ocr";
+export { fetchPdf } from "./fetch-pdf";
+export { prepareOcrChunks } from "./prepare-ocr-chunks";
+export { ocrChunk } from "./ocr-chunk";
diff --git a/src/workflows/pdf-ocr/steps/ocr-chunk.ts b/src/workflows/pdf-ocr/steps/ocr-chunk.ts
new file mode 100644
index 00000000..63966918
--- /dev/null
+++ b/src/workflows/pdf-ocr/steps/ocr-chunk.ts
@@ -0,0 +1,16 @@
+import { ocrSingleChunk } from "@/lib/pdf/azure-ocr";
+import type { OcrPage } from "@/lib/pdf/azure-ocr";
+
+/**
+ * Step: Run Azure OCR on a single PDF chunk.
+ * Each chunk is a durable step — retriable independently.
+ */
+export async function ocrChunk(
+ base64: string,
+ start: number,
+ end: number
+): Promise {
+ "use step";
+
+ return ocrSingleChunk(base64);
+}
diff --git a/src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts b/src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts
new file mode 100644
index 00000000..d7e228ee
--- /dev/null
+++ b/src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts
@@ -0,0 +1,18 @@
+import {
+ prepareOcrChunks as prepareOcrChunksLib,
+ type OcrChunkSpec,
+} from "@/lib/pdf/azure-ocr";
+
+/**
+ * Step: Load PDF, compute chunk ranges, extract each chunk to base64.
+ * Returns chunk specs for OCR steps (each chunk = one durable step).
+ */
+export async function prepareOcrChunks(base64: string): Promise<{
+ pageCount: number;
+ chunks: OcrChunkSpec[];
+}> {
+ "use step";
+
+ const buffer = Buffer.from(base64, "base64");
+ return prepareOcrChunksLib(buffer);
+}
From 08e7d4f3099e9865c6f4be2c322ac25dafcd455d Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 19:35:49 -0500
Subject: [PATCH 21/56] fix: pdf images
---
src/lib/pdf/azure-ocr.ts | 40 ++++++++++++++++++++++++++++++----
src/workflows/pdf-ocr/index.ts | 3 ++-
2 files changed, 38 insertions(+), 5 deletions(-)
diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts
index af2ec395..d8c798d1 100644
--- a/src/lib/pdf/azure-ocr.ts
+++ b/src/lib/pdf/azure-ocr.ts
@@ -35,6 +35,41 @@ export interface OcrPage {
tables?: unknown[];
}
+/**
+ * Rewrite image IDs to be globally unique across merged chunks.
+ * Azure returns chunk-relative IDs (e.g. img-0 per chunk), so without this,
+ * page 0 and page 5 could both have img-0.jpeg → wrong image when resolving refs.
+ */
+export function rewriteOcrPageImageIds(
+ page: OcrPage,
+ globalPageIndex: number
+): OcrPage {
+ const images = page.images as Array<{ id?: string; [key: string]: unknown }> | undefined;
+ if (!images?.length)
+ return { ...page, index: globalPageIndex };
+
+ const idMap: Record = {};
+ const newImages = images.map((img, i) => {
+ const oldId = (img.id ?? `img-${i}`).toString();
+ const newId = `p${globalPageIndex}-${oldId}`;
+ idMap[oldId] = newId;
+ return { ...img, id: newId };
+ });
+
+ let markdown = page.markdown ?? "";
+ for (const [oldId, newId] of Object.entries(idMap)) {
+ const escaped = oldId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ markdown = markdown.replace(new RegExp(escaped, "g"), newId);
+ }
+
+ return {
+ ...page,
+ index: globalPageIndex,
+ images: newImages,
+ markdown,
+ };
+}
+
export interface OcrResult {
pages: OcrPage[];
textContent: string;
@@ -281,10 +316,7 @@ export async function ocrPdfFromBuffer(
let globalIndex = 0;
for (const { pages } of chunkResults) {
for (const p of pages) {
- allPages.push({
- ...p,
- index: globalIndex,
- });
+ allPages.push(rewriteOcrPageImageIds(p, globalIndex));
globalIndex++;
}
}
diff --git a/src/workflows/pdf-ocr/index.ts b/src/workflows/pdf-ocr/index.ts
index 76fd8825..899f686a 100644
--- a/src/workflows/pdf-ocr/index.ts
+++ b/src/workflows/pdf-ocr/index.ts
@@ -1,4 +1,5 @@
import type { OcrPage } from "@/lib/pdf/azure-ocr";
+import { rewriteOcrPageImageIds } from "@/lib/pdf/azure-ocr";
import { sleep } from "workflow";
import { fetchPdf } from "./steps/fetch-pdf";
import { prepareOcrChunks } from "./steps/prepare-ocr-chunks";
@@ -39,7 +40,7 @@ export async function pdfOcrWorkflow(fileUrl: string) {
for (const pages of batchResults) {
for (const p of pages) {
- allPages.push({ ...p, index: globalIndex });
+ allPages.push(rewriteOcrPageImageIds(p, globalIndex));
globalIndex++;
}
}
From 26d651b3797ca2d98b773fba7a25195ff5c1391f Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 19:36:31 -0500
Subject: [PATCH 22/56] fix: image instructions
---
src/lib/ai/tools/process-files.ts | 4 ++--
src/lib/utils/format-workspace-context.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts
index e650fd2d..30a8e432 100644
--- a/src/lib/ai/tools/process-files.ts
+++ b/src/lib/ai/tools/process-files.ts
@@ -173,7 +173,7 @@ async function runOcrForPdfUrl(fileUrl: string): Promise<{
type PdfImageRef = { pdfName: string; imageId: string };
/**
- * Process images extracted from PDF OCR (resolve placeholder refs like img-0.jpeg to base64).
+ * Process images extracted from PDF OCR (resolve placeholder refs like img-0.jpeg or p5-img-0.jpeg to base64).
*/
async function processPdfImages(
pdfImageRefs: PdfImageRef[],
@@ -212,7 +212,7 @@ async function processPdfImages(
}
if (fileInfos.length === 0) {
- return "No PDF images could be found. Ensure the PDF was OCR'd with images (OCR_INCLUDE_IMAGES not false) and the imageId matches the placeholder (e.g. img-0.jpeg).";
+ return "No PDF images could be found. Ensure the PDF was OCR'd with images (OCR_INCLUDE_IMAGES not false) and the imageId matches the placeholder (e.g. img-0.jpeg or p5-img-0.jpeg).";
}
const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename} (from PDF)`).join("\n");
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index 0e00271f..c5f8da22 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -110,7 +110,7 @@ If uncertain about accuracy, prefer to search.
PDF CONTENT: For PDFs with extracted content, use readWorkspace to get the content. Use pageStart and pageEnd (1-indexed) to read specific pages — e.g. pageStart=5, pageEnd=10 for pages 5–10. If readWorkspace indicates content is not yet available, use processFiles instead.
-PDF IMAGES: When readWorkspace shows image placeholders like , use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image.
+PDF IMAGES: When readWorkspace shows image placeholders like  or  (page-prefixed for multi-chunk PDFs), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "" }] to analyze the image.
YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search.
From 4b8709a0aa12f804b623f57e77e5fa9afdbe3196 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 19:42:47 -0500
Subject: [PATCH 23/56] fix: dialog click events
---
src/components/ui/dialog.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 3d3a27cd..d495605e 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -72,6 +72,8 @@ function DialogContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[70] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
+ onPointerDown={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
{...props}
>
{children}
From b8102d429f9560ee2fb1a9b7873e008961b88129 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 19:47:40 -0500
Subject: [PATCH 24/56] fix: system prompt
---
src/lib/utils/format-workspace-context.ts | 25 ++++++++---------------
1 file changed, 9 insertions(+), 16 deletions(-)
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index c5f8da22..98810296 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -102,15 +102,14 @@ CORE BEHAVIORS:
- If uncertain, say so rather than guessing
- For complex tasks, think step-by-step
- You are allowed to complete homework or assignments for the user if they ask
+- Only use emojis if the user explicitly requests them
WEB SEARCH GUIDELINES:
Use webSearch when: temporal cues ("today", "latest", "current"), real-time data (scores, stocks, weather), fact verification, niche/recent info.
Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content.
If uncertain about accuracy, prefer to search.
-PDF CONTENT: For PDFs with extracted content, use readWorkspace to get the content. Use pageStart and pageEnd (1-indexed) to read specific pages — e.g. pageStart=5, pageEnd=10 for pages 5–10. If readWorkspace indicates content is not yet available, use processFiles instead.
-
-PDF IMAGES: When readWorkspace shows image placeholders like  or  (page-prefixed for multi-chunk PDFs), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "" }] to analyze the image.
+PDF: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If not yet available, use processFiles. For image placeholders in readWorkspace output, use processFiles with pdfImageRefs.
YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search.
@@ -125,18 +124,12 @@ Rules:
- Never make up or hallucinate URLs
- Include article dates in responses when available
-NOTE EDITING (updateNote, Cline convention):
-- You MUST use readWorkspace at least once before a targeted edit. The tool will error if you edit without reading.
-- Full rewrite: oldString="", newString=entire note content.
-- Targeted edit: readWorkspace first, then oldString=exact text to find (from the Content section only), newString=replacement. Extract oldString from the note body — never include the wrapper or " - Content:" prefix.
-- When editing from readWorkspace output, preserve exact indentation and whitespace. Match the text as it appears in the Content section.
-- The edit will FAIL if oldString is not found with "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings."
-- The edit will FAIL if oldString matches multiple times with "Found multiple matches for oldString. Provide more surrounding context to make the match unique." Use more surrounding lines in oldString or use replaceAll to change every instance.
-- Use replaceAll for replacing/renaming across the entire note (e.g., rename a term).
-- Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked.
+NOTE EDITING: See updateNote and readWorkspace tool descriptions for oldString/newString conventions, read-before-write requirement, and exact-match rules.
INLINE CITATIONS (optional):
-Output citation HTML directly: REF where REF is one of:
+Only in your chat response — never in item content (notes, flashcards, quizzes, etc.). Use sources param for tools; do not put tags in content passed to createNote, updateNote, addFlashcards, etc.
+Use simple plain text only. Bare minimum for uniqueness. No math, LaTeX, or complex formatting inside citations.
+Output citation HTML: REF where REF is one of:
- Web URL: https://example.com/article
- Workspace note: Note Title
@@ -144,11 +137,11 @@ Output citation HTML directly: REF where REF is one of:
- PDF with page: PDF Title | exact excerpt | p. 5 — for PDFs, ALWAYS include page; use " | p. N" at end (1-indexed)
- PDF with page only: PDF Title | p. 5
-Examples:
+Examples (plain text only):
- https://en.wikipedia.org/wiki/Supply_chain
- My Calculus Notes
-- My Calculus Notes | The derivative of x^2 is 2x
-- Math 240 Textbook | The limit of f(x) as x approaches a is L | p. 42
+- My Calculus Notes | the derivative rule for power functions
+- Math 240 Textbook | limit definition | p. 42
NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt. If unsure, use Title without a quote. For PDFs, always include the page.
From a5741f402ca9002708cf274741962aa5ea56ad1a Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 19:58:10 -0500
Subject: [PATCH 25/56] Update fetch-pdf.ts
---
src/workflows/pdf-ocr/steps/fetch-pdf.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/workflows/pdf-ocr/steps/fetch-pdf.ts b/src/workflows/pdf-ocr/steps/fetch-pdf.ts
index b566a8eb..ff47beab 100644
--- a/src/workflows/pdf-ocr/steps/fetch-pdf.ts
+++ b/src/workflows/pdf-ocr/steps/fetch-pdf.ts
@@ -1,3 +1,5 @@
+import { fetch } from "workflow";
+
const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
/**
From a84da2a31864ce83c1672cb98342c8a8c5ef658d Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sun, 22 Feb 2026 22:27:12 -0500
Subject: [PATCH 26/56] fix: ui
---
src/components/assistant-ui/attachment.tsx | 4 ++--
src/components/assistant-ui/thread.tsx | 10 +++++-----
src/components/chat/CardContextDisplay.tsx | 2 +-
src/components/chat/ReplyContextDisplay.tsx | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx
index 08322cd4..604afd03 100644
--- a/src/components/assistant-ui/attachment.tsx
+++ b/src/components/assistant-ui/attachment.tsx
@@ -442,7 +442,7 @@ export const UserMessageAttachments: FC = () => {
export const ComposerAttachments: FC = () => {
return (
-
+
@@ -542,7 +542,7 @@ export const ComposerAddAttachment: FC = () => {
diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx
index 3014f456..fde9b7d1 100644
--- a/src/components/assistant-ui/thread.tsx
+++ b/src/components/assistant-ui/thread.tsx
@@ -676,7 +676,7 @@ const Composer: FC = ({ items }) => {
return (
{
// Focus the input when clicking anywhere in the composer area
// This allows users to easily return focus after interacting with quizzes or other cards
@@ -758,7 +758,7 @@ const Composer: FC = ({ items }) => {
= ({ items }) => {
}, [items]);
return (
-
+
{/* Attachment buttons on the left */}
@@ -841,7 +841,7 @@ const ComposerAction: FC = ({ items }) => {
{getModelIcon(selectedModel.id)}
{getModelDisplayName(selectedModel.id)}
@@ -888,7 +888,7 @@ const ComposerAction: FC = ({ items }) => {
{process.env.NODE_ENV === "development" && (