feat: wire brain service to platform gateway

- Gateway proxies /api/brain/* to brain-api:8200/api/* via pangolin network
- User identity injected via X-Gateway-User-Id header
- Brain app registered in gateway database (sort_order 9)
- Added to GATEWAY_KEY_SERVICES for dashboard integration
- Tested: health, config, list, create all working through gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 16:32:53 -05:00
parent c9e776df59
commit 2072c359aa
34 changed files with 16745 additions and 1379 deletions

View File

@@ -0,0 +1,590 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
let {
url,
name = 'Document',
onDelete = () => {},
deleting = false
}: {
url: string;
name?: string;
onDelete?: () => void;
deleting?: boolean;
} = $props();
let inlinePagesHost = $state<HTMLDivElement | null>(null);
let pagesHost = $state<HTMLDivElement | null>(null);
let loading = $state(true);
let expandedLoading = $state(false);
let error = $state('');
let isExpanded = $state(false);
let isInlineOpen = $state(false);
let pageCount = $state(0);
let pdfModulePromise: Promise<any> | null = null;
let pdfDocumentPromise: Promise<any> | null = null;
async function getPdfModule() {
if (!pdfModulePromise) {
pdfModulePromise = import('pdfjs-dist').then((mod) => {
const pdfjs = mod.default ?? mod;
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
}
return pdfjs;
});
}
return pdfModulePromise;
}
async function getPdfDocument() {
if (!pdfDocumentPromise) {
pdfDocumentPromise = getPdfModule().then((pdfjs) =>
pdfjs.getDocument({
url,
withCredentials: true
}).promise
);
}
return pdfDocumentPromise;
}
async function renderPageToCanvas(canvas: HTMLCanvasElement, pageNumber: number, width: number) {
const pdf = await getPdfDocument();
const page = await pdf.getPage(pageNumber);
const baseViewport = page.getViewport({ scale: 1 });
const scale = width / baseViewport.width;
const viewport = page.getViewport({ scale });
const context = canvas.getContext('2d');
const pixelRatio = typeof window !== 'undefined' ? Math.max(window.devicePixelRatio || 1, 1) : 1;
if (!context) return;
canvas.width = Math.floor(viewport.width * pixelRatio);
canvas.height = Math.floor(viewport.height * pixelRatio);
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
await page.render({
canvasContext: context,
viewport
}).promise;
}
async function renderPages(host: HTMLDivElement, width: number, pageClass = 'expanded-page', canvasClass = 'expanded-canvas') {
host.replaceChildren();
const pdf = await getPdfDocument();
pageCount = pdf.numPages;
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
const wrapper = document.createElement('div');
wrapper.className = pageClass;
const label = document.createElement('div');
label.className = 'expanded-page-label';
label.textContent = `Page ${pageNumber}`;
const canvas = document.createElement('canvas');
canvas.className = canvasClass;
wrapper.append(label, canvas);
host.appendChild(wrapper);
await renderPageToCanvas(canvas, pageNumber, width);
}
}
async function renderInline() {
if (!inlinePagesHost) return;
loading = true;
error = '';
try {
await renderPages(inlinePagesHost, 420, 'inline-page', 'inline-canvas');
} catch (err) {
console.error('PDF preview failed', err);
error = 'Preview unavailable';
} finally {
loading = false;
}
}
async function openInline() {
isInlineOpen = true;
await tick();
await renderInline();
}
function closeInline() {
isInlineOpen = false;
}
async function openExpanded() {
isExpanded = true;
expandedLoading = true;
error = '';
await tick();
if (!pagesHost) {
expandedLoading = false;
return;
}
try {
const hostWidth = Math.max(280, Math.min(860, pagesHost.clientWidth - 8 || 860));
await renderPages(pagesHost, hostWidth);
} catch (err) {
console.error('Expanded PDF render failed', err);
error = 'Unable to load PDF';
} finally {
expandedLoading = false;
}
}
function closeExpanded() {
isExpanded = false;
}
onMount(() => {
renderInline();
});
</script>
<div class="pdf-preview-shell">
<div class="preview-frame">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="preview-head"
role="button"
tabindex="0"
onclick={isInlineOpen ? closeInline : openInline}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isInlineOpen) closeInline();
else openInline();
}
}}
>
<div class="preview-docline">
<svg class="preview-docicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<div class="preview-name">{name}</div>
</div>
<div class="preview-head-actions">
<button class="doc-delete" onclick={(e) => { e.stopPropagation(); onDelete(); }} disabled={deleting}>{deleting ? '…' : '×'}</button>
</div>
</div>
{#if isInlineOpen}
<div class="preview-topline">
<div class="preview-badge">PDF preview</div>
<div class="preview-actions">
<button class="preview-action" onclick={openExpanded}>Full view</button>
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
</div>
<div class="preview-card">
{#if error}
<div class="preview-state preview-error">{error}</div>
{:else}
<div class="preview-stage">
<div bind:this={inlinePagesHost} class="inline-pages"></div>
{#if loading}
<div class="preview-loading">Rendering pages…</div>
{/if}
</div>
<div class="preview-footer">
<div class="preview-pages">{pageCount} page{pageCount === 1 ? '' : 's'}</div>
</div>
{/if}
</div>
{:else}
<div class="preview-collapsed-meta">
<div class="preview-pages">{pageCount > 0 ? `${pageCount} page${pageCount === 1 ? '' : 's'}` : 'PDF document'}</div>
<a class="preview-inline-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
{/if}
</div>
</div>
{#if isExpanded}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-overlay" onclick={closeExpanded}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-sheet" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div class="expanded-head">
<div class="expanded-copy">
<div class="expanded-kicker">Inline document</div>
<div class="expanded-title">{name}</div>
</div>
<div class="expanded-head-actions">
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">Open</a>
<button class="expanded-close" onclick={closeExpanded}>Close</button>
</div>
</div>
<div class="expanded-body">
{#if expandedLoading}
<div class="expanded-state">Loading full document…</div>
{:else if error}
<div class="expanded-state preview-error">{error}</div>
{/if}
<div bind:this={pagesHost} class="expanded-pages"></div>
</div>
</div>
</div>
{/if}
<style>
.pdf-preview-shell {
display: block;
}
.preview-frame {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 6px;
border-top: 1px solid rgba(127, 101, 74, 0.12);
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.preview-docline {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.preview-docicon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: rgba(111, 88, 64, 0.7);
}
.preview-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.preview-badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.7);
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-action {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.preview-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
border-radius: 16px;
background: rgba(247, 239, 229, 0.78);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-stage {
position: relative;
border-radius: 16px;
overflow: hidden;
background: rgba(250, 243, 235, 0.72);
border: 1px solid rgba(127, 101, 74, 0.14);
}
.preview-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(247, 239, 229, 0.82);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-error {
color: #8c3c2d;
}
.inline-pages {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 520px;
overflow: auto;
padding: 10px;
}
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-name {
font-size: 0.92rem;
font-weight: 600;
color: #2f2116;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-pages {
font-size: 0.82rem;
color: rgba(93, 72, 55, 0.76);
}
.preview-collapsed-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-inline-link {
font-size: 0.82rem;
font-weight: 600;
color: #5d4737;
text-decoration: none;
}
.doc-delete {
background: none;
border: none;
color: rgba(112, 86, 62, 0.64);
cursor: pointer;
font-size: 1.1rem;
padding: 2px 4px;
}
.doc-delete:hover {
color: #8f3928;
}
.expanded-overlay {
position: fixed;
inset: 0;
z-index: 85;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(19, 13, 10, 0.46);
backdrop-filter: blur(10px);
}
.expanded-sheet {
width: min(980px, 100%);
max-height: min(92vh, 100%);
display: flex;
flex-direction: column;
border-radius: 28px;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(247, 239, 229, 0.98));
box-shadow: 0 28px 64px rgba(35, 24, 15, 0.28);
}
.expanded-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 22px 24px 18px;
border-bottom: 1px solid rgba(123, 97, 71, 0.12);
}
.expanded-copy {
display: flex;
flex-direction: column;
gap: 6px;
}
.expanded-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.72);
}
.expanded-title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.03em;
color: #27190f;
}
.expanded-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.expanded-close {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.expanded-body {
flex: 1;
overflow: auto;
padding: 18px 24px 24px;
}
.expanded-state {
padding: 24px;
border-radius: 18px;
background: rgba(247, 239, 229, 0.74);
color: rgba(90, 71, 54, 0.8);
text-align: center;
}
.expanded-pages {
display: flex;
flex-direction: column;
gap: 20px;
}
:global(.inline-page) {
display: flex;
flex-direction: column;
gap: 8px;
}
:global(.inline-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 14px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 10px 20px rgba(63, 42, 22, 0.05);
}
:global(.expanded-page) {
display: flex;
flex-direction: column;
gap: 10px;
}
:global(.expanded-page-label) {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.68);
}
:global(.expanded-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 18px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 14px 28px rgba(63, 42, 22, 0.06);
}
@media (max-width: 768px) {
.preview-topline {
align-items: flex-start;
flex-direction: column;
}
.preview-actions {
width: 100%;
}
.preview-action {
flex: 1;
text-align: center;
}
.expanded-overlay {
padding: 10px;
}
.expanded-sheet {
max-height: 96vh;
border-radius: 22px;
}
.expanded-head,
.expanded-body {
padding-left: 16px;
padding-right: 16px;
}
.expanded-head {
flex-direction: column;
}
}
</style>