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
+235 -57
View File
@@ -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<string, string> = {
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<HTMLDivElement>(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() {
<div
key={session.id}
className={cn(
'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'
"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",
)}
onClick={() => selectSession(session.id)}
>
<div className="flex min-w-0 items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{session.title}</span>
<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" />
<span className="truncate">{session.title}</span>
</div>
{session.projectName && (
<span className="ml-5 truncate text-[10px] text-muted-foreground">
{session.projectName}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
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" />
</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 gap-2">
<h3 className="text-sm font-medium">
{activeSession?.title ?? 'Chat AI'}
{activeSession?.title ?? "Chat AI"}
</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>
)}
<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)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowConfig(!showConfig)}
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
@@ -98,14 +227,42 @@ export function AiChatModule() {
{/* Config banner */}
{showConfig && (
<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="mt-1">
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.
</p>
<p className="mt-1">
Momentan, conversațiile sunt salvate local, dar fără generare de răspunsuri AI reale.
</p>
<p className="font-medium">Configurare API</p>
{providerConfig ? (
<>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<span>Provider:</span>
<span className="font-medium text-foreground">
{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 className="mt-1">Se verifică conexiunea la API...</p>
)}
</div>
)}
@@ -115,7 +272,9 @@ export function AiChatModule() {
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<MessageSquare className="mb-3 h-10 w-10 opacity-30" />
<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 className="space-y-4">
@@ -123,21 +282,37 @@ export function AiChatModule() {
<div
key={msg.id}
className={cn(
'max-w-[80%] rounded-lg px-3 py-2 text-sm',
msg.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted'
"max-w-[80%] rounded-lg px-3 py-2 text-sm",
msg.role === "user"
? "ml-auto bg-primary text-primary-foreground"
: "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={cn(
'mt-1 text-[10px]',
msg.role === 'user' ? 'text-primary-foreground/60' : 'text-muted-foreground'
)}>
{new Date(msg.timestamp).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit' })}
<p
className={cn(
"mt-1 text-[10px]",
msg.role === "user"
? "text-primary-foreground/60"
: "text-muted-foreground",
)}
>
{new Date(msg.timestamp).toLocaleTimeString("ro-RO", {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</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>
)}
@@ -149,12 +324,15 @@ export function AiChatModule() {
<Input
value={input}
onChange={(e) => 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"
/>
<Button onClick={handleSend} disabled={!input.trim()}>
<Send className="h-4 w-4" />
<Button onClick={handleSend} disabled={!input.trim() || sending}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
+3 -3
View File
@@ -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'],
};
+95 -2
View File
@@ -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<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
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;
@@ -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<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(
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,
+1 -1
View File
@@ -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';
+13 -1
View File
@@ -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;
}