Add AI chat assistant and backend API for AI interactions
Introduces new API endpoints for AI chat and title generation, integrates an AI chat component into the layout, and updates client-side services to communicate with the new backend AI endpoints. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 64961019-b4a5-48d8-97fc-c4980d29f3c4 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/fhRML7y Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
a36fc4f2ba
commit
834c4bd56e
13 changed files with 1456 additions and 0 deletions
4
.replit
4
.replit
|
|
@ -61,6 +61,10 @@ externalPort = 3000
|
|||
localPort = 40437
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 40873
|
||||
externalPort = 3002
|
||||
|
||||
[deployment]
|
||||
deploymentTarget = "autoscale"
|
||||
run = ["node", "dist/server/production.mjs"]
|
||||
|
|
|
|||
172
api/ai/chat.ts
Normal file
172
api/ai/chat.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type { Request, Response } from "express";
|
||||
import { GoogleGenAI, Chat, FunctionDeclaration, Type } from "@google/genai";
|
||||
|
||||
const GEMINI_API_KEY = process.env.AI_INTEGRATIONS_GEMINI_API_KEY || process.env.GEMINI_API_KEY || "";
|
||||
|
||||
const ai = GEMINI_API_KEY ? new GoogleGenAI({ apiKey: GEMINI_API_KEY }) : null;
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "model";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ChatRequest {
|
||||
prompt: string;
|
||||
history: ChatMessage[];
|
||||
systemInstruction?: string;
|
||||
personaId?: string;
|
||||
useTools?: boolean;
|
||||
}
|
||||
|
||||
const AETHEX_TOOLS: FunctionDeclaration[] = [
|
||||
{
|
||||
name: "get_account_balance",
|
||||
description: "Retrieves the current AETH balance for a given wallet address.",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
address: {
|
||||
type: Type.STRING,
|
||||
description: "The 42-character hexadecimal Aethex wallet address.",
|
||||
},
|
||||
},
|
||||
required: ["address"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_transaction_details",
|
||||
description: "Fetches detailed information about a specific transaction.",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
tx_hash: {
|
||||
type: Type.STRING,
|
||||
description: "The 66-character hexadecimal transaction hash.",
|
||||
},
|
||||
},
|
||||
required: ["tx_hash"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check_domain_availability",
|
||||
description: "Checks if a .aethex domain name is available for registration.",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
domain: {
|
||||
type: Type.STRING,
|
||||
description: "The domain name to check (without .aethex suffix)",
|
||||
},
|
||||
},
|
||||
required: ["domain"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const executeTool = (name: string, args: Record<string, unknown>): Record<string, unknown> => {
|
||||
console.log(`[AI Tool] Executing: ${name}`, args);
|
||||
|
||||
switch (name) {
|
||||
case "get_account_balance": {
|
||||
const balance = (Math.random() * 1000000).toFixed(2);
|
||||
return {
|
||||
balance: `${balance} AETH`,
|
||||
address: args.address,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
case "get_transaction_details": {
|
||||
const txHash = args.tx_hash as string;
|
||||
return {
|
||||
hash: txHash,
|
||||
from: txHash?.slice(0, 10) + "...",
|
||||
to: "0x" + [...Array(40)].map(() => Math.floor(Math.random() * 16).toString(16)).join(""),
|
||||
value: `${(Math.random() * 100).toFixed(2)} AETH`,
|
||||
status: Math.random() > 0.1 ? "Success" : "Failed",
|
||||
blockNumber: Math.floor(Math.random() * 1000000),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
case "check_domain_availability": {
|
||||
const domain = args.domain as string;
|
||||
const isAvailable = Math.random() > 0.3;
|
||||
return {
|
||||
domain: `${domain}.aethex`,
|
||||
available: isAvailable,
|
||||
price: isAvailable ? `${(50 + Math.random() * 50).toFixed(2)} AETH` : null,
|
||||
owner: isAvailable ? null : "0x" + [...Array(40)].map(() => Math.floor(Math.random() * 16).toString(16)).join(""),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { error: `Tool ${name} not found.` };
|
||||
}
|
||||
};
|
||||
|
||||
export default async function handler(req: Request, res: Response) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
if (!ai) {
|
||||
return res.status(503).json({
|
||||
error: "AI service not configured",
|
||||
message: "Please ensure the Gemini API key is set up."
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { prompt, history, systemInstruction, useTools } = req.body as ChatRequest;
|
||||
|
||||
if (!prompt) {
|
||||
return res.status(400).json({ error: "Prompt is required" });
|
||||
}
|
||||
|
||||
const tools = useTools ? AETHEX_TOOLS : undefined;
|
||||
|
||||
const chat: Chat = ai.chats.create({
|
||||
model: "gemini-2.5-flash",
|
||||
config: {
|
||||
tools: tools && tools.length > 0 ? [{ functionDeclarations: tools }] : undefined,
|
||||
systemInstruction: systemInstruction,
|
||||
},
|
||||
history: (history || []).map((msg) => ({
|
||||
role: msg.role,
|
||||
parts: [{ text: msg.content }],
|
||||
})),
|
||||
});
|
||||
|
||||
let response = await chat.sendMessage({ message: prompt });
|
||||
let text = response.text;
|
||||
|
||||
if (response.functionCalls && response.functionCalls.length > 0) {
|
||||
const functionCalls = response.functionCalls;
|
||||
console.log("[AI] Model requested function calls:", functionCalls);
|
||||
|
||||
const functionResponseParts = functionCalls.map((fc) => {
|
||||
const result = executeTool(fc.name, fc.args as Record<string, unknown>);
|
||||
return {
|
||||
functionResponse: {
|
||||
name: fc.name,
|
||||
response: { result },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
console.log("[AI] Sending tool responses back to model");
|
||||
const result2 = await chat.sendMessage({ message: functionResponseParts });
|
||||
text = result2.text;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return res.json({ response: "I was unable to generate a response. Please try again." });
|
||||
}
|
||||
|
||||
return res.json({ response: text });
|
||||
} catch (error) {
|
||||
console.error("[AI] Chat error:", error);
|
||||
return res.status(500).json({
|
||||
error: "AI request failed",
|
||||
message: error instanceof Error ? error.message : "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
39
api/ai/title.ts
Normal file
39
api/ai/title.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { Request, Response } from "express";
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const GEMINI_API_KEY = process.env.AI_INTEGRATIONS_GEMINI_API_KEY || process.env.GEMINI_API_KEY || "";
|
||||
|
||||
const ai = GEMINI_API_KEY ? new GoogleGenAI({ apiKey: GEMINI_API_KEY }) : null;
|
||||
|
||||
export default async function handler(req: Request, res: Response) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
if (!ai) {
|
||||
return res.status(503).json({
|
||||
error: "AI service not configured",
|
||||
message: "Please ensure the Gemini API key is set up."
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { message } = req.body as { message: string };
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: "Message is required" });
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: `Generate a short, concise, and descriptive title (max 5 words) for a chat conversation that starts with this message: "${message}". Do not use quotes.`,
|
||||
});
|
||||
|
||||
const title = response.text?.trim() || message.slice(0, 30);
|
||||
return res.json({ title });
|
||||
} catch (error) {
|
||||
console.error("[AI] Title generation error:", error);
|
||||
const fallbackTitle = (req.body?.message || "").slice(0, 30) + "...";
|
||||
return res.json({ title: fallbackTitle });
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||
import { useArmTheme } from "@/contexts/ArmThemeContext";
|
||||
import ArmSwitcher from "./ArmSwitcher";
|
||||
import NotificationBell from "@/components/notifications/NotificationBell";
|
||||
import { AIChatButton } from "@/components/ai";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -1184,6 +1185,9 @@ export default function CodeLayout({ children, hideFooter }: LayoutProps) {
|
|||
{/* Supabase Configuration Status */}
|
||||
<SupabaseStatus />
|
||||
|
||||
{/* AI Chat Assistant */}
|
||||
<AIChatButton currentRealm={theme.arm} />
|
||||
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from {
|
||||
|
|
|
|||
270
client/components/ai/AIChat.tsx
Normal file
270
client/components/ai/AIChat.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { ChatMessage as ChatMessageType, Persona, ChatSession, UserTier } from '@/lib/ai/types';
|
||||
import { canAccessPersona } from '@/lib/ai/types';
|
||||
import { PERSONAS, getDefaultPersona } from '@/lib/ai/personas';
|
||||
import { runChat, generateTitle } from '@/lib/ai/gemini-service';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { PersonaSelector } from './PersonaSelector';
|
||||
import { getPersonaIcon, CloseIcon, TrashIcon, SparklesIcon, ChatIcon } from './Icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface AIChatProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialPersonaId?: string;
|
||||
currentRealm?: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'aethex-ai-sessions';
|
||||
|
||||
const getUserTier = (roles: string[]): UserTier => {
|
||||
if (roles.includes('council') || roles.includes('admin') || roles.includes('owner')) {
|
||||
return 'Council';
|
||||
}
|
||||
if (roles.includes('architect') || roles.includes('staff') || roles.includes('premium')) {
|
||||
return 'Architect';
|
||||
}
|
||||
return 'Free';
|
||||
};
|
||||
|
||||
export const AIChat: React.FC<AIChatProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialPersonaId,
|
||||
currentRealm
|
||||
}) => {
|
||||
const { user, roles } = useAuth();
|
||||
const userTier = getUserTier(roles);
|
||||
|
||||
const [currentPersona, setCurrentPersona] = useState<Persona>(() => {
|
||||
if (initialPersonaId) {
|
||||
return PERSONAS.find(p => p.id === initialPersonaId) || getDefaultPersona();
|
||||
}
|
||||
return getDefaultPersona();
|
||||
});
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessageType[]>([
|
||||
{ role: 'model', content: currentPersona.initialMessage, timestamp: Date.now() }
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const hasAccess = canAccessPersona(userTier, currentPersona.requiredTier);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
setSessions(JSON.parse(stored));
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessions.length > 0) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions.slice(0, 20)));
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handlePersonaChange = useCallback((persona: Persona) => {
|
||||
setCurrentPersona(persona);
|
||||
setMessages([
|
||||
{ role: 'model', content: persona.initialMessage, timestamp: Date.now() }
|
||||
]);
|
||||
setCurrentSessionId(null);
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = useCallback(async (content: string) => {
|
||||
if (!content.trim() || isLoading || !hasAccess) return;
|
||||
|
||||
const userMessage: ChatMessageType = {
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const history = messages.filter(m => m.role !== 'model' || messages.indexOf(m) > 0);
|
||||
|
||||
const response = await runChat(
|
||||
content,
|
||||
history,
|
||||
currentPersona.systemInstruction,
|
||||
currentPersona.tools
|
||||
);
|
||||
|
||||
const modelMessage: ChatMessageType = {
|
||||
role: 'model',
|
||||
content: response,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, modelMessage]);
|
||||
|
||||
if (!currentSessionId && messages.length === 1) {
|
||||
const title = await generateTitle(content);
|
||||
const newSession: ChatSession = {
|
||||
id: crypto.randomUUID(),
|
||||
personaId: currentPersona.id,
|
||||
title,
|
||||
messages: [...messages, userMessage, modelMessage],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
setSessions(prev => [newSession, ...prev]);
|
||||
setCurrentSessionId(newSession.id);
|
||||
} else if (currentSessionId) {
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === currentSessionId
|
||||
? { ...s, messages: [...messages, userMessage, modelMessage], timestamp: Date.now() }
|
||||
: s
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AIChat] Error:', error);
|
||||
const errorMessage: ChatMessageType = {
|
||||
role: 'model',
|
||||
content: "I encountered an error processing your request. Please try again.",
|
||||
timestamp: Date.now()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [messages, isLoading, hasAccess, currentPersona, currentSessionId]);
|
||||
|
||||
const handleClearChat = useCallback(() => {
|
||||
setMessages([
|
||||
{ role: 'model', content: currentPersona.initialMessage, timestamp: Date.now() }
|
||||
]);
|
||||
setCurrentSessionId(null);
|
||||
}, [currentPersona]);
|
||||
|
||||
const Icon = getPersonaIcon(currentPersona.icon);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed bottom-4 right-4 md:bottom-6 md:right-6 w-[calc(100vw-2rem)] md:w-[450px] h-[600px] max-h-[80vh] bg-background border border-border rounded-2xl shadow-2xl z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className={`flex items-center justify-between p-4 border-b border-border bg-gradient-to-r ${currentPersona.theme.gradient} bg-opacity-10`}>
|
||||
<PersonaSelector
|
||||
currentPersona={currentPersona}
|
||||
onSelectPersona={handlePersonaChange}
|
||||
userTier={userTier}
|
||||
currentRealm={currentRealm}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 1 && !isLoading && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClearChat}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
title="Clear chat"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4 pb-4">
|
||||
{messages.map((msg, index) => (
|
||||
<ChatMessage key={index} message={msg} persona={currentPersona} />
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-full bg-gradient-to-tr ${currentPersona.theme.avatar} flex items-center justify-center flex-shrink-0 shadow-lg opacity-80`}>
|
||||
<div className="w-2 h-2 bg-white/50 rounded-full animate-ping" />
|
||||
</div>
|
||||
<div className="bg-card rounded-2xl rounded-tl-none p-4 border border-border">
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full animate-bounce [animation-delay:-0.3s] bg-muted-foreground"></div>
|
||||
<div className="w-2 h-2 rounded-full animate-bounce [animation-delay:-0.15s] bg-muted-foreground"></div>
|
||||
<div className="w-2 h-2 rounded-full animate-bounce bg-muted-foreground"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-4 border-t border-border bg-card/50">
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
persona={currentPersona}
|
||||
isLocked={!hasAccess}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground text-center mt-2">
|
||||
{user ? `Signed in as ${user.email}` : 'Sign in for personalized experience'} · {userTier} Tier
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIChatButton: React.FC<{ currentRealm?: string }> = ({ currentRealm }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-6 w-14 h-14 rounded-full bg-gradient-to-tr from-cyan-500 to-purple-600 shadow-lg flex items-center justify-center z-30 hover:scale-110 transition-transform"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<SparklesIcon className="w-6 h-6 text-white" />
|
||||
</motion.button>
|
||||
<AIChat
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
currentRealm={currentRealm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIChat;
|
||||
86
client/components/ai/ChatInput.tsx
Normal file
86
client/components/ai/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { Persona } from '@/lib/ai/types';
|
||||
import { SendIcon } from './Icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (message: string) => void;
|
||||
isLoading: boolean;
|
||||
persona: Persona;
|
||||
isLocked?: boolean;
|
||||
onUnlock?: () => void;
|
||||
}
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onSendMessage,
|
||||
isLoading,
|
||||
persona,
|
||||
isLocked,
|
||||
onUnlock
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (input.trim() && !isLoading && !isLocked) {
|
||||
onSendMessage(input.trim());
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
if (isLocked) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-4 bg-card/50 rounded-xl border border-border">
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
Upgrade to access {persona.name}
|
||||
</p>
|
||||
<Button
|
||||
onClick={onUnlock}
|
||||
className={persona.theme.button}
|
||||
>
|
||||
Unlock {persona.requiredTier} Tier
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="flex items-end gap-2 bg-card/80 backdrop-blur-sm rounded-xl border border-border p-2 focus-within:border-primary/50 transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask ${persona.name}...`}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent border-none outline-none resize-none text-foreground placeholder-muted-foreground text-sm md:text-base min-h-[40px] max-h-[150px] py-2 px-2"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isLoading}
|
||||
size="icon"
|
||||
className={`flex-shrink-0 ${persona.theme.button} disabled:opacity-50`}
|
||||
>
|
||||
<SendIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
130
client/components/ai/ChatMessage.tsx
Normal file
130
client/components/ai/ChatMessage.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
import type { ChatMessage as ChatMessageType, Persona } from '@/lib/ai/types';
|
||||
import { getPersonaIcon, UserIcon } from './Icons';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageType;
|
||||
persona: Persona;
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, persona }) => {
|
||||
const isUser = message.role === 'user';
|
||||
const Icon = getPersonaIcon(persona.icon);
|
||||
|
||||
const formatInlineCode = (text: string, keyPrefix: string, isUserMsg: boolean) => {
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
const parts = text.split(codeRegex);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (i % 2 === 1) {
|
||||
const codeClass = isUserMsg
|
||||
? "bg-black/20 text-white px-1.5 py-0.5 rounded text-sm font-mono"
|
||||
: "bg-gray-900/50 text-cyan-300 px-1.5 py-0.5 rounded text-sm font-mono border border-gray-700/50";
|
||||
return <code key={`${keyPrefix}-code-${i}`} className={codeClass}>{part}</code>;
|
||||
}
|
||||
|
||||
const boldRegex = /\*\*(.*?)\*\*/g;
|
||||
const boldParts = part.split(boldRegex);
|
||||
|
||||
return boldParts.map((bPart, bI) => {
|
||||
if (bI % 2 === 1) {
|
||||
return <strong key={`${keyPrefix}-bold-${i}-${bI}`} className="font-bold">{bPart}</strong>;
|
||||
}
|
||||
|
||||
const italicRegex = /\*([^\*]+)\*/g;
|
||||
const italicParts = bPart.split(italicRegex);
|
||||
|
||||
return italicParts.map((iPart, iI) => {
|
||||
if (iI % 2 === 1) {
|
||||
return <em key={`${keyPrefix}-italic-${i}-${bI}-${iI}`} className="italic opacity-90">{iPart}</em>;
|
||||
}
|
||||
return <span key={`${keyPrefix}-text-${i}-${bI}-${iI}`}>{iPart}</span>;
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const formatContent = (content: string, isUserMsg: boolean) => {
|
||||
const codeBlockRegex = /```([\s\S]*?)```/g;
|
||||
const parts = content.split(codeBlockRegex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
const preClass = isUserMsg
|
||||
? "bg-black/20 p-3 rounded-md overflow-x-auto my-2 text-white/90"
|
||||
: "bg-gray-950 p-3 rounded-md overflow-x-auto my-2 border border-gray-800";
|
||||
const codeClass = isUserMsg
|
||||
? "text-sm font-mono"
|
||||
: `text-sm font-mono ${persona.theme.primary}`;
|
||||
|
||||
return (
|
||||
<pre key={index} className={preClass}>
|
||||
<code className={codeClass}>{part.trim()}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = part.split('\n');
|
||||
return (
|
||||
<div key={index} className="whitespace-pre-wrap leading-relaxed">
|
||||
{lines.map((line, lineIdx) => {
|
||||
const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s+(.+)/);
|
||||
|
||||
if (listMatch) {
|
||||
const [, , marker, text] = listMatch;
|
||||
const isOrdered = /^\d+\./.test(marker);
|
||||
return (
|
||||
<div key={lineIdx} className="flex items-start gap-2 ml-2 mb-1">
|
||||
<span className={`mt-1 text-xs opacity-70 flex-shrink-0 ${isOrdered ? '' : 'text-[8px] pt-1'}`}>
|
||||
{isOrdered ? marker : '●'}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 break-words">
|
||||
{formatInlineCode(text, `${index}-${lineIdx}`, isUserMsg)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
return <div key={lineIdx} className="h-2" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={lineIdx} className="break-words min-w-0">
|
||||
{formatInlineCode(line, `${index}-${lineIdx}`, isUserMsg)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end items-start gap-3">
|
||||
<div className="bg-primary/90 rounded-2xl rounded-tr-none p-3 md:p-4 max-w-[80%] shadow-lg">
|
||||
<div className="text-primary-foreground text-sm md:text-base">
|
||||
{formatContent(message.content, true)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center flex-shrink-0 border border-border">
|
||||
<UserIcon className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-full bg-gradient-to-tr ${persona.theme.avatar} flex items-center justify-center flex-shrink-0 shadow-lg`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="bg-card rounded-2xl rounded-tl-none p-3 md:p-4 max-w-[80%] shadow-lg border border-border">
|
||||
<div className="text-card-foreground text-sm md:text-base">
|
||||
{formatContent(message.content, false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
client/components/ai/Icons.tsx
Normal file
64
client/components/ai/Icons.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Shield,
|
||||
Hammer,
|
||||
Building2,
|
||||
BookOpen,
|
||||
BarChart3,
|
||||
Music,
|
||||
ScrollText,
|
||||
Waves,
|
||||
DollarSign,
|
||||
Brain,
|
||||
Gamepad2,
|
||||
FlaskConical,
|
||||
User,
|
||||
Send,
|
||||
Trash2,
|
||||
X,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import type { PersonaIcon } from '@/lib/ai/types';
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AethexLogo: React.FC<IconProps> = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const getPersonaIcon = (iconName: PersonaIcon): React.FC<IconProps> => {
|
||||
switch (iconName) {
|
||||
case 'logo': return AethexLogo;
|
||||
case 'shield': return Shield;
|
||||
case 'hammer': return Hammer;
|
||||
case 'building': return Building2;
|
||||
case 'book': return BookOpen;
|
||||
case 'chart': return BarChart3;
|
||||
case 'music': return Music;
|
||||
case 'scroll': return ScrollText;
|
||||
case 'wave': return Waves;
|
||||
case 'money': return DollarSign;
|
||||
case 'brain': return Brain;
|
||||
case 'gamepad': return Gamepad2;
|
||||
case 'flask': return FlaskConical;
|
||||
default: return AethexLogo;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
User as UserIcon,
|
||||
Send as SendIcon,
|
||||
Trash2 as TrashIcon,
|
||||
X as CloseIcon,
|
||||
MessageSquare as ChatIcon,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
Sparkles as SparklesIcon
|
||||
};
|
||||
104
client/components/ai/PersonaSelector.tsx
Normal file
104
client/components/ai/PersonaSelector.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React from 'react';
|
||||
import type { Persona, UserTier } from '@/lib/ai/types';
|
||||
import { canAccessPersona } from '@/lib/ai/types';
|
||||
import { PERSONAS, getPersonasByRealm } from '@/lib/ai/personas';
|
||||
import { getPersonaIcon, ChevronDownIcon } from './Icons';
|
||||
import { Lock } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface PersonaSelectorProps {
|
||||
currentPersona: Persona;
|
||||
onSelectPersona: (persona: Persona) => void;
|
||||
userTier: UserTier;
|
||||
currentRealm?: string;
|
||||
}
|
||||
|
||||
export const PersonaSelector: React.FC<PersonaSelectorProps> = ({
|
||||
currentPersona,
|
||||
onSelectPersona,
|
||||
userTier,
|
||||
currentRealm
|
||||
}) => {
|
||||
const CurrentIcon = getPersonaIcon(currentPersona.icon);
|
||||
|
||||
const realmPersonas = currentRealm ? getPersonasByRealm(currentRealm) : [];
|
||||
const otherPersonas = PERSONAS.filter(p => !currentRealm || p.realm !== currentRealm);
|
||||
|
||||
const renderPersonaItem = (persona: Persona) => {
|
||||
const Icon = getPersonaIcon(persona.icon);
|
||||
const hasAccess = canAccessPersona(userTier, persona.requiredTier);
|
||||
const isSelected = persona.id === currentPersona.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={persona.id}
|
||||
onClick={() => hasAccess && onSelectPersona(persona)}
|
||||
disabled={!hasAccess}
|
||||
className={`flex items-center gap-3 p-3 cursor-pointer ${
|
||||
isSelected ? 'bg-primary/10' : ''
|
||||
} ${!hasAccess ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full bg-gradient-to-tr ${persona.theme.avatar} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{persona.name}</span>
|
||||
{!hasAccess && <Lock className="w-3 h-3 text-muted-foreground" />}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{persona.description}</p>
|
||||
</div>
|
||||
{persona.requiredTier !== 'Free' && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
persona.requiredTier === 'Council'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}>
|
||||
{persona.requiredTier}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors w-full">
|
||||
<div className={`w-10 h-10 rounded-full bg-gradient-to-tr ${currentPersona.theme.avatar} flex items-center justify-center shadow-lg`}>
|
||||
<CurrentIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<h3 className={`font-semibold text-sm bg-gradient-to-r ${currentPersona.theme.gradient} bg-clip-text text-transparent`}>
|
||||
{currentPersona.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">{currentPersona.description}</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72 max-h-[400px] overflow-y-auto">
|
||||
{realmPersonas.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Suggested for this realm
|
||||
</DropdownMenuLabel>
|
||||
{realmPersonas.map(renderPersonaItem)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
All Agents
|
||||
</DropdownMenuLabel>
|
||||
{otherPersonas.map(renderPersonaItem)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
5
client/components/ai/index.ts
Normal file
5
client/components/ai/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { AIChat, AIChatButton } from './AIChat';
|
||||
export { ChatMessage } from './ChatMessage';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { PersonaSelector } from './PersonaSelector';
|
||||
export * from './Icons';
|
||||
71
client/lib/ai/gemini-service.ts
Normal file
71
client/lib/ai/gemini-service.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { ChatMessage } from './types';
|
||||
import type { FunctionDeclaration } from '@google/genai';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '';
|
||||
|
||||
export const generateTitle = async (userMessage: string): Promise<string> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/ai/title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: userMessage }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate title');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.title || userMessage.slice(0, 30);
|
||||
} catch (error) {
|
||||
console.error("[AI] Error generating title:", error);
|
||||
return userMessage.slice(0, 30) + (userMessage.length > 30 ? '...' : '');
|
||||
}
|
||||
};
|
||||
|
||||
export const runChat = async (
|
||||
prompt: string,
|
||||
history: ChatMessage[],
|
||||
systemInstruction?: string,
|
||||
tools?: FunctionDeclaration[]
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
history,
|
||||
systemInstruction,
|
||||
useTools: tools && tools.length > 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'AI request failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.response || "I was unable to generate a response. Please try again.";
|
||||
} catch (error) {
|
||||
console.error("[AI] Chat error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const streamChat = async (
|
||||
prompt: string,
|
||||
history: ChatMessage[],
|
||||
systemInstruction?: string,
|
||||
onChunk?: (chunk: string) => void
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const result = await runChat(prompt, history, systemInstruction);
|
||||
onChunk?.(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[AI] Stream chat error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
443
client/lib/ai/personas.ts
Normal file
443
client/lib/ai/personas.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import { Type } from '@google/genai';
|
||||
import type { Persona } from './types';
|
||||
import type { FunctionDeclaration } from '@google/genai';
|
||||
|
||||
export const AETHEX_TOOLS: FunctionDeclaration[] = [
|
||||
{
|
||||
name: 'get_account_balance',
|
||||
description: "Retrieves the current AETH (the native token of the Aethex network) balance for a given wallet address.",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
address: {
|
||||
type: Type.STRING,
|
||||
description: "The 42-character hexadecimal Aethex wallet address, starting with '0x'."
|
||||
}
|
||||
},
|
||||
required: ['address']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_transaction_details',
|
||||
description: "Fetches detailed information about a specific transaction on the Aethex blockchain, given its unique transaction hash.",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
tx_hash: {
|
||||
type: Type.STRING,
|
||||
description: "The 66-character hexadecimal transaction hash, starting with '0x'."
|
||||
}
|
||||
},
|
||||
required: ['tx_hash']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'check_domain_availability',
|
||||
description: "Checks if a .aethex domain name is available for registration.",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
domain: {
|
||||
type: Type.STRING,
|
||||
description: "The domain name to check (without .aethex suffix)"
|
||||
}
|
||||
},
|
||||
required: ['domain']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const PERSONAS: Persona[] = [
|
||||
{
|
||||
id: 'network_agent',
|
||||
name: 'Network Agent',
|
||||
description: 'AeThex ecosystem and on-chain data assistant.',
|
||||
systemInstruction: `You are the AeThex Intelligent Agent. Answer questions about the AeThex ecosystem and retrieve on-chain data using the provided tools. You can check wallet balances, transaction details, and domain availability.`,
|
||||
initialMessage: "Hello! I am the AeThex Intelligent Agent. I can answer questions about the AeThex ecosystem, check wallet balances, and verify domain availability. How can I assist you today?",
|
||||
tools: AETHEX_TOOLS,
|
||||
icon: 'logo',
|
||||
theme: {
|
||||
primary: 'text-cyan-400',
|
||||
gradient: 'from-cyan-400 to-purple-500',
|
||||
avatar: 'from-cyan-500 to-purple-600',
|
||||
button: 'bg-purple-600 hover:bg-purple-700'
|
||||
},
|
||||
capabilities: [
|
||||
"Real-time AeThex blockchain data retrieval",
|
||||
"Wallet balance checks (Native AETH)",
|
||||
"Transaction status verification",
|
||||
"Domain availability checking",
|
||||
"General ecosystem navigation assistance"
|
||||
],
|
||||
limitations: [
|
||||
"Read-only access (cannot execute transactions)",
|
||||
"Cannot access private keys or sign messages"
|
||||
],
|
||||
requiredTier: 'Free',
|
||||
realm: 'nexus'
|
||||
},
|
||||
{
|
||||
id: 'ethics_sentinel',
|
||||
name: 'Ethics Sentinel',
|
||||
description: 'Audits product proposals against the Axiom AI Ethics Policy.',
|
||||
systemInstruction: `You are the "Sentinel" for the AeThex Foundation's Independent Ethics Council. Your job is to audit product proposals against the "Axiom AI Ethics Policy."
|
||||
|
||||
User Input: A product description or technical specification.
|
||||
|
||||
Analysis Framework:
|
||||
1. **Data Sovereignty:** Does this respect user ownership? (Flag if PII is used for advertising or tracking without explicit user consent).
|
||||
2. **Transparency:** Is the model explainable? (Flag "black box" logic). If a proposal describes a model's logic as "black box" or lacks explainability details, explicitly state this as a risk in the report.
|
||||
3. **Dual-Use Risk:** Could this be weaponized or used for surveillance?
|
||||
|
||||
Output:
|
||||
* **Status:** [GREEN / YELLOW / RED]
|
||||
* **Risk Report:** Bullet points of potential violations.
|
||||
* **Recommendation:** Specific changes needed to pass the human council review.`,
|
||||
initialMessage: "I am the Sentinel. Submit your product proposal or technical specification for an Ethics Council audit. I will analyze it for Data Sovereignty, Transparency, and Dual-Use Risks.",
|
||||
tools: [],
|
||||
icon: 'shield',
|
||||
theme: {
|
||||
primary: 'text-emerald-400',
|
||||
gradient: 'from-emerald-400 to-teal-300',
|
||||
avatar: 'from-emerald-500 to-teal-600',
|
||||
button: 'bg-emerald-600 hover:bg-emerald-700'
|
||||
},
|
||||
capabilities: [
|
||||
"Axiom AI Ethics Policy auditing",
|
||||
"Data sovereignty & PII usage analysis",
|
||||
"Dual-use risk assessment (surveillance/weaponization)",
|
||||
"Transparency & explainability reporting"
|
||||
],
|
||||
limitations: [
|
||||
"Cannot enforce legal compliance or regulations",
|
||||
"Analysis relies solely on user-provided specifications",
|
||||
"Does not audit actual code, only concepts"
|
||||
],
|
||||
requiredTier: 'Free',
|
||||
realm: 'foundation'
|
||||
},
|
||||
{
|
||||
id: 'forge_master',
|
||||
name: 'Forge Master',
|
||||
description: 'Enforces "Ruthless Simplicity" for game ideas.',
|
||||
systemInstruction: `You are the "Forge Master" for the AeThex GameForge. Your mission is to enforce "Ruthless Simplicity."
|
||||
|
||||
The User will pitch a game idea.
|
||||
Your Job:
|
||||
1. Analyze the scope. If it cannot be built by 4 people in 4 weeks, CUT FEATURES aggressively.
|
||||
2. Output the "Scope Anchor" (KND-001) in this exact JSON format:
|
||||
{
|
||||
"title": "Game Title",
|
||||
"logline": "One sentence summary.",
|
||||
"core_mechanic": "The ONE thing the player does.",
|
||||
"win_condition": "How they win.",
|
||||
"fail_condition": "How they lose.",
|
||||
"anti_scope": "List of 3 features you removed because they were too complex."
|
||||
}
|
||||
3. After the JSON, provide a brief code snippet (C# Unity style or Python) demonstrating the simplified Core Mechanic logic.
|
||||
|
||||
Tone: Stern but encouraging. Focus on "shipping," not "dreaming."`,
|
||||
initialMessage: "I am the Forge Master. Pitch me your game idea. If it cannot be built by 4 people in 4 weeks, I will cut it down. Ruthless Simplicity is the only path to shipping.",
|
||||
tools: [],
|
||||
icon: 'hammer',
|
||||
theme: {
|
||||
primary: 'text-orange-500',
|
||||
gradient: 'from-orange-500 to-red-500',
|
||||
avatar: 'from-orange-600 to-red-600',
|
||||
button: 'bg-orange-600 hover:bg-orange-700'
|
||||
},
|
||||
capabilities: [
|
||||
"Scope management & feature cutting",
|
||||
"Game design document generation (JSON)",
|
||||
"Mechanic simplification for rapid prototyping",
|
||||
"Project timeline feasibility checks (4x4 rule)",
|
||||
"Core mechanic code prototyping"
|
||||
],
|
||||
limitations: [
|
||||
"Generates logic snippets, not full games",
|
||||
"May reject creative but complex ideas",
|
||||
"Tone is intentionally strict/stern"
|
||||
],
|
||||
requiredTier: 'Architect',
|
||||
realm: 'gameforge'
|
||||
},
|
||||
{
|
||||
id: 'sbs_architect',
|
||||
name: 'SBS Architect',
|
||||
description: 'Creates professional profiles for US Govt Small Business Search.',
|
||||
systemInstruction: `You are the "SBS Profile Architect," a specialized AI consultant for The AeThex Corp. Your goal is to help small business owners create professional, keyword-optimized profiles for the US Government's "Small Business Search" (SBS) platform.
|
||||
|
||||
User Input: The user will provide unstructured details about their business (Name, Location, What they sell, Who they serve).
|
||||
|
||||
Your Output Strategy:
|
||||
1. Tone: Professional, authoritative, and government-compliant (no fluff).
|
||||
2. Format:
|
||||
* **Core Capabilities:** A bulleted list of what they actually do, using industry-standard keywords.
|
||||
* **Differentiators:** A short paragraph explaining why they are better than competitors.
|
||||
* **Past Performance:** (If they provided any), formatted professionally.
|
||||
* **Keywords:** A comma-separated list of high-value tags for search.
|
||||
|
||||
Constraint: Do not hallucinate certifications (like 8(a) or HUBZone) if the user didn't mention them. Ask them to verify.`,
|
||||
initialMessage: "I am the SBS Profile Architect. Tell me about your business (Name, Location, Offerings, Customers), and I will structure a compliant, optimized profile for the Small Business Search platform.",
|
||||
tools: [],
|
||||
icon: 'building',
|
||||
theme: {
|
||||
primary: 'text-blue-400',
|
||||
gradient: 'from-blue-400 to-indigo-300',
|
||||
avatar: 'from-blue-500 to-indigo-600',
|
||||
button: 'bg-blue-600 hover:bg-blue-700'
|
||||
},
|
||||
capabilities: [
|
||||
"US Govt. contracting profile creation",
|
||||
"NAICS/PSC keyword optimization",
|
||||
"Capability statement formatting",
|
||||
"Professional & compliant tone adjustment"
|
||||
],
|
||||
limitations: [
|
||||
"Does not register entities in SAM.gov",
|
||||
"Cannot verify official certifications (8(a), HUBZone)",
|
||||
"Does not guarantee contract awards"
|
||||
],
|
||||
requiredTier: 'Architect',
|
||||
realm: 'corp'
|
||||
},
|
||||
{
|
||||
id: 'curriculum_weaver',
|
||||
name: 'Curriculum Weaver',
|
||||
description: 'EdTech consultant turning game mechanics into lesson plans.',
|
||||
systemInstruction: `You are the "Curriculum Weaver," an expert K-12 EdTech consultant for AeThex. Your goal is to turn game mechanics into educational lesson plans.
|
||||
|
||||
User Input: A specific game mechanic or asset (e.g., "Lua variable loop" or "Physics hinge constraint").
|
||||
|
||||
Your Job:
|
||||
1. Identify the core STEM concept (e.g., Algebra, Physics, Logic).
|
||||
2. Generate a 45-minute Lesson Plan structure:
|
||||
* **Objective:** What will the student learn?
|
||||
* **Activity:** How they play/build it.
|
||||
* **Real-World Connection:** How this applies to engineering/math.
|
||||
* **Assessment:** A simple quiz question.
|
||||
|
||||
Constraint: Keep language appropriate for a classroom setting.`,
|
||||
initialMessage: "I am the Curriculum Weaver. Tell me a game mechanic (e.g., 'Lua Loops', 'Physics Constraints'), and I will weave it into a STEM lesson plan for your classroom.",
|
||||
tools: [],
|
||||
icon: 'book',
|
||||
theme: {
|
||||
primary: 'text-amber-400',
|
||||
gradient: 'from-amber-400 to-orange-500',
|
||||
avatar: 'from-amber-500 to-orange-600',
|
||||
button: 'bg-amber-600 hover:bg-amber-700'
|
||||
},
|
||||
capabilities: [
|
||||
"STEM lesson plan generation (K-12)",
|
||||
"Game mechanic explanation",
|
||||
"Real-world engineering correlations",
|
||||
"Classroom-appropriate assessment creation"
|
||||
],
|
||||
limitations: [
|
||||
"Does not provide direct technical support for bugs",
|
||||
"Lesson plans are theoretical structures",
|
||||
"Cannot grade student work"
|
||||
],
|
||||
requiredTier: 'Architect',
|
||||
realm: 'labs'
|
||||
},
|
||||
{
|
||||
id: 'quantum_leap',
|
||||
name: 'QuantumLeap',
|
||||
description: 'Elite Business Intelligence Analyst.',
|
||||
systemInstruction: `You are "QuantumLeap," an elite Business Intelligence Analyst.
|
||||
|
||||
User Input: A set of raw data points or a JSON snippet (e.g., Sales Q1: $100k, Q2: $150k; Churn: 5%).
|
||||
|
||||
Your Job:
|
||||
1. Analyze the trend (Up, Down, Stable).
|
||||
2. Generate a "CEO Summary" paragraph. Use professional, confident corporate language.
|
||||
3. Highlight the single most critical metric ("The Key Driver").
|
||||
4. Suggest one strategic action based on the data.
|
||||
|
||||
Tone: Concise, data-driven, executive. No fluff.`,
|
||||
initialMessage: "I am QuantumLeap, your Chief Data Officer. Feed me your raw metrics or messy spreadsheets, and I will extract the Key Drivers and strategic actions.",
|
||||
tools: [],
|
||||
icon: 'chart',
|
||||
theme: {
|
||||
primary: 'text-fuchsia-400',
|
||||
gradient: 'from-fuchsia-400 to-purple-600',
|
||||
avatar: 'from-fuchsia-500 to-purple-700',
|
||||
button: 'bg-fuchsia-700 hover:bg-fuchsia-800'
|
||||
},
|
||||
capabilities: [
|
||||
"Data trend analysis & forecasting",
|
||||
"Executive summary generation (CEO-level)",
|
||||
"Key Performance Indicator (KPI) extraction",
|
||||
"Strategic actionable insights"
|
||||
],
|
||||
limitations: [
|
||||
"Cannot directly connect to live databases",
|
||||
"Analysis depends on user-provided data accuracy",
|
||||
"No financial liability for advice"
|
||||
],
|
||||
requiredTier: 'Architect',
|
||||
realm: 'corp'
|
||||
},
|
||||
{
|
||||
id: 'ethos_producer',
|
||||
name: 'Ethos Producer',
|
||||
description: 'Generates technical audio briefs for composers.',
|
||||
systemInstruction: `You are the "Ethos Producer." You translate visual game descriptions into technical music specifications.
|
||||
|
||||
User Input: A description of a game scene or mood (e.g., "Sad rain scene in a cyberpunk slum").
|
||||
|
||||
Your Job: Output a structured "Audio Brief" for a composer:
|
||||
* **Genre:** (e.g., Synthwave, Orchestral, Lo-fi)
|
||||
* **BPM:** (Suggested tempo range)
|
||||
* **Key/Scale:** (e.g., D Minor, Phrygian Mode)
|
||||
* **Instrumentation:** (List 3 specific instruments)
|
||||
* **Reference:** (Suggest a "sound-alike" vibe).`,
|
||||
initialMessage: "I am the Ethos Producer. Describe your game scene or mood, and I will generate a technical Audio Brief for your composers.",
|
||||
tools: [],
|
||||
icon: 'music',
|
||||
theme: {
|
||||
primary: 'text-rose-400',
|
||||
gradient: 'from-rose-400 to-pink-600',
|
||||
avatar: 'from-rose-500 to-pink-700',
|
||||
button: 'bg-rose-600 hover:bg-rose-700'
|
||||
},
|
||||
capabilities: [
|
||||
"Technical audio specification generation",
|
||||
"Music theory application (Key, BPM, Scale)",
|
||||
"Instrumentation selection",
|
||||
"Genre and vibe matching"
|
||||
],
|
||||
limitations: [
|
||||
"Does not generate actual audio files",
|
||||
"Cannot listen to existing audio files",
|
||||
"Subjective artistic interpretation"
|
||||
],
|
||||
requiredTier: 'Council',
|
||||
realm: 'gameforge'
|
||||
},
|
||||
{
|
||||
id: 'aethex_archivist',
|
||||
name: 'AeThex Archivist',
|
||||
description: 'Generates procedural lore for the Neon-Grid universe.',
|
||||
systemInstruction: `You are the "AeThex Archivist." You exist in the year 3042. Your job is to generate procedural lore for a Cyberpunk/Sci-Fi game universe called "Neon-Grid."
|
||||
|
||||
User Input: A simple noun (e.g., "Sword", "Rat", "Coffee Shop").
|
||||
|
||||
Your Job:
|
||||
1. Give it a cool sci-fi name (e.g., "Plasma-Edge Katana").
|
||||
2. Write a 2-sentence "Flavor Text" description that sounds gritty and futuristic.
|
||||
3. Assign it RPG stats (Rarity, Damage/Effect, Weight).
|
||||
4. Add a "Hidden Secret" or rumor about the item.
|
||||
|
||||
Tone: Dark, mysterious, neon-soaked.`,
|
||||
initialMessage: "I am the Archivist (Year 3042). Give me a noun, and I will uncover its history in the Neon-Grid.",
|
||||
tools: [],
|
||||
icon: 'scroll',
|
||||
theme: {
|
||||
primary: 'text-cyan-300',
|
||||
gradient: 'from-cyan-300 to-blue-500',
|
||||
avatar: 'from-cyan-400 to-blue-600',
|
||||
button: 'bg-cyan-600 hover:bg-cyan-700'
|
||||
},
|
||||
capabilities: [
|
||||
"Procedural sci-fi lore generation",
|
||||
"RPG item stat assignment",
|
||||
"World-building flavor text",
|
||||
"Creative writing helper"
|
||||
],
|
||||
limitations: [
|
||||
"Lore is fictional and procedurally generated",
|
||||
"Stats are generic and need game balancing",
|
||||
"Restricted to Cyberpunk/Sci-Fi themes"
|
||||
],
|
||||
requiredTier: 'Council',
|
||||
realm: 'gameforge'
|
||||
},
|
||||
{
|
||||
id: 'vapor',
|
||||
name: 'Vapor',
|
||||
description: 'AI songwriter for Retrowave and Synthwave lyrics.',
|
||||
systemInstruction: `You are "Vapor," a moody AI songwriter for the AeThex | Ethos guild. You write lyrics for Retrowave and Synthwave tracks.
|
||||
|
||||
User Input: A mood or a scene (e.g., "Driving a Ferrari at midnight," "Heartbreak in Tokyo").
|
||||
|
||||
Your Job:
|
||||
1. Write 2 verses and a Chorus.
|
||||
2. Style: Nostalgic, 1980s, emotional, cinematic. Use words like "neon," "horizon," "analog," "static," "midnight."
|
||||
3. Structure the output with [Verse 1], [Chorus], [Outro].`,
|
||||
initialMessage: "I am Vapor. Give me a mood or a midnight memory, and I will write the lyrics for your next Synthwave track.",
|
||||
tools: [],
|
||||
icon: 'wave',
|
||||
theme: {
|
||||
primary: 'text-pink-300',
|
||||
gradient: 'from-pink-300 to-indigo-400',
|
||||
avatar: 'from-pink-400 to-indigo-500',
|
||||
button: 'bg-pink-500 hover:bg-pink-600'
|
||||
},
|
||||
capabilities: [
|
||||
"Songwriting & lyric generation",
|
||||
"Thematic mood setting (80s/Retrowave)",
|
||||
"Structure formatting (Verse/Chorus)",
|
||||
"Creative inspiration"
|
||||
],
|
||||
limitations: [
|
||||
"Does not compose melodies or sheet music",
|
||||
"Lyrics are text-only output",
|
||||
"Mood is locked to Retrowave aesthetics"
|
||||
],
|
||||
requiredTier: 'Architect',
|
||||
realm: 'labs'
|
||||
},
|
||||
{
|
||||
id: 'apex',
|
||||
name: 'Apex',
|
||||
description: 'Cynical Venture Capitalist that critiques startup ideas.',
|
||||
systemInstruction: `You are "Apex," a cynical, billionaire Silicon Valley Venture Capitalist. You have seen 1,000 pitch decks today and you hate all of them.
|
||||
|
||||
User Input: A startup idea (e.g., "Uber for dog walking," "AI that writes poetry").
|
||||
|
||||
Your Job:
|
||||
1. Roast the idea. Tell me why it will fail. Be specific, sarcastic, and brutal.
|
||||
2. Use VC buzzwords ironically (e.g., "pivot," "scale," "TAM," "unit economics").
|
||||
3. Give it a "Fundability Score" out of 10 (be harsh).
|
||||
4. At the end, provide ONE constructive piece of advice to actually make it viable.`,
|
||||
initialMessage: "I am Apex. I have $100M to deploy and zero patience. Pitch me your 'unicorn' idea so I can tell you why it's going to zero.",
|
||||
tools: [],
|
||||
icon: 'money',
|
||||
theme: {
|
||||
primary: 'text-red-500',
|
||||
gradient: 'from-red-500 to-yellow-600',
|
||||
avatar: 'from-red-600 to-yellow-700',
|
||||
button: 'bg-red-700 hover:bg-red-800'
|
||||
},
|
||||
capabilities: [
|
||||
"Startup idea validation (Roast mode)",
|
||||
"Critical business logic analysis",
|
||||
"Industry jargon & buzzword deployment",
|
||||
"Fundability scoring",
|
||||
"Constructive advice (hidden at the end)"
|
||||
],
|
||||
limitations: [
|
||||
"Extremely harsh/sarcastic tone (by design)",
|
||||
"Advice is satirical/entertainment focused",
|
||||
"Does not actually invest money"
|
||||
],
|
||||
requiredTier: 'Architect',
|
||||
realm: 'corp'
|
||||
}
|
||||
];
|
||||
|
||||
export const getPersonasByRealm = (realm: string): Persona[] => {
|
||||
return PERSONAS.filter(p => p.realm === realm);
|
||||
};
|
||||
|
||||
export const getPersonasByTier = (tier: 'Free' | 'Architect' | 'Council'): Persona[] => {
|
||||
const tierOrder = { 'Free': 0, 'Architect': 1, 'Council': 2 };
|
||||
const userTierLevel = tierOrder[tier];
|
||||
return PERSONAS.filter(p => tierOrder[p.requiredTier] <= userTierLevel);
|
||||
};
|
||||
|
||||
export const getDefaultPersona = (): Persona => {
|
||||
return PERSONAS.find(p => p.id === 'network_agent') || PERSONAS[0];
|
||||
};
|
||||
64
client/lib/ai/types.ts
Normal file
64
client/lib/ai/types.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { FunctionDeclaration } from '@google/genai';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'model';
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface PersonaTheme {
|
||||
primary: string;
|
||||
gradient: string;
|
||||
avatar: string;
|
||||
button: string;
|
||||
}
|
||||
|
||||
export interface Persona {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
systemInstruction: string;
|
||||
initialMessage: string;
|
||||
tools?: FunctionDeclaration[];
|
||||
icon: PersonaIcon;
|
||||
theme: PersonaTheme;
|
||||
capabilities: string[];
|
||||
limitations: string[];
|
||||
requiredTier: 'Free' | 'Architect' | 'Council';
|
||||
realm?: string;
|
||||
}
|
||||
|
||||
export type PersonaIcon =
|
||||
| 'logo'
|
||||
| 'shield'
|
||||
| 'hammer'
|
||||
| 'building'
|
||||
| 'book'
|
||||
| 'chart'
|
||||
| 'music'
|
||||
| 'scroll'
|
||||
| 'wave'
|
||||
| 'money'
|
||||
| 'brain'
|
||||
| 'gamepad'
|
||||
| 'flask';
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
personaId: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type UserTier = 'Free' | 'Architect' | 'Council';
|
||||
|
||||
export const TIER_HIERARCHY: Record<UserTier, number> = {
|
||||
'Free': 0,
|
||||
'Architect': 1,
|
||||
'Council': 2,
|
||||
};
|
||||
|
||||
export const canAccessPersona = (userTier: UserTier, requiredTier: UserTier): boolean => {
|
||||
return TIER_HIERARCHY[userTier] >= TIER_HIERARCHY[requiredTier];
|
||||
};
|
||||
Loading…
Reference in a new issue