made tiny updates
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
// ── tRPC HTTP handler (Next.js App Router) ────────────────────────────────────
|
||||
// Mounts the tRPC appRouter at /api/trpc/* using the fetch adapter.
|
||||
// Both GET (queries) and POST (mutations) are needed.
|
||||
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { appRouter } from '@/server/routers/index';
|
||||
import { createTRPCContext } from '@/server/trpc';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
onError: ({ path, error }) => {
|
||||
console.error(`tRPC error on /${path}:`, error.message);
|
||||
},
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { FluentProvider } from '@fluentui/react-components';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useTheme } from '@/store/theme';
|
||||
import { TourOverlay } from '@/components/walkthrough/TourOverlay';
|
||||
|
||||
@@ -23,6 +25,16 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
}),
|
||||
);
|
||||
|
||||
// tRPC client — shares the QueryClient so tRPC queries/mutations go into the
|
||||
// same cache as all other TanStack Query calls in the app.
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({ url: '/api/trpc' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const mode = useTheme((s) => s.mode);
|
||||
const theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme;
|
||||
|
||||
@@ -33,11 +45,13 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||
{children}
|
||||
<TourOverlay />
|
||||
</FluentProvider>
|
||||
</QueryClientProvider>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||
{children}
|
||||
<TourOverlay />
|
||||
</FluentProvider>
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCanvas } from '@/store/canvas';
|
||||
import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { Artboard } from './Artboard';
|
||||
import { CompletionZone } from './CompletionZone';
|
||||
|
||||
export function Canvas() {
|
||||
const T = useCanvasTheme();
|
||||
@@ -77,8 +78,13 @@ export function Canvas() {
|
||||
|
||||
// Zone tool: drag to draw a completion zone
|
||||
const zoneStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
// Completion preview: AI result overlaid on the artboard at zone coords (spec Layer 6)
|
||||
const [completionPreview, setCompletionPreview] = useState<{
|
||||
result: string;
|
||||
bounds: { x: number; y: number; w: number; h: number };
|
||||
} | null>(null);
|
||||
|
||||
// Wheel: pan or pinch-zoom
|
||||
useEffect(() => {
|
||||
@@ -274,15 +280,93 @@ export function Canvas() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI completion preview — rendered in artboard space at zone bounds.
|
||||
Spec Layer 6: "A preview overlay showing the AI-generated completion
|
||||
on the artboard." Positioned inside the transform layer so it tracks
|
||||
pan/zoom automatically with no coordinate conversion needed. */}
|
||||
{completionPreview && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: completionPreview.bounds.x,
|
||||
top: completionPreview.bounds.y,
|
||||
width: completionPreview.bounds.w,
|
||||
height: completionPreview.bounds.h,
|
||||
background: 'rgba(20,22,30,0.82)',
|
||||
border: '1.5px solid rgba(51,133,255,0.55)',
|
||||
borderRadius: 6,
|
||||
backdropFilter: 'blur(6px)',
|
||||
padding: '10px 12px',
|
||||
boxSizing: 'border-box',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* "AI" badge */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.45rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(51,133,255,0.9)',
|
||||
background: 'rgba(51,133,255,0.12)',
|
||||
border: '1px solid rgba(51,133,255,0.3)',
|
||||
borderRadius: 3,
|
||||
padding: '1px 4px',
|
||||
}}>
|
||||
⚡ AI Preview
|
||||
</span>
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onMouseDown={e => { e.stopPropagation(); setCompletionPreview(null); }}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 10,
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '0.625rem',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
lineHeight: 1.55,
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{completionPreview.result}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zone prompt overlay — shown after a zone drag completes */}
|
||||
{/* Completion zone popup — shown after a zone drag completes.
|
||||
Lives in screen space (outside the transform layer) so the input
|
||||
isn't scaled by zoom. */}
|
||||
{zoneDone && (
|
||||
<ZonePromptOverlay
|
||||
<CompletionZone
|
||||
bounds={zoneDone}
|
||||
artboardId={selectedArtboardId}
|
||||
panX={panX} panY={panY} zoom={zoom}
|
||||
onClose={() => setZoneDone(null)}
|
||||
onClose={() => { setZoneDone(null); }}
|
||||
onResult={(result, bounds) => setCompletionPreview({ result, bounds })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -443,145 +527,4 @@ function UrlOnboardingOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Zone prompt overlay ──────────────────────────────────── */
|
||||
function ZonePromptOverlay({
|
||||
bounds, artboardId, panX, panY, zoom, onClose,
|
||||
}: {
|
||||
bounds: { x: number; y: number; w: number; h: number };
|
||||
artboardId: string | null;
|
||||
panX: number; panY: number; zoom: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
|
||||
const [result, setResult] = useState('');
|
||||
|
||||
// Convert canvas → screen coordinates (relative to canvas container)
|
||||
const screenX = bounds.x * zoom + panX;
|
||||
const screenY = (bounds.y + bounds.h) * zoom + panY + 10; // 10px below zone
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!prompt.trim() || !artboardId) return;
|
||||
setStatus('loading');
|
||||
try {
|
||||
const res = await fetch('/api/ai/completion-zone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
artboard_id: artboardId,
|
||||
bounds: { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h },
|
||||
prompt: prompt.trim(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json() as { completion?: string; result?: string };
|
||||
setResult(data.completion ?? data.result ?? 'Done');
|
||||
setStatus('done');
|
||||
} catch (e) {
|
||||
console.error('[ZonePrompt]', e);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [prompt, artboardId, bounds]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: Math.max(8, screenX),
|
||||
top: Math.max(8, screenY),
|
||||
zIndex: 50,
|
||||
width: 280,
|
||||
background: '#1A1A20',
|
||||
border: '1px solid rgba(51,133,255,0.35)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
||||
padding: '12px 14px',
|
||||
fontFamily: "'Inter', -apple-system, sans-serif",
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'rgba(51,133,255,0.9)', letterSpacing: '-0.01em' }}>
|
||||
⚡ Completion zone · {bounds.w}×{bounds.h}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'rgba(255,255,255,0.3)', cursor: 'pointer', fontSize: 13, padding: 0, lineHeight: 1 }}>✕</button>
|
||||
</div>
|
||||
|
||||
{status === 'done' ? (
|
||||
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.75)', lineHeight: 1.6, marginBottom: 10 }}>
|
||||
{result}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void submit();
|
||||
if (e.key === 'Escape') onClose();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="Describe what to generate in this zone…"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 6, padding: '8px 10px',
|
||||
fontSize: '0.75rem', color: 'rgba(255,255,255,0.85)',
|
||||
fontFamily: 'inherit', resize: 'none', outline: 'none',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = '#3385FF')}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)')}
|
||||
/>
|
||||
{status === 'error' && (
|
||||
<p style={{ fontSize: '0.625rem', color: '#FF8080', margin: '0 0 6px' }}>Request failed — try again</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => void submit()}
|
||||
disabled={status === 'loading' || !prompt.trim() || !artboardId}
|
||||
style={{
|
||||
flex: 1, padding: '7px 0', borderRadius: 6,
|
||||
background: !prompt.trim() || !artboardId ? 'rgba(51,133,255,0.3)' : '#3385FF',
|
||||
border: 'none', color: '#fff',
|
||||
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', opacity: status === 'loading' ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{status === 'loading' ? 'Generating…' : 'Generate ⌘↵'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '7px 12px', borderRadius: 6,
|
||||
background: 'transparent', border: '1px solid rgba(255,255,255,0.12)',
|
||||
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'done' && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '100%', padding: '7px 0', borderRadius: 6,
|
||||
background: 'rgba(255,255,255,0.07)', border: 'none',
|
||||
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ZonePromptOverlay extracted to ./CompletionZone.tsx (spec Layer 6.4)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
// ── CompletionZone ────────────────────────────────────────────────────────────
|
||||
// Floating prompt overlay shown after a user draws an AI completion zone on the
|
||||
// canvas. Handles the full AI → result → IntentDiff flow.
|
||||
//
|
||||
// Spec Layer 6: AI calls go through the tRPC server-side router (ai.fillCompletionZone)
|
||||
// so they are authenticated and workspace-attributed before reaching the AI layer.
|
||||
// Spec Layer 6.4: completion zone fills are recorded as DRAFT IntentDiffs with
|
||||
// changeType 'insertion' so the diff engine can classify them correctly.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button, Textarea } from '@fluentui/react-components';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ZoneBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CompletionZoneProps {
|
||||
bounds: ZoneBounds;
|
||||
artboardId: string | null;
|
||||
/** Canvas pan/zoom — used to convert artboard coords to screen position */
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Fires when the AI returns a result — Canvas uses this to render a
|
||||
* preview overlay at the zone bounds in artboard coordinate space.
|
||||
* (spec Layer 6: "A preview overlay showing the AI-generated completion")
|
||||
*/
|
||||
onResult?: (result: string, bounds: ZoneBounds) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CompletionZone({
|
||||
bounds, artboardId, panX, panY, zoom, onClose, onResult,
|
||||
}: CompletionZoneProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
|
||||
const [result, setResult] = useState('');
|
||||
const [diffStatus, setDiffStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
const workspaceId = useCanvas(s => s.workspaceId);
|
||||
const activeAgentSessionId = useCanvas(s => s.activeAgentSessionId);
|
||||
const fillZone = trpc.ai.fillCompletionZone.useMutation();
|
||||
|
||||
// Convert artboard → screen coordinates (relative to canvas container).
|
||||
// Position the popover 10px below the drawn zone.
|
||||
const screenX = bounds.x * zoom + panX;
|
||||
const screenY = (bounds.y + bounds.h) * zoom + panY + 10;
|
||||
|
||||
// ── Submit: call completion-zone via tRPC (spec Layer 6) ─────────────────
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!prompt.trim() || !artboardId || !workspaceId) return;
|
||||
setStatus('loading');
|
||||
setDiffStatus('idle');
|
||||
try {
|
||||
const data = await fillZone.mutateAsync({
|
||||
artboardId,
|
||||
workspaceId,
|
||||
intent: prompt.trim(),
|
||||
bounds: { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h },
|
||||
});
|
||||
setResult(data.completion);
|
||||
setStatus('done');
|
||||
// Notify canvas to render the preview overlay at the zone bounds
|
||||
onResult?.(data.completion, bounds);
|
||||
} catch (e) {
|
||||
console.error('[CompletionZone]', e);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [prompt, artboardId, workspaceId, bounds, fillZone, onResult]);
|
||||
|
||||
// ── Accept: save as DRAFT IntentDiff (spec Layer 6.4) ────────────────────
|
||||
// Uses ComponentChange with changeType 'insertion' so validateChange() and
|
||||
// the diff overlay can classify this as a new element being added.
|
||||
|
||||
const acceptCompletion = useCallback(async () => {
|
||||
if (!artboardId || !result || diffStatus !== 'idle') return;
|
||||
setDiffStatus('saving');
|
||||
try {
|
||||
const zoneContext = { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h };
|
||||
// Synthetic componentId derived from zone position — ensures multiple fills
|
||||
// on the same artboard produce distinct diff records.
|
||||
const componentChange = {
|
||||
componentId: `zone-${bounds.x}-${bounds.y}-${bounds.w}x${bounds.h}`,
|
||||
displayName: 'AICompletionZone',
|
||||
changeType: 'insertion' as const,
|
||||
before: {},
|
||||
after: { description: result, zoneContext },
|
||||
humanSummary: result,
|
||||
};
|
||||
const res = await fetch('/api/diffs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
artboard_id: artboardId,
|
||||
aggregate_summary: result,
|
||||
changes: { changes: [componentChange] },
|
||||
status: 'draft',
|
||||
// Links diff to active agent session (if any) for Agent Bridge sync.
|
||||
// Empty string when no session is running — matches DB DEFAULT ''.
|
||||
session_id: activeAgentSessionId ?? '',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
setDiffStatus('saved');
|
||||
} catch (e) {
|
||||
console.error('[CompletionZone] accept diff failed', e);
|
||||
setDiffStatus('error');
|
||||
}
|
||||
}, [artboardId, result, diffStatus, bounds, activeAgentSessionId]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
// Positioned in screen space (outside the canvas transform layer) so the
|
||||
// input controls render at normal scale regardless of canvas zoom.
|
||||
// Spec Layer 6.4: uses Fluent 2 Textarea + Button per spec requirement.
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: Math.max(8, screenX),
|
||||
top: Math.max(8, screenY),
|
||||
zIndex: 50,
|
||||
width: 300,
|
||||
background: '#1A1A20',
|
||||
border: '1px solid rgba(51,133,255,0.35)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
||||
padding: '12px 14px',
|
||||
fontFamily: "'Inter', -apple-system, sans-serif",
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'rgba(51,133,255,0.9)', letterSpacing: '-0.01em' }}>
|
||||
⚡ Completion zone · {bounds.w}×{bounds.h}
|
||||
</span>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
style={{ minWidth: 0, padding: '0 4px', color: 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status === 'done' ? (
|
||||
<>
|
||||
{/* AI result preview */}
|
||||
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.75)', lineHeight: 1.6, marginBottom: 10 }}>
|
||||
{result}
|
||||
</div>
|
||||
|
||||
{/* Accept (spec: "Accept" commits to IntentDiff) / Reject (spec) */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
disabled={diffStatus !== 'idle'}
|
||||
onClick={() => void acceptCompletion()}
|
||||
title="Save this completion as a draft intent diff"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{diffStatus === 'saving' ? 'Saving…' : diffStatus === 'saved' ? '✓ Saved' : diffStatus === 'error' ? 'Save failed' : '✓ Accept'}
|
||||
</Button>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Fluent 2 Textarea for intent input (spec Layer 6.4) */}
|
||||
<Textarea
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
value={prompt}
|
||||
onChange={(_, d) => setPrompt(d.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void submit();
|
||||
if (e.key === 'Escape') onClose();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="Describe what to generate in this zone…"
|
||||
rows={3}
|
||||
resize="none"
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
/>
|
||||
{status === 'error' && (
|
||||
<p style={{ fontSize: '0.625rem', color: 'var(--colorPaletteRedForeground1)', margin: '0 0 6px' }}>
|
||||
Request failed — try again
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
disabled={status === 'loading' || !prompt.trim() || !artboardId}
|
||||
onClick={() => void submit()}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{status === 'loading' ? 'Generating…' : 'Generate ⌘↵'}
|
||||
</Button>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Badge } from '@fluentui/react-components';
|
||||
import { useHistory } from '@/store/history';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
import type { Violation } from '@originmain/design-language';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -179,7 +181,7 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
|
||||
const onResizeCommitRef = useRef(onResizeCommit);
|
||||
useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
|
||||
|
||||
const { patchStyleEdit, dispatchRemoveElement } = useCanvas();
|
||||
const { patchStyleEdit, dispatchRemoveElement, activeViolations } = useCanvas();
|
||||
|
||||
// Sync rect when selection changes to a different element
|
||||
useEffect(() => {
|
||||
@@ -309,6 +311,11 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
|
||||
{Math.round(width)} × {Math.round(height)}
|
||||
</span>
|
||||
|
||||
{/* DLF violation badge — shown when the Inspector has detected violations */}
|
||||
{activeViolations.length > 0 && (
|
||||
<ViolationBadge violations={activeViolations} />
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
title="Delete element (⌫)"
|
||||
@@ -380,6 +387,31 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
|
||||
);
|
||||
}
|
||||
|
||||
// ── Violation badge (Fluent 2 Badge — spec Layer 5.2-R3) ─────────────────────
|
||||
// Shown in the SelectionHandles label row when the Inspector has found DLF
|
||||
// violations for the currently-selected component.
|
||||
// Uses Fluent 2 Badge with appearance="filled" and color="warning"|"danger"
|
||||
// exactly as required by the spec.
|
||||
|
||||
function ViolationBadge({ violations }: { violations: Violation[] }) {
|
||||
const hasError = violations.some(v => v.severity === 'error');
|
||||
const tooltip = violations
|
||||
.map(v => `[${v.severity}] ${v.prop}: ${v.message}`)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<span title={tooltip} style={{ cursor: 'default' }}>
|
||||
<Badge
|
||||
appearance="filled"
|
||||
color={hasError ? 'danger' : 'warning'}
|
||||
size="small"
|
||||
>
|
||||
{violations.length} {violations.length === 1 ? 'violation' : 'violations'}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function hitTestFiber(node: FiberNode, x: number, y: number): FiberNode | null {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Badge } from '@fluentui/react-components';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useHistory } from '@/store/history';
|
||||
import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
|
||||
import { useDiffs } from '@/hooks/useDiffs';
|
||||
import { useDlf } from '@/hooks/useDlf';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { checkComponentConstraints } from '@originmain/design-language';
|
||||
import type { Violation } from '@originmain/design-language';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
|
||||
@@ -109,6 +114,7 @@ export function Inspector() {
|
||||
componentId={selectedComponentId}
|
||||
componentData={selectedComponentData}
|
||||
styles={selectedComponentStyles}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
) : tab === 'props' ? (
|
||||
<PropsTab
|
||||
@@ -534,14 +540,36 @@ function DesignTab({
|
||||
componentId,
|
||||
componentData,
|
||||
styles,
|
||||
workspaceId,
|
||||
}: {
|
||||
artboardId: string | null;
|
||||
componentId: string | null;
|
||||
componentData: FiberNode | null;
|
||||
styles: Record<string, string> | null;
|
||||
workspaceId: string | null | undefined;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren } = useCanvas();
|
||||
const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren, setActiveViolations } = useCanvas();
|
||||
const { dlf } = useDlf(workspaceId);
|
||||
|
||||
// Re-run constraint checks whenever the selected component or active DLF changes.
|
||||
// We pass component.props (React props) — not CSS styles — to the validator since
|
||||
// DLF component rules govern variant/size/etc., not raw CSS properties.
|
||||
const dlfViolations = useMemo<Violation[]>(() => {
|
||||
if (!dlf || !componentData?.name) return [];
|
||||
return checkComponentConstraints({
|
||||
componentName: componentData.name,
|
||||
props: (componentData.props ?? {}) as Record<string, unknown>,
|
||||
dlf,
|
||||
});
|
||||
}, [dlf, componentData?.name, componentData?.props]);
|
||||
|
||||
// Sync violations to the canvas store so SelectionOverlay can render inline badges.
|
||||
// Runs after every render where dlfViolations changes; clears on component deselect.
|
||||
useEffect(() => {
|
||||
setActiveViolations(dlfViolations);
|
||||
return () => { setActiveViolations([]); };
|
||||
}, [dlfViolations, setActiveViolations]);
|
||||
|
||||
if (!artboardId) {
|
||||
return (
|
||||
@@ -659,6 +687,11 @@ function DesignTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── DLF violation banner ─────────────────────────────────── */}
|
||||
{dlfViolations.length > 0 && (
|
||||
<DlfViolationBanner violations={dlfViolations} />
|
||||
)}
|
||||
|
||||
{/* ── Section components ───────────────────────────────────── */}
|
||||
<FrameSection styles={styles} onPatch={patch} />
|
||||
<ConstraintsSection styles={styles} onPatch={patch} />
|
||||
@@ -1148,13 +1181,74 @@ function HSep() {
|
||||
return <div style={{ height: 1, background: T.sep, margin: '2px 0' }} />;
|
||||
}
|
||||
|
||||
/* ── DLF violation banner ─────────────────────────────────── */
|
||||
|
||||
function DlfViolationBanner({ violations }: { violations: Violation[] }) {
|
||||
const T = useCanvasTheme();
|
||||
const hasError = violations.some(v => v.severity === 'error');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: '4px 10px 2px',
|
||||
padding: '8px 10px',
|
||||
background: hasError ? 'rgba(255,80,80,0.07)' : 'rgba(255,186,123,0.07)',
|
||||
border: `1px solid ${hasError ? 'rgba(255,80,80,0.4)' : 'rgba(255,186,123,0.4)'}`,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{/* Section header */}
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: hasError ? '#FF8080' : '#FFBA7B',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
{hasError ? 'Design system violations' : 'Design system warnings'}
|
||||
</div>
|
||||
|
||||
{/* Per-violation Fluent 2 badges (spec Layer 5.2-R3) */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{violations.map((v, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 5 }}>
|
||||
<Badge
|
||||
appearance="filled"
|
||||
color={v.severity === 'error' ? 'danger' : 'warning'}
|
||||
size="small"
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
>
|
||||
{v.severity}
|
||||
</Badge>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5625rem',
|
||||
color: T.fgMuted,
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{v.prop && <strong style={{ color: T.fg }}>{v.prop}: </strong>}
|
||||
{v.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Diff tab ─────────────────────────────────────────────── */
|
||||
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
const T = useCanvasTheme();
|
||||
const { stacks } = useHistory();
|
||||
const T = useCanvasTheme();
|
||||
const { stacks } = useHistory();
|
||||
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
|
||||
const { workspaceId, activeAgentSessionId } = useCanvas();
|
||||
const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle');
|
||||
|
||||
// tRPC mutation for AI diff summary (spec Layer 6 — server-side, authenticated)
|
||||
const summarizeDiff = trpc.ai.generateDiffSummary.useMutation();
|
||||
|
||||
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
|
||||
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
|
||||
const hasChanges = pendingChanges.length > 0;
|
||||
@@ -1162,39 +1256,37 @@ function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
const exportDiff = useCallback(async () => {
|
||||
if (!artboardId || !hasChanges) return;
|
||||
|
||||
// 1. Generate AI summary (best-effort — fall back to empty string on failure)
|
||||
// 1. Generate AI summary via tRPC (best-effort — fall back to empty string)
|
||||
let summary = '';
|
||||
const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged');
|
||||
if (meaningfulChanges.length > 0) {
|
||||
if (meaningfulChanges.length > 0 && workspaceId) {
|
||||
setSummaryStatus('summarising');
|
||||
try {
|
||||
const res = await fetch('/api/ai/diff-summary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
changesJson: JSON.stringify(meaningfulChanges),
|
||||
componentName: meaningfulChanges[0]?.key ?? 'Component',
|
||||
}),
|
||||
const data = await summarizeDiff.mutateAsync({
|
||||
artboardId,
|
||||
workspaceId,
|
||||
changesJson: JSON.stringify(meaningfulChanges),
|
||||
componentName: meaningfulChanges[0]?.key ?? 'Component',
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { summary?: string };
|
||||
summary = data.summary ?? '';
|
||||
}
|
||||
summary = data.summary;
|
||||
} catch { /* non-fatal — proceed without summary */ }
|
||||
}
|
||||
|
||||
// 2. Export diff with AI-generated summary included
|
||||
// 2. Export diff with AI-generated summary included.
|
||||
// session_id links this diff to the active agent session (if any) so the
|
||||
// Agent Bridge can query diffs-by-session. Empty string = no active session.
|
||||
setSummaryStatus('exporting');
|
||||
createDiff.mutate(
|
||||
{
|
||||
artboard_id: artboardId,
|
||||
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
|
||||
summary,
|
||||
status: 'DRAFT',
|
||||
artboard_id: artboardId,
|
||||
changes: { propChanges: pendingChanges, styleChanges: [] },
|
||||
aggregate_summary: summary,
|
||||
status: 'draft',
|
||||
session_id: activeAgentSessionId ?? '',
|
||||
},
|
||||
{ onSettled: () => setSummaryStatus('idle') },
|
||||
);
|
||||
}, [artboardId, pendingChanges, hasChanges, createDiff]);
|
||||
}, [artboardId, pendingChanges, hasChanges, createDiff, activeAgentSessionId]);
|
||||
|
||||
if (!artboardId) {
|
||||
return (
|
||||
@@ -1277,7 +1369,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
|
||||
function SavedDiffRow({ diff }: { diff: IntentDiff }) {
|
||||
const T = useCanvasTheme();
|
||||
const changes = diff.changes_jsonb as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null;
|
||||
const changes = diff.changes as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null;
|
||||
const count = (changes?.propChanges?.length ?? 0) + (changes?.styleChanges?.length ?? 0);
|
||||
const color = STATUS_COLOR[diff.status] ?? T.dim;
|
||||
return (
|
||||
@@ -1290,9 +1382,9 @@ function SavedDiffRow({ diff }: { diff: IntentDiff }) {
|
||||
{count} change{count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{diff.summary && (
|
||||
{diff.aggregate_summary && (
|
||||
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: T.fgMuted, lineHeight: 1.4, display: 'block' }}>
|
||||
{diff.summary}
|
||||
{diff.aggregate_summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// ── useDlf hook ───────────────────────────────────────────────────────────────
|
||||
// Fetches the active Design Language File for the current workspace and returns
|
||||
// its parsed body as a typed DesignLanguageFileBody (tokens, component rules,
|
||||
// screen rules, voice, accessibility). The raw DB row's schema_jsonb field is
|
||||
// validated through the Zod schema at cache-write time so every consumer gets
|
||||
// a fully typed result without re-parsing on each render.
|
||||
//
|
||||
// Stale time is 5 minutes — design systems change infrequently (deploy-time
|
||||
// events) so we avoid redundant round-trips during normal editing sessions.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { DesignLanguageFile } from '@originmain/origin-graph';
|
||||
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from '@originmain/design-language';
|
||||
|
||||
// ── Fetch + parse ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchDlf(workspaceId: string): Promise<DesignLanguageFileBody | null> {
|
||||
const url = `/api/design-language?workspaceId=${encodeURIComponent(workspaceId)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`DLF fetch failed: ${res.status}`);
|
||||
|
||||
// API returns the raw DB row or null when no DLF is uploaded yet.
|
||||
const file = (await res.json()) as DesignLanguageFile | null;
|
||||
if (!file) return null;
|
||||
|
||||
// Validate schema_jsonb through the typed Zod schema.
|
||||
// We throw on failure so TanStack Query surfaces it via query.error —
|
||||
// callers can distinguish "no DLF" (null) from "malformed DLF" (error).
|
||||
const parsed = DesignLanguageFileBodySchema.safeParse(file.schema_jsonb);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Design language file schema is invalid: ${parsed.error.errors.map(e => e.message).join('; ')}`,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDlf(workspaceId: string | null | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['dlf', workspaceId] as const,
|
||||
queryFn: () => fetchDlf(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
// Design language files change at deploy-time, not interactively.
|
||||
// 5-minute staleness keeps the inspector snappy without burning requests.
|
||||
staleTime: 5 * 60_000,
|
||||
// Retry once on transient network errors, then surface the error.
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
/** Parsed DLF body, or null if no file is uploaded for this workspace. */
|
||||
dlf: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
/** Set when the DLF fetch succeeded but the schema failed Zod validation. */
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
// ── tRPC client (Next.js App Router, client components) ───────────────────────
|
||||
// Creates a typed tRPC client backed by the TanStack Query context already
|
||||
// provided by the app's QueryClientProvider.
|
||||
//
|
||||
// Usage (in client components):
|
||||
// const summarize = trpc.ai.summarizeDiff.useMutation();
|
||||
// await summarize.mutateAsync({ artboardId, workspaceId, changes });
|
||||
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@/server/routers/index';
|
||||
|
||||
// Typed tRPC hooks — import `trpc` in client components for .useQuery / .useMutation
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
|
||||
// Raw client for server-side usage (e.g., in Server Actions)
|
||||
export function makeTrpcClient() {
|
||||
return trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: '/api/trpc',
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// ── AI tRPC router ────────────────────────────────────────────────────────────
|
||||
// Spec Layer 6: server-side AI feature routes. All mutations are authenticated
|
||||
// (protectedProcedure) and include the caller's workspace ID for attribution.
|
||||
//
|
||||
// Procedures:
|
||||
// ai.generateDiffSummary — generate an aggregate_summary for a pending diff
|
||||
// ai.fillCompletionZone — fill an AI completion zone with a proposed tree
|
||||
// ai.answerAgentQuestion — answer a coding-agent design question
|
||||
// ai.queryArtboards — cross-artboard semantic search (spec Layer 6)
|
||||
|
||||
import { z } from 'zod';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { AIGateway } from '@originmain/ai-layer';
|
||||
import { getArtboard, getArtboardsByWorkspace } from '@originmain/origin-graph';
|
||||
|
||||
// ── Singleton gateway (lazily created) ───────────────────────────────────────
|
||||
|
||||
let _gateway: AIGateway | null = null;
|
||||
function getGateway(): AIGateway {
|
||||
if (!_gateway) _gateway = new AIGateway();
|
||||
return _gateway;
|
||||
}
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const aiRouter = router({
|
||||
|
||||
// ── generateDiffSummary ────────────────────────────────────────────────────
|
||||
// Generates an AI aggregate_summary for a set of pending prop changes.
|
||||
// Input: artboardId + workspaceId + changes[]
|
||||
// Returns { summary: string }
|
||||
|
||||
generateDiffSummary: protectedProcedure
|
||||
.input(z.object({
|
||||
artboardId: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
changesJson: z.string(), // JSON-serialised PropChange[]
|
||||
componentName: z.string(), // name of the changed component
|
||||
dlfJson: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const gateway = getGateway();
|
||||
|
||||
const result = await gateway.generateDiffSummary({
|
||||
changesJson: input.changesJson,
|
||||
componentName: input.componentName,
|
||||
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
|
||||
});
|
||||
|
||||
return { summary: result.summary };
|
||||
}),
|
||||
|
||||
// ── fillCompletionZone ────────────────────────────────────────────────────
|
||||
// Fills an AI completion zone with a proposed component tree.
|
||||
// Returns { completion: string, proposedTree: unknown }.
|
||||
|
||||
fillCompletionZone: protectedProcedure
|
||||
.input(z.object({
|
||||
artboardId: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
intent: z.string().min(1),
|
||||
bounds: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }),
|
||||
dlfJson: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const gateway = getGateway();
|
||||
|
||||
let componentTreeJson = '';
|
||||
try {
|
||||
const artboard = await getArtboard(ctx.db, input.artboardId);
|
||||
componentTreeJson = JSON.stringify({ artboard });
|
||||
} catch { /* non-fatal — artboard context is optional */ }
|
||||
|
||||
const result = await gateway.fillCompletionZone({
|
||||
componentTreeJson,
|
||||
intent: input.intent,
|
||||
workspaceId: input.workspaceId,
|
||||
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
completion: result.description,
|
||||
proposedTree: result.proposedTree,
|
||||
};
|
||||
}),
|
||||
|
||||
// ── answerAgentQuestion ───────────────────────────────────────────────────
|
||||
// Answers a coding-agent question about a design diff (spec Layer 6.3-R3).
|
||||
// Screenshots (before/after) are passed as base64 data URLs.
|
||||
|
||||
answerAgentQuestion: protectedProcedure
|
||||
.input(z.object({
|
||||
question: z.string().min(1),
|
||||
diffId: z.string().uuid(),
|
||||
artboardId: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
dlfJson: z.string().optional(),
|
||||
beforeScreenshotBase64: z.string().optional(),
|
||||
afterScreenshotBase64: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const gateway = getGateway();
|
||||
|
||||
let artboardContextJson = '';
|
||||
try {
|
||||
const artboard = await getArtboard(ctx.db, input.artboardId);
|
||||
artboardContextJson = JSON.stringify(artboard);
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
const result = await gateway.answerAgentQuery({
|
||||
question: input.question,
|
||||
diffId: input.diffId,
|
||||
artboardContextJson,
|
||||
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
|
||||
...(input.beforeScreenshotBase64 !== undefined ? { beforeScreenshotBase64: input.beforeScreenshotBase64 } : {}),
|
||||
...(input.afterScreenshotBase64 !== undefined ? { afterScreenshotBase64: input.afterScreenshotBase64 } : {}),
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
// ── queryArtboards ────────────────────────────────────────────────────────
|
||||
// Cross-artboard semantic search — finds artboards relevant to a natural
|
||||
// language query (spec Layer 6: "cross-artboard queries via AI").
|
||||
// Returns ranked results with relevance scores and reasoning.
|
||||
|
||||
queryArtboards: protectedProcedure
|
||||
.input(z.object({
|
||||
query: z.string().min(1),
|
||||
workspaceId: z.string().uuid(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const gateway = getGateway();
|
||||
|
||||
// Fetch all artboards in the workspace as context for the AI
|
||||
let artboardsJson = '[]';
|
||||
try {
|
||||
const artboards = await getArtboardsByWorkspace(ctx.db, input.workspaceId);
|
||||
artboardsJson = JSON.stringify(artboards);
|
||||
} catch { /* non-fatal — AI returns empty results with no context */ }
|
||||
|
||||
const result = await gateway.queryArtboards({
|
||||
query: input.query,
|
||||
artboardsJson,
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
export type AIRouter = typeof aiRouter;
|
||||
@@ -0,0 +1,9 @@
|
||||
// ── App router — root tRPC router ─────────────────────────────────────────────
|
||||
import { router } from '../trpc.js';
|
||||
import { aiRouter } from './ai.js';
|
||||
|
||||
export const appRouter = router({
|
||||
ai: aiRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,43 @@
|
||||
// ── tRPC server initialisation ────────────────────────────────────────────────
|
||||
// Creates the tRPC context (Clerk auth + Supabase server client), the router
|
||||
// factory, and the protected-procedure middleware.
|
||||
//
|
||||
// Spec Layer 6: all AI feature calls MUST go through tRPC server-side routes
|
||||
// so they are authenticated, rate-limited, and attributed to a workspace.
|
||||
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import type { DbClient } from '@originmain/origin-graph';
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TRPCContext {
|
||||
userId: string | null;
|
||||
db: DbClient;
|
||||
}
|
||||
|
||||
export async function createTRPCContext(): Promise<TRPCContext> {
|
||||
const { userId } = await auth();
|
||||
const db = serverClient();
|
||||
return { userId, db };
|
||||
}
|
||||
|
||||
// ── Initialisation ────────────────────────────────────────────────────────────
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
// ── Auth middleware ───────────────────────────────────────────────────────────
|
||||
// Throws UNAUTHORIZED if the caller has no Clerk session.
|
||||
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return next({ ctx: { ...ctx, userId: ctx.userId } });
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
import type { Violation } from '@originmain/design-language';
|
||||
|
||||
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
|
||||
export interface ProjectMeta {
|
||||
@@ -83,6 +84,20 @@ interface CanvasStore {
|
||||
/** Project metadata fetched from GET /health on CLI connection */
|
||||
projectMeta: ProjectMeta | null;
|
||||
setProjectMeta: (meta: ProjectMeta | null) => void;
|
||||
|
||||
// ── DLF violations (spec Layer 5.2) ──────────────────────────────────────────
|
||||
// Written by Inspector's DesignTab when it evaluates the selected component
|
||||
// against the active DLF; read by SelectionOverlay to render inline badges.
|
||||
activeViolations: Violation[];
|
||||
setActiveViolations: (violations: Violation[]) => void;
|
||||
|
||||
// ── Active agent session (spec Layer 6 — diff attribution) ──────────────────
|
||||
// Set by the Agent Bridge when a session starts/ends. Inspector and
|
||||
// CompletionZone read this to populate session_id on intent_diffs so the
|
||||
// Agent Bridge can later query diffs by session (getDiffsByStatus etc.).
|
||||
// null = no agent session active; user-created diffs get session_id ''.
|
||||
activeAgentSessionId: string | null;
|
||||
setActiveAgentSessionId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useCanvas = create<CanvasStore>((set) => ({
|
||||
@@ -155,4 +170,10 @@ export const useCanvas = create<CanvasStore>((set) => ({
|
||||
|
||||
projectMeta: null,
|
||||
setProjectMeta: (meta) => set({ projectMeta: meta }),
|
||||
|
||||
activeViolations: [],
|
||||
setActiveViolations: (violations) => set({ activeViolations: violations }),
|
||||
|
||||
activeAgentSessionId: null,
|
||||
setActiveAgentSessionId: (id) => set({ activeAgentSessionId: id }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user