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 (
+
+ );
+}
+
+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 */}
+
+
+ {/* Messages */}
+
+
+ {messages.length === 0 && (
+
+ Ask a question about your documents or get help with PDF tools.
+
+ )}
+ {messages.map((msg) => (
+
+ ))}
+ {isLoading && (
+
+ )}
+
+
+
+ {/* 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: {