diff --git a/apps/web/src/app/api/account/avatar/route.ts b/apps/web/src/app/api/account/avatar/route.ts index 6b6db98d7..fa3a2f487 100644 --- a/apps/web/src/app/api/account/avatar/route.ts +++ b/apps/web/src/app/api/account/avatar/route.ts @@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { db, users, eq } from '@pagespace/db'; +import { PROCESSOR_URL } from '@/lib/processor-config'; +import { createAvatarServiceToken } from '@/lib/auth/avatar-service'; + const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true }; -import { createUserServiceToken, type ServiceScope } from '@pagespace/lib'; // Maximum file size: 5MB const MAX_FILE_SIZE = 5 * 1024 * 1024; @@ -11,24 +13,6 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // Allowed image types const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -// Processor service URL -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; - -const REQUIRED_AVATAR_SCOPES: ServiceScope[] = ['avatars:write']; - -async function createAvatarServiceToken( - userId: string, - expirationTime: string -): Promise<{ token: string }> { - // createUserServiceToken validates that the user is accessing their own resources - const { token } = await createUserServiceToken( - userId, - REQUIRED_AVATAR_SCOPES, - expirationTime - ); - return { token }; -} - export async function POST(request: NextRequest) { try { // Verify authentication diff --git a/apps/web/src/app/api/account/route.ts b/apps/web/src/app/api/account/route.ts index 99c1045f6..b39a8ccef 100644 --- a/apps/web/src/app/api/account/route.ts +++ b/apps/web/src/app/api/account/route.ts @@ -2,8 +2,9 @@ import { users, db, eq } from '@pagespace/db'; import { createHash } from 'crypto'; import { loggers, accountRepository, activityLogRepository } from '@pagespace/lib/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; -import { createUserServiceToken, type ServiceScope } from '@pagespace/lib'; import { getActorInfo, logUserActivity } from '@pagespace/lib/monitoring/activity-logger'; +import { PROCESSOR_URL } from '@/lib/processor-config'; +import { createAvatarServiceToken } from '@/lib/auth/avatar-service'; const AUTH_OPTIONS_READ = { allow: ['session'] as const, requireCSRF: false }; const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true }; @@ -114,9 +115,6 @@ export async function PATCH(req: Request) { } } -// Processor service URL -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; - /** * Create an anonymized identifier for GDPR-compliant audit trail preservation. * Uses a deterministic hash so the same user ID always produces the same anonymized ID. @@ -126,21 +124,6 @@ function createAnonymizedActorEmail(userId: string): string { return `deleted_user_${hash}`; } -const REQUIRED_AVATAR_SCOPES: ServiceScope[] = ['avatars:write']; - -async function createAvatarServiceToken( - userId: string, - expirationTime: string -): Promise<{ token: string }> { - // createUserServiceToken validates that the user is accessing their own resources - const { token } = await createUserServiceToken( - userId, - REQUIRED_AVATAR_SCOPES, - expirationTime - ); - return { token }; -} - export async function DELETE(req: Request) { const auth = await authenticateRequestWithOptions(req, AUTH_OPTIONS_WRITE); if (isAuthError(auth)) { diff --git a/apps/web/src/app/api/channels/[pageId]/upload/route.ts b/apps/web/src/app/api/channels/[pageId]/upload/route.ts index ba9b5dcd6..f69fa9960 100644 --- a/apps/web/src/app/api/channels/[pageId]/upload/route.ts +++ b/apps/web/src/app/api/channels/[pageId]/upload/route.ts @@ -14,7 +14,7 @@ import { createUploadServiceToken, isPermissionDeniedError } from '@pagespace/li import { sanitizeFilenameForHeader } from '@pagespace/lib/utils/file-security'; import { getActorInfo, logFileActivity } from '@pagespace/lib/monitoring/activity-logger'; -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; +import { PROCESSOR_URL } from '@/lib/processor-config'; const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true }; diff --git a/apps/web/src/app/api/files/[id]/convert-to-document/route.ts b/apps/web/src/app/api/files/[id]/convert-to-document/route.ts index 33f15e03e..d0865afff 100644 --- a/apps/web/src/app/api/files/[id]/convert-to-document/route.ts +++ b/apps/web/src/app/api/files/[id]/convert-to-document/route.ts @@ -6,6 +6,7 @@ import mammoth from 'mammoth'; import { createId } from '@paralleldrive/cuid2'; import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; import { getActorInfo, logFileActivity, logPageActivity } from '@pagespace/lib/monitoring/activity-logger'; +import { PROCESSOR_URL } from '@/lib/processor-config'; const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true }; @@ -78,7 +79,6 @@ export async function POST( } // Fetch file from processor service using content hash - const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; const contentHash = filePage.filePath; // filePath stores the content hash console.log('[Convert] Fetching file from processor:', { diff --git a/apps/web/src/app/api/files/[id]/download/route.ts b/apps/web/src/app/api/files/[id]/download/route.ts index 6d1350d31..e1cdb7d1b 100644 --- a/apps/web/src/app/api/files/[id]/download/route.ts +++ b/apps/web/src/app/api/files/[id]/download/route.ts @@ -11,7 +11,7 @@ interface RouteParams { }>; } -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; +import { PROCESSOR_URL } from '@/lib/processor-config'; /** * Fetch a file from the processor and return it as a download diff --git a/apps/web/src/app/api/files/[id]/view/route.ts b/apps/web/src/app/api/files/[id]/view/route.ts index 79e754824..b1786252b 100644 --- a/apps/web/src/app/api/files/[id]/view/route.ts +++ b/apps/web/src/app/api/files/[id]/view/route.ts @@ -11,7 +11,7 @@ interface RouteParams { }>; } -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; +import { PROCESSOR_URL } from '@/lib/processor-config'; /** * Fetch a file from the processor service and return it with appropriate headers diff --git a/apps/web/src/app/api/pages/[pageId]/processing-status/route.ts b/apps/web/src/app/api/pages/[pageId]/processing-status/route.ts index 9a3b06d19..3ea360378 100644 --- a/apps/web/src/app/api/pages/[pageId]/processing-status/route.ts +++ b/apps/web/src/app/api/pages/[pageId]/processing-status/route.ts @@ -3,7 +3,7 @@ import { db, pages, eq } from '@pagespace/db'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { createPageServiceToken, canUserViewPage } from '@pagespace/lib'; -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; +import { PROCESSOR_URL } from '@/lib/processor-config'; const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: false }; export async function GET( diff --git a/apps/web/src/app/api/pages/[pageId]/reprocess/route.ts b/apps/web/src/app/api/pages/[pageId]/reprocess/route.ts index 46b76a28b..ec23cc0e1 100644 --- a/apps/web/src/app/api/pages/[pageId]/reprocess/route.ts +++ b/apps/web/src/app/api/pages/[pageId]/reprocess/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { db, pages, eq } from '@pagespace/db'; -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; +import { PROCESSOR_URL } from '@/lib/processor-config'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { createPageServiceToken } from '@pagespace/lib'; import { getActorInfo } from '@pagespace/lib/monitoring/activity-logger'; diff --git a/apps/web/src/app/api/upload/route.ts b/apps/web/src/app/api/upload/route.ts index 49f19df68..ae16b4ad7 100644 --- a/apps/web/src/app/api/upload/route.ts +++ b/apps/web/src/app/api/upload/route.ts @@ -30,8 +30,7 @@ interface FileMetadata { [key: string]: string | number | boolean | undefined; } -// Processor service URL -const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; +import { PROCESSOR_URL } from '@/lib/processor-config'; const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true }; diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx index 6ed995cc1..c76231eb7 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx @@ -29,46 +29,16 @@ import { SearchResultsRenderer, type SearchResult } from './SearchResultsRendere import { AgentListRenderer, type AgentInfo } from './AgentListRenderer'; import { ActivityRenderer, type ActivityItem } from './ActivityRenderer'; import { WebSearchRenderer, type WebSearchResult } from './WebSearchRenderer'; - -interface ToolPart { - type: string; - toolName?: string; - toolCallId?: string; - state?: 'input-streaming' | 'input-available' | 'output-available' | 'output-error' | 'done' | 'streaming'; - input?: unknown; - output?: unknown; - errorText?: string; -} - -interface CompactToolCallRendererProps { - part: ToolPart; -} - -// Helper function to count pages in tree structure (moved outside component) -const countPages = (items: TreeItem[]): number => { - return items.reduce((count, item) => { - return count + 1 + (item.children ? countPages(item.children) : 0); - }, 0); -}; - -// Helper for safe JSON parsing -const safeJsonParse = (value: unknown): Record | null => { - if (typeof value === 'string') { - try { - return JSON.parse(value); - } catch { - return null; - } - } - if (typeof value === 'object' && value !== null) { - return value as Record; - } - return null; -}; - -// Tool name mapping (moved outside component to avoid recreation) -const TOOL_NAME_MAP: Record = { - 'ask_agent': 'Ask Agent', +import { + type ToolPart, + safeJsonParse, + formatToolName, + countPages, + flattenActivityGroups, +} from './toolCallUtils'; + +// Compact-specific short name overrides for the condensed view +const COMPACT_TOOL_NAMES: Record = { 'list_drives': 'List Drives', 'list_pages': 'List Pages', 'read_page': 'Read', @@ -78,9 +48,13 @@ const TOOL_NAME_MAP: Record = { 'trash': 'Trash', 'restore': 'Restore', 'move_page': 'Move', - 'list_trash': 'List Trash' + 'list_trash': 'List Trash', }; +interface CompactToolCallRendererProps { + part: ToolPart; +} + // Internal renderer component with hooks const CompactToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = memo(function CompactToolCallRendererInternal({ part, toolName }) { const [isExpanded, setIsExpanded] = useState(false); @@ -144,12 +118,9 @@ const CompactToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: stri } }, [state, error]); - // Memoize formatted tool name + // Memoize formatted tool name (compact-specific short names take priority) const formattedToolName = useMemo(() => { - return TOOL_NAME_MAP[toolName] || toolName - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); + return COMPACT_TOOL_NAMES[toolName] || formatToolName(toolName); }, [toolName]); // Memoize descriptive title @@ -575,43 +546,7 @@ const CompactToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: stri }>; }>; - const opToAction = (op: string): 'created' | 'updated' | 'deleted' | 'restored' | 'moved' | 'renamed' => { - switch (op) { - case 'create': return 'created'; - case 'update': return 'updated'; - case 'delete': case 'trash': return 'deleted'; - case 'restore': return 'restored'; - case 'move': case 'reorder': return 'moved'; - case 'rename': return 'renamed'; - default: return 'updated'; - } - }; - - const flatActivities: ActivityItem[] = []; - for (const group of driveGroups) { - for (const activity of group.activities) { - const actor = actors[activity.actor]; - flatActivities.push({ - id: activity.id, - action: opToAction(activity.op), - pageId: activity.pageId || undefined, - pageTitle: activity.title || undefined, - pageType: activity.res === 'page' ? undefined : activity.res, - driveId: group.drive.id, - driveName: group.drive.name, - actorName: actor?.name || actor?.email || undefined, - timestamp: activity.ts, - summary: activity.ai ? `AI-generated (${activity.ai})` : undefined, - }); - } - } - - flatActivities.sort((a, b) => { - const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; - const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; - return timeB - timeA; - }); - + const flatActivities = flattenActivityGroups(driveGroups, actors); const meta = result.meta as { window?: string } | undefined; const period = meta?.window ? `Last ${meta.window}` : undefined; diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx index c7354d8cf..5b95782d7 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx @@ -16,35 +16,19 @@ import { SearchResultsRenderer, type SearchResult } from './SearchResultsRendere import { AgentListRenderer, type AgentInfo } from './AgentListRenderer'; import { ActivityRenderer, type ActivityItem } from './ActivityRenderer'; import { WebSearchRenderer, type WebSearchResult } from './WebSearchRenderer'; - -interface ToolPart { - type: string; - toolName?: string; - toolCallId?: string; - state?: 'input-streaming' | 'input-available' | 'output-available' | 'output-error' | 'done' | 'streaming'; - input?: unknown; - output?: unknown; - errorText?: string; -} +import { + type ToolPart, + safeJsonParse as safeJsonParseBase, + formatToolName, + flattenActivityGroups, +} from './toolCallUtils'; interface ToolCallRendererProps { part: ToolPart; } -// Helper for safe JSON parsing -const safeJsonParse = (value: unknown): Record | null => { - if (typeof value === 'string') { - try { - return JSON.parse(value); - } catch { - return { raw: value }; - } - } - if (typeof value === 'object' && value !== null) { - return value as Record; - } - return null; -}; +// This renderer uses 'raw' fallback for unparseable strings +const safeJsonParse = (value: unknown) => safeJsonParseBase(value, 'raw'); // Helper to parse list_pages paths format into tree structure // Path format: "📁 [FOLDER](Task) ID: xxx Path: /drive/folder" @@ -125,44 +109,7 @@ const parsePathsToTree = (paths: string[], _driveId?: string): TreeItem[] => { return buildTreeFromParsed(parsedPages, 1, []); }; -// Tool name mapping -const TOOL_NAME_MAP: Record = { - // Drive tools - 'list_drives': 'Workspaces', - 'create_drive': 'Create Workspace', - 'rename_drive': 'Rename Workspace', - 'update_drive_context': 'Update Context', - // Page read tools - 'list_pages': 'Pages', - 'read_page': 'Read Page', - 'list_trash': 'Trash', - 'list_conversations': 'Conversations', - 'read_conversation': 'Conversation', - // Page write tools - 'replace_lines': 'Edit Document', - 'create_page': 'Create Page', - 'rename_page': 'Rename Page', - 'trash': 'Move to Trash', - 'restore': 'Restore', - 'move_page': 'Move Page', - 'edit_sheet_cells': 'Edit Sheet', - // Search tools - 'regex_search': 'Search', - 'glob_search': 'Find Pages', - 'multi_drive_search': 'Search All', - // Task tools - 'update_task': 'Update Task', - 'get_assigned_tasks': 'Assigned Tasks', - // Agent tools - 'update_agent_config': 'Configure Agent', - 'list_agents': 'Agents', - 'multi_drive_list_agents': 'All Agents', - 'ask_agent': 'Ask Agent', - // Web search - 'web_search': 'Web Search', - // Activity - 'get_activity': 'Activity', -}; +// TOOL_NAME_MAP and formatToolName imported from ./toolCallUtils // Internal renderer component with hooks const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = memo(function ToolCallRendererInternal({ part, toolName }) { @@ -193,9 +140,7 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = }, [output]); // Memoize formatted tool name - const formattedToolName = useMemo(() => { - return TOOL_NAME_MAP[toolName] || toolName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); - }, [toolName]); + const formattedToolName = useMemo(() => formatToolName(toolName), [toolName]); // Memoize descriptive title const descriptiveTitle = useMemo(() => { @@ -575,48 +520,7 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = stats: { total: number; byOp: Record; aiCount: number }; }>; - // Map operation names to action types - const opToAction = (op: string): 'created' | 'updated' | 'deleted' | 'restored' | 'moved' | 'commented' | 'renamed' => { - switch (op) { - case 'create': return 'created'; - case 'update': return 'updated'; - case 'delete': - case 'trash': return 'deleted'; - case 'restore': return 'restored'; - case 'move': - case 'reorder': return 'moved'; - case 'rename': return 'renamed'; - default: return 'updated'; - } - }; - - // Flatten grouped activities - const flatActivities: ActivityItem[] = []; - for (const group of driveGroups) { - for (const activity of group.activities) { - const actor = actors[activity.actor]; - flatActivities.push({ - id: activity.id, - action: opToAction(activity.op), - pageId: activity.pageId || undefined, - pageTitle: activity.title || undefined, - pageType: activity.res === 'page' ? undefined : activity.res, - driveId: group.drive.id, - driveName: group.drive.name, - actorName: actor?.name || actor?.email || undefined, - timestamp: activity.ts, - summary: activity.ai ? `AI-generated (${activity.ai})` : undefined, - }); - } - } - - // Sort by timestamp descending - flatActivities.sort((a, b) => { - const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; - const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; - return timeB - timeA; - }); - + const flatActivities = flattenActivityGroups(driveGroups, actors); const meta = parsedOutput.meta as { window?: string } | undefined; const period = meta?.window ? `Last ${meta.window}` : undefined; diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/toolCallUtils.ts b/apps/web/src/components/ai/shared/chat/tool-calls/toolCallUtils.ts new file mode 100644 index 000000000..87b42beed --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/tool-calls/toolCallUtils.ts @@ -0,0 +1,156 @@ +/** + * Shared utilities for ToolCallRenderer and CompactToolCallRenderer. + * Extracted to eliminate ~200 lines of duplication between the two renderers. + */ + +import type { TreeItem } from './PageTreeRenderer'; +import type { ActivityItem } from './ActivityRenderer'; + +// ── Shared Types ── + +export interface ToolPart { + type: string; + toolName?: string; + toolCallId?: string; + state?: 'input-streaming' | 'input-available' | 'output-available' | 'output-error' | 'done' | 'streaming'; + input?: unknown; + output?: unknown; + errorText?: string; +} + +// ── JSON Parsing ── + +export const safeJsonParse = (value: unknown, fallback: 'raw' | 'null' = 'null'): Record | null => { + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return fallback === 'raw' ? { raw: value } : null; + } + } + if (typeof value === 'object' && value !== null) { + return value as Record; + } + return null; +}; + +// ── Tool Name Mapping ── + +export const TOOL_NAME_MAP: Record = { + // Drive tools + 'list_drives': 'Workspaces', + 'create_drive': 'Create Workspace', + 'rename_drive': 'Rename Workspace', + 'update_drive_context': 'Update Context', + // Page read tools + 'list_pages': 'Pages', + 'read_page': 'Read Page', + 'list_trash': 'Trash', + 'list_conversations': 'Conversations', + 'read_conversation': 'Conversation', + // Page write tools + 'replace_lines': 'Edit Document', + 'create_page': 'Create Page', + 'rename_page': 'Rename Page', + 'trash': 'Move to Trash', + 'restore': 'Restore', + 'move_page': 'Move Page', + 'edit_sheet_cells': 'Edit Sheet', + // Search tools + 'regex_search': 'Search', + 'glob_search': 'Find Pages', + 'multi_drive_search': 'Search All', + // Task tools + 'update_task': 'Update Task', + 'get_assigned_tasks': 'Assigned Tasks', + // Agent tools + 'update_agent_config': 'Configure Agent', + 'list_agents': 'Agents', + 'multi_drive_list_agents': 'All Agents', + 'ask_agent': 'Ask Agent', + // Web search + 'web_search': 'Web Search', + // Activity + 'get_activity': 'Activity', +}; + +export function formatToolName(toolName: string): string { + return TOOL_NAME_MAP[toolName] || toolName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +} + +// ── Tree Utilities ── + +export const countPages = (items: TreeItem[]): number => { + return items.reduce((count, item) => { + return count + 1 + (item.children ? countPages(item.children) : 0); + }, 0); +}; + +// ── Activity Utilities ── + +export type ActivityAction = 'created' | 'updated' | 'deleted' | 'restored' | 'moved' | 'commented' | 'renamed'; + +export const opToAction = (op: string): ActivityAction => { + switch (op) { + case 'create': return 'created'; + case 'update': return 'updated'; + case 'delete': + case 'trash': return 'deleted'; + case 'restore': return 'restored'; + case 'move': + case 'reorder': return 'moved'; + case 'rename': return 'renamed'; + default: return 'updated'; + } +}; + +interface DriveGroup { + drive: { id: string; name: string; slug: string; context?: string | null }; + activities: Array<{ + id: string; + ts: string; + op: string; + res: string; + title: string | null; + pageId: string | null; + actor: number; + ai?: string; + fields?: string[]; + delta?: Record; + }>; + stats?: { total: number; byOp: Record; aiCount: number }; +} + +interface Actor { + email: string; + name: string | null; + isYou: boolean; + count: number; +} + +export function flattenActivityGroups(driveGroups: DriveGroup[], actors: Actor[]): ActivityItem[] { + const flatActivities: ActivityItem[] = []; + for (const group of driveGroups) { + for (const activity of group.activities) { + const actor = actors[activity.actor]; + flatActivities.push({ + id: activity.id, + action: opToAction(activity.op), + pageId: activity.pageId || undefined, + pageTitle: activity.title || undefined, + pageType: activity.res === 'page' ? undefined : activity.res, + driveId: group.drive.id, + driveName: group.drive.name, + actorName: actor?.name || actor?.email || undefined, + timestamp: activity.ts, + summary: activity.ai ? `AI-generated (${activity.ai})` : undefined, + }); + } + } + flatActivities.sort((a, b) => { + const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return timeB - timeA; + }); + return flatActivities; +} diff --git a/apps/web/src/lib/ai/types/global-prompt.ts b/apps/web/src/lib/ai/types/global-prompt.ts index 4966a0258..e337a7609 100644 --- a/apps/web/src/lib/ai/types/global-prompt.ts +++ b/apps/web/src/lib/ai/types/global-prompt.ts @@ -7,30 +7,12 @@ * - /admin/global-prompt/GlobalPromptClient.tsx */ -export interface JsonSchemaProperty { - type: string; - description?: string; - enum?: string[]; - items?: JsonSchemaProperty; - properties?: Record; - required?: string[]; - default?: unknown; - optional?: boolean; -} - -export interface JsonSchema { - type: string; - properties: Record; - required: string[]; - description?: string; -} +// Re-export shared schema types from canonical locations +export type { JsonSchemaProperty, JsonSchema, ToolSchemaInfo } from '../core/schema-introspection'; +export type { ToolDefinition } from '../core/complete-request-builder'; -export interface ToolSchemaInfo { - name: string; - description: string; - parameters: JsonSchema; - tokenEstimate: number; -} +import type { ToolSchemaInfo } from '../core/schema-introspection'; +import type { ToolDefinition } from '../core/complete-request-builder'; export interface PromptSection { name: string; @@ -40,12 +22,6 @@ export interface PromptSection { tokens: number; } -export interface ToolDefinition { - name: string; - description: string; - parameters: JsonSchema; -} - export interface CompleteAIRequest { model: string; system: string; diff --git a/apps/web/src/lib/attachment-utils.ts b/apps/web/src/lib/attachment-utils.ts index f02f12015..e884ee068 100644 --- a/apps/web/src/lib/attachment-utils.ts +++ b/apps/web/src/lib/attachment-utils.ts @@ -44,11 +44,8 @@ export function getAttachmentSize(m: MessageWithAttachment): number | null { return m.attachmentMeta?.size ?? m.file?.sizeBytes ?? null; } -export function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} +/** @deprecated Use formatBytes from @/lib/utils instead */ +export { formatBytes as formatFileSize } from '@/lib/utils'; export function hasAttachment(m: MessageWithAttachment): boolean { return !!(m.attachmentMeta || m.file) && getFileId(m) !== null; diff --git a/apps/web/src/lib/auth/avatar-service.ts b/apps/web/src/lib/auth/avatar-service.ts new file mode 100644 index 000000000..dfd2502b0 --- /dev/null +++ b/apps/web/src/lib/auth/avatar-service.ts @@ -0,0 +1,15 @@ +import { createUserServiceToken, type ServiceScope } from '@pagespace/lib'; + +const REQUIRED_AVATAR_SCOPES: ServiceScope[] = ['avatars:write']; + +export async function createAvatarServiceToken( + userId: string, + expirationTime: string +): Promise<{ token: string }> { + const { token } = await createUserServiceToken( + userId, + REQUIRED_AVATAR_SCOPES, + expirationTime + ); + return { token }; +} diff --git a/apps/web/src/lib/processor-config.ts b/apps/web/src/lib/processor-config.ts new file mode 100644 index 000000000..b404274ab --- /dev/null +++ b/apps/web/src/lib/processor-config.ts @@ -0,0 +1,5 @@ +/** + * Processor service configuration. + * Centralized to avoid repeating the URL constant across API routes. + */ +export const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; diff --git a/packages/lib/src/client-safe.ts b/packages/lib/src/client-safe.ts index 8e4a5e1ba..4548675e0 100644 --- a/packages/lib/src/client-safe.ts +++ b/packages/lib/src/client-safe.ts @@ -25,37 +25,8 @@ export * from './sheets'; // Page content parsing (safe - no server dependencies) export * from './content/page-content-parser'; -// Browser-safe format bytes utility -export function formatBytes(bytes: number): string { - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 B'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; -} - -// Parse human-readable size to bytes -export function parseBytes(size: string): number { - if (!size || typeof size !== 'string') { - throw new Error(`Invalid size parameter: expected string, got ${typeof size}`); - } - - const match = size.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i); - if (!match) { - throw new Error(`Invalid size format: ${size}. Expected format like "500MB" or "2GB"`); - } - - const [, num, unit] = match; - const bytes = parseFloat(num); - - switch (unit.toUpperCase()) { - case 'B': return bytes; - case 'KB': return bytes * 1024; - case 'MB': return bytes * 1024 * 1024; - case 'GB': return bytes * 1024 * 1024 * 1024; - case 'TB': return bytes * 1024 * 1024 * 1024 * 1024; - default: throw new Error(`Unsupported size unit: ${unit}`); - } -} +// Browser-safe format/parse bytes utilities +export { formatBytes, parseBytes } from './utils/format'; // Client-safe notification types and guards (no database dependencies) export * from './notifications/types'; diff --git a/packages/lib/src/services/storage-limits.ts b/packages/lib/src/services/storage-limits.ts index b5934df4b..de2f0a089 100644 --- a/packages/lib/src/services/storage-limits.ts +++ b/packages/lib/src/services/storage-limits.ts @@ -1,5 +1,6 @@ import { db, users, pages, drives, storageEvents, eq, sql, and, isNull, inArray } from '@pagespace/db'; import { getStorageConfigFromSubscription, getStorageTierFromSubscription, type SubscriptionTier } from './subscription-utils'; +import { formatBytes } from '../utils/format'; export interface StorageQuota { userId: string; @@ -353,15 +354,7 @@ function getWarningLevel(percent: number): 'none' | 'warning' | 'critical' { return 'none'; } -/** - * Format bytes to human-readable string - */ -export function formatBytes(bytes: number): string { - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 B'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; -} +export { formatBytes } from '../utils/format'; /** * Parse human-readable size to bytes diff --git a/packages/lib/src/services/subscription-utils.ts b/packages/lib/src/services/subscription-utils.ts index 0eaa438fe..cb03ba37d 100644 --- a/packages/lib/src/services/subscription-utils.ts +++ b/packages/lib/src/services/subscription-utils.ts @@ -90,12 +90,4 @@ export function subscriptionAllows(subscriptionTier: SubscriptionTier, feature: return config.features.includes(feature); } -/** - * Format bytes to human-readable string - */ -export function formatBytes(bytes: number): string { - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 B'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; -} \ No newline at end of file +export { formatBytes } from '../utils/format'; \ No newline at end of file diff --git a/packages/lib/src/utils/format.ts b/packages/lib/src/utils/format.ts new file mode 100644 index 000000000..b22fd081c --- /dev/null +++ b/packages/lib/src/utils/format.ts @@ -0,0 +1,35 @@ +/** + * Canonical byte formatting and parsing utilities. + * All formatBytes/parseBytes usages across the monorepo should import from here. + */ + +const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']; + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${SIZE_UNITS[i]}`; +} + +export function parseBytes(size: string): number { + if (!size || typeof size !== 'string') { + throw new Error(`Invalid size parameter: expected string, got ${typeof size}`); + } + + const match = size.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i); + if (!match) { + throw new Error(`Invalid size format: ${size}. Expected format like "500MB" or "2GB"`); + } + + const [, num, unit] = match; + const bytes = parseFloat(num); + + switch (unit.toUpperCase()) { + case 'B': return bytes; + case 'KB': return bytes * 1024; + case 'MB': return bytes * 1024 * 1024; + case 'GB': return bytes * 1024 * 1024 * 1024; + case 'TB': return bytes * 1024 * 1024 * 1024 * 1024; + default: throw new Error(`Unsupported size unit: ${unit}`); + } +} diff --git a/packages/lib/src/utils/index.ts b/packages/lib/src/utils/index.ts index 64eb30370..cc890a2c7 100644 --- a/packages/lib/src/utils/index.ts +++ b/packages/lib/src/utils/index.ts @@ -7,4 +7,5 @@ export * from './api-utils'; export * from './enums'; export * from './environment'; export * from './file-security'; +export * from './format'; export * from './utils';