rdesign/frontend/src/components/DesignAssistant.tsx

437 lines
16 KiB
TypeScript

'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Bot, User, Loader2, Sparkles, X, Paperclip } from 'lucide-react';
const LLM_API = process.env.NEXT_PUBLIC_LLM_API_URL || 'https://llm.jeffemmett.com';
const RDESIGN_API = process.env.NEXT_PUBLIC_RDESIGN_API_URL || 'https://scribus.rspace.online';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
}
interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
interface ToolResult {
toolCallId: string;
name: string;
result: unknown;
}
// MCP-style tools for the design assistant
const DESIGN_TOOLS = [
{
type: 'function' as const,
function: {
name: 'list_templates',
description: 'List available Scribus document templates. Returns template names, categories, and variable placeholders.',
parameters: { type: 'object', properties: { category: { type: 'string', description: 'Filter by category (flyer, poster, brochure, imported, general)' } } },
},
},
{
type: 'function' as const,
function: {
name: 'export_template',
description: 'Export a Scribus template to PDF or PNG. Supports variable substitution for dynamic content.',
parameters: {
type: 'object',
properties: {
template: { type: 'string', description: 'Template slug' },
variables: { type: 'object', description: 'Key-value pairs for template variables (e.g. {"title": "My Event", "date": "2026-04-01"})' },
format: { type: 'string', enum: ['pdf', 'png'], description: 'Output format' },
},
required: ['template'],
},
},
},
{
type: 'function' as const,
function: {
name: 'batch_export',
description: 'Generate multiple documents from one template with different data. Like mail-merge: one template + multiple rows of variables = multiple PDFs.',
parameters: {
type: 'object',
properties: {
template: { type: 'string', description: 'Template slug' },
rows: { type: 'array', items: { type: 'object' }, description: 'Array of variable objects, one per document' },
},
required: ['template', 'rows'],
},
},
},
{
type: 'function' as const,
function: {
name: 'list_rswag_designs',
description: 'List available rSwag merchandise designs (stickers, shirts, prints) that can be exported to print-ready PDFs.',
parameters: { type: 'object', properties: { category: { type: 'string', description: 'stickers, shirts, or prints' } } },
},
},
{
type: 'function' as const,
function: {
name: 'export_rswag_design',
description: 'Export an rSwag design to a print-ready PDF with professional bleed and crop marks.',
parameters: {
type: 'object',
properties: {
design_slug: { type: 'string' },
category: { type: 'string', enum: ['stickers', 'shirts', 'prints'] },
paper_size: { type: 'string', enum: ['A4', 'A3', 'A5', 'Letter'] },
},
required: ['design_slug', 'category'],
},
},
},
{
type: 'function' as const,
function: {
name: 'check_job_status',
description: 'Check the status of a running export/conversion job.',
parameters: {
type: 'object',
properties: { job_id: { type: 'string' } },
required: ['job_id'],
},
},
},
{
type: 'function' as const,
function: {
name: 'open_studio',
description: 'Open the interactive Scribus Studio (GUI in browser via noVNC). Use this when the user wants to design something visually or edit a template by hand.',
parameters: { type: 'object', properties: {} },
},
},
];
async function executeToolCall(name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'list_templates': {
const params = args.category ? `?category=${args.category}` : '';
const res = await fetch(`${RDESIGN_API}/templates${params}`);
return res.json();
}
case 'export_template': {
const res = await fetch(`${RDESIGN_API}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
return res.json();
}
case 'batch_export': {
const res = await fetch(`${RDESIGN_API}/export/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
return res.json();
}
case 'list_rswag_designs': {
const params = args.category ? `?category=${args.category}` : '';
const res = await fetch(`${RDESIGN_API}/rswag/designs${params}`);
return res.json();
}
case 'export_rswag_design': {
const res = await fetch(`${RDESIGN_API}/rswag/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
return res.json();
}
case 'check_job_status': {
const res = await fetch(`${RDESIGN_API}/jobs/${args.job_id}`);
return res.json();
}
case 'open_studio': {
window.open('/studio', '_blank');
return { status: 'opened', url: '/studio' };
}
default:
return { error: `Unknown tool: ${name}` };
}
}
const SYSTEM_PROMPT = `You are the rDesign AI assistant — a mycelial intelligence helping users create beautiful documents, export print-ready PDFs, and manage design templates.
You have access to a self-hosted Scribus instance for professional desktop publishing. You can:
- List and export document templates with variable substitution
- Batch-generate documents (mail-merge style) from templates
- Export rSwag merchandise designs to print-ready PDFs with bleed/crop marks
- Convert InDesign IDML files to Scribus format
- Open the interactive Scribus Studio for visual editing
Be helpful, creative, and concise. When users want to create something, suggest appropriate templates or offer to open the Studio. When they need automated output, use the export tools.
Like mycelium connecting a forest, you connect the design tools into a unified creative experience.`;
export function DesignAssistant() {
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
role: 'assistant',
content: "Welcome to rDesign. I can help you create documents, export PDFs, batch-generate from templates, or prepare rSwag designs for print. What would you like to create?",
timestamp: new Date(),
},
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: input.trim(),
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
// Build conversation for LLM
const conversationMessages = [
{ role: 'system' as const, content: SYSTEM_PROMPT },
...messages.filter((m) => m.role !== 'system' && m.id !== 'welcome').map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
{ role: 'user' as const, content: userMessage.content },
];
// Call LiteLLM with tools
let response = await fetch(`${LLM_API}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-sonnet',
messages: conversationMessages,
tools: DESIGN_TOOLS,
max_tokens: 2048,
}),
});
if (!response.ok) {
throw new Error(`LLM API error: ${response.status}`);
}
let data = await response.json();
let choice = data.choices?.[0];
// Handle tool calls in a loop
const updatedMessages = [...conversationMessages];
while (choice?.message?.tool_calls?.length) {
const toolCalls: ToolCall[] = choice.message.tool_calls.map((tc: { id: string; function: { name: string; arguments: string } }) => ({
id: tc.id,
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || '{}'),
}));
// Show tool call status
const toolMessage: Message = {
id: `tool-${Date.now()}`,
role: 'assistant',
content: toolCalls.map((tc) => `Running \`${tc.name}\`...`).join('\n'),
timestamp: new Date(),
toolCalls,
};
setMessages((prev) => [...prev, toolMessage]);
// Execute tools
updatedMessages.push({
role: 'assistant' as const,
content: choice.message.content || '',
tool_calls: choice.message.tool_calls,
} as { role: 'assistant'; content: string; tool_calls?: unknown[] });
for (const tc of toolCalls) {
const result = await executeToolCall(tc.name, tc.arguments);
updatedMessages.push({
role: 'tool' as const,
content: JSON.stringify(result),
tool_call_id: tc.id,
} as { role: 'tool'; content: string; tool_call_id?: string });
}
// Get next LLM response
response = await fetch(`${LLM_API}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-sonnet',
messages: updatedMessages,
tools: DESIGN_TOOLS,
max_tokens: 2048,
}),
});
if (!response.ok) throw new Error(`LLM API error: ${response.status}`);
data = await response.json();
choice = data.choices?.[0];
}
// Final assistant message
const assistantContent = choice?.message?.content || 'I encountered an issue processing that request.';
const assistantMessage: Message = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: assistantContent,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: 'assistant',
content: `Something went wrong: ${error instanceof Error ? error.message : 'Unknown error'}. Try again or check the API status.`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 bg-primary text-primary-foreground rounded-full shadow-lg hover:scale-105 transition-transform flex items-center justify-center z-50"
title="Design Assistant"
>
<Sparkles size={24} />
</button>
);
}
return (
<div className="fixed bottom-6 right-6 w-[420px] h-[600px] bg-card border border-slate-700 rounded-2xl shadow-2xl flex flex-col z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700 bg-card">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<Sparkles size={16} className="text-primary" />
</div>
<div>
<div className="text-sm font-semibold">Design Assistant</div>
<div className="text-[10px] text-muted">Mycelial Intelligence</div>
</div>
</div>
<button onClick={() => setIsOpen(false)} className="p-1 hover:bg-slate-700 rounded">
<X size={16} className="text-slate-400" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 chat-panel">
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.role !== 'user' && (
<div className="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center flex-shrink-0 mt-1">
<Bot size={12} className="text-primary" />
</div>
)}
<div
className={`max-w-[85%] rounded-xl px-3 py-2 text-sm ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: msg.toolCalls
? 'bg-slate-800/50 text-muted text-xs font-mono'
: 'bg-slate-800 text-foreground ai-message'
}`}
>
{msg.content.split('\n').map((line, i) => (
<p key={i} className={i > 0 ? 'mt-1' : ''}>
{/* Render links as clickable */}
{line.includes('http') ? (
line.split(/(https?:\/\/\S+)/).map((part, j) =>
part.match(/^https?:\/\//) ? (
<a key={j} href={part} target="_blank" rel="noopener noreferrer" className="underline text-primary hover:text-primary/80">
{part.includes('/output/') ? 'Download' : part}
</a>
) : (
part
)
)
) : (
line
)}
</p>
))}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 rounded-full bg-slate-700 flex items-center justify-center flex-shrink-0 mt-1">
<User size={12} className="text-slate-300" />
</div>
)}
</div>
))}
{isLoading && (
<div className="flex gap-2 items-center">
<div className="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center">
<Loader2 size={12} className="text-primary animate-spin" />
</div>
<div className="text-xs text-muted">Thinking...</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-slate-700 p-3">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me to create a document, export a template..."
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:border-primary max-h-24 min-h-[40px]"
rows={1}
/>
<button
onClick={sendMessage}
disabled={!input.trim() || isLoading}
className="p-2 bg-primary text-primary-foreground rounded-lg disabled:opacity-40 hover:bg-primary/90 transition-colors"
>
<Send size={16} />
</button>
</div>
</div>
</div>
);
}