Add generic plugin architecture and watch-directory feature

Introduces a manifest contract so feature containers self-describe their
settings (JSON Schema + access rules). Backend and frontend gain generic
plugin proxy and dynamic Extensions UI with zero feature-specific code.

Doc-service is the first plugin consumer: exposes /plugin/manifest and
/plugin/settings, adds a watchdog-based file watcher that auto-ingests
PDFs from a mounted directory, maps subfolders to categories, supports
AI-suggested folder/filename (user-confirmed), and enforces a no-remove
policy. Access is gated by is_superuser or doc-service-admin group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 02:09:50 +02:00
parent 2d7207b62f
commit 00466a9801
29 changed files with 1373 additions and 52 deletions
+20 -2
View File
@@ -23,6 +23,7 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte
| `/admin/groups` | `AdminGroupsPage` | Admin only |
| `/profile` | `ProfilePage` | Required |
| `/settings` | `SettingsPage` (placeholder) | Required |
| `/settings/plugins/:id` | `PluginSettingsPage` | Required (per-plugin access control) |
`PrivateRoute` redirects to `/login` when no token. `AdminRoute` redirects to `/` when not admin.
@@ -60,6 +61,12 @@ Cards are rendered dynamically from `GET /api/services` (polled every 30 s via T
- Sections auto-open when navigating to their route
- In collapsed (icons-only) mode, clicking the Apps icon navigates to `/apps`
**Extensions** section (dynamic):
- Populated from `GET /api/plugins` (polled via TanStack Query, `retry: false`)
- Only shown when the user has access to at least one plugin
- Each entry links to `/settings/plugins/:id`
- No code changes needed to add future plugin-enabled feature containers
### Documents page (`/apps/documents`)
**Upload:** PDF file input, 202 response, error display.
@@ -140,6 +147,10 @@ Key functions:
| `removeCategory(docId, catId)` | Remove |
| `updateDocumentTags(id, tags)` | `PATCH /documents/{id}/tags` |
| `updateDocumentTitle(id, title)` | `PATCH /documents/{id}/title` |
| `confirmFolderSuggestion(docId)` | `POST /documents/{id}/suggestions/folder/confirm` |
| `rejectFolderSuggestion(docId)` | `POST /documents/{id}/suggestions/folder/reject` |
| `confirmFilenameSuggestion(docId)` | `POST /documents/{id}/suggestions/filename/confirm` |
| `rejectFilenameSuggestion(docId)` | `POST /documents/{id}/suggestions/filename/reject` |
| `getAISettings()` | `GET /settings/ai` (masked) |
| `updateAISettings(data)` | `PATCH /settings/ai` |
| `testAIConnection()` | `POST /settings/ai/test` |
@@ -152,6 +163,10 @@ Key functions:
| `adminAddGroupMember(gId, uId)` | `POST /admin/groups/{gId}/members/{uId}` |
| `adminRemoveGroupMember(gId, uId)` | `DELETE /admin/groups/{gId}/members/{uId}` |
| `updateDocumentLimits(data)` | `PATCH /settings/documents/limits` |
| `getPlugins()` | `GET /plugins` — list accessible plugins |
| `getPluginManifest(id)` | `GET /plugins/{id}/manifest` |
| `getPluginSettings(id)` | `GET /plugins/{id}/settings` |
| `updatePluginSettings(id, data)` | `PATCH /plugins/{id}/settings` |
---
@@ -168,8 +183,10 @@ Key functions:
| Component | Path | Description |
|-----------|------|-------------|
| `AppShell` | `src/components/AppShell.tsx` | Layout wrapper: Sidebar + scrollable main content |
| `Sidebar` | `src/components/Sidebar.tsx` | Collapsible left nav (icons-only ↔ icons+labels) |
| `Sidebar` | `src/components/Sidebar.tsx` | Collapsible left nav; includes dynamic "Extensions" section |
| `ThemeToggle` | `src/components/ThemeToggle.tsx` | Sun/moon ghost icon button; persists to localStorage |
| `PluginSchemaForm` | `src/components/PluginSchemaForm.tsx` | JSON Schema → React form (boolean/string/number/readOnly fields) |
| `PluginSettingsPage` | `src/pages/PluginSettingsPage.tsx` | Generic plugin settings page (manifest-driven) |
| `Button` | `src/components/ui/button.tsx` | shadcn/ui Button (default, ghost, outline, destructive) |
| `Input` | `src/components/ui/input.tsx` | shadcn/ui Input |
@@ -188,10 +205,11 @@ Key functions:
- [x] UI component library: shadcn/ui + Tailwind CSS — installed and wired up
- [x] AppShell + Sidebar replacing inline Nav component
- [x] Light/dark theme context with OS preference detection
- [x] Generic plugin infrastructure: Extensions sidebar section, PluginSchemaForm, PluginSettingsPage
- [ ] Suggestion badges in DocumentsPage for `suggested_folder` / `suggested_filename` (confirm/reject buttons)
- [ ] Toast notification system (upload success, save feedback, errors)
- [ ] Loading skeletons
- [ ] `POST /queue/jobs` integration — show AI processing queue status / progress per document
- [ ] Re-process document button (`POST /documents/{id}/reprocess` — needs backend endpoint first)
- [ ] Advanced filter: extracted data fields (vendor, due date, amount) — needs backend support
- [x] Groups admin UI — list, create, edit, delete, add/remove members
- [ ] App permissions UI per group (blocked on backend group_app_permissions)
+2
View File
@@ -15,6 +15,7 @@ import DocumentsPage from "./pages/DocumentsPage";
import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage";
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
import SettingsPage from "./pages/SettingsPage";
import PluginSettingsPage from "./pages/PluginSettingsPage";
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuth();
@@ -55,6 +56,7 @@ export default function App() {
/>
<Route path="/profile" element={<PrivateRoute><ProfilePage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
<Route path="/settings/plugins/:id" element={<PrivateRoute><PluginSettingsPage /></PrivateRoute>} />
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
<Route path="/admin/groups" element={<AdminRoute><AdminGroupsPage /></AdminRoute>} />
+60
View File
@@ -107,6 +107,10 @@ export interface DocumentOut {
created_at: string;
processed_at: string | null;
categories: CategoryOut[];
source: string;
watch_path: string | null;
suggested_folder: string | null;
suggested_filename: string | null;
}
export interface DocumentPage {
@@ -371,3 +375,59 @@ export const updateSystemPrompt = (
api
.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data)
.then((r) => r.data);
// --- Document suggestions (watch-ingested documents) ---
export const confirmFolderSuggestion = (docId: string) =>
api.post(`/documents/${docId}/suggestions/folder/confirm`);
export const rejectFolderSuggestion = (docId: string) =>
api.post(`/documents/${docId}/suggestions/folder/reject`);
export const confirmFilenameSuggestion = (docId: string) =>
api.post(`/documents/${docId}/suggestions/filename/confirm`);
export const rejectFilenameSuggestion = (docId: string) =>
api.post(`/documents/${docId}/suggestions/filename/reject`);
// --- Plugins ---
export interface PluginOut {
id: string;
name: string;
icon: string;
version: string;
}
export interface PluginSchemaProperty {
type: string;
title: string;
description?: string;
readOnly?: boolean;
}
export interface PluginManifest {
id: string;
name: string;
icon: string;
version: string;
access: {
allow_superuser: boolean;
required_groups: string[];
};
settings_schema: {
type: string;
title?: string;
properties: Record<string, PluginSchemaProperty>;
};
}
export const getPlugins = () =>
api.get<PluginOut[]>("/plugins").then((r) => r.data);
export const getPluginManifest = (id: string) =>
api.get<PluginManifest>(`/plugins/${id}/manifest`).then((r) => r.data);
export const getPluginSettings = (id: string) =>
api.get<Record<string, unknown>>(`/plugins/${id}/settings`).then((r) => r.data);
export const updatePluginSettings = (id: string, data: Record<string, unknown>) =>
api.patch<Record<string, unknown>>(`/plugins/${id}/settings`, data).then((r) => r.data);
@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { PluginSchemaProperty } from "@/api/client";
interface PluginSchema {
type: string;
title?: string;
properties: Record<string, PluginSchemaProperty>;
}
interface PluginSchemaFormProps {
schema: PluginSchema;
values: Record<string, unknown>;
onSave: (values: Record<string, unknown>) => void;
isPending?: boolean;
isError?: boolean;
isSuccess?: boolean;
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
checked ? "bg-primary" : "bg-muted/60 border border-border"
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
}
export default function PluginSchemaForm({
schema,
values,
onSave,
isPending,
isError,
isSuccess,
}: PluginSchemaFormProps) {
const [form, setForm] = useState<Record<string, unknown>>(values);
useEffect(() => {
setForm(values);
}, [values]);
const setField = (key: string, value: unknown) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
return (
<div className="space-y-6">
{Object.entries(schema.properties).map(([key, prop]) => (
<div key={key} className="space-y-1.5">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{prop.title}</p>
{prop.description && (
<p className="text-xs text-muted mt-0.5">{prop.description}</p>
)}
</div>
{prop.type === "boolean" && !prop.readOnly && (
<Toggle
checked={Boolean(form[key])}
onChange={(v) => setField(key, v)}
/>
)}
</div>
{prop.type === "string" && prop.readOnly && (
<p className="text-sm text-muted font-mono bg-muted/20 px-3 py-1.5 rounded-md border border-border">
{String(form[key] ?? "")}
</p>
)}
{prop.type === "string" && !prop.readOnly && (
<Input
value={String(form[key] ?? "")}
onChange={(e) => setField(key, e.target.value)}
className="h-9"
/>
)}
{prop.type === "number" && !prop.readOnly && (
<Input
type="number"
value={String(form[key] ?? "")}
onChange={(e) => setField(key, Number(e.target.value))}
className="h-9"
/>
)}
</div>
))}
<div className="flex items-center gap-3 pt-2">
<Button onClick={() => onSave(form)} disabled={isPending} size="sm">
{isPending ? "Saving…" : "Save changes"}
</Button>
{isError && (
<span className="text-sm text-destructive">Failed to save. Please try again.</span>
)}
{isSuccess && !isPending && (
<span className="text-sm text-green-600 dark:text-green-400">Saved successfully.</span>
)}
</div>
</div>
);
}
+44 -1
View File
@@ -16,11 +16,12 @@ import {
Users,
UsersRound,
Palette,
Puzzle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import ThemeToggle from "@/components/ThemeToggle";
import { useAuth } from "@/hooks/useAuth";
import { getMe, listCategories } from "@/api/client";
import { getMe, getPlugins, listCategories } from "@/api/client";
import { cn } from "@/lib/utils";
export default function Sidebar() {
@@ -56,6 +57,14 @@ export default function Sidebar() {
enabled: appsOpen && docsOpen && !!user,
});
const { data: plugins = [] } = useQuery({
queryKey: ["plugins"],
queryFn: getPlugins,
enabled: !!user,
// Empty array on 404/error — regular users simply see no plugins
retry: false,
});
const navItemClass = (isActive: boolean) =>
cn(
"flex items-center rounded-lg transition-colors",
@@ -209,6 +218,40 @@ export default function Sidebar() {
)}
</NavLink>
{/* Extensions — visible only when the user has accessible plugins */}
{plugins.length > 0 && (
<div>
{sidebarExpanded ? (
<>
<div className="px-3 py-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted">
Extensions
</span>
</div>
<div className="space-y-0.5">
{plugins.map((plugin) => (
<NavLink
key={plugin.id}
to={`/settings/plugins/${plugin.id}`}
className={({ isActive }) => subItemClass(isActive)}
>
<Puzzle className="h-4 w-4 shrink-0" />
<span className="whitespace-nowrap truncate">{plugin.name}</span>
</NavLink>
))}
</div>
</>
) : (
<NavLink
to={`/settings/plugins/${plugins[0].id}`}
className={({ isActive }) => navItemClass(isActive)}
>
<Puzzle className="h-5 w-5 shrink-0" />
</NavLink>
)}
</div>
)}
{/* Admin — expandable */}
{user?.is_admin && (
<div>
+63
View File
@@ -0,0 +1,63 @@
import { useParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getPluginManifest, getPluginSettings, updatePluginSettings } from "@/api/client";
import PluginSchemaForm from "@/components/PluginSchemaForm";
export default function PluginSettingsPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data: manifest, isLoading: manifestLoading, isError: manifestError } = useQuery({
queryKey: ["plugin-manifest", id],
queryFn: () => getPluginManifest(id!),
enabled: !!id,
retry: false,
});
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["plugin-settings", id],
queryFn: () => getPluginSettings(id!),
enabled: !!id && !!manifest,
});
const mutation = useMutation({
mutationFn: (values: Record<string, unknown>) => updatePluginSettings(id!, values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["plugin-settings", id] });
},
});
if (manifestLoading || settingsLoading) {
return <p className="text-sm text-muted p-6">Loading</p>;
}
if (manifestError || !manifest) {
return (
<p className="text-sm text-destructive p-6">
Plugin not found or you do not have access to its settings.
</p>
);
}
return (
<div className="max-w-xl p-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">
{manifest.settings_schema.title ?? manifest.name}
</h1>
<p className="text-sm text-muted mt-1">
{manifest.name} · v{manifest.version}
</p>
</div>
<PluginSchemaForm
schema={manifest.settings_schema}
values={settings ?? {}}
onSave={(values) => mutation.mutate(values)}
isPending={mutation.isPending}
isError={mutation.isError}
isSuccess={mutation.isSuccess}
/>
</div>
);
}