mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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".
|
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,
|
* 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".
|
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
|
* Content outside of the above mentioned directories or restrictions above is
|
||||||
available under the MIT License as defined below.
|
available under the MIT License as defined below.
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export default defineConfig(
|
|||||||
files: [
|
files: [
|
||||||
'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}',
|
'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}',
|
||||||
'src/saas/**/*.{js,mjs,jsx,ts,tsx}',
|
'src/saas/**/*.{js,mjs,jsx,ts,tsx}',
|
||||||
|
'src/prototypes/**/*.{js,mjs,jsx,ts,tsx}',
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"dev:proprietary": "npm run prep && vite --mode proprietary",
|
"dev:proprietary": "npm run prep && vite --mode proprietary",
|
||||||
"dev:saas": "npm run prep:saas && vite --mode saas",
|
"dev:saas": "npm run prep:saas && vite --mode saas",
|
||||||
"dev:desktop": "npm run prep:desktop && vite --mode desktop",
|
"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": "npm run lint:eslint && npm run lint:cycles",
|
||||||
"lint:eslint": "eslint --max-warnings=0",
|
"lint:eslint": "eslint --max-warnings=0",
|
||||||
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
|
"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:proprietary": "npm run prep && vite build --mode proprietary",
|
||||||
"build:saas": "npm run prep:saas && vite build --mode saas",
|
"build:saas": "npm run prep:saas && vite build --mode saas",
|
||||||
"build:desktop": "npm run prep:desktop && vite build --mode desktop",
|
"build:desktop": "npm run prep:desktop && vite build --mode desktop",
|
||||||
|
"build:prototypes": "npm run prep && vite build --mode prototypes",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri-dev": "npm run prep:desktop && tauri dev --no-watch",
|
"tauri-dev": "npm run prep:desktop && tauri dev --no-watch",
|
||||||
"tauri-build": "npm run prep:desktop-build && tauri build",
|
"tauri-build": "npm run prep:desktop-build && tauri build",
|
||||||
@@ -110,8 +112,9 @@
|
|||||||
"typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json",
|
"typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json",
|
||||||
"typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json",
|
"typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json",
|
||||||
"typecheck:desktop": "tsc --noEmit --project src/desktop/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: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",
|
"check": "npm run typecheck && npm run lint && npm run test:run",
|
||||||
"generate-licenses": "node scripts/generate-licenses.js",
|
"generate-licenses": "node scripts/generate-licenses.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js",
|
"generate-icons": "node scripts/generate-icons.js",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||||
|
|
||||||
|
export function HomePageExtensions() {
|
||||||
|
return <ChatPanel />;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
"."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react-swc';
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
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];
|
type BuildMode = typeof VALID_MODES[number];
|
||||||
|
|
||||||
const TSCONFIG_MAP: Record<BuildMode, string> = {
|
const TSCONFIG_MAP: Record<BuildMode, string> = {
|
||||||
@@ -11,6 +11,7 @@ const TSCONFIG_MAP: Record<BuildMode, string> = {
|
|||||||
proprietary: './tsconfig.proprietary.vite.json',
|
proprietary: './tsconfig.proprietary.vite.json',
|
||||||
saas: './tsconfig.saas.vite.json',
|
saas: './tsconfig.saas.vite.json',
|
||||||
desktop: './tsconfig.desktop.vite.json',
|
desktop: './tsconfig.desktop.vite.json',
|
||||||
|
prototypes: './tsconfig.prototypes.vite.json',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
|
|||||||
@@ -100,6 +100,24 @@ export default defineConfig({
|
|||||||
target: 'es2020'
|
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: {
|
esbuild: {
|
||||||
|
|||||||
Reference in New Issue
Block a user