437 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|