feat(3.15): AI Tools extindere si integrare

Prompt Generator:
- Search bar cu cautare in name/description/tags/category
- Filtru target type (text/image) cu toggle rapid 'Imagine'
- 4 template-uri noi imagine: Midjourney Exterior, SD Interior,
  Midjourney Infographic, SD Material Texture (18 total)
- Config v0.2.0

AI Chat  Real API Integration:
- /api/ai-chat route: multi-provider (OpenAI, Anthropic, Ollama, demo)
- System prompt default in romana pt context arhitectura
- GET: config status, POST: message routing
- use-chat.ts: sendMessage() cu fetch real, sending state,
  providerConfig fetch, updateSession() pt project linking
- UI: provider status badge (Wifi/WifiOff), Bot icon pe mesaje,
  loading spinner la generare, disable input while sending
- Config banner cu detalii provider/model/stare

AI Chat + Tag Manager:
- Project selector dropdown in chat header (useTags project)
- Session linking: projectTagId + projectName on ChatSession
- Project name display in session sidebar
- Project context injected in system prompt

Docker:
- AI env vars: AI_PROVIDER, AI_API_KEY, AI_MODEL, AI_BASE_URL, AI_MAX_TOKENS
This commit is contained in:
AI Assistant
2026-02-28 04:51:36 +02:00
parent 11b35c750f
commit d34c722167
12 changed files with 1550 additions and 189 deletions
+10 -5
View File
@@ -385,13 +385,18 @@
-**Extractor Paletă Culori:** Upload imagine → canvas downscale → top 8 culori ca swatches hex clickabile -**Extractor Paletă Culori:** Upload imagine → canvas downscale → top 8 culori ca swatches hex clickabile
- ⏭️ **DWG to DXF:** Amânat — necesită serviciu backend (ODA File Converter sau similar) - ⏭️ **DWG to DXF:** Amânat — necesită serviciu backend (ODA File Converter sau similar)
### 3.13 `[BUSINESS]` Tag Manager — Sincronizare ManicTime ### 3.13 `[BUSINESS]` Tag Manager — Sincronizare ManicTime (11b35c7)
**Cerințe noi:** **Implementat:**
- **Sincronizare Bidirecțională:** Modulul trebuie să citească și să scrie direct în fișierul text folosit de ManicTime (`\\time\tags\Tags.txt` aflat pe serverul Windows 10 Enterprise LTSC). Orice tag adăugat în ArchiTools trebuie să apară în ManicTime și invers. - **Sincronizare Bidirecțională:** Parser service (`manictime-service.ts`) parsează/serializează Tags.txt; API route `/api/manictime` cu GET (citire + plan sync) + POST (pull/push/both); UI panel cu buton verificare, import, export, sincronizare completă
- **Versionare Fișier (Backup):** La fiecare modificare a fișierului `Tags.txt` din ArchiTools, sistemul trebuie să creeze automat un backup al versiunii anterioare (ex: `Tags_backup_YYYYMMDD_HHMMSS.txt`) în același folder, pentru a preveni pierderea accidentală a structurii de tag-uri. - **Versionare Fișier (Backup):** Backup automat `Tags_backup_YYYYMMDD_HHMMSS.txt` la fiecare scriere (push/both)
- **Validare Ierarhie:** Asigurarea că tag-urile adăugate respectă formatul ierarhic cerut de ManicTime (ex: `Proiect, Faza, Activitate`). - **Validare Ierarhie:** Verificare cod proiect, asociere companie, detectare duplicate; avertizări afișate în UI
-**Docker config:** `MANICTIME_TAGS_PATH` env var, volume mount `/mnt/manictime`; necesită configurare SMB pe host
**Infrastructură necesară (pe server):**
- Montare SMB share: `//time/tags → /mnt/manictime` pe host-ul Ubuntu (cifs-utils)
### 3.14 `[ARCHITECTURE]` Storage & Securitate ✅ ### 3.14 `[ARCHITECTURE]` Storage & Securitate ✅
+6
View File
@@ -33,6 +33,12 @@ services:
- ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256 - ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256
# ManicTime Tags.txt sync (SMB mount path) # ManicTime Tags.txt sync (SMB mount path)
- MANICTIME_TAGS_PATH=/mnt/manictime/Tags.txt - MANICTIME_TAGS_PATH=/mnt/manictime/Tags.txt
# AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key)
- AI_PROVIDER=${AI_PROVIDER:-demo}
- AI_API_KEY=${AI_API_KEY:-}
- AI_MODEL=${AI_MODEL:-}
- AI_BASE_URL=${AI_BASE_URL:-}
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
volumes: volumes:
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
- /mnt/manictime:/mnt/manictime - /mnt/manictime:/mnt/manictime
+254
View File
@@ -0,0 +1,254 @@
import { NextRequest, NextResponse } from "next/server";
/**
* AI Chat API Route
*
* Supports multiple providers: OpenAI, Anthropic (Claude), Ollama (local).
* Provider and API key configured via environment variables:
*
* - AI_PROVIDER: 'openai' | 'anthropic' | 'ollama' (default: 'demo')
* - AI_API_KEY: API key for OpenAI or Anthropic
* - AI_MODEL: Model name (default: per provider)
* - AI_BASE_URL: Custom base URL (required for Ollama, optional for others)
* - AI_MAX_TOKENS: Max response tokens (default: 2048)
*/
interface ChatRequestBody {
messages: Array<{
role: "user" | "assistant" | "system";
content: string;
}>;
systemPrompt?: string;
maxTokens?: number;
}
function getConfig() {
return {
provider: (process.env.AI_PROVIDER ?? "demo") as string,
apiKey: process.env.AI_API_KEY ?? "",
model: process.env.AI_MODEL ?? "",
baseUrl: process.env.AI_BASE_URL ?? "",
maxTokens: parseInt(process.env.AI_MAX_TOKENS ?? "2048", 10),
};
}
const DEFAULT_SYSTEM_PROMPT = `Ești un asistent AI pentru un birou de arhitectură. Răspunzi în limba română.
Ești specializat în:
- Arhitectură și proiectare
- Urbanism și PUZ/PUG/PUD
- Legislația construcțiilor din România (Legea 50/1991, Legea 350/2001)
- Certificat de Urbanism, Autorizație de Construire
- Norme tehnice (P118, normative de proiectare)
- Documentație tehnică (DTAC, PT, memorii)
Răspunde clar, concis și profesional.`;
async function callOpenAI(
messages: ChatRequestBody["messages"],
systemPrompt: string,
config: ReturnType<typeof getConfig>,
): Promise<string> {
const baseUrl = config.baseUrl || "https://api.openai.com/v1";
const model = config.model || "gpt-4o-mini";
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "system", content: systemPrompt }, ...messages],
max_tokens: config.maxTokens,
temperature: 0.7,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error (${response.status}): ${error}`);
}
const data = (await response.json()) as {
choices: Array<{ message: { content: string } }>;
};
return data.choices[0]?.message?.content ?? "";
}
async function callAnthropic(
messages: ChatRequestBody["messages"],
systemPrompt: string,
config: ReturnType<typeof getConfig>,
): Promise<string> {
const model = config.model || "claude-sonnet-4-20250514";
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model,
max_tokens: config.maxTokens,
system: systemPrompt,
messages: messages.map((m) => ({
role: m.role === "system" ? "user" : m.role,
content: m.content,
})),
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Anthropic API error (${response.status}): ${error}`);
}
const data = (await response.json()) as {
content: Array<{ type: string; text: string }>;
};
return data.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
}
async function callOllama(
messages: ChatRequestBody["messages"],
systemPrompt: string,
config: ReturnType<typeof getConfig>,
): Promise<string> {
const baseUrl = config.baseUrl || "http://localhost:11434";
const model = config.model || "llama3.2";
const response = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "system", content: systemPrompt }, ...messages],
stream: false,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Ollama API error (${response.status}): ${error}`);
}
const data = (await response.json()) as {
message: { content: string };
};
return data.message?.content ?? "";
}
function getDemoResponse(): string {
const responses = [
"Modulul AI Chat funcționează în mod demonstrativ. Pentru a activa răspunsuri reale, configurați variabilele de mediu:\n\n" +
"- `AI_PROVIDER`: openai / anthropic / ollama\n" +
"- `AI_API_KEY`: cheia API\n" +
"- `AI_MODEL`: modelul dorit (opțional)\n\n" +
"Consultați documentația pentru detalii.",
"Aceasta este o conversație demonstrativă. Mesajele sunt salvate, dar răspunsurile AI nu sunt generate fără o conexiune API configurată.",
];
return (
responses[Math.floor(Math.random() * responses.length)] ?? responses[0]!
);
}
/**
* GET /api/ai-chat — Return provider config (without API key)
*/
export async function GET() {
const config = getConfig();
return NextResponse.json({
provider: config.provider,
model: config.model || "(default)",
baseUrl: config.baseUrl || "(default)",
maxTokens: config.maxTokens,
isConfigured:
config.provider !== "demo" &&
(config.provider === "ollama" || !!config.apiKey),
});
}
/**
* POST /api/ai-chat — Send messages and get AI response
*/
export async function POST(request: NextRequest) {
const config = getConfig();
let body: ChatRequestBody;
try {
body = (await request.json()) as ChatRequestBody;
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
if (!body.messages || body.messages.length === 0) {
return NextResponse.json({ error: "no_messages" }, { status: 400 });
}
const systemPrompt = body.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
try {
let responseText: string;
switch (config.provider) {
case "openai":
if (!config.apiKey) {
return NextResponse.json(
{
error: "missing_api_key",
message: "AI_API_KEY nu este configurat.",
},
{ status: 500 },
);
}
responseText = await callOpenAI(body.messages, systemPrompt, config);
break;
case "anthropic":
if (!config.apiKey) {
return NextResponse.json(
{
error: "missing_api_key",
message: "AI_API_KEY nu este configurat.",
},
{ status: 500 },
);
}
responseText = await callAnthropic(body.messages, systemPrompt, config);
break;
case "ollama":
responseText = await callOllama(body.messages, systemPrompt, config);
break;
default:
// Demo mode
responseText = getDemoResponse();
break;
}
return NextResponse.json({
content: responseText,
provider: config.provider,
model: config.model || "(default)",
timestamp: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{
error: "api_error",
message:
error instanceof Error
? error.message
: "Eroare necunoscută la apelul API AI.",
provider: config.provider,
},
{ status: 502 },
);
}
}
+5 -2
View File
@@ -10,7 +10,8 @@ import {
manicTimeTagToCreateData, manicTimeTagToCreateData,
} from "@/modules/tag-manager/services/manictime-service"; } from "@/modules/tag-manager/services/manictime-service";
import type { Tag } from "@/core/tagging/types"; import type { Tag } from "@/core/tagging/types";
import { prisma } from "@/core/storage/prisma";import type { Prisma } from '@prisma/client'; import { prisma } from "@/core/storage/prisma";
import type { Prisma } from "@prisma/client";
const NAMESPACE = "tags"; const NAMESPACE = "tags";
/** /**
@@ -155,7 +156,9 @@ export async function POST(request: NextRequest) {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
const tagJson = JSON.parse(JSON.stringify(tag)) as Prisma.InputJsonValue; const tagJson = JSON.parse(
JSON.stringify(tag),
) as Prisma.InputJsonValue;
await prisma.keyValueStore.upsert({ await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: NAMESPACE, key: id } }, where: { namespace_key: { namespace: NAMESPACE, key: id } },
+232 -54
View File
@@ -1,48 +1,73 @@
'use client'; "use client";
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from "react";
import { Plus, Send, Trash2, MessageSquare, Settings } from 'lucide-react'; import {
import { Button } from '@/shared/components/ui/button'; Plus,
import { Input } from '@/shared/components/ui/input'; Send,
import { Card, CardContent } from '@/shared/components/ui/card'; Trash2,
import { Badge } from '@/shared/components/ui/badge'; MessageSquare,
import { cn } from '@/shared/lib/utils'; Settings,
import { useChat } from '../hooks/use-chat'; Loader2,
Bot,
Wifi,
WifiOff,
FolderOpen,
X,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Badge } from "@/shared/components/ui/badge";
import { cn } from "@/shared/lib/utils";
import { useChat } from "../hooks/use-chat";
import { useTags } from "@/core/tagging/use-tags";
const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI",
anthropic: "Claude",
ollama: "Ollama",
demo: "Demo",
};
export function AiChatModule() { export function AiChatModule() {
const { const {
sessions, activeSession, activeSessionId, sessions,
createSession, addMessage, deleteSession, selectSession, activeSession,
activeSessionId,
sending,
providerConfig,
createSession,
updateSession,
sendMessage,
deleteSession,
selectSession,
} = useChat(); } = useChat();
const [input, setInput] = useState(''); const { tags: projectTags } = useTags("project");
const [input, setInput] = useState("");
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [showProjectPicker, setShowProjectPicker] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [activeSession?.messages.length]); }, [activeSession?.messages.length]);
const providerLabel = providerConfig
? (PROVIDER_LABELS[providerConfig.provider] ?? providerConfig.provider)
: "Demo";
const isConfigured = providerConfig?.isConfigured ?? false;
const handleSend = async () => { const handleSend = async () => {
if (!input.trim()) return; if (!input.trim() || sending) return;
const text = input.trim(); const text = input.trim();
setInput(''); setInput("");
if (!activeSessionId) { if (!activeSessionId) {
await createSession(); await createSession();
} }
await addMessage(text, 'user'); await sendMessage(text);
// Simulate AI response (no real API connected)
setTimeout(async () => {
await addMessage(
'Acest modul necesită configurarea unei conexiuni API către un model AI (ex: Claude, GPT). ' +
'Momentan funcționează în mod demonstrativ — mesajele sunt salvate local, dar răspunsurile AI nu sunt generate.\n\n' +
'Pentru a activa răspunsurile AI, configurați cheia API în setările modulului.',
'assistant'
);
}, 500);
}; };
return ( return (
@@ -58,20 +83,32 @@ export function AiChatModule() {
<div <div
key={session.id} key={session.id}
className={cn( className={cn(
'group flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm transition-colors', "group flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm transition-colors",
session.id === activeSessionId ? 'bg-accent' : 'hover:bg-accent/50' session.id === activeSessionId
? "bg-accent"
: "hover:bg-accent/50",
)} )}
onClick={() => selectSession(session.id)} onClick={() => selectSession(session.id)}
> >
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <MessageSquare className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{session.title}</span> <span className="truncate">{session.title}</span>
</div> </div>
{session.projectName && (
<span className="ml-5 truncate text-[10px] text-muted-foreground">
{session.projectName}
</span>
)}
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100" className="h-6 w-6 opacity-0 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); deleteSession(session.id); }} onClick={(e) => {
e.stopPropagation();
deleteSession(session.id);
}}
> >
<Trash2 className="h-3 w-3 text-destructive" /> <Trash2 className="h-3 w-3 text-destructive" />
</Button> </Button>
@@ -86,11 +123,103 @@ export function AiChatModule() {
<div className="flex items-center justify-between border-b px-4 py-2"> <div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium"> <h3 className="text-sm font-medium">
{activeSession?.title ?? 'Chat AI'} {activeSession?.title ?? "Chat AI"}
</h3> </h3>
<Badge variant="outline" className="text-[10px]">Demo</Badge> {/* Project link */}
{activeSession && (
<div className="relative">
{activeSession.projectName ? (
<Badge
variant="secondary"
className="cursor-pointer gap-1 text-[10px]"
onClick={() => setShowProjectPicker(!showProjectPicker)}
>
<FolderOpen className="h-2.5 w-2.5" />
{activeSession.projectName}
<X
className="h-2.5 w-2.5 opacity-60 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
updateSession(activeSession.id, {
projectTagId: undefined,
projectName: undefined,
});
}}
/>
</Badge>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-[10px] text-muted-foreground"
onClick={() => setShowProjectPicker(!showProjectPicker)}
>
<FolderOpen className="h-3 w-3" />
Proiect
</Button>
)}
{showProjectPicker && projectTags.length > 0 && (
<div className="absolute left-0 top-full z-50 mt-1 max-h-48 w-52 overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
{projectTags.map((tag) => (
<button
key={tag.id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent",
activeSession.projectTagId === tag.id &&
"bg-accent font-medium",
)}
onClick={() => {
updateSession(activeSession.id, {
projectTagId: tag.id,
projectName: tag.projectCode
? `${tag.projectCode}${tag.label}`
: tag.label,
});
setShowProjectPicker(false);
}}
>
{tag.color && (
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
)}
<span className="truncate">
{tag.projectCode && (
<span className="mr-1 font-mono text-muted-foreground">
{tag.projectCode}
</span>
)}
{tag.label}
</span>
</button>
))}
</div> </div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setShowConfig(!showConfig)}> )}
</div>
)}
<Badge
variant={isConfigured ? "default" : "outline"}
className={cn(
"text-[10px]",
isConfigured && "bg-green-600 hover:bg-green-700",
)}
>
{isConfigured ? (
<Wifi className="mr-1 h-2.5 w-2.5" />
) : (
<WifiOff className="mr-1 h-2.5 w-2.5" />
)}
{providerLabel}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowConfig(!showConfig)}
>
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -98,14 +227,42 @@ export function AiChatModule() {
{/* Config banner */} {/* Config banner */}
{showConfig && ( {showConfig && (
<div className="border-b bg-muted/30 px-4 py-3 text-xs text-muted-foreground"> <div className="border-b bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium">Configurare API (viitor)</p> <p className="font-medium">Configurare API</p>
<p className="mt-1"> {providerConfig ? (
Modulul va suporta conectarea la API-uri AI (Anthropic Claude, OpenAI, modele locale via Ollama). <>
Cheia API și endpoint-ul se vor configura din setările aplicației sau variabile de mediu. <div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
</p> <span>Provider:</span>
<p className="mt-1"> <span className="font-medium text-foreground">
Momentan, conversațiile sunt salvate local, dar fără generare de răspunsuri AI reale. {providerLabel}
</span>
<span>Model:</span>
<span className="font-medium text-foreground">
{providerConfig.model}
</span>
<span>Max tokens:</span>
<span className="font-medium text-foreground">
{providerConfig.maxTokens}
</span>
<span>Stare:</span>
<span
className={cn(
"font-medium",
isConfigured ? "text-green-600" : "text-amber-600",
)}
>
{isConfigured ? "Configurat" : "Neconfigurat (mod demo)"}
</span>
</div>
{!isConfigured && (
<p className="mt-2 text-amber-600">
Setați variabilele de mediu AI_PROVIDER, AI_API_KEY și
AI_MODEL pentru a activa răspunsurile AI reale.
</p> </p>
)}
</>
) : (
<p className="mt-1">Se verifică conexiunea la API...</p>
)}
</div> </div>
)} )}
@@ -115,7 +272,9 @@ export function AiChatModule() {
<div className="flex h-full flex-col items-center justify-center text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<MessageSquare className="mb-3 h-10 w-10 opacity-30" /> <MessageSquare className="mb-3 h-10 w-10 opacity-30" />
<p className="text-sm">Începe o conversație nouă</p> <p className="text-sm">Începe o conversație nouă</p>
<p className="mt-1 text-xs">Scrie un mesaj sau creează o sesiune nouă din bara laterală.</p> <p className="mt-1 text-xs">
Scrie un mesaj sau creează o sesiune nouă din bara laterală.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -123,21 +282,37 @@ export function AiChatModule() {
<div <div
key={msg.id} key={msg.id}
className={cn( className={cn(
'max-w-[80%] rounded-lg px-3 py-2 text-sm', "max-w-[80%] rounded-lg px-3 py-2 text-sm",
msg.role === 'user' msg.role === "user"
? 'ml-auto bg-primary text-primary-foreground' ? "ml-auto bg-primary text-primary-foreground"
: 'bg-muted' : "bg-muted",
)} )}
> >
{msg.role === "assistant" && (
<Bot className="mb-1 inline h-3.5 w-3.5 text-muted-foreground" />
)}
<p className="whitespace-pre-wrap">{msg.content}</p> <p className="whitespace-pre-wrap">{msg.content}</p>
<p className={cn( <p
'mt-1 text-[10px]', className={cn(
msg.role === 'user' ? 'text-primary-foreground/60' : 'text-muted-foreground' "mt-1 text-[10px]",
)}> msg.role === "user"
{new Date(msg.timestamp).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit' })} ? "text-primary-foreground/60"
: "text-muted-foreground",
)}
>
{new Date(msg.timestamp).toLocaleTimeString("ro-RO", {
hour: "2-digit",
minute: "2-digit",
})}
</p> </p>
</div> </div>
))} ))}
{sending && (
<div className="flex max-w-[80%] items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>Se generează răspunsul...</span>
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
)} )}
@@ -149,12 +324,15 @@ export function AiChatModule() {
<Input <Input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()} onKeyDown={(e) =>
placeholder="Scrie un mesaj..." e.key === "Enter" && !e.shiftKey && handleSend()
}
placeholder={sending ? "Se generează..." : "Scrie un mesaj..."}
disabled={sending}
className="flex-1" className="flex-1"
/> />
<Button onClick={handleSend} disabled={!input.trim()}> <Button onClick={handleSend} disabled={!input.trim() || sending}>
<Send className="h-4 w-4" /> {sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button> </Button>
</div> </div>
</div> </div>
+3 -3
View File
@@ -3,15 +3,15 @@ import type { ModuleConfig } from '@/core/module-registry/types';
export const aiChatConfig: ModuleConfig = { export const aiChatConfig: ModuleConfig = {
id: 'ai-chat', id: 'ai-chat',
name: 'Chat AI', name: 'Chat AI',
description: 'Interfață de conversație cu modele AI pentru asistență profesională', description: 'Interfață de conversație cu modele AI (OpenAI, Claude, Ollama) pentru asistență profesională',
icon: 'message-square', icon: 'message-square',
route: '/ai-chat', route: '/ai-chat',
category: 'ai', category: 'ai',
featureFlag: 'module.ai-chat', featureFlag: 'module.ai-chat',
visibility: 'all', visibility: 'all',
version: '0.1.0', version: '0.2.0',
dependencies: [], dependencies: [],
storageNamespace: 'ai-chat', storageNamespace: 'ai-chat',
navOrder: 51, navOrder: 51,
tags: ['chat', 'ai', 'conversație'], tags: ['chat', 'ai', 'conversație', 'openai', 'claude', 'ollama'],
}; };
+95 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage"; import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import type { ChatMessage, ChatSession } from "../types"; import type { ChatMessage, ChatSession, AiProviderConfig } from "../types";
const SESSION_PREFIX = "session:"; const SESSION_PREFIX = "session:";
@@ -12,6 +12,10 @@ export function useChat() {
const [sessions, setSessions] = useState<ChatSession[]>([]); const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null); const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [providerConfig, setProviderConfig] = useState<AiProviderConfig | null>(
null,
);
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null; const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
@@ -29,18 +33,27 @@ export function useChat() {
setLoading(false); setLoading(false);
}, [storage]); }, [storage]);
// Load sessions + check provider config
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { useEffect(() => {
refresh(); refresh();
fetch("/api/ai-chat")
.then((r) => r.json())
.then((data) => setProviderConfig(data as AiProviderConfig))
.catch(() => {
/* silent */
});
}, [refresh]); }, [refresh]);
const createSession = useCallback( const createSession = useCallback(
async (title?: string) => { async (title?: string, projectTagId?: string, projectName?: string) => {
const session: ChatSession = { const session: ChatSession = {
id: uuid(), id: uuid(),
title: title || `Conversație ${new Date().toLocaleDateString("ro-RO")}`, title: title || `Conversație ${new Date().toLocaleDateString("ro-RO")}`,
messages: [], messages: [],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
projectTagId,
projectName,
}; };
await storage.set(`${SESSION_PREFIX}${session.id}`, session); await storage.set(`${SESSION_PREFIX}${session.id}`, session);
setSessions((prev) => [session, ...prev]); setSessions((prev) => [session, ...prev]);
@@ -50,6 +63,22 @@ export function useChat() {
[storage], [storage],
); );
const updateSession = useCallback(
async (
id: string,
updates: Partial<
Pick<ChatSession, "title" | "projectTagId" | "projectName">
>,
) => {
const current = sessions.find((s) => s.id === id);
if (!current) return;
const updated = { ...current, ...updates };
await storage.set(`${SESSION_PREFIX}${id}`, updated);
setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)));
},
[storage, sessions],
);
const addMessage = useCallback( const addMessage = useCallback(
async (content: string, role: ChatMessage["role"]) => { async (content: string, role: ChatMessage["role"]) => {
if (!activeSessionId) return; if (!activeSessionId) return;
@@ -79,6 +108,66 @@ export function useChat() {
[storage, activeSessionId, sessions], [storage, activeSessionId, sessions],
); );
/**
* Send a user message and get AI response from /api/ai-chat.
* Falls back to demo mode if provider is not configured.
*/
const sendMessage = useCallback(
async (content: string) => {
if (!activeSessionId || sending) return;
// Add user message first
await addMessage(content, "user");
setSending(true);
try {
// Build message history for API from current + the new message
const current = sessions.find((s) => s.id === activeSessionId);
const apiMessages = [
...(current?.messages ?? []).map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user" as const, content },
];
// Add project context to system prompt if linked
let systemPrompt: string | undefined;
if (current?.projectName) {
systemPrompt = `Ești un asistent AI pentru biroul de arhitectură. Contextul conversației: proiectul "${current.projectName}". Răspunzi în limba română cu terminologie profesională de arhitectură, urbanism și construcții.`;
}
const res = await fetch("/api/ai-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: apiMessages, systemPrompt }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
message?: string;
};
await addMessage(
`Eroare API: ${err.message ?? `HTTP ${res.status}`}`,
"assistant",
);
return;
}
const data = (await res.json()) as { content: string };
await addMessage(data.content, "assistant");
} catch (error) {
await addMessage(
`Eroare de conexiune: ${error instanceof Error ? error.message : "necunoscută"}`,
"assistant",
);
} finally {
setSending(false);
}
},
[activeSessionId, sessions, sending, addMessage],
);
const deleteSession = useCallback( const deleteSession = useCallback(
async (id: string) => { async (id: string) => {
await storage.delete(`${SESSION_PREFIX}${id}`); await storage.delete(`${SESSION_PREFIX}${id}`);
@@ -99,7 +188,11 @@ export function useChat() {
activeSession, activeSession,
activeSessionId, activeSessionId,
loading, loading,
sending,
providerConfig,
createSession, createSession,
updateSession,
sendMessage,
addMessage, addMessage,
deleteSession, deleteSession,
selectSession, selectSession,
+1 -1
View File
@@ -1,3 +1,3 @@
export { aiChatConfig } from './config'; export { aiChatConfig } from './config';
export { AiChatModule } from './components/ai-chat-module'; export { AiChatModule } from './components/ai-chat-module';
export type { ChatMessage, ChatRole, ChatSession } from './types'; export type { ChatMessage, ChatRole, ChatSession, AiProviderConfig } from './types';
+13 -1
View File
@@ -1,4 +1,4 @@
export type ChatRole = 'user' | 'assistant'; export type ChatRole = "user" | "assistant";
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
@@ -12,4 +12,16 @@ export interface ChatSession {
title: string; title: string;
messages: ChatMessage[]; messages: ChatMessage[];
createdAt: string; createdAt: string;
/** Linked project tag id from Tag Manager */
projectTagId?: string;
/** Project display name for quick reference */
projectName?: string;
}
export interface AiProviderConfig {
provider: string;
model: string;
baseUrl: string;
maxTokens: number;
isConfigured: boolean;
} }
@@ -1,52 +1,87 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { ArrowLeft, Copy, Check, Save, Trash2, History, Sparkles } from 'lucide-react'; import {
import { Button } from '@/shared/components/ui/button'; ArrowLeft,
import { Input } from '@/shared/components/ui/input'; Copy,
import { Label } from '@/shared/components/ui/label'; Check,
import { Textarea } from '@/shared/components/ui/textarea'; Save,
import { Badge } from '@/shared/components/ui/badge'; Trash2,
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/components/ui/card'; History,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; Sparkles,
import { Separator } from '@/shared/components/ui/separator'; Search,
import type { PromptTemplate, PromptVariable } from '../types'; Image,
import { usePromptGenerator } from '../hooks/use-prompt-generator'; } from "lucide-react";
import { cn } from '@/shared/lib/utils'; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
import type { PromptTemplate, PromptVariable } from "../types";
import { usePromptGenerator } from "../hooks/use-prompt-generator";
import { cn } from "@/shared/lib/utils";
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
architecture: 'Arhitectură', architecture: "Arhitectură",
legal: 'Legal', legal: "Legal",
technical: 'Tehnic', technical: "Tehnic",
administrative: 'Administrativ', administrative: "Administrativ",
gis: 'GIS', gis: "GIS",
bim: 'BIM', bim: "BIM",
rendering: 'Vizualizare', rendering: "Vizualizare",
procurement: 'Achiziții', procurement: "Achiziții",
general: 'General', general: "General",
}; };
const TARGET_LABELS: Record<string, string> = { const TARGET_LABELS: Record<string, string> = {
text: 'Text', image: 'Imagine', code: 'Cod', review: 'Review', rewrite: 'Rescriere', text: "Text",
image: "Imagine",
code: "Cod",
review: "Review",
rewrite: "Rescriere",
}; };
type ViewMode = 'templates' | 'compose' | 'history'; type ViewMode = "templates" | "compose" | "history";
export function PromptGeneratorModule() { export function PromptGeneratorModule() {
const { const {
allTemplates, selectedTemplate, values, composedPrompt, allTemplates,
history, selectTemplate, updateValue, saveToHistory, selectedTemplate,
deleteHistoryEntry, clearSelection, values,
composedPrompt,
history,
selectTemplate,
updateValue,
saveToHistory,
deleteHistoryEntry,
clearSelection,
} = usePromptGenerator(); } = usePromptGenerator();
const [viewMode, setViewMode] = useState<ViewMode>('templates'); const [viewMode, setViewMode] = useState<ViewMode>("templates");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [filterCategory, setFilterCategory] = useState<string>('all'); const [filterCategory, setFilterCategory] = useState<string>("all");
const [filterTarget, setFilterTarget] = useState<string>("all");
const [searchQuery, setSearchQuery] = useState("");
const handleSelectTemplate = (template: PromptTemplate) => { const handleSelectTemplate = (template: PromptTemplate) => {
selectTemplate(template); selectTemplate(template);
setViewMode('compose'); setViewMode("compose");
}; };
const handleCopy = async () => { const handleCopy = async () => {
@@ -54,7 +89,9 @@ export function PromptGeneratorModule() {
await navigator.clipboard.writeText(composedPrompt); await navigator.clipboard.writeText(composedPrompt);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch { /* silent */ } } catch {
/* silent */
}
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -65,51 +102,108 @@ export function PromptGeneratorModule() {
const handleBack = () => { const handleBack = () => {
clearSelection(); clearSelection();
setViewMode('templates'); setViewMode("templates");
}; };
const filteredTemplates = filterCategory === 'all' const filteredTemplates = allTemplates.filter((t) => {
? allTemplates if (filterCategory !== "all" && t.category !== filterCategory) return false;
: allTemplates.filter((t) => t.category === filterCategory); if (filterTarget !== "all" && t.targetAiType !== filterTarget) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return (
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.tags.some((tag) => tag.toLowerCase().includes(q)) ||
(CATEGORY_LABELS[t.category] ?? "").toLowerCase().includes(q)
);
}
return true;
});
const usedCategories = [...new Set(allTemplates.map((t) => t.category))]; const usedCategories = [...new Set(allTemplates.map((t) => t.category))];
const usedTargets = [...new Set(allTemplates.map((t) => t.targetAiType))];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Navigation */} {/* Navigation */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant={viewMode === 'templates' ? 'default' : 'outline'} variant={viewMode === "templates" ? "default" : "outline"}
size="sm" size="sm"
onClick={() => { clearSelection(); setViewMode('templates'); }} onClick={() => {
clearSelection();
setViewMode("templates");
}}
> >
<Sparkles className="mr-1 h-3.5 w-3.5" /> Șabloane <Sparkles className="mr-1 h-3.5 w-3.5" /> Șabloane
</Button> </Button>
<Button <Button
variant={viewMode === 'history' ? 'default' : 'outline'} variant={viewMode === "history" ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setViewMode('history')} onClick={() => setViewMode("history")}
> >
<History className="mr-1 h-3.5 w-3.5" /> Istoric ({history.length}) <History className="mr-1 h-3.5 w-3.5" /> Istoric ({history.length})
</Button> </Button>
</div> </div>
{/* Template browser */} {/* Template browser */}
{viewMode === 'templates' && ( {viewMode === "templates" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Label>Categorie:</Label> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Caută șabloane..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterCategory} onValueChange={setFilterCategory}> <Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger> <SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate categoriile</SelectItem>
{usedCategories.map((cat) => ( {usedCategories.map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat] ?? cat}</SelectItem> <SelectItem key={cat} value={cat}>
{CATEGORY_LABELS[cat] ?? cat}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filterTarget} onValueChange={setFilterTarget}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{usedTargets.map((t) => (
<SelectItem key={t} value={t}>
{TARGET_LABELS[t] ?? t}
</SelectItem>
))}
</SelectContent>
</Select>
{allTemplates.filter((t) => t.targetAiType === "image").length >
0 && (
<Button
variant={filterTarget === "image" ? "default" : "outline"}
size="sm"
onClick={() =>
setFilterTarget(filterTarget === "image" ? "all" : "image")
}
>
<Image className="mr-1 h-3.5 w-3.5" /> Imagine
</Button>
)}
</div> </div>
{filteredTemplates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Niciun șablon găsit pentru &ldquo;{searchQuery}&rdquo;.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{filteredTemplates.map((template) => ( {filteredTemplates.map((template) => (
<Card <Card
@@ -120,25 +214,37 @@ export function PromptGeneratorModule() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CardTitle className="text-sm">{template.name}</CardTitle> <CardTitle className="text-sm">{template.name}</CardTitle>
<Badge variant="outline" className="text-[10px]">{TARGET_LABELS[template.targetAiType]}</Badge> <Badge variant="outline" className="text-[10px]">
{TARGET_LABELS[template.targetAiType]}
</Badge>
</div> </div>
<CardDescription className="text-xs">{template.description}</CardDescription> <CardDescription className="text-xs">
{template.description}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[template.category] ?? template.category}</Badge> <Badge variant="secondary" className="text-[10px]">
<Badge variant="secondary" className="text-[10px]">{template.variables.length} variabile</Badge> {CATEGORY_LABELS[template.category] ??
<Badge variant="secondary" className="text-[10px]">{template.blocks.length} blocuri</Badge> template.category}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{template.variables.length} variabile
</Badge>
<Badge variant="secondary" className="text-[10px]">
{template.blocks.length} blocuri
</Badge>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)}
</div> </div>
)} )}
{/* Composition view */} {/* Composition view */}
{viewMode === 'compose' && selectedTemplate && ( {viewMode === "compose" && selectedTemplate && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={handleBack}> <Button variant="ghost" size="sm" onClick={handleBack}>
@@ -146,14 +252,18 @@ export function PromptGeneratorModule() {
</Button> </Button>
<div> <div>
<h3 className="font-semibold">{selectedTemplate.name}</h3> <h3 className="font-semibold">{selectedTemplate.name}</h3>
<p className="text-xs text-muted-foreground">{selectedTemplate.description}</p> <p className="text-xs text-muted-foreground">
{selectedTemplate.description}
</p>
</div> </div>
</div> </div>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{/* Variable form */} {/* Variable form */}
<Card> <Card>
<CardHeader><CardTitle className="text-base">Variabile</CardTitle></CardHeader> <CardHeader>
<CardTitle className="text-base">Variabile</CardTitle>
</CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{selectedTemplate.variables.map((variable) => ( {selectedTemplate.variables.map((variable) => (
<VariableField <VariableField
@@ -171,23 +281,46 @@ export function PromptGeneratorModule() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Prompt compus</h3> <h3 className="text-sm font-semibold">Prompt compus</h3>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Button variant="outline" size="sm" onClick={handleSave} disabled={!composedPrompt}> <Button
variant="outline"
size="sm"
onClick={handleSave}
disabled={!composedPrompt}
>
<Save className="mr-1 h-3.5 w-3.5" /> <Save className="mr-1 h-3.5 w-3.5" />
{saved ? 'Salvat!' : 'Salvează'} {saved ? "Salvat!" : "Salvează"}
</Button> </Button>
<Button size="sm" onClick={handleCopy} disabled={!composedPrompt}> <Button
{copied ? <Check className="mr-1 h-3.5 w-3.5" /> : <Copy className="mr-1 h-3.5 w-3.5" />} size="sm"
{copied ? 'Copiat!' : 'Copiază'} onClick={handleCopy}
disabled={!composedPrompt}
>
{copied ? (
<Check className="mr-1 h-3.5 w-3.5" />
) : (
<Copy className="mr-1 h-3.5 w-3.5" />
)}
{copied ? "Copiat!" : "Copiază"}
</Button> </Button>
</div> </div>
</div> </div>
<div className="min-h-[300px] whitespace-pre-wrap rounded-lg border bg-muted/30 p-4 text-sm"> <div className="min-h-[300px] whitespace-pre-wrap rounded-lg border bg-muted/30 p-4 text-sm">
{composedPrompt || <span className="text-muted-foreground italic">Completează variabilele pentru a genera promptul...</span>} {composedPrompt || (
<span className="text-muted-foreground italic">
Completează variabilele pentru a genera promptul...
</span>
)}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[10px]">Output: {selectedTemplate.outputMode}</Badge> <Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-[10px]">Blocuri: {selectedTemplate.blocks.length}</Badge> Output: {selectedTemplate.outputMode}
<Badge variant="outline" className="text-[10px]">Caractere: {composedPrompt.length}</Badge> </Badge>
<Badge variant="outline" className="text-[10px]">
Blocuri: {selectedTemplate.blocks.length}
</Badge>
<Badge variant="outline" className="text-[10px]">
Caractere: {composedPrompt.length}
</Badge>
</div> </div>
</div> </div>
</div> </div>
@@ -195,10 +328,12 @@ export function PromptGeneratorModule() {
)} )}
{/* History view */} {/* History view */}
{viewMode === 'history' && ( {viewMode === "history" && (
<div className="space-y-3"> <div className="space-y-3">
{history.length === 0 ? ( {history.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun prompt salvat în istoric.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Niciun prompt salvat în istoric.
</p>
) : ( ) : (
history.map((entry) => ( history.map((entry) => (
<Card key={entry.id} className="group"> <Card key={entry.id} className="group">
@@ -206,11 +341,16 @@ export function PromptGeneratorModule() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-medium">{entry.templateName}</p> <p className="text-sm font-medium">
<Badge variant="outline" className="text-[10px]">{entry.outputMode}</Badge> {entry.templateName}
</p>
<Badge variant="outline" className="text-[10px]">
{entry.outputMode}
</Badge>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{new Date(entry.createdAt).toLocaleString('ro-RO')} {entry.composedPrompt.length} caractere {new Date(entry.createdAt).toLocaleString("ro-RO")} {" "}
{entry.composedPrompt.length} caractere
</p> </p>
<pre className="mt-2 max-h-24 overflow-hidden truncate text-xs text-muted-foreground"> <pre className="mt-2 max-h-24 overflow-hidden truncate text-xs text-muted-foreground">
{entry.composedPrompt.slice(0, 200)}... {entry.composedPrompt.slice(0, 200)}...
@@ -218,7 +358,12 @@ export function PromptGeneratorModule() {
</div> </div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<CopyHistoryButton text={entry.composedPrompt} /> <CopyHistoryButton text={entry.composedPrompt} />
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteHistoryEntry(entry.id)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => deleteHistoryEntry(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -233,44 +378,68 @@ export function PromptGeneratorModule() {
); );
} }
function VariableField({ variable, value, onChange }: { function VariableField({
variable,
value,
onChange,
}: {
variable: PromptVariable; variable: PromptVariable;
value: unknown; value: unknown;
onChange: (val: unknown) => void; onChange: (val: unknown) => void;
}) { }) {
const strVal = value !== undefined && value !== null ? String(value) : ''; const strVal = value !== undefined && value !== null ? String(value) : "";
return ( return (
<div> <div>
<Label className={cn(variable.required && 'after:content-["*"] after:ml-0.5 after:text-destructive')}> <Label
className={cn(
variable.required &&
'after:content-["*"] after:ml-0.5 after:text-destructive',
)}
>
{variable.label} {variable.label}
</Label> </Label>
{variable.helperText && ( {variable.helperText && (
<p className="text-[11px] text-muted-foreground">{variable.helperText}</p> <p className="text-[11px] text-muted-foreground">
{variable.helperText}
</p>
)} )}
{(variable.type === 'text' || variable.type === 'number') && ( {(variable.type === "text" || variable.type === "number") && (
<Input <Input
type={variable.type === 'number' ? 'number' : 'text'} type={variable.type === "number" ? "number" : "text"}
value={strVal} value={strVal}
onChange={(e) => onChange(variable.type === 'number' ? Number(e.target.value) : e.target.value)} onChange={(e) =>
onChange(
variable.type === "number"
? Number(e.target.value)
: e.target.value,
)
}
placeholder={variable.placeholder} placeholder={variable.placeholder}
className="mt-1" className="mt-1"
/> />
)} )}
{(variable.type === 'select' || variable.type === 'tone-selector' || variable.type === 'company-selector') && variable.options && ( {(variable.type === "select" ||
variable.type === "tone-selector" ||
variable.type === "company-selector") &&
variable.options && (
<Select value={strVal} onValueChange={onChange}> <Select value={strVal} onValueChange={onChange}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Selectează..." /></SelectTrigger> <SelectTrigger className="mt-1">
<SelectValue placeholder="Selectează..." />
</SelectTrigger>
<SelectContent> <SelectContent>
{variable.options.map((opt) => ( {variable.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem> <SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
{variable.type === 'boolean' && ( {variable.type === "boolean" && (
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
@@ -278,26 +447,33 @@ function VariableField({ variable, value, onChange }: {
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded accent-primary" className="h-4 w-4 rounded accent-primary"
/> />
<span className="text-sm text-muted-foreground">{variable.placeholder ?? 'Da/Nu'}</span> <span className="text-sm text-muted-foreground">
{variable.placeholder ?? "Da/Nu"}
</span>
</div> </div>
)} )}
{variable.type === 'multi-select' && variable.options && ( {variable.type === "multi-select" && variable.options && (
<div className="mt-1 flex flex-wrap gap-1.5"> <div className="mt-1 flex flex-wrap gap-1.5">
{variable.options.map((opt) => { {variable.options.map((opt) => {
const selected = Array.isArray(value) && (value as string[]).includes(opt.value); const selected =
Array.isArray(value) && (value as string[]).includes(opt.value);
return ( return (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
onClick={() => { onClick={() => {
const arr = Array.isArray(value) ? [...(value as string[])] : []; const arr = Array.isArray(value)
? [...(value as string[])]
: [];
if (selected) onChange(arr.filter((v) => v !== opt.value)); if (selected) onChange(arr.filter((v) => v !== opt.value));
else onChange([...arr, opt.value]); else onChange([...arr, opt.value]);
}} }}
className={cn( className={cn(
'rounded-full border px-2.5 py-0.5 text-xs transition-colors', "rounded-full border px-2.5 py-0.5 text-xs transition-colors",
selected ? 'border-primary bg-primary text-primary-foreground' : 'hover:bg-accent' selected
? "border-primary bg-primary text-primary-foreground"
: "hover:bg-accent",
)} )}
> >
{opt.label} {opt.label}
@@ -317,11 +493,22 @@ function CopyHistoryButton({ text }: { text: string }) {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ } } catch {
/* silent */
}
}; };
return ( return (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}> <Button
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />} variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button> </Button>
); );
} }
+3 -3
View File
@@ -3,15 +3,15 @@ import type { ModuleConfig } from '@/core/module-registry/types';
export const promptGeneratorConfig: ModuleConfig = { export const promptGeneratorConfig: ModuleConfig = {
id: 'prompt-generator', id: 'prompt-generator',
name: 'Generator Prompturi', name: 'Generator Prompturi',
description: 'Generator structurat de prompturi pe bază de șabloane parametrizate, organizate pe domenii profesionale', description: 'Generator structurat de prompturi pe bază de șabloane parametrizate, cu căutare și filtrare pe domenii profesionale și tipuri AI',
icon: 'sparkles', icon: 'sparkles',
route: '/prompt-generator', route: '/prompt-generator',
category: 'ai', category: 'ai',
featureFlag: 'module.prompt-generator', featureFlag: 'module.prompt-generator',
visibility: 'all', visibility: 'all',
version: '0.1.0', version: '0.2.0',
dependencies: [], dependencies: [],
storageNamespace: 'prompt-generator', storageNamespace: 'prompt-generator',
navOrder: 50, navOrder: 50,
tags: ['prompt', 'ai', 'generator', 'șabloane'], tags: ['prompt', 'ai', 'generator', 'șabloane', 'midjourney', 'stable-diffusion', 'imagine'],
}; };
@@ -1839,4 +1839,627 @@ export const BUILTIN_TEMPLATES: PromptTemplate[] = [
author: "ArchiTools", author: "ArchiTools",
visibility: "all", visibility: "all",
}, },
// ── Image Generation Templates (Midjourney / Stable Diffusion) ──
{
id: "midjourney-exterior",
name: "Randare Exterioară Midjourney",
category: "rendering",
domain: "architecture-visualization",
description:
"Generează un prompt optimizat pentru Midjourney v6 — randare arhitecturală exterioară fotorealistă.",
targetAiType: "image",
blocks: [
{
id: "b1",
type: "task",
label: "Subiect principal",
content:
"{{buildingType}}, {{style}} architecture, {{materials}}, located in {{environment}}",
order: 1,
required: true,
},
{
id: "b2",
type: "context",
label: "Atmosferă și lumină",
content:
"{{lighting}} lighting, {{timeOfDay}}, {{weather}}, {{season}}",
order: 2,
required: true,
},
{
id: "b3",
type: "format",
label: "Parametri tehnici",
content:
"architectural photography, {{cameraAngle}}, {{lensType}}, high detail, 8k, photorealistic --ar {{aspectRatio}} --v 6 --style raw --s {{stylize}}",
order: 3,
required: true,
},
{
id: "b4",
type: "constraints",
label: "Constrângeri negative",
content: "{{negativePrompt}}",
order: 4,
required: false,
conditional: {
variableId: "negativePrompt",
operator: "truthy",
},
},
],
variables: [
{
id: "buildingType",
label: "Tip clădire",
type: "select",
required: true,
options: [
{
value: "modern residential house",
label: "Casă rezidențială modernă",
},
{
value: "contemporary apartment building",
label: "Bloc apartamente contemporan",
},
{ value: "commercial office building", label: "Clădire birouri" },
{ value: "cultural center", label: "Centru cultural" },
{ value: "mixed-use development", label: "Complex multifuncțional" },
{ value: "villa with pool", label: "Vilă cu piscină" },
],
},
{
id: "style",
label: "Stil arhitectural",
type: "select",
required: true,
options: [
{ value: "minimalist", label: "Minimalist" },
{ value: "brutalist", label: "Brutalist" },
{ value: "parametric", label: "Parametric" },
{ value: "organic", label: "Organic" },
{ value: "neo-traditional", label: "Neo-tradițional" },
{ value: "high-tech", label: "High-tech" },
],
},
{
id: "materials",
label: "Materiale",
type: "text",
required: true,
placeholder: "ex: exposed concrete, timber cladding, glass facade",
},
{
id: "environment",
label: "Mediu înconjurător",
type: "select",
required: true,
options: [
{ value: "urban context with trees", label: "Urban cu arbori" },
{ value: "hillside with panoramic view", label: "Versant panoramic" },
{ value: "waterfront", label: "Lângă apă" },
{ value: "forest clearing", label: "Poiană în pădure" },
{ value: "dense city center", label: "Centru urban dens" },
],
},
{
id: "lighting",
label: "Iluminare",
type: "select",
required: true,
options: [
{ value: "golden hour", label: "Golden hour" },
{ value: "soft diffused", label: "Difuză" },
{ value: "dramatic", label: "Dramatică" },
{ value: "overcast ambient", label: "Ambient înnorat" },
],
},
{
id: "timeOfDay",
label: "Moment al zilei",
type: "select",
required: true,
options: [
{ value: "early morning", label: "Dimineață devreme" },
{ value: "midday", label: "Amiază" },
{ value: "late afternoon", label: "Seară" },
{ value: "blue hour dusk", label: "Blue hour / amurg" },
{
value: "night with interior lights",
label: "Noapte cu lumini interioare",
},
],
},
{
id: "weather",
label: "Vreme",
type: "select",
required: false,
options: [
{ value: "clear sky", label: "Cer senin" },
{ value: "partly cloudy", label: "Parțial înnorat" },
{ value: "light rain", label: "Ploaie ușoară" },
{ value: "snow covered", label: "Acoperit de zăpadă" },
],
},
{
id: "season",
label: "Anotimp",
type: "select",
required: false,
options: [
{ value: "spring with blossoms", label: "Primăvară" },
{ value: "lush summer", label: "Vară" },
{ value: "autumn colors", label: "Toamnă" },
{ value: "winter", label: "Iarnă" },
],
},
{
id: "cameraAngle",
label: "Unghi cameră",
type: "select",
required: true,
options: [
{ value: "eye-level perspective", label: "Nivel ochiului" },
{ value: "low angle dramatic", label: "Unghi jos dramatic" },
{ value: "aerial bird's eye", label: "Aerian" },
{ value: "three-quarter view", label: "Perspectivă 3/4" },
],
},
{
id: "lensType",
label: "Tip obiectiv",
type: "select",
required: false,
options: [
{ value: "wide angle 24mm", label: "Wide 24mm" },
{ value: "standard 50mm", label: "Standard 50mm" },
{ value: "telephoto 85mm", label: "Telephoto 85mm" },
{ value: "tilt-shift", label: "Tilt-shift" },
],
},
{
id: "aspectRatio",
label: "Aspect ratio",
type: "select",
required: true,
options: [
{ value: "16:9", label: "16:9 (landscape)" },
{ value: "4:3", label: "4:3" },
{ value: "1:1", label: "1:1 (square)" },
{ value: "9:16", label: "9:16 (portrait)" },
{ value: "3:2", label: "3:2" },
],
},
{
id: "stylize",
label: "Stylize (0-1000)",
type: "number",
required: false,
defaultValue: 250,
placeholder: "250",
helperText: "Valori mici = mai fidel textului, mari = mai artistic",
},
{
id: "negativePrompt",
label: "Prompt negativ (opțional)",
type: "text",
required: false,
placeholder: "ex: --no people, cars, text, watermark",
},
],
outputMode: "short",
tags: ["midjourney", "exterior", "randare", "fotorealist", "arhitectură"],
version: "1.0.0",
author: "ArchiTools",
visibility: "all",
},
{
id: "sd-interior-design",
name: "Design Interior — Stable Diffusion",
category: "rendering",
domain: "architecture-visualization",
description:
"Prompt optimizat pentru Stable Diffusion XL — vizualizare design interior cu control detaliat.",
targetAiType: "image",
blocks: [
{
id: "b1",
type: "task",
label: "Scenă",
content:
"interior design photograph, {{roomType}}, {{designStyle}} style, {{colorPalette}} color palette",
order: 1,
required: true,
},
{
id: "b2",
type: "context",
label: "Mobilier și detalii",
content:
"featuring {{furniture}}, {{flooring}} flooring, {{wallTreatment}} walls, {{decorElements}}",
order: 2,
required: true,
},
{
id: "b3",
type: "format",
label: "Calitate și stil",
content:
"professional interior photography, {{lightingMood}}, high resolution, 4k, detailed textures, editorial quality, architectural digest style",
order: 3,
required: true,
},
],
variables: [
{
id: "roomType",
label: "Tip cameră",
type: "select",
required: true,
options: [
{ value: "spacious living room", label: "Living spațios" },
{ value: "modern kitchen", label: "Bucătărie modernă" },
{ value: "master bedroom", label: "Dormitor principal" },
{ value: "home office", label: "Birou acasă" },
{
value: "bathroom with natural stone",
label: "Baie cu piatră naturală",
},
{ value: "open-plan loft", label: "Loft open-plan" },
],
},
{
id: "designStyle",
label: "Stil design",
type: "select",
required: true,
options: [
{ value: "Scandinavian minimalist", label: "Scandinav minimalist" },
{ value: "Japanese wabi-sabi", label: "Wabi-sabi japonez" },
{ value: "industrial chic", label: "Industrial chic" },
{ value: "mid-century modern", label: "Mid-century modern" },
{ value: "Mediterranean warm", label: "Mediteranean cald" },
{ value: "contemporary luxury", label: "Lux contemporan" },
],
},
{
id: "colorPalette",
label: "Paletă de culori",
type: "text",
required: true,
placeholder: "ex: warm neutrals with sage green accents",
},
{
id: "furniture",
label: "Mobilier principal",
type: "text",
required: true,
placeholder: "ex: modular sofa, oak dining table, pendant lights",
},
{
id: "flooring",
label: "Pardoseală",
type: "select",
required: false,
options: [
{ value: "hardwood oak", label: "Parchet stejar" },
{ value: "polished concrete", label: "Beton șlefuit" },
{ value: "marble", label: "Marmură" },
{ value: "terrazzo", label: "Terrazzo" },
{ value: "ceramic tile", label: "Gresie ceramică" },
],
},
{
id: "wallTreatment",
label: "Finisaj pereți",
type: "select",
required: false,
options: [
{ value: "smooth plaster", label: "Tencuială netedă" },
{ value: "exposed brick", label: "Cărămidă aparentă" },
{ value: "wood panel", label: "Lambriu lemn" },
{ value: "textured wallpaper", label: "Tapet texturat" },
],
},
{
id: "decorElements",
label: "Elemente decorative",
type: "text",
required: false,
placeholder: "ex: indoor plants, art prints, ceramic vases",
},
{
id: "lightingMood",
label: "Atmosferă luminoasă",
type: "select",
required: true,
options: [
{
value: "warm natural daylight streaming through large windows",
label: "Lumină naturală caldă",
},
{
value: "soft ambient evening light with warm lamps",
label: "Ambient seară cu lămpi calde",
},
{
value: "bright and airy with skylights",
label: "Luminos cu lucarnă",
},
{
value: "moody dramatic with accent lighting",
label: "Dramatic cu accente",
},
],
},
],
outputMode: "short",
tags: ["stable diffusion", "interior", "design", "vizualizare"],
version: "1.0.0",
author: "ArchiTools",
visibility: "all",
},
{
id: "midjourney-infographic",
name: "Infografic Arhitectural Midjourney",
category: "rendering",
domain: "architecture-visualization",
description:
"Prompt pentru generarea de infografice, diagrame explicate și prezentări vizuale pentru proiecte de arhitectură.",
targetAiType: "image",
blocks: [
{
id: "b1",
type: "task",
label: "Subiect",
content:
"architectural {{infographicType}}, showing {{subject}}, {{visualStyle}} visual style",
order: 1,
required: true,
},
{
id: "b2",
type: "context",
label: "Conținut",
content:
"illustrating {{keyElements}}, with {{annotations}}, target audience: {{audience}}",
order: 2,
required: true,
},
{
id: "b3",
type: "format",
label: "Stil grafic",
content:
"clean vector style, {{colorScheme}}, professional infographic layout, high contrast, editorial quality --ar {{aspectRatio}} --v 6",
order: 3,
required: true,
},
],
variables: [
{
id: "infographicType",
label: "Tip infografic",
type: "select",
required: true,
options: [
{
value: "exploded axonometric diagram",
label: "Axonometrie explodată",
},
{
value: "section diagram with labels",
label: "Secțiune cu etichete",
},
{ value: "site analysis diagram", label: "Diagramă analiză sit" },
{ value: "material palette board", label: "Paletar materiale" },
{
value: "sustainability features diagram",
label: "Diagramă sustenabilitate",
},
{
value: "construction phases timeline",
label: "Timeline faze construcție",
},
],
},
{
id: "subject",
label: "Subiect",
type: "text",
required: true,
placeholder: "ex: passive house energy flow, structural system",
},
{
id: "visualStyle",
label: "Stil vizual",
type: "select",
required: true,
options: [
{ value: "minimal flat", label: "Minimal flat" },
{ value: "isometric 3D", label: "Izometric 3D" },
{ value: "technical line drawing", label: "Desen tehnic" },
{ value: "watercolor sketch", label: "Schiță acuarelă" },
{ value: "collage mixed media", label: "Colaj mixed media" },
],
},
{
id: "keyElements",
label: "Elemente cheie",
type: "text",
required: true,
placeholder: "ex: insulation layers, ventilation system, solar panels",
},
{
id: "annotations",
label: "Adnotări",
type: "select",
required: false,
options: [
{ value: "numbered callouts", label: "Callout-uri numerotate" },
{ value: "legend with icons", label: "Legendă cu pictograme" },
{ value: "dimension lines", label: "Cote dimensionale" },
{ value: "no annotations", label: "Fără adnotări" },
],
},
{
id: "audience",
label: "Public țintă",
type: "select",
required: false,
options: [
{ value: "client presentation", label: "Prezentare client" },
{ value: "technical review", label: "Comisie tehnică" },
{ value: "public exhibition", label: "Expoziție publică" },
{ value: "academic portfolio", label: "Portofoliu academic" },
],
},
{
id: "colorScheme",
label: "Schemă de culori",
type: "text",
required: false,
placeholder: "ex: muted earth tones, black and white with accent red",
},
{
id: "aspectRatio",
label: "Aspect ratio",
type: "select",
required: true,
options: [
{ value: "16:9", label: "16:9 (landscape)" },
{ value: "1:1", label: "1:1 (square)" },
{ value: "9:16", label: "9:16 (poster)" },
{ value: "3:4", label: "3:4 (print)" },
],
},
],
outputMode: "short",
tags: ["midjourney", "infografic", "diagramă", "prezentare", "arhitectură"],
version: "1.0.0",
author: "ArchiTools",
visibility: "all",
},
{
id: "sd-material-texture",
name: "Textură Material — Stable Diffusion",
category: "rendering",
domain: "architecture-visualization",
description:
"Generează texturi seamless pentru materiale de construcție — utilizare în SketchUp, 3ds Max, Blender.",
targetAiType: "image",
blocks: [
{
id: "b1",
type: "task",
label: "Material",
content:
"seamless tileable texture of {{material}}, {{surfaceFinish}} finish, {{patternType}} pattern",
order: 1,
required: true,
},
{
id: "b2",
type: "context",
label: "Detalii",
content: "{{aging}} aging, {{scale}} scale, viewed from {{viewAngle}}",
order: 2,
required: true,
},
{
id: "b3",
type: "format",
label: "Parametri tehnici",
content:
"PBR material texture, high resolution, 4k, seamless tiling, physically based rendering, no distortion, uniform lighting --ar 1:1 --tile",
order: 3,
required: true,
},
],
variables: [
{
id: "material",
label: "Material",
type: "select",
required: true,
options: [
{ value: "natural oak wood", label: "Lemn stejar natural" },
{ value: "Carrara marble", label: "Marmură Carrara" },
{ value: "raw concrete", label: "Beton brut" },
{ value: "red brick", label: "Cărămidă roșie" },
{ value: "weathering steel corten", label: "Oțel Corten" },
{ value: "terracotta tiles", label: "Teracotă" },
],
},
{
id: "surfaceFinish",
label: "Finisaj suprafață",
type: "select",
required: true,
options: [
{ value: "matte", label: "Mat" },
{ value: "polished", label: "Lustruit" },
{ value: "rough", label: "Rugos" },
{ value: "brushed", label: "Periat" },
{ value: "sandblasted", label: "Sablat" },
],
},
{
id: "patternType",
label: "Tip pattern",
type: "select",
required: false,
options: [
{ value: "natural grain", label: "Fibră naturală" },
{ value: "herringbone", label: "Herringbone" },
{ value: "running bond", label: "Running bond" },
{ value: "random mosaic", label: "Mozaic random" },
{ value: "linear", label: "Linear" },
],
},
{
id: "aging",
label: "Uzură / Patină",
type: "select",
required: false,
options: [
{ value: "brand new pristine", label: "Nou" },
{ value: "slightly weathered", label: "Ușor patinat" },
{ value: "heavily worn vintage", label: "Foarte uzat / vintage" },
{ value: "moss and lichen covered", label: "Acoperit de mușchi" },
],
},
{
id: "scale",
label: "Scală",
type: "select",
required: false,
options: [
{ value: "close-up macro", label: "Macro close-up" },
{ value: "medium 1:1", label: "Mediu 1:1" },
{ value: "wide repeating panel", label: "Panou larg repetitiv" },
],
},
{
id: "viewAngle",
label: "Unghi vedere",
type: "select",
required: false,
options: [
{ value: "straight on orthographic", label: "Frontal ortografic" },
{ value: "slight angle", label: "Ușor înclinat" },
],
},
],
outputMode: "short",
tags: ["stable diffusion", "textură", "material", "PBR", "seamless"],
version: "1.0.0",
author: "ArchiTools",
visibility: "all",
},
]; ];