Files
sam-kd/ai_sam/index.php
hskwon aca1767eb9 초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
2025-12-10 20:14:31 +09:00

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>