From d34c7221675c4dc350ca3897015604af64a79597 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 28 Feb 2026 04:51:36 +0200 Subject: [PATCH] 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 --- ROADMAP.md | 15 +- docker-compose.yml | 6 + src/app/api/ai-chat/route.ts | 254 +++++++ src/app/api/manictime/route.ts | 7 +- .../ai-chat/components/ai-chat-module.tsx | 292 ++++++-- src/modules/ai-chat/config.ts | 6 +- src/modules/ai-chat/hooks/use-chat.ts | 97 ++- src/modules/ai-chat/index.ts | 2 +- src/modules/ai-chat/types.ts | 14 +- .../components/prompt-generator-module.tsx | 417 ++++++++---- src/modules/prompt-generator/config.ts | 6 +- .../services/builtin-templates.ts | 623 ++++++++++++++++++ 12 files changed, 1550 insertions(+), 189 deletions(-) create mode 100644 src/app/api/ai-chat/route.ts diff --git a/ROADMAP.md b/ROADMAP.md index ade4c4a..97b3e42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -385,13 +385,18 @@ - ✅ **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) -### 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. -- **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. -- **Validare Ierarhie:** Asigurarea că tag-urile adăugate respectă formatul ierarhic cerut de ManicTime (ex: `Proiect, Faza, Activitate`). +- ✅ **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):** Backup automat `Tags_backup_YYYYMMDD_HHMMSS.txt` la fiecare scriere (push/both) +- ✅ **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 ✅ diff --git a/docker-compose.yml b/docker-compose.yml index 3267212..b553fe3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,12 @@ services: - ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256 # ManicTime Tags.txt sync (SMB mount path) - 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: # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) - /mnt/manictime:/mnt/manictime diff --git a/src/app/api/ai-chat/route.ts b/src/app/api/ai-chat/route.ts new file mode 100644 index 0000000..1ffb2c0 --- /dev/null +++ b/src/app/api/ai-chat/route.ts @@ -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, +): Promise { + 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, +): Promise { + 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, +): Promise { + 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 }, + ); + } +} diff --git a/src/app/api/manictime/route.ts b/src/app/api/manictime/route.ts index 6c8d7f5..677b9c0 100644 --- a/src/app/api/manictime/route.ts +++ b/src/app/api/manictime/route.ts @@ -10,7 +10,8 @@ import { manicTimeTagToCreateData, } from "@/modules/tag-manager/services/manictime-service"; 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"; /** @@ -155,7 +156,9 @@ export async function POST(request: NextRequest) { 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({ where: { namespace_key: { namespace: NAMESPACE, key: id } }, diff --git a/src/modules/ai-chat/components/ai-chat-module.tsx b/src/modules/ai-chat/components/ai-chat-module.tsx index 3935cea..4511390 100644 --- a/src/modules/ai-chat/components/ai-chat-module.tsx +++ b/src/modules/ai-chat/components/ai-chat-module.tsx @@ -1,48 +1,73 @@ -'use client'; +"use client"; -import { useState, useRef, useEffect } from 'react'; -import { Plus, Send, Trash2, MessageSquare, Settings } from 'lucide-react'; -import { Button } from '@/shared/components/ui/button'; -import { Input } from '@/shared/components/ui/input'; -import { Card, CardContent } from '@/shared/components/ui/card'; -import { Badge } from '@/shared/components/ui/badge'; -import { cn } from '@/shared/lib/utils'; -import { useChat } from '../hooks/use-chat'; +import { useState, useRef, useEffect } from "react"; +import { + Plus, + Send, + Trash2, + MessageSquare, + Settings, + 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 = { + openai: "OpenAI", + anthropic: "Claude", + ollama: "Ollama", + demo: "Demo", +}; export function AiChatModule() { const { - sessions, activeSession, activeSessionId, - createSession, addMessage, deleteSession, selectSession, + sessions, + activeSession, + activeSessionId, + sending, + providerConfig, + createSession, + updateSession, + sendMessage, + deleteSession, + selectSession, } = useChat(); - const [input, setInput] = useState(''); + const { tags: projectTags } = useTags("project"); + + const [input, setInput] = useState(""); const [showConfig, setShowConfig] = useState(false); + const [showProjectPicker, setShowProjectPicker] = useState(false); const messagesEndRef = useRef(null); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [activeSession?.messages.length]); + const providerLabel = providerConfig + ? (PROVIDER_LABELS[providerConfig.provider] ?? providerConfig.provider) + : "Demo"; + const isConfigured = providerConfig?.isConfigured ?? false; + const handleSend = async () => { - if (!input.trim()) return; + if (!input.trim() || sending) return; const text = input.trim(); - setInput(''); + setInput(""); if (!activeSessionId) { await createSession(); } - await addMessage(text, 'user'); - - // 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); + await sendMessage(text); }; return ( @@ -58,20 +83,32 @@ export function AiChatModule() {
selectSession(session.id)} > -
- - {session.title} +
+
+ + {session.title} +
+ {session.projectName && ( + + {session.projectName} + + )}
@@ -86,11 +123,103 @@ export function AiChatModule() {

- {activeSession?.title ?? 'Chat AI'} + {activeSession?.title ?? "Chat AI"}

- Demo + {/* Project link */} + {activeSession && ( +
+ {activeSession.projectName ? ( + setShowProjectPicker(!showProjectPicker)} + > + + {activeSession.projectName} + { + e.stopPropagation(); + updateSession(activeSession.id, { + projectTagId: undefined, + projectName: undefined, + }); + }} + /> + + ) : ( + + )} + {showProjectPicker && projectTags.length > 0 && ( +
+ {projectTags.map((tag) => ( + + ))} +
+ )} +
+ )} + + {isConfigured ? ( + + ) : ( + + )} + {providerLabel} +
-
@@ -98,14 +227,42 @@ export function AiChatModule() { {/* Config banner */} {showConfig && (
-

Configurare API (viitor)

-

- 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. -

-

- Momentan, conversațiile sunt salvate local, dar fără generare de răspunsuri AI reale. -

+

Configurare API

+ {providerConfig ? ( + <> +
+ Provider: + + {providerLabel} + + Model: + + {providerConfig.model} + + Max tokens: + + {providerConfig.maxTokens} + + Stare: + + {isConfigured ? "Configurat" : "Neconfigurat (mod demo)"} + +
+ {!isConfigured && ( +

+ Setați variabilele de mediu AI_PROVIDER, AI_API_KEY și + AI_MODEL pentru a activa răspunsurile AI reale. +

+ )} + + ) : ( +

Se verifică conexiunea la API...

+ )}
)} @@ -115,7 +272,9 @@ export function AiChatModule() {

Începe o conversație nouă

-

Scrie un mesaj sau creează o sesiune nouă din bara laterală.

+

+ Scrie un mesaj sau creează o sesiune nouă din bara laterală. +

) : (
@@ -123,21 +282,37 @@ export function AiChatModule() {
+ {msg.role === "assistant" && ( + + )}

{msg.content}

-

- {new Date(msg.timestamp).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit' })} +

+ {new Date(msg.timestamp).toLocaleTimeString("ro-RO", { + hour: "2-digit", + minute: "2-digit", + })}

))} + {sending && ( +
+ + Se generează răspunsul... +
+ )}
)} @@ -149,12 +324,15 @@ export function AiChatModule() { setInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()} - placeholder="Scrie un mesaj..." + onKeyDown={(e) => + e.key === "Enter" && !e.shiftKey && handleSend() + } + placeholder={sending ? "Se generează..." : "Scrie un mesaj..."} + disabled={sending} className="flex-1" /> -
diff --git a/src/modules/ai-chat/config.ts b/src/modules/ai-chat/config.ts index 283a034..ed59a0a 100644 --- a/src/modules/ai-chat/config.ts +++ b/src/modules/ai-chat/config.ts @@ -3,15 +3,15 @@ import type { ModuleConfig } from '@/core/module-registry/types'; export const aiChatConfig: ModuleConfig = { id: 'ai-chat', 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', route: '/ai-chat', category: 'ai', featureFlag: 'module.ai-chat', visibility: 'all', - version: '0.1.0', + version: '0.2.0', dependencies: [], storageNamespace: 'ai-chat', navOrder: 51, - tags: ['chat', 'ai', 'conversație'], + tags: ['chat', 'ai', 'conversație', 'openai', 'claude', 'ollama'], }; diff --git a/src/modules/ai-chat/hooks/use-chat.ts b/src/modules/ai-chat/hooks/use-chat.ts index d495f5d..0c09cbb 100644 --- a/src/modules/ai-chat/hooks/use-chat.ts +++ b/src/modules/ai-chat/hooks/use-chat.ts @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react"; import { useStorage } from "@/core/storage"; import { v4 as uuid } from "uuid"; -import type { ChatMessage, ChatSession } from "../types"; +import type { ChatMessage, ChatSession, AiProviderConfig } from "../types"; const SESSION_PREFIX = "session:"; @@ -12,6 +12,10 @@ export function useChat() { const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [providerConfig, setProviderConfig] = useState( + null, + ); const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null; @@ -29,18 +33,27 @@ export function useChat() { setLoading(false); }, [storage]); + // Load sessions + check provider config // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { refresh(); + fetch("/api/ai-chat") + .then((r) => r.json()) + .then((data) => setProviderConfig(data as AiProviderConfig)) + .catch(() => { + /* silent */ + }); }, [refresh]); const createSession = useCallback( - async (title?: string) => { + async (title?: string, projectTagId?: string, projectName?: string) => { const session: ChatSession = { id: uuid(), title: title || `Conversație ${new Date().toLocaleDateString("ro-RO")}`, messages: [], createdAt: new Date().toISOString(), + projectTagId, + projectName, }; await storage.set(`${SESSION_PREFIX}${session.id}`, session); setSessions((prev) => [session, ...prev]); @@ -50,6 +63,22 @@ export function useChat() { [storage], ); + const updateSession = useCallback( + async ( + id: string, + updates: Partial< + Pick + >, + ) => { + 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( async (content: string, role: ChatMessage["role"]) => { if (!activeSessionId) return; @@ -79,6 +108,66 @@ export function useChat() { [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( async (id: string) => { await storage.delete(`${SESSION_PREFIX}${id}`); @@ -99,7 +188,11 @@ export function useChat() { activeSession, activeSessionId, loading, + sending, + providerConfig, createSession, + updateSession, + sendMessage, addMessage, deleteSession, selectSession, diff --git a/src/modules/ai-chat/index.ts b/src/modules/ai-chat/index.ts index db96443..39e1ff0 100644 --- a/src/modules/ai-chat/index.ts +++ b/src/modules/ai-chat/index.ts @@ -1,3 +1,3 @@ export { aiChatConfig } from './config'; export { AiChatModule } from './components/ai-chat-module'; -export type { ChatMessage, ChatRole, ChatSession } from './types'; +export type { ChatMessage, ChatRole, ChatSession, AiProviderConfig } from './types'; diff --git a/src/modules/ai-chat/types.ts b/src/modules/ai-chat/types.ts index 606dfd0..68e2284 100644 --- a/src/modules/ai-chat/types.ts +++ b/src/modules/ai-chat/types.ts @@ -1,4 +1,4 @@ -export type ChatRole = 'user' | 'assistant'; +export type ChatRole = "user" | "assistant"; export interface ChatMessage { id: string; @@ -12,4 +12,16 @@ export interface ChatSession { title: string; messages: ChatMessage[]; 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; } diff --git a/src/modules/prompt-generator/components/prompt-generator-module.tsx b/src/modules/prompt-generator/components/prompt-generator-module.tsx index f24ddc0..2315c2b 100644 --- a/src/modules/prompt-generator/components/prompt-generator-module.tsx +++ b/src/modules/prompt-generator/components/prompt-generator-module.tsx @@ -1,52 +1,87 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { ArrowLeft, Copy, Check, Save, Trash2, History, Sparkles } from 'lucide-react'; -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'; +import { useState } from "react"; +import { + ArrowLeft, + Copy, + Check, + Save, + Trash2, + History, + Sparkles, + Search, + Image, +} from "lucide-react"; +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 = { - architecture: 'Arhitectură', - legal: 'Legal', - technical: 'Tehnic', - administrative: 'Administrativ', - gis: 'GIS', - bim: 'BIM', - rendering: 'Vizualizare', - procurement: 'Achiziții', - general: 'General', + architecture: "Arhitectură", + legal: "Legal", + technical: "Tehnic", + administrative: "Administrativ", + gis: "GIS", + bim: "BIM", + rendering: "Vizualizare", + procurement: "Achiziții", + general: "General", }; const TARGET_LABELS: Record = { - 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() { const { - allTemplates, selectedTemplate, values, composedPrompt, - history, selectTemplate, updateValue, saveToHistory, - deleteHistoryEntry, clearSelection, + allTemplates, + selectedTemplate, + values, + composedPrompt, + history, + selectTemplate, + updateValue, + saveToHistory, + deleteHistoryEntry, + clearSelection, } = usePromptGenerator(); - const [viewMode, setViewMode] = useState('templates'); + const [viewMode, setViewMode] = useState("templates"); const [copied, setCopied] = useState(false); const [saved, setSaved] = useState(false); - const [filterCategory, setFilterCategory] = useState('all'); + const [filterCategory, setFilterCategory] = useState("all"); + const [filterTarget, setFilterTarget] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); const handleSelectTemplate = (template: PromptTemplate) => { selectTemplate(template); - setViewMode('compose'); + setViewMode("compose"); }; const handleCopy = async () => { @@ -54,7 +89,9 @@ export function PromptGeneratorModule() { await navigator.clipboard.writeText(composedPrompt); setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch { /* silent */ } + } catch { + /* silent */ + } }; const handleSave = async () => { @@ -65,80 +102,149 @@ export function PromptGeneratorModule() { const handleBack = () => { clearSelection(); - setViewMode('templates'); + setViewMode("templates"); }; - const filteredTemplates = filterCategory === 'all' - ? allTemplates - : allTemplates.filter((t) => t.category === filterCategory); + const filteredTemplates = allTemplates.filter((t) => { + if (filterCategory !== "all" && t.category !== filterCategory) return false; + 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 usedTargets = [...new Set(allTemplates.map((t) => t.targetAiType))]; return (
{/* Navigation */}
{/* Template browser */} - {viewMode === 'templates' && ( + {viewMode === "templates" && (
-
- +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + {allTemplates.filter((t) => t.targetAiType === "image").length > + 0 && ( + + )}
-
- {filteredTemplates.map((template) => ( - handleSelectTemplate(template)} - > - -
- {template.name} - {TARGET_LABELS[template.targetAiType]} -
- {template.description} -
- -
- {CATEGORY_LABELS[template.category] ?? template.category} - {template.variables.length} variabile - {template.blocks.length} blocuri -
-
-
- ))} -
+ {filteredTemplates.length === 0 ? ( +

+ Niciun șablon găsit pentru “{searchQuery}”. +

+ ) : ( +
+ {filteredTemplates.map((template) => ( + handleSelectTemplate(template)} + > + +
+ {template.name} + + {TARGET_LABELS[template.targetAiType]} + +
+ + {template.description} + +
+ +
+ + {CATEGORY_LABELS[template.category] ?? + template.category} + + + {template.variables.length} variabile + + + {template.blocks.length} blocuri + +
+
+
+ ))} +
+ )}
)} {/* Composition view */} - {viewMode === 'compose' && selectedTemplate && ( + {viewMode === "compose" && selectedTemplate && (

{selectedTemplate.name}

-

{selectedTemplate.description}

+

+ {selectedTemplate.description} +

{/* Variable form */} - Variabile + + Variabile + {selectedTemplate.variables.map((variable) => (

Prompt compus

- -
- {composedPrompt || Completează variabilele pentru a genera promptul...} + {composedPrompt || ( + + Completează variabilele pentru a genera promptul... + + )}
- Output: {selectedTemplate.outputMode} - Blocuri: {selectedTemplate.blocks.length} - Caractere: {composedPrompt.length} + + Output: {selectedTemplate.outputMode} + + + Blocuri: {selectedTemplate.blocks.length} + + + Caractere: {composedPrompt.length} +
@@ -195,10 +328,12 @@ export function PromptGeneratorModule() { )} {/* History view */} - {viewMode === 'history' && ( + {viewMode === "history" && (
{history.length === 0 ? ( -

Niciun prompt salvat în istoric.

+

+ Niciun prompt salvat în istoric. +

) : ( history.map((entry) => ( @@ -206,11 +341,16 @@ export function PromptGeneratorModule() {
-

{entry.templateName}

- {entry.outputMode} +

+ {entry.templateName} +

+ + {entry.outputMode} +

- {new Date(entry.createdAt).toLocaleString('ro-RO')} — {entry.composedPrompt.length} caractere + {new Date(entry.createdAt).toLocaleString("ro-RO")} —{" "} + {entry.composedPrompt.length} caractere

                         {entry.composedPrompt.slice(0, 200)}...
@@ -218,7 +358,12 @@ export function PromptGeneratorModule() {
                     
-
@@ -233,44 +378,68 @@ export function PromptGeneratorModule() { ); } -function VariableField({ variable, value, onChange }: { +function VariableField({ + variable, + value, + onChange, +}: { variable: PromptVariable; value: unknown; onChange: (val: unknown) => void; }) { - const strVal = value !== undefined && value !== null ? String(value) : ''; + const strVal = value !== undefined && value !== null ? String(value) : ""; return (
-