- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
1053 lines
57 KiB
PHP
1053 lines
57 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Sam AI - Intelligent Work Assistant</title>
|
|
|
|
<!-- Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
<!-- React & ReactDOM -->
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
|
|
|
<!-- Babel for JSX -->
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
|
|
<!-- Google GenAI SDK - ES Module로 로드 -->
|
|
<script type="module">
|
|
// Google GenAI SDK 동적 로드
|
|
const loadGenAI = async () => {
|
|
try {
|
|
const module = await import('https://aistudiocdn.com/@google/genai@^1.31.0');
|
|
window.GoogleGenAI = module.GoogleGenAI;
|
|
window.Modality = module.Modality;
|
|
window.FunctionDeclaration = module.FunctionDeclaration;
|
|
window.Type = module.Type;
|
|
window.GenAILoaded = true;
|
|
|
|
// 로드 완료 이벤트 발생
|
|
window.dispatchEvent(new Event('genai-loaded'));
|
|
} catch (error) {
|
|
console.error('Failed to load Google GenAI SDK:', error);
|
|
window.GenAILoaded = false;
|
|
}
|
|
};
|
|
loadGenAI();
|
|
</script>
|
|
|
|
<style>
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
}
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: #f1f1f1;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: #888;
|
|
border-radius: 4px;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #555;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-50 text-slate-900 overflow-hidden">
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef, useCallback } = React;
|
|
|
|
// Constants
|
|
const MOCK_FILES = [
|
|
{ id: '1', name: 'Q3_Financial_Report.xlsx', type: 'xlsx', date: '2023-10-24', size: '2.4 MB', tags: ['finance', 'report', 'q3'] },
|
|
{ id: '2', name: 'Project_Alpha_Overview.pptx', type: 'pptx', date: '2023-10-22', size: '15 MB', tags: ['project', 'alpha', 'presentation'] },
|
|
{ id: '3', name: 'Employee_Handbook_2024.pdf', type: 'pdf', date: '2023-11-01', size: '4.1 MB', tags: ['hr', 'policy'] },
|
|
{ id: '4', name: 'Marketing_Strategy_Draft.docx', type: 'docx', date: '2023-10-28', size: '1.2 MB', tags: ['marketing', 'draft'] },
|
|
{ id: '5', name: 'Engineering_Sync_Notes.docx', type: 'docx', date: '2023-11-05', size: '0.5 MB', tags: ['engineering', 'meeting'] },
|
|
{ id: '6', name: 'Q4_Projections.xlsx', type: 'xlsx', date: '2023-11-02', size: '1.8 MB', tags: ['finance', 'forecast'] },
|
|
{ id: '7', name: 'Client_List_APAC.xlsx', type: 'xlsx', date: '2023-09-15', size: '3.2 MB', tags: ['sales', 'apac'] },
|
|
{ id: '8', name: 'Logo_Assets.folder', type: 'folder', date: '2023-08-10', size: '--', tags: ['design', 'brand'] },
|
|
];
|
|
|
|
const MOCK_MENU = [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: 'LayoutDashboard' },
|
|
{ id: 'files', label: 'My Files', icon: 'FolderOpen' },
|
|
{ id: 'analytics', label: 'Analytics', icon: 'BarChart3' },
|
|
{ id: 'settings', label: 'Settings', icon: 'Settings' },
|
|
{ id: 'team', label: 'Team Directory', icon: 'Users' },
|
|
{ id: 'calendar', label: 'Calendar', icon: 'Calendar' },
|
|
];
|
|
|
|
const SYSTEM_INSTRUCTION = `You are "Sam", an intelligent and helpful corporate AI assistant.
|
|
Your goal is to help the user navigate the company dashboard and find work files efficiently.
|
|
You have access to tools that can control the UI.
|
|
|
|
1. If the user asks to see a specific page or menu (e.g., "Go to settings", "Show me analytics"), use the 'navigateToPage' tool.
|
|
2. If the user asks to find a file (e.g., "Find the financial report", "Search for marketing drafts"), use the 'searchDocuments' tool with their query.
|
|
3. Be concise, professional, but friendly.
|
|
4. You can speak Korean if the user speaks Korean. (한국어로 대답해 주세요).
|
|
5. If you perform an action, briefly confirm it verbally (e.g., "Opening Settings for you," or "Here are the files related to finance.").`;
|
|
|
|
const ConnectionStatus = {
|
|
DISCONNECTED: 'disconnected',
|
|
CONNECTING: 'connecting',
|
|
CONNECTED: 'connected',
|
|
ERROR: 'error',
|
|
};
|
|
|
|
// Icon Component
|
|
const Icon = ({ name, className = '' }) => {
|
|
const icons = {
|
|
LayoutDashboard: <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>,
|
|
FolderOpen: <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-3.25 7a2 2 0 0 1-1.8 1.18H7a2 2 0 0 1-2-2Z"/><path d="M3 7v10a2 2 0 0 0 2 2h2"/><path d="M3 7l2.6-2.6A2 2 0 0 1 7 3h7a2 2 0 0 1 2 2v2"/></svg>,
|
|
BarChart3: <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>,
|
|
Settings: <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>,
|
|
Users: <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
|
|
Calendar: <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>,
|
|
};
|
|
return icons[name] || null;
|
|
};
|
|
|
|
// Sidebar Component
|
|
const Sidebar = ({ menus, activeId, onMenuClick }) => {
|
|
return (
|
|
<div className="w-64 bg-slate-900 text-white h-screen flex flex-col border-r border-slate-800">
|
|
<div className="p-6 flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
|
|
<span className="font-bold text-lg">S</span>
|
|
</div>
|
|
<span className="text-xl font-bold tracking-tight">Sam AI</span>
|
|
</div>
|
|
|
|
<nav className="flex-1 px-4 py-6 space-y-2">
|
|
{menus.map((menu) => (
|
|
<div
|
|
key={menu.id}
|
|
onClick={() => onMenuClick(menu.id)}
|
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 cursor-pointer ${
|
|
menu.id === activeId
|
|
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-900/50'
|
|
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<Icon name={menu.icon} className="w-5 h-5" />
|
|
<span className="font-medium">{menu.label}</span>
|
|
{menu.id === activeId && (
|
|
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-white shadow-[0_0_8px_rgba(255,255,255,0.8)]"></div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="p-4 border-t border-slate-800">
|
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50">
|
|
<div className="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center text-xs">JD</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium">John Doe</span>
|
|
<span className="text-xs text-slate-400">Pro Member</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// FileList Component
|
|
const FileList = ({ files, filter }) => {
|
|
const filteredFiles = files.filter(f =>
|
|
f.name.toLowerCase().includes(filter.toLowerCase()) ||
|
|
f.tags.some(t => t.toLowerCase().includes(filter.toLowerCase()))
|
|
);
|
|
|
|
return (
|
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden flex flex-col h-full">
|
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-slate-800">Recent Files</h2>
|
|
{filter && (
|
|
<span className="px-2 py-1 bg-indigo-50 text-indigo-700 text-xs rounded-full font-medium">
|
|
Filtering by: "{filter}"
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
<table className="w-full text-left text-sm text-slate-600">
|
|
<thead className="text-xs uppercase bg-slate-50 text-slate-500 font-semibold">
|
|
<tr>
|
|
<th className="px-4 py-3 rounded-l-lg">Name</th>
|
|
<th className="px-4 py-3">Date</th>
|
|
<th className="px-4 py-3">Size</th>
|
|
<th className="px-4 py-3 rounded-r-lg">Tags</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredFiles.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={4} className="px-4 py-8 text-center text-slate-400">
|
|
No files found matching "{filter}"
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredFiles.map((file) => (
|
|
<tr key={file.id} className="hover:bg-slate-50 transition-colors border-b border-slate-50 last:border-0">
|
|
<td className="px-4 py-3 font-medium text-slate-900 flex items-center gap-2">
|
|
<span className={`w-8 h-8 rounded flex items-center justify-center text-xs font-bold ${
|
|
file.type === 'pdf' ? 'bg-red-100 text-red-600' :
|
|
file.type === 'xlsx' ? 'bg-green-100 text-green-600' :
|
|
file.type === 'docx' ? 'bg-blue-100 text-blue-600' :
|
|
file.type === 'pptx' ? 'bg-orange-100 text-orange-600' :
|
|
'bg-slate-100 text-slate-600'
|
|
}`}>
|
|
{file.type.toUpperCase().slice(0, 3)}
|
|
</span>
|
|
{file.name}
|
|
</td>
|
|
<td className="px-4 py-3">{file.date}</td>
|
|
<td className="px-4 py-3">{file.size}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex gap-1 flex-wrap">
|
|
{file.tags.map(tag => (
|
|
<span key={tag} className="px-2 py-0.5 bg-slate-100 rounded-full text-xs text-slate-500">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Visualizer Component
|
|
const Visualizer = ({ analyser, active }) => {
|
|
const canvasRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!canvasRef.current) return;
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
let animationId;
|
|
const bufferLength = analyser ? analyser.frequencyBinCount : 0;
|
|
const dataArray = new Uint8Array(bufferLength);
|
|
|
|
const draw = () => {
|
|
animationId = requestAnimationFrame(draw);
|
|
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
if (!active || !analyser) {
|
|
const time = Date.now() / 1000;
|
|
ctx.beginPath();
|
|
ctx.arc(width / 2, height / 2, 20 + Math.sin(time * 2) * 2, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'rgba(99, 102, 241, 0.5)';
|
|
ctx.fill();
|
|
return;
|
|
}
|
|
|
|
analyser.getByteFrequencyData(dataArray);
|
|
|
|
const barWidth = (width / bufferLength) * 2.5;
|
|
let barHeight;
|
|
let x = 0;
|
|
const centerX = width / 2;
|
|
|
|
for (let i = 0; i < bufferLength; i++) {
|
|
barHeight = dataArray[i] / 2;
|
|
|
|
const gradient = ctx.createLinearGradient(0, height / 2 - barHeight, 0, height / 2 + barHeight);
|
|
gradient.addColorStop(0, '#818cf8');
|
|
gradient.addColorStop(1, '#c084fc');
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(centerX + x, height / 2 - barHeight / 2, barWidth, barHeight);
|
|
ctx.fillRect(centerX - x - barWidth, height / 2 - barHeight / 2, barWidth, barHeight);
|
|
|
|
x += barWidth + 1;
|
|
}
|
|
};
|
|
|
|
draw();
|
|
|
|
return () => cancelAnimationFrame(animationId);
|
|
}, [analyser, active]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={300}
|
|
height={100}
|
|
className="w-full h-full"
|
|
/>
|
|
);
|
|
};
|
|
|
|
// Audio Utilities
|
|
const decode = (base64) => {
|
|
const binaryString = atob(base64);
|
|
const len = binaryString.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
};
|
|
|
|
const encode = (bytes) => {
|
|
let binary = '';
|
|
const len = bytes.byteLength;
|
|
for (let i = 0; i < len; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
};
|
|
|
|
const decodeAudioData = async (data, ctx, sampleRate = 24000, numChannels = 1) => {
|
|
const dataInt16 = new Int16Array(data.buffer);
|
|
const frameCount = dataInt16.length / numChannels;
|
|
const buffer = ctx.createBuffer(numChannels, frameCount, sampleRate);
|
|
|
|
for (let channel = 0; channel < numChannels; channel++) {
|
|
const channelData = buffer.getChannelData(channel);
|
|
for (let i = 0; i < frameCount; i++) {
|
|
channelData[i] = dataInt16[i * numChannels + channel] / 32768.0;
|
|
}
|
|
}
|
|
return buffer;
|
|
};
|
|
|
|
const createPcmBlob = (data) => {
|
|
const l = data.length;
|
|
const int16 = new Int16Array(l);
|
|
for (let i = 0; i < l; i++) {
|
|
const s = Math.max(-1, Math.min(1, data[i]));
|
|
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
|
}
|
|
return {
|
|
data: encode(new Uint8Array(int16.buffer)),
|
|
mimeType: 'audio/pcm;rate=16000',
|
|
};
|
|
};
|
|
|
|
// LiveManager Class
|
|
class LiveManager {
|
|
constructor(apiKey, toolHandler, onStatusChange, onAudioLevel) {
|
|
this.ai = null;
|
|
this.inputAudioContext = null;
|
|
this.outputAudioContext = null;
|
|
this.sessionPromise = null;
|
|
this.session = null;
|
|
this.nextStartTime = 0;
|
|
this.sources = new Set();
|
|
this.toolHandler = toolHandler;
|
|
this.onStatusChange = onStatusChange;
|
|
this.onAudioLevel = onAudioLevel;
|
|
this.outputAnalyser = null;
|
|
this.apiKey = apiKey;
|
|
this.isConnected = false;
|
|
this.scriptProcessor = null;
|
|
this.mediaStream = null;
|
|
this.debugLog = [];
|
|
this.log = (msg, data = null) => {
|
|
const timestamp = new Date().toISOString();
|
|
const logEntry = { timestamp, msg, data };
|
|
this.debugLog.push(logEntry);
|
|
console.log(`[Sam AI Debug ${timestamp}]`, msg, data || '');
|
|
};
|
|
}
|
|
|
|
async initAI() {
|
|
this.log('initAI 시작', { hasApiKey: !!this.apiKey, apiKeyLength: this.apiKey?.length });
|
|
|
|
// GenAI SDK가 로드될 때까지 대기
|
|
if (!window.GoogleGenAI) {
|
|
this.log('GoogleGenAI SDK가 아직 로드되지 않음', { GenAILoaded: window.GenAILoaded });
|
|
|
|
if (window.GenAILoaded === false) {
|
|
this.log('Google GenAI SDK 로드 실패');
|
|
throw new Error('Google GenAI SDK 로드 실패');
|
|
}
|
|
|
|
this.log('Google GenAI SDK 로드 대기 중...');
|
|
await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
this.log('Google GenAI SDK 로드 타임아웃');
|
|
reject(new Error('Google GenAI SDK 로드 타임아웃'));
|
|
}, 10000);
|
|
|
|
if (window.GoogleGenAI) {
|
|
this.log('Google GenAI SDK 이미 로드됨');
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const checkInterval = setInterval(() => {
|
|
if (window.GoogleGenAI) {
|
|
this.log('Google GenAI SDK 로드 완료 (interval)');
|
|
clearInterval(checkInterval);
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
}
|
|
}, 100);
|
|
|
|
// 이벤트 리스너로도 확인
|
|
window.addEventListener('genai-loaded', () => {
|
|
this.log('Google GenAI SDK 로드 완료 (event)');
|
|
clearInterval(checkInterval);
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
}, { once: true });
|
|
});
|
|
}
|
|
|
|
this.log('GoogleGenAI 인스턴스 생성 시작', { hasGoogleGenAI: !!window.GoogleGenAI });
|
|
this.ai = new window.GoogleGenAI({ apiKey: this.apiKey });
|
|
this.log('GoogleGenAI 인스턴스 생성 완료', { hasAI: !!this.ai });
|
|
}
|
|
|
|
async connect() {
|
|
this.log('=== connect() 시작 ===');
|
|
try {
|
|
await this.initAI();
|
|
this.log('initAI 완료, 연결 시작');
|
|
this.onStatusChange('connecting');
|
|
} catch (error) {
|
|
this.log('initAI 실패', { error: error.message, stack: error.stack });
|
|
throw error;
|
|
}
|
|
|
|
this.log('AudioContext 생성 시작');
|
|
try {
|
|
this.inputAudioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
|
this.log('inputAudioContext 생성 완료', { state: this.inputAudioContext.state, sampleRate: this.inputAudioContext.sampleRate });
|
|
} catch (error) {
|
|
this.log('inputAudioContext 생성 실패', { error: error.message });
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
this.outputAudioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
|
|
this.log('outputAudioContext 생성 완료', { state: this.outputAudioContext.state, sampleRate: this.outputAudioContext.sampleRate });
|
|
} catch (error) {
|
|
this.log('outputAudioContext 생성 실패', { error: error.message });
|
|
throw error;
|
|
}
|
|
|
|
this.outputAnalyser = this.outputAudioContext.createAnalyser();
|
|
this.outputAnalyser.fftSize = 256;
|
|
this.outputAnalyser.connect(this.outputAudioContext.destination);
|
|
this.log('outputAnalyser 설정 완료');
|
|
|
|
const navigateToPage = {
|
|
name: 'navigateToPage',
|
|
parameters: {
|
|
type: window.Type.OBJECT,
|
|
description: 'Navigate the user to a specific page in the application menu.',
|
|
properties: {
|
|
pageId: {
|
|
type: window.Type.STRING,
|
|
description: 'The ID of the page to navigate to (e.g., dashboard, files, analytics, settings, team, calendar).',
|
|
},
|
|
},
|
|
required: ['pageId'],
|
|
},
|
|
};
|
|
|
|
const searchDocuments = {
|
|
name: 'searchDocuments',
|
|
parameters: {
|
|
type: window.Type.OBJECT,
|
|
description: 'Search for work documents or files based on a query.',
|
|
properties: {
|
|
query: {
|
|
type: window.Type.STRING,
|
|
description: 'The search keywords to filter files by.',
|
|
},
|
|
},
|
|
required: ['query'],
|
|
},
|
|
};
|
|
|
|
try {
|
|
this.log('live.connect() 호출 시작', {
|
|
hasAI: !!this.ai,
|
|
hasLive: !!this.ai?.live,
|
|
model: 'gemini-2.5-flash-native-audio-preview-09-2025'
|
|
});
|
|
|
|
this.sessionPromise = this.ai.live.connect({
|
|
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
|
|
config: {
|
|
responseModalities: [window.Modality.AUDIO],
|
|
systemInstruction: SYSTEM_INSTRUCTION,
|
|
tools: [{ functionDeclarations: [navigateToPage, searchDocuments] }],
|
|
},
|
|
callbacks: {
|
|
onopen: () => {
|
|
this.log('WebSocket onopen 콜백 호출');
|
|
this.handleOpen();
|
|
},
|
|
onmessage: (message) => {
|
|
this.log('WebSocket onmessage 콜백 호출', {
|
|
hasServerContent: !!message.serverContent,
|
|
hasToolCall: !!message.toolCall
|
|
});
|
|
this.handleMessage(message);
|
|
},
|
|
onclose: (event) => {
|
|
this.log('WebSocket onclose 콜백 호출', {
|
|
code: event?.code,
|
|
reason: event?.reason,
|
|
wasClean: event?.wasClean
|
|
});
|
|
this.isConnected = false;
|
|
this.session = null;
|
|
this.cleanup();
|
|
this.onStatusChange('disconnected');
|
|
},
|
|
onerror: (err) => {
|
|
this.log('WebSocket onerror 콜백 호출', {
|
|
error: err?.message || err,
|
|
errorType: err?.constructor?.name,
|
|
errorStack: err?.stack
|
|
});
|
|
console.error('WebSocket error:', err);
|
|
this.isConnected = false;
|
|
this.session = null;
|
|
this.cleanup();
|
|
this.onStatusChange('error');
|
|
},
|
|
},
|
|
});
|
|
|
|
this.log('live.connect() 호출 완료, Promise 반환됨', { hasPromise: !!this.sessionPromise });
|
|
|
|
// 세션 객체 저장
|
|
this.sessionPromise.then(session => {
|
|
this.log('Session Promise resolved', { hasSession: !!session });
|
|
this.session = session;
|
|
}).catch(err => {
|
|
this.log('Session Promise rejected', {
|
|
error: err?.message || err,
|
|
errorType: err?.constructor?.name,
|
|
errorStack: err?.stack
|
|
});
|
|
console.error("Session promise failed", err);
|
|
this.isConnected = false;
|
|
this.onStatusChange('error');
|
|
});
|
|
} catch (error) {
|
|
this.log('live.connect() 호출 중 예외 발생', {
|
|
error: error.message,
|
|
errorType: error.constructor?.name,
|
|
errorStack: error.stack
|
|
});
|
|
console.error("Connection failed", error);
|
|
this.isConnected = false;
|
|
this.onStatusChange('error');
|
|
}
|
|
}
|
|
|
|
async handleOpen() {
|
|
this.log('=== handleOpen() 시작 ===');
|
|
this.isConnected = true;
|
|
this.log('isConnected = true 설정');
|
|
this.onStatusChange('connected');
|
|
this.log('상태를 connected로 변경');
|
|
|
|
try {
|
|
this.log('getUserMedia 호출 시작');
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
this.mediaStream = stream;
|
|
this.log('getUserMedia 성공', {
|
|
hasStream: !!stream,
|
|
activeTracks: stream.getTracks().filter(t => t.readyState === 'live').length
|
|
});
|
|
|
|
if (!this.inputAudioContext) {
|
|
this.log('inputAudioContext가 없음, cleanup 호출');
|
|
this.cleanup();
|
|
return;
|
|
}
|
|
|
|
this.log('MediaStreamSource 생성 시작');
|
|
const source = this.inputAudioContext.createMediaStreamSource(stream);
|
|
this.log('ScriptProcessor 생성 시작');
|
|
this.scriptProcessor = this.inputAudioContext.createScriptProcessor(4096, 1, 1);
|
|
|
|
this.scriptProcessor.onaudioprocess = (e) => {
|
|
// 연결 상태 확인
|
|
if (!this.isConnected || !this.session) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const inputData = e.inputBuffer.getChannelData(0);
|
|
let sum = 0;
|
|
for (let i = 0; i < inputData.length; i++) sum += inputData[i] * inputData[i];
|
|
this.onAudioLevel(Math.sqrt(sum / inputData.length));
|
|
|
|
const pcmBlob = createPcmBlob(inputData);
|
|
|
|
// 세션이 유효한지 확인 후 전송
|
|
if (this.session && this.isConnected) {
|
|
try {
|
|
this.session.sendRealtimeInput({ media: pcmBlob });
|
|
} catch (sendError) {
|
|
// WebSocket이 닫혔을 수 있음
|
|
if (sendError.message && (sendError.message.includes('CLOSING') || sendError.message.includes('CLOSED'))) {
|
|
this.log('WebSocket이 닫힘, 연결 종료', { error: sendError.message });
|
|
this.isConnected = false;
|
|
this.cleanup();
|
|
return;
|
|
}
|
|
console.warn('Send error:', sendError);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Audio process error:', err);
|
|
}
|
|
};
|
|
|
|
this.log('오디오 소스 연결 시작');
|
|
source.connect(this.scriptProcessor);
|
|
this.scriptProcessor.connect(this.inputAudioContext.destination);
|
|
this.log('오디오 소스 연결 완료');
|
|
} catch (err) {
|
|
this.log('getUserMedia 실패', {
|
|
error: err.message,
|
|
errorName: err.name,
|
|
errorStack: err.stack
|
|
});
|
|
console.error("Microphone access denied", err);
|
|
this.isConnected = false;
|
|
this.cleanup();
|
|
this.onStatusChange('error');
|
|
}
|
|
}
|
|
|
|
async handleMessage(message) {
|
|
const base64Audio = message.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
|
|
if (base64Audio && this.outputAudioContext && this.outputAnalyser) {
|
|
if(this.outputAudioContext.state === 'suspended') {
|
|
await this.outputAudioContext.resume();
|
|
}
|
|
|
|
const audioBuffer = await decodeAudioData(
|
|
decode(base64Audio),
|
|
this.outputAudioContext,
|
|
24000
|
|
);
|
|
|
|
this.nextStartTime = Math.max(this.outputAudioContext.currentTime, this.nextStartTime);
|
|
|
|
const source = this.outputAudioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(this.outputAnalyser);
|
|
source.start(this.nextStartTime);
|
|
|
|
this.nextStartTime += audioBuffer.duration;
|
|
this.sources.add(source);
|
|
|
|
source.onended = () => this.sources.delete(source);
|
|
}
|
|
|
|
if (message.toolCall) {
|
|
for (const fc of message.toolCall.functionCalls) {
|
|
console.log(`Tool Call: ${fc.name}`, fc.args);
|
|
let result;
|
|
try {
|
|
result = await this.toolHandler(fc.name, fc.args);
|
|
} catch(e) {
|
|
result = { error: 'Failed to execute tool' };
|
|
}
|
|
|
|
// 세션이 유효하고 연결되어 있을 때만 응답 전송
|
|
if (this.session && this.isConnected) {
|
|
try {
|
|
this.session.sendToolResponse({
|
|
functionResponses: {
|
|
id: fc.id,
|
|
name: fc.name,
|
|
response: { result },
|
|
}
|
|
});
|
|
} catch (sendError) {
|
|
console.warn('Tool response send error:', sendError);
|
|
if (sendError.message && (sendError.message.includes('CLOSING') || sendError.message.includes('CLOSED'))) {
|
|
this.isConnected = false;
|
|
this.cleanup();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (message.serverContent?.interrupted) {
|
|
this.sources.forEach(s => s.stop());
|
|
this.sources.clear();
|
|
this.nextStartTime = 0;
|
|
}
|
|
}
|
|
|
|
cleanup() {
|
|
this.log('=== cleanup() 시작 ===');
|
|
|
|
// 오디오 스트림 정리
|
|
if (this.mediaStream) {
|
|
this.log('미디어 스트림 트랙 정지');
|
|
this.mediaStream.getTracks().forEach(track => {
|
|
this.log('트랙 정지', { kind: track.kind, label: track.label, enabled: track.enabled });
|
|
track.stop();
|
|
});
|
|
this.mediaStream = null;
|
|
}
|
|
|
|
// ScriptProcessor 정리
|
|
if (this.scriptProcessor) {
|
|
this.log('ScriptProcessor 연결 해제');
|
|
try {
|
|
this.scriptProcessor.disconnect();
|
|
} catch (e) {
|
|
this.log('ScriptProcessor disconnect 오류 (무시)', { error: e.message });
|
|
}
|
|
this.scriptProcessor = null;
|
|
}
|
|
|
|
// 오디오 소스 정리
|
|
this.log('오디오 소스 정리', { sourceCount: this.sources.size });
|
|
this.sources.forEach(s => {
|
|
try {
|
|
s.stop();
|
|
} catch (e) {
|
|
// 이미 정지된 경우 무시
|
|
}
|
|
});
|
|
this.sources.clear();
|
|
|
|
// AudioContext 정리
|
|
if (this.inputAudioContext) {
|
|
this.log('inputAudioContext 종료', { state: this.inputAudioContext.state });
|
|
try {
|
|
this.inputAudioContext.close();
|
|
} catch (e) {
|
|
this.log('inputAudioContext close 오류 (무시)', { error: e.message });
|
|
}
|
|
this.inputAudioContext = null;
|
|
}
|
|
|
|
if (this.outputAudioContext) {
|
|
this.log('outputAudioContext 종료', { state: this.outputAudioContext.state });
|
|
try {
|
|
this.outputAudioContext.close();
|
|
} catch (e) {
|
|
this.log('outputAudioContext close 오류 (무시)', { error: e.message });
|
|
}
|
|
this.outputAudioContext = null;
|
|
}
|
|
|
|
// 세션 정리
|
|
if (this.session && typeof this.session.close === 'function') {
|
|
this.log('세션 종료');
|
|
try {
|
|
this.session.close();
|
|
} catch (e) {
|
|
this.log('세션 close 오류 (무시)', { error: e.message });
|
|
}
|
|
}
|
|
this.session = null;
|
|
this.sessionPromise = null;
|
|
this.log('=== cleanup() 완료 ===');
|
|
}
|
|
|
|
disconnect() {
|
|
this.isConnected = false;
|
|
this.cleanup();
|
|
this.onStatusChange('disconnected');
|
|
}
|
|
}
|
|
|
|
// Main App Component
|
|
const App = () => {
|
|
const [activeMenuId, setActiveMenuId] = useState('dashboard');
|
|
const [fileFilter, setFileFilter] = useState('');
|
|
const [status, setStatus] = useState(ConnectionStatus.DISCONNECTED);
|
|
const [errorMsg, setErrorMsg] = useState(null);
|
|
const [analyser, setAnalyser] = useState(null);
|
|
const liveManagerRef = useRef(null);
|
|
const [apiKey, setApiKey] = useState('');
|
|
|
|
useEffect(() => {
|
|
// API 키를 서버에서 가져오기 (Codebridge-Chatbot 프로젝트)
|
|
fetch('api/get_api_key.php')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
if (data.apiKey) {
|
|
// API 키 사용
|
|
setApiKey(data.apiKey);
|
|
console.log('API Key loaded from Codebridge-Chatbot project');
|
|
} else if (data.accessToken) {
|
|
// OAuth 토큰 사용 (일반적으로 Gemini Live API는 API 키를 사용)
|
|
setErrorMsg('OAuth 토큰은 Gemini Live API에서 지원되지 않을 수 있습니다. API 키를 사용해주세요.');
|
|
console.warn('OAuth token received, but Gemini Live API requires API key');
|
|
} else {
|
|
setErrorMsg('인증 정보를 가져올 수 없습니다.');
|
|
}
|
|
} else {
|
|
setErrorMsg(data.error || 'API Key를 가져올 수 없습니다. 설정을 확인해주세요.');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('API Key fetch error:', err);
|
|
setErrorMsg('API Key를 가져오는 중 오류가 발생했습니다.');
|
|
});
|
|
}, []);
|
|
|
|
const handleToolCall = useCallback(async (name, args) => {
|
|
console.log("App executing tool:", name, args);
|
|
if (name === 'navigateToPage') {
|
|
const pageId = args.pageId.toLowerCase();
|
|
const exists = MOCK_MENU.some(m => m.id === pageId);
|
|
if (exists) {
|
|
setActiveMenuId(pageId);
|
|
return { success: true, message: `Navigated to ${pageId}` };
|
|
} else {
|
|
return { success: false, message: `Page ${pageId} not found` };
|
|
}
|
|
} else if (name === 'searchDocuments') {
|
|
setFileFilter(args.query);
|
|
setActiveMenuId('files');
|
|
return { success: true, count: 5 };
|
|
}
|
|
return { error: 'Unknown tool' };
|
|
}, []);
|
|
|
|
const connectToSam = useCallback(() => {
|
|
console.log('=== connectToSam() 호출 ===', { hasApiKey: !!apiKey, apiKeyLength: apiKey?.length });
|
|
|
|
if (!apiKey) {
|
|
console.error('API Key가 없음');
|
|
setErrorMsg("API Key가 설정되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
if (liveManagerRef.current) {
|
|
console.log('기존 LiveManager 연결 해제');
|
|
liveManagerRef.current.disconnect();
|
|
}
|
|
|
|
console.log('새 LiveManager 인스턴스 생성');
|
|
const manager = new LiveManager(
|
|
apiKey,
|
|
handleToolCall,
|
|
(s) => {
|
|
console.log('상태 변경 콜백', { status: s });
|
|
if (s === 'connected') {
|
|
console.log('상태: CONNECTED');
|
|
setStatus(ConnectionStatus.CONNECTED);
|
|
setAnalyser(manager.outputAnalyser);
|
|
// 디버그 로그 출력
|
|
console.log('=== 연결 성공 디버그 로그 ===');
|
|
manager.debugLog.forEach(log => {
|
|
console.log(`[${log.timestamp}] ${log.msg}`, log.data);
|
|
});
|
|
} else if (s === 'disconnected') {
|
|
console.log('상태: DISCONNECTED');
|
|
setStatus(ConnectionStatus.DISCONNECTED);
|
|
setAnalyser(null);
|
|
// 디버그 로그 출력
|
|
console.log('=== 연결 종료 디버그 로그 ===');
|
|
manager.debugLog.forEach(log => {
|
|
console.log(`[${log.timestamp}] ${log.msg}`, log.data);
|
|
});
|
|
} else if (s === 'error') {
|
|
console.error('상태: ERROR');
|
|
setStatus(ConnectionStatus.ERROR);
|
|
setAnalyser(null);
|
|
// 디버그 로그 출력
|
|
console.error('=== 연결 오류 디버그 로그 ===');
|
|
manager.debugLog.forEach(log => {
|
|
console.error(`[${log.timestamp}] ${log.msg}`, log.data);
|
|
});
|
|
} else {
|
|
console.log('상태: CONNECTING');
|
|
setStatus(ConnectionStatus.CONNECTING);
|
|
}
|
|
},
|
|
(level) => {
|
|
// Audio level for visualizer
|
|
}
|
|
);
|
|
|
|
liveManagerRef.current = manager;
|
|
console.log('manager.connect() 호출');
|
|
manager.connect().catch(err => {
|
|
console.error('manager.connect() 실패', {
|
|
error: err.message,
|
|
errorType: err.constructor?.name,
|
|
errorStack: err.stack
|
|
});
|
|
// 디버그 로그 출력
|
|
console.error('=== 연결 실패 디버그 로그 ===');
|
|
manager.debugLog.forEach(log => {
|
|
console.error(`[${log.timestamp}] ${log.msg}`, log.data);
|
|
});
|
|
});
|
|
}, [apiKey, handleToolCall]);
|
|
|
|
const disconnect = () => {
|
|
liveManagerRef.current?.disconnect();
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-screen w-full bg-slate-50">
|
|
<Sidebar menus={MOCK_MENU} activeId={activeMenuId} onMenuClick={setActiveMenuId} />
|
|
|
|
<main className="flex-1 flex flex-col h-screen overflow-hidden relative">
|
|
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-8 z-10">
|
|
<h1 className="text-xl font-bold text-slate-800">
|
|
{MOCK_MENU.find(m => m.id === activeMenuId)?.label || 'Dashboard'}
|
|
</h1>
|
|
|
|
<div className="flex items-center gap-4">
|
|
{status === ConnectionStatus.ERROR && (
|
|
<span className="text-red-500 text-sm font-medium">Connection Error</span>
|
|
)}
|
|
|
|
{status === ConnectionStatus.CONNECTED ? (
|
|
<button
|
|
onClick={disconnect}
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-600 rounded-full hover:bg-red-100 transition-colors border border-red-200"
|
|
>
|
|
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse"></div>
|
|
<span className="font-semibold text-sm">Disconnect Sam</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={connectToSam}
|
|
disabled={status === ConnectionStatus.CONNECTING || !apiKey}
|
|
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white rounded-full hover:bg-slate-800 transition-all shadow-lg shadow-indigo-500/20 disabled:opacity-50"
|
|
>
|
|
{status === ConnectionStatus.CONNECTING ? (
|
|
<span className="flex items-center gap-2">
|
|
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
|
Connecting...
|
|
</span>
|
|
) : (
|
|
<>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
|
<span>Call Sam</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 p-8 overflow-hidden flex gap-6">
|
|
<div className="flex-1 flex flex-col gap-6 h-full overflow-hidden">
|
|
{activeMenuId === 'dashboard' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-200">
|
|
<div className="text-slate-500 text-sm mb-1">Total Revenue</div>
|
|
<div className="text-3xl font-bold text-slate-900">$124,500</div>
|
|
<div className="text-green-500 text-sm font-medium mt-2">+12.5% from last month</div>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-200">
|
|
<div className="text-slate-500 text-sm mb-1">Active Projects</div>
|
|
<div className="text-3xl font-bold text-slate-900">8</div>
|
|
<div className="text-slate-400 text-sm font-medium mt-2">2 pending approval</div>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-200">
|
|
<div className="text-slate-500 text-sm mb-1">Team Efficiency</div>
|
|
<div className="text-3xl font-bold text-slate-900">94%</div>
|
|
<div className="text-indigo-500 text-sm font-medium mt-2">Top 5% in sector</div>
|
|
</div>
|
|
|
|
<div className="col-span-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 h-64 flex items-center justify-center text-slate-400 bg-slate-50/50">
|
|
Analytics Chart Placeholder
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(activeMenuId === 'files' || activeMenuId !== 'dashboard') && (
|
|
<FileList files={MOCK_FILES} filter={fileFilter} />
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-80 flex flex-col gap-4">
|
|
<div className={`rounded-3xl p-6 shadow-xl transition-all duration-500 border relative overflow-hidden flex flex-col items-center justify-center
|
|
${status === ConnectionStatus.CONNECTED ? 'bg-gradient-to-br from-indigo-900 to-slate-900 border-indigo-500/50 h-64' : 'bg-white border-slate-200 h-48'}
|
|
`}>
|
|
{status === ConnectionStatus.CONNECTED && (
|
|
<div className="absolute inset-0 opacity-40">
|
|
<Visualizer analyser={analyser} active={true} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative z-10 flex flex-col items-center text-center">
|
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center mb-4 transition-all duration-300 ${status === ConnectionStatus.CONNECTED ? 'bg-white text-indigo-600 shadow-[0_0_30px_rgba(255,255,255,0.3)]' : 'bg-slate-100 text-slate-400'}`}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
|
</div>
|
|
|
|
<h3 className={`text-lg font-bold mb-1 ${status === ConnectionStatus.CONNECTED ? 'text-white' : 'text-slate-800'}`}>
|
|
{status === ConnectionStatus.CONNECTED ? 'Sam is Listening' : 'Sam is Idle'}
|
|
</h3>
|
|
<p className={`text-sm ${status === ConnectionStatus.CONNECTED ? 'text-indigo-200' : 'text-slate-500'}`}>
|
|
{status === ConnectionStatus.CONNECTED ? 'Say "Find the quarterly report"' : 'Click "Call Sam" to start'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl p-6 shadow-sm border border-slate-200 flex-1">
|
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-4">Try Asking</h4>
|
|
<div className="space-y-3">
|
|
<button className="w-full text-left p-3 rounded-xl bg-slate-50 hover:bg-slate-100 transition-colors text-sm text-slate-700 flex items-center gap-3 group">
|
|
<span className="w-6 h-6 rounded-full bg-white border border-slate-200 flex items-center justify-center text-indigo-500 group-hover:scale-110 transition-transform">?</span>
|
|
"Show me the finance dashboard"
|
|
</button>
|
|
<button className="w-full text-left p-3 rounded-xl bg-slate-50 hover:bg-slate-100 transition-colors text-sm text-slate-700 flex items-center gap-3 group">
|
|
<span className="w-6 h-6 rounded-full bg-white border border-slate-200 flex items-center justify-center text-indigo-500 group-hover:scale-110 transition-transform">?</span>
|
|
"Find the Q3 report from October"
|
|
</button>
|
|
<button className="w-full text-left p-3 rounded-xl bg-slate-50 hover:bg-slate-100 transition-colors text-sm text-slate-700 flex items-center gap-3 group">
|
|
<span className="w-6 h-6 rounded-full bg-white border border-slate-200 flex items-center justify-center text-indigo-500 group-hover:scale-110 transition-transform">?</span>
|
|
"Settings 화면으로 이동해줘"
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{errorMsg && (
|
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 bg-red-600 text-white px-6 py-3 rounded-full shadow-lg z-50 animate-bounce">
|
|
{errorMsg}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Render App
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|