Add prototypes folder to test new functionality in (#6081)

# Description of Changes
Add prototypes folder to test new functionality in. This build of the
app is spawnable with `npm run dev:prototypes`.

Currently just contains a very developer-y chat interface to help us
develop & explore the AI backend before we make the frontend for it for
real.
This commit is contained in:
James Brunton
2026-04-09 09:21:07 +01:00
committed by GitHub
parent b130242688
commit cc1604a802
14 changed files with 621 additions and 2 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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 (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
// Participant signing page — token-gated, no login required
function ParticipantViewPage() {
const { token } = useParams<{ token: string }>();
if (!token) return null;
return <ParticipantView token={token} />;
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
{/* Participant signing — public, token-gated, no auth required */}
<Route
path="/workflow/sign/:token"
element={
<MinimalProviders>
<ParticipantViewPage />
</MinimalProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}

View File

@@ -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.

View File

@@ -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 (
<ProprietaryAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<ChatProvider>
{children}
</ChatProvider>
</ProprietaryAppProviders>
);
}

View File

@@ -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<Record<string, unknown>>;
}
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<void>;
}
const ChatContext = createContext<ChatContextValue | null>(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 (
<ChatContext.Provider value={{
messages: state.messages,
isOpen: state.isOpen,
isLoading: state.isLoading,
toggleOpen,
setOpen,
sendMessage,
}}>
{children}
</ChatContext.Provider>
);
}
export function useChat(): ChatContextValue {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");
}
return context;
}

View File

@@ -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;
}

View File

@@ -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 (
<div className={`chat-message chat-message-${role}`}>
<Paper
className={`chat-bubble chat-bubble-${role}`}
p="xs"
radius="md"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>{content}</Text>
</Paper>
</div>
);
}
export function ChatPanel() {
const { messages, isOpen, isLoading, toggleOpen, sendMessage } = useChat();
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<>
{/* Toggle button - always visible */}
{!isOpen && (
<ActionIcon
className="chat-toggle-button"
variant="filled"
color="blue"
size="xl"
radius="xl"
onClick={toggleOpen}
aria-label="Open chat"
>
<ChatBubbleOutlineIcon sx={{ fontSize: 24 }} />
</ActionIcon>
)}
{/* Chat panel */}
<Transition mounted={isOpen} transition="slide-left" duration={200}>
{(styles) => (
<Box className="chat-panel" style={styles}>
{/* Header */}
<div className="chat-panel-header">
<Text fw={600} size="sm">AI Assistant</Text>
<ActionIcon variant="subtle" size="sm" onClick={toggleOpen} aria-label="Close chat">
<CloseIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</div>
{/* Messages */}
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
<Stack gap="sm" p="sm">
{messages.length === 0 && (
<Text size="sm" c="dimmed" ta="center" py="xl">
Ask a question about your documents or get help with PDF tools.
</Text>
)}
{messages.map((msg) => (
<ChatMessageBubble key={msg.id} role={msg.role} content={msg.content} />
))}
{isLoading && (
<div className="chat-message chat-message-assistant">
<Paper className="chat-bubble chat-bubble-assistant" p="xs" radius="md">
<Text size="sm" c="dimmed">Thinking...</Text>
</Paper>
</div>
)}
</Stack>
</ScrollArea>
{/* Input */}
<div className="chat-panel-input">
<TextInput
ref={inputRef}
placeholder="Type a message..."
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
rightSection={
<ActionIcon
variant="filled"
color="blue"
size="sm"
onClick={handleSend}
disabled={!input.trim() || isLoading}
aria-label="Send message"
>
<SendIcon sx={{ fontSize: 14 }} />
</ActionIcon>
}
rightSectionWidth={36}
style={{ flex: 1 }}
/>
</div>
</Box>
)}
</Transition>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { ChatPanel } from "@app/components/chat/ChatPanel";
export function HomePageExtensions() {
return <ChatPanel />;
}

View File

@@ -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",
"."
]
}

View File

@@ -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"
]
}

View File

@@ -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<BuildMode, string> = {
@@ -11,6 +11,7 @@ const TSCONFIG_MAP: Record<BuildMode, string> = {
proprietary: './tsconfig.proprietary.vite.json',
saas: './tsconfig.saas.vite.json',
desktop: './tsconfig.desktop.vite.json',
prototypes: './tsconfig.prototypes.vite.json',
};
export default defineConfig(({ mode }) => {

View File

@@ -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: {