diff --git a/LICENSE b/LICENSE index e7a8034e41..ea74278a4e 100644 --- a/LICENSE +++ b/LICENSE @@ -14,6 +14,8 @@ if that directory exists, is licensed under the license defined in "frontend/src if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE". * All content that resides under the "frontend/src/saas/" directory of this repository, if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE". +* All content that resides under the "frontend/src/prototypes/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/src/prototypes/LICENSE". * Content outside of the above mentioned directories or restrictions above is available under the MIT License as defined below. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index efe45f17b5..9bbe913d60 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -86,6 +86,7 @@ export default defineConfig( files: [ 'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}', 'src/saas/**/*.{js,mjs,jsx,ts,tsx}', + 'src/prototypes/**/*.{js,mjs,jsx,ts,tsx}', ], languageOptions: { parserOptions: { diff --git a/frontend/package.json b/frontend/package.json index b4c1f6263c..17c031f7a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,6 +88,7 @@ "dev:proprietary": "npm run prep && vite --mode proprietary", "dev:saas": "npm run prep:saas && vite --mode saas", "dev:desktop": "npm run prep:desktop && vite --mode desktop", + "dev:prototypes": "npm run prep && vite --mode prototypes", "lint": "npm run lint:eslint && npm run lint:cycles", "lint:eslint": "eslint --max-warnings=0", "lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1", @@ -96,6 +97,7 @@ "build:proprietary": "npm run prep && vite build --mode proprietary", "build:saas": "npm run prep:saas && vite build --mode saas", "build:desktop": "npm run prep:desktop && vite build --mode desktop", + "build:prototypes": "npm run prep && vite build --mode prototypes", "preview": "vite preview", "tauri-dev": "npm run prep:desktop && tauri dev --no-watch", "tauri-build": "npm run prep:desktop-build && tauri build", @@ -110,8 +112,9 @@ "typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json", "typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json", "typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json", + "typecheck:prototypes": "tsc --noEmit --project src/prototypes/tsconfig.json", "typecheck:scripts": "tsc --noEmit --project scripts/tsconfig.json", - "typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop && npm run typecheck:scripts", + "typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop && npm run typecheck:prototypes && npm run typecheck:scripts", "check": "npm run typecheck && npm run lint && npm run test:run", "generate-licenses": "node scripts/generate-licenses.js", "generate-icons": "node scripts/generate-icons.js", diff --git a/frontend/src/prototypes/App.tsx b/frontend/src/prototypes/App.tsx new file mode 100644 index 0000000000..2b3b67b19b --- /dev/null +++ b/frontend/src/prototypes/App.tsx @@ -0,0 +1,81 @@ +import { Suspense } from "react"; +import { Routes, Route, useParams } from "react-router-dom"; +import { AppProviders } from "@app/components/AppProviders"; +import { AppLayout } from "@app/components/AppLayout"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider"; +import Landing from "@app/routes/Landing"; +import Login from "@app/routes/Login"; +import Signup from "@app/routes/Signup"; +import AuthCallback from "@app/routes/AuthCallback"; +import InviteAccept from "@app/routes/InviteAccept"; +import ShareLinkPage from "@app/routes/ShareLinkPage"; +import ParticipantView from "@app/components/workflow/ParticipantView"; +import Onboarding from "@app/components/onboarding/Onboarding"; + +// Import global styles +import "@app/styles/tailwind.css"; +import "@app/styles/cookieconsent.css"; +import "@app/styles/index.css"; +import "@app/styles/auth-theme.css"; + +// Import file ID debugging helpers (development only) +import "@app/utils/fileIdSafety"; + +// Minimal providers for public routes - no API calls, no authentication +function MinimalProviders({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +// Participant signing page — token-gated, no login required +function ParticipantViewPage() { + const { token } = useParams<{ token: string }>(); + if (!token) return null; + return ; +} + +export default function App() { + return ( + }> + + {/* Participant signing — public, token-gated, no auth required */} + + + + } + /> + + {/* All other routes need AppProviders for backend integration */} + + + + } /> + } /> + } /> + } /> + } /> + {/* Main app routes - Landing handles auth logic */} + } /> + + + + + } + /> + + + ); +} diff --git a/frontend/src/prototypes/LICENSE b/frontend/src/prototypes/LICENSE new file mode 100644 index 0000000000..d268556808 --- /dev/null +++ b/frontend/src/prototypes/LICENSE @@ -0,0 +1,51 @@ +Stirling PDF User License + +Copyright (c) 2025 Stirling PDF Inc. + +License Scope & Usage Rights + +Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License. + +For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files +provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical +processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service +(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription +covering the appropriate number of licensed users. + +Trial and Minimal Use + +You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that: +* Use is limited to the capabilities and restrictions defined by the Software itself; +* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts. + +Continued use beyond this scope requires a valid Stirling PDF User License. + +Modifications and Derivative Works + +You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works: + +* May not be deployed in production environments without a valid User License; +* May not be distributed or sublicensed; +* Remain the intellectual property of Stirling PDF and/or its licensors; +* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription. + +Prohibited Actions + +Unless explicitly permitted by a paid license or separate agreement, you may not: + +* Use the Software in production environments; +* Copy, merge, distribute, sublicense, or sell the Software; +* Remove or alter any licensing or copyright notices; +* Circumvent access restrictions or licensing requirements. + +Third-Party Components + +The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by +their original license terms as provided by their respective owners. + +Disclaimer + +THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/frontend/src/prototypes/components/AppProviders.tsx b/frontend/src/prototypes/components/AppProviders.tsx new file mode 100644 index 0000000000..f8729b1830 --- /dev/null +++ b/frontend/src/prototypes/components/AppProviders.tsx @@ -0,0 +1,18 @@ +import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders"; +import { type AppProvidersProps } from "@core/components/AppProviders"; +import { ChatProvider } from "@app/components/chat/ChatContext"; + +export type { AppProvidersProps }; + +export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/prototypes/components/chat/ChatContext.tsx b/frontend/src/prototypes/components/chat/ChatContext.tsx new file mode 100644 index 0000000000..b92f17280e --- /dev/null +++ b/frontend/src/prototypes/components/chat/ChatContext.tsx @@ -0,0 +1,180 @@ +import { createContext, useContext, useReducer, useCallback, type ReactNode } from "react"; +import { useAllFiles } from "@app/contexts/FileContext"; + +export interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: number; +} + +type AiWorkflowOutcome = + | "answer" + | "not_found" + | "need_content" + | "plan" + | "need_clarification" + | "cannot_do" + | "tool_call" + | "completed" + | "unsupported_capability" + | "cannot_continue"; + +interface AiWorkflowResponse { + outcome: AiWorkflowOutcome; + answer?: string; + summary?: string; + rationale?: string; + reason?: string; + question?: string; + capability?: string; + message?: string; + evidence?: Array<{ pageNumber: number; text: string }>; + steps?: Array>; +} + +interface ChatState { + messages: ChatMessage[]; + isOpen: boolean; + isLoading: boolean; +} + +type ChatAction = + | { type: "ADD_MESSAGE"; message: ChatMessage } + | { type: "SET_LOADING"; loading: boolean } + | { type: "TOGGLE_OPEN" } + | { type: "SET_OPEN"; open: boolean }; + +function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case "ADD_MESSAGE": + return { ...state, messages: [...state.messages, action.message] }; + case "SET_LOADING": + return { ...state, isLoading: action.loading }; + case "TOGGLE_OPEN": + return { ...state, isOpen: !state.isOpen }; + case "SET_OPEN": + return { ...state, isOpen: action.open }; + } +} + +function formatWorkflowResponse(data: AiWorkflowResponse): string { + switch (data.outcome) { + case "answer": + case "completed": + return data.answer ?? data.summary ?? "Done."; + case "need_clarification": + return data.question ?? "Could you clarify your request?"; + case "cannot_do": + return data.reason ?? "I'm unable to do that."; + case "not_found": + return data.reason ?? "I couldn't find the requested information."; + case "unsupported_capability": + return data.message ?? `Unsupported capability: ${data.capability ?? "unknown"}`; + case "cannot_continue": + return data.reason ?? "Something went wrong and I can't continue."; + case "plan": + return data.rationale + ? `${data.rationale}\n\n${(data.steps ?? []).map((s, i) => `${i + 1}. ${JSON.stringify(s)}`).join("\n")}` + : JSON.stringify(data.steps, null, 2); + case "need_content": + case "tool_call": + return data.rationale ?? data.summary ?? `Processing (${data.outcome})...`; + default: + return data.answer ?? data.summary ?? data.message ?? JSON.stringify(data); + } +} + +interface ChatContextValue { + messages: ChatMessage[]; + isOpen: boolean; + isLoading: boolean; + toggleOpen: () => void; + setOpen: (open: boolean) => void; + sendMessage: (content: string) => Promise; +} + +const ChatContext = createContext(null); + +const initialState: ChatState = { + messages: [], + isOpen: false, + isLoading: false, +}; + +export function ChatProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(chatReducer, initialState); + const { files: activeFiles } = useAllFiles(); + + const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []); + const setOpen = useCallback((open: boolean) => dispatch({ type: "SET_OPEN", open }), []); + + const sendMessage = useCallback(async (content: string) => { + const userMessage: ChatMessage = { + id: crypto.randomUUID(), + role: "user", + content, + timestamp: Date.now(), + }; + dispatch({ type: "ADD_MESSAGE", message: userMessage }); + dispatch({ type: "SET_LOADING", loading: true }); + + try { + const formData = new FormData(); + formData.append("userMessage", content); + activeFiles.forEach((file, i) => { + formData.append(`fileInputs[${i}].fileInput`, file); + }); + + const response = await fetch("/api/v1/ai/orchestrate", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`AI engine request failed: ${response.status}`); + } + + const data: AiWorkflowResponse = await response.json(); + const replyContent = formatWorkflowResponse(data); + const assistantMessage: ChatMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: replyContent, + timestamp: Date.now(), + }; + dispatch({ type: "ADD_MESSAGE", message: assistantMessage }); + } catch { + const errorMessage: ChatMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: "Failed to get a response. The AI engine may not be available yet.", + timestamp: Date.now(), + }; + dispatch({ type: "ADD_MESSAGE", message: errorMessage }); + } finally { + dispatch({ type: "SET_LOADING", loading: false }); + } + }, [activeFiles]); + + return ( + + {children} + + ); +} + +export function useChat(): ChatContextValue { + const context = useContext(ChatContext); + if (!context) { + throw new Error("useChat must be used within a ChatProvider"); + } + return context; +} diff --git a/frontend/src/prototypes/components/chat/ChatPanel.css b/frontend/src/prototypes/components/chat/ChatPanel.css new file mode 100644 index 0000000000..8f153b925b --- /dev/null +++ b/frontend/src/prototypes/components/chat/ChatPanel.css @@ -0,0 +1,74 @@ +.chat-toggle-button { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.chat-panel { + position: fixed; + top: 0; + right: 0; + width: 380px; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--mantine-color-body); + border-left: 1px solid var(--border-subtle, var(--mantine-color-default-border)); + z-index: 999; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); +} + +.chat-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-subtle, var(--mantine-color-default-border)); + flex-shrink: 0; +} + +.chat-panel-messages { + flex: 1; + min-height: 0; +} + +.chat-panel-input { + display: flex; + gap: 0.5rem; + padding: 0.75rem; + border-top: 1px solid var(--border-subtle, var(--mantine-color-default-border)); + flex-shrink: 0; +} + +/* Message layout */ +.chat-message { + display: flex; +} + +.chat-message-user { + justify-content: flex-end; +} + +.chat-message-assistant { + justify-content: flex-start; +} + +/* Bubble styling */ +.chat-bubble { + max-width: 85%; +} + +.chat-bubble-user { + background: var(--mantine-color-blue-filled) !important; + color: white !important; +} + +.chat-bubble-user * { + color: white !important; +} + +.chat-bubble-assistant { + background: var(--mantine-color-default-hover) !important; +} diff --git a/frontend/src/prototypes/components/chat/ChatPanel.tsx b/frontend/src/prototypes/components/chat/ChatPanel.tsx new file mode 100644 index 0000000000..f47a185de7 --- /dev/null +++ b/frontend/src/prototypes/components/chat/ChatPanel.tsx @@ -0,0 +1,135 @@ +import { useRef, useEffect, useState, type KeyboardEvent } from "react"; +import { ActionIcon, ScrollArea, TextInput, Stack, Text, Paper, Box, Transition } from "@mantine/core"; +import SendIcon from "@mui/icons-material/Send"; +import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; +import CloseIcon from "@mui/icons-material/Close"; +import { useChat } from "@app/components/chat/ChatContext"; +import "@app/components/chat/ChatPanel.css"; + +function ChatMessageBubble({ role, content }: { role: "user" | "assistant"; content: string }) { + return ( +
+ + {content} + +
+ ); +} + +export function ChatPanel() { + const { messages, isOpen, isLoading, toggleOpen, sendMessage } = useChat(); + const [input, setInput] = useState(""); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); + } + }, [messages]); + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + + const handleSend = () => { + const trimmed = input.trim(); + if (!trimmed || isLoading) return; + setInput(""); + sendMessage(trimmed); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( + <> + {/* Toggle button - always visible */} + {!isOpen && ( + + + + )} + + {/* Chat panel */} + + {(styles) => ( + + {/* Header */} +
+ AI Assistant + + + +
+ + {/* Messages */} + + + {messages.length === 0 && ( + + Ask a question about your documents or get help with PDF tools. + + )} + {messages.map((msg) => ( + + ))} + {isLoading && ( +
+ + Thinking... + +
+ )} +
+
+ + {/* Input */} +
+ setInput(e.currentTarget.value)} + onKeyDown={handleKeyDown} + disabled={isLoading} + rightSection={ + + + + } + rightSectionWidth={36} + style={{ flex: 1 }} + /> +
+
+ )} +
+ + ); +} diff --git a/frontend/src/prototypes/components/home/HomePageExtensions.tsx b/frontend/src/prototypes/components/home/HomePageExtensions.tsx new file mode 100644 index 0000000000..1e44646ce2 --- /dev/null +++ b/frontend/src/prototypes/components/home/HomePageExtensions.tsx @@ -0,0 +1,5 @@ +import { ChatPanel } from "@app/components/chat/ChatPanel"; + +export function HomePageExtensions() { + return ; +} diff --git a/frontend/src/prototypes/tsconfig.json b/frontend/src/prototypes/tsconfig.json new file mode 100644 index 0000000000..5dad506bd5 --- /dev/null +++ b/frontend/src/prototypes/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../../", + "paths": { + "@app/*": [ + "src/prototypes/*", + "src/proprietary/*", + "src/core/*" + ], + "@proprietary/*": [ + "src/proprietary/*" + ], + "@core/*": [ + "src/core/*" + ] + } + }, + "include": [ + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + "../core/setupTests.ts", + "." + ] +} diff --git a/frontend/tsconfig.prototypes.vite.json b/frontend/tsconfig.prototypes.vite.json new file mode 100644 index 0000000000..6d5e4629b9 --- /dev/null +++ b/frontend/tsconfig.prototypes.vite.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.proprietary.vite.json", + "compilerOptions": { + "paths": { + "@app/*": [ + "src/prototypes/*", + "src/proprietary/*", + "src/core/*" + ], + "@proprietary/*": ["src/proprietary/*"], + "@core/*": ["src/core/*"] + } + }, + "exclude": [ + "src/core/**/*.test.ts*", + "src/core/**/*.spec.ts*", + "src/proprietary/**/*.test.ts*", + "src/proprietary/**/*.spec.ts*", + "src/desktop", + "src/saas", + "node_modules" + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4e36220aaf..1a5a85f413 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { viteStaticCopy } from 'vite-plugin-static-copy'; -const VALID_MODES = ['core', 'proprietary', 'saas', 'desktop'] as const; +const VALID_MODES = ['core', 'proprietary', 'saas', 'desktop', 'prototypes'] as const; type BuildMode = typeof VALID_MODES[number]; const TSCONFIG_MAP: Record = { @@ -11,6 +11,7 @@ const TSCONFIG_MAP: Record = { proprietary: './tsconfig.proprietary.vite.json', saas: './tsconfig.saas.vite.json', desktop: './tsconfig.desktop.vite.json', + prototypes: './tsconfig.prototypes.vite.json', }; export default defineConfig(({ mode }) => { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 63fef12cfb..dc029485aa 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -100,6 +100,24 @@ export default defineConfig({ target: 'es2020' } }, + { + test: { + name: 'prototypes', + include: ['src/prototypes/**/*.test.{ts,tsx}'], + environment: 'jsdom', + globals: true, + setupFiles: ['./src/core/setupTests.ts'], + }, + plugins: [ + react(), + tsconfigPaths({ + projects: ['./tsconfig.prototypes.vite.json'], + }), + ], + esbuild: { + target: 'es2020' + } + }, ], }, esbuild: {