feat(lab): SAM AI 음성 어시스턴트 레거시 마이그레이션

레거시 5130.sam.kr/ai_sam의 Google Gemini Live API 음성 어시스턴트를
MNG 프로젝트로 이전 (React → Pure JS + Blade)

변경 내용:
- GeminiController: API 키 제공 엔드포인트 추가
- sam-ai-live.js: LiveManager, AudioVisualizer ES 모듈
- sam-ai-menu.blade.php: 전면 재작성 (Tailwind UI)
- 환경변수: GEMINI_API_KEY, GEMINI_PROJECT_ID 추가

기능:
- 실시간 음성 입출력 (WebAudio API)
- UI 도구: navigateToPage, searchDocuments
- 오디오 시각화 (Canvas API)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-14 02:13:20 +09:00
parent aadcfa3e0c
commit 5d0f2d1346
6 changed files with 1238 additions and 45 deletions

View File

@@ -63,3 +63,7 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Google Gemini API (SAM AI 음성 어시스턴트)
GEMINI_API_KEY=
GEMINI_PROJECT_ID=

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Gemini API Controller
*
* Google Gemini Live API 연동을 위한 API 키 제공 컨트롤러
* SAM AI 음성 어시스턴트에서 사용
*/
class GeminiController extends Controller
{
/**
* Gemini API 키 조회
*
* 인증된 사용자에게만 API 키를 제공합니다.
* .env 파일의 GEMINI_API_KEY 환경변수를 사용합니다.
*/
public function getApiKey(Request $request): JsonResponse
{
// 환경변수에서 API 키 조회
$apiKey = config('services.gemini.api_key');
if (empty($apiKey)) {
return response()->json([
'success' => false,
'error' => 'Gemini API 키가 설정되지 않았습니다. .env 파일에 GEMINI_API_KEY를 설정해주세요.',
], 500);
}
// API 키 유효성 검사 (최소 길이)
if (strlen($apiKey) < 20) {
return response()->json([
'success' => false,
'error' => '유효하지 않은 Gemini API 키입니다.',
], 500);
}
return response()->json([
'success' => true,
'apiKey' => $apiKey,
'projectId' => config('services.gemini.project_id', 'codebridge-chatbot'),
]);
}
}

View File

@@ -35,4 +35,9 @@
],
],
'gemini' => [
'api_key' => env('GEMINI_API_KEY'),
'project_id' => env('GEMINI_PROJECT_ID', 'codebridge-chatbot'),
],
];

605
public/js/sam-ai-live.js Normal file
View File

@@ -0,0 +1,605 @@
/**
* SAM AI Live - Google Gemini Live API 음성 어시스턴트
*
* 레거시 5130.sam.kr/ai_sam 기능을 MNG로 이전
* - 음성 입출력 (WebAudio API)
* - Google Gemini Live API 연동
* - UI 제어 도구 (메뉴 이동, 파일 검색)
*/
// ============================================================================
// Audio Utilities
// ============================================================================
/**
* Base64 문자열을 Uint8Array로 디코딩
*/
export function decodeBase64(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;
}
/**
* Uint8Array를 Base64 문자열로 인코딩
*/
export function encodeBase64(bytes) {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* PCM 데이터를 AudioBuffer로 변환
*/
export async function decodeAudioData(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;
}
/**
* Float32Array를 PCM Blob으로 변환
*/
export function 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: encodeBase64(new Uint8Array(int16.buffer)),
mimeType: 'audio/pcm;rate=16000',
};
}
// ============================================================================
// Connection Status
// ============================================================================
export const ConnectionStatus = {
DISCONNECTED: 'disconnected',
CONNECTING: 'connecting',
CONNECTED: 'connected',
ERROR: 'error',
};
// ============================================================================
// System Instruction
// ============================================================================
export 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.").`;
// ============================================================================
// LiveManager Class
// ============================================================================
export 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(ConnectionStatus.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(ConnectionStatus.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(ConnectionStatus.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(ConnectionStatus.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(ConnectionStatus.ERROR);
}
}
async handleOpen() {
this.log('=== handleOpen() 시작 ===');
this.isConnected = true;
this.log('isConnected = true 설정');
this.onStatusChange(ConnectionStatus.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) {
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(ConnectionStatus.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(
decodeBase64(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;
}
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();
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(ConnectionStatus.DISCONNECTED);
}
getOutputAnalyser() {
return this.outputAnalyser;
}
}
// ============================================================================
// Audio Visualizer
// ============================================================================
export class AudioVisualizer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.analyser = null;
this.active = false;
this.animationId = null;
}
setAnalyser(analyser) {
this.analyser = analyser;
}
setActive(active) {
this.active = active;
}
start() {
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
this.animationId = requestAnimationFrame(draw);
const width = this.canvas.width;
const height = this.canvas.height;
this.ctx.clearRect(0, 0, width, height);
if (!this.active || !this.analyser) {
const time = Date.now() / 1000;
this.ctx.beginPath();
this.ctx.arc(width / 2, height / 2, 20 + Math.sin(time * 2) * 2, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(99, 102, 241, 0.5)';
this.ctx.fill();
return;
}
this.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 = this.ctx.createLinearGradient(0, height / 2 - barHeight, 0, height / 2 + barHeight);
gradient.addColorStop(0, '#818cf8');
gradient.addColorStop(1, '#c084fc');
this.ctx.fillStyle = gradient;
this.ctx.fillRect(centerX + x, height / 2 - barHeight / 2, barWidth, barHeight);
this.ctx.fillRect(centerX - x - barWidth, height / 2 - barHeight / 2, barWidth, barHeight);
x += barWidth + 1;
}
};
draw();
}
stop() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
}

View File

@@ -1,62 +1,578 @@
@extends('layouts.presentation')
@section('title', 'SAM AI 메뉴 이동')
@section('title', 'SAM AI - 음성 어시스턴트')
@push('styles')
<style>
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; }
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; }
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; }
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; }
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; }
.sam-container { display: flex; height: 100vh; width: 100%; background-color: #f8fafc; }
/* Sidebar */
.sam-sidebar { width: 16rem; background-color: #0f172a; color: white; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid #1e293b; }
.sam-sidebar-header { padding: 1.5rem; display: flex; align-items: center; gap: 0.75rem; }
.sam-sidebar-logo { width: 2rem; height: 2rem; border-radius: 0.5rem; background: linear-gradient(135deg, #6366f1, #9333ea); display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.125rem; }
.sam-sidebar-title { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; }
.sam-sidebar-nav { flex: 1; padding: 1.5rem 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
.sam-menu-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; border-radius: 0.75rem; transition: all 0.2s; cursor: pointer; color: #94a3b8; }
.sam-menu-item:hover { background-color: #1e293b; color: white; }
.sam-menu-item.active { background-color: #4f46e5; color: white; box-shadow: 0 10px 15px -3px rgba(79, 70, 229, 0.3); }
.sam-menu-item.active .sam-menu-indicator { display: block; }
.sam-menu-icon { width: 1.25rem; height: 1.25rem; }
.sam-menu-label { font-weight: 500; }
.sam-menu-indicator { display: none; margin-left: auto; width: 0.375rem; height: 0.375rem; border-radius: 9999px; background-color: white; box-shadow: 0 0 8px rgba(255,255,255,0.8); }
.sam-sidebar-footer { padding: 1rem; border-top: 1px solid #1e293b; }
.sam-user-info { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border-radius: 0.5rem; background-color: rgba(30, 41, 59, 0.5); }
.sam-user-avatar { width: 2rem; height: 2rem; border-radius: 9999px; background-color: #334155; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; }
.sam-user-name { font-size: 0.875rem; font-weight: 500; }
.sam-user-role { font-size: 0.75rem; color: #94a3b8; }
/* Main Content */
.sam-main { flex: 1; display: flex; flex-direction: column; height: 100vh; overflow: hidden; position: relative; }
.sam-header { height: 4rem; background-color: white; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; justify-content: space-between; padding: 0 2rem; z-index: 10; }
.sam-header-title { font-size: 1.25rem; font-weight: 700; color: #1e293b; }
.sam-header-actions { display: flex; align-items: center; gap: 1rem; }
.sam-error-msg { color: #ef4444; font-size: 0.875rem; font-weight: 500; }
.sam-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1.5rem; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; transition: all 0.2s; cursor: pointer; border: none; }
.sam-btn-primary { background-color: #0f172a; color: white; box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.2); }
.sam-btn-primary:hover { background-color: #1e293b; }
.sam-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.sam-btn-danger { background-color: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
.sam-btn-danger:hover { background-color: #fee2e2; }
.sam-btn-icon { width: 1.125rem; height: 1.125rem; }
.sam-status-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; }
.sam-status-dot.connecting { background-color: #eab308; animation: pulse 1s infinite; }
.sam-status-dot.connected { background-color: #dc2626; animation: pulse 1s infinite; }
/* Content Area */
.sam-content { flex: 1; padding: 2rem; overflow: hidden; display: flex; gap: 1.5rem; }
.sam-content-main { flex: 1; display: flex; flex-direction: column; gap: 1.5rem; height: 100%; overflow: hidden; }
.sam-content-sidebar { width: 20rem; display: flex; flex-direction: column; gap: 1rem; }
/* Dashboard Cards */
.sam-dashboard { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
.sam-card { background-color: white; padding: 1.5rem; border-radius: 1rem; box-shadow: 0 1px 2px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; }
.sam-card-label { color: #64748b; font-size: 0.875rem; margin-bottom: 0.25rem; }
.sam-card-value { font-size: 1.875rem; font-weight: 700; color: #0f172a; }
.sam-card-change { font-size: 0.875rem; font-weight: 500; margin-top: 0.5rem; }
.sam-card-change.positive { color: #22c55e; }
.sam-card-change.neutral { color: #94a3b8; }
.sam-card-change.highlight { color: #6366f1; }
.sam-chart-placeholder { grid-column: span 3; height: 16rem; display: flex; align-items: center; justify-content: center; color: #94a3b8; background-color: rgba(248, 250, 252, 0.5); }
/* File List */
.sam-file-list { background-color: white; border-radius: 1rem; box-shadow: 0 1px 2px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; overflow: hidden; display: flex; flex-direction: column; height: 100%; }
.sam-file-header { padding: 1rem 1.5rem; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; }
.sam-file-title { font-size: 1.125rem; font-weight: 600; color: #1e293b; }
.sam-filter-badge { padding: 0.25rem 0.5rem; background-color: #eef2ff; color: #4338ca; font-size: 0.75rem; border-radius: 9999px; font-weight: 500; }
.sam-file-body { flex: 1; overflow-y: auto; padding: 0.5rem; }
.sam-file-table { width: 100%; text-align: left; font-size: 0.875rem; color: #475569; }
.sam-file-table thead { font-size: 0.75rem; text-transform: uppercase; background-color: #f8fafc; color: #64748b; font-weight: 600; }
.sam-file-table th { padding: 0.75rem 1rem; }
.sam-file-table th:first-child { border-radius: 0.5rem 0 0 0.5rem; }
.sam-file-table th:last-child { border-radius: 0 0.5rem 0.5rem 0; }
.sam-file-table tbody tr { border-bottom: 1px solid #f8fafc; transition: background-color 0.2s; }
.sam-file-table tbody tr:hover { background-color: #f8fafc; }
.sam-file-table tbody tr:last-child { border-bottom: none; }
.sam-file-table td { padding: 0.75rem 1rem; }
.sam-file-name { font-weight: 500; color: #0f172a; display: flex; align-items: center; gap: 0.5rem; }
.sam-file-icon { width: 2rem; height: 2rem; border-radius: 0.25rem; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700; }
.sam-file-icon.pdf { background-color: #fee2e2; color: #dc2626; }
.sam-file-icon.xlsx { background-color: #dcfce7; color: #16a34a; }
.sam-file-icon.docx { background-color: #dbeafe; color: #2563eb; }
.sam-file-icon.pptx { background-color: #ffedd5; color: #ea580c; }
.sam-file-icon.folder { background-color: #f1f5f9; color: #475569; }
.sam-file-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.sam-file-tag { padding: 0.125rem 0.5rem; background-color: #f1f5f9; border-radius: 9999px; font-size: 0.75rem; color: #64748b; }
.sam-file-empty { padding: 2rem 1rem; text-align: center; color: #94a3b8; }
/* Sam Panel */
.sam-panel { border-radius: 1.5rem; padding: 1.5rem; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); transition: all 0.5s; border: 1px solid; position: relative; overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.sam-panel.idle { background-color: white; border-color: #e2e8f0; height: 12rem; }
.sam-panel.active { background: linear-gradient(135deg, #312e81, #0f172a); border-color: rgba(99, 102, 241, 0.5); height: 16rem; }
.sam-visualizer { position: absolute; inset: 0; opacity: 0.4; }
.sam-panel-content { position: relative; z-index: 10; display: flex; flex-direction: column; align-items: center; text-align: center; }
.sam-panel-icon { width: 4rem; height: 4rem; border-radius: 9999px; display: flex; align-items: center; justify-content: center; margin-bottom: 1rem; transition: all 0.3s; }
.sam-panel.idle .sam-panel-icon { background-color: #f1f5f9; color: #94a3b8; }
.sam-panel.active .sam-panel-icon { background-color: white; color: #4f46e5; box-shadow: 0 0 30px rgba(255,255,255,0.3); }
.sam-panel-title { font-size: 1.125rem; font-weight: 700; margin-bottom: 0.25rem; }
.sam-panel.idle .sam-panel-title { color: #1e293b; }
.sam-panel.active .sam-panel-title { color: white; }
.sam-panel-subtitle { font-size: 0.875rem; }
.sam-panel.idle .sam-panel-subtitle { color: #64748b; }
.sam-panel.active .sam-panel-subtitle { color: #a5b4fc; }
/* Try Asking */
.sam-suggestions { background-color: white; border-radius: 1rem; padding: 1.5rem; box-shadow: 0 1px 2px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; flex: 1; }
.sam-suggestions-title { font-size: 0.75rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
.sam-suggestions-list { display: flex; flex-direction: column; gap: 0.75rem; }
.sam-suggestion { width: 100%; text-align: left; padding: 0.75rem; border-radius: 0.75rem; background-color: #f8fafc; transition: background-color 0.2s; font-size: 0.875rem; color: #334155; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; border: none; }
.sam-suggestion:hover { background-color: #f1f5f9; }
.sam-suggestion-icon { width: 1.5rem; height: 1.5rem; border-radius: 9999px; background-color: white; border: 1px solid #e2e8f0; display: flex; align-items: center; justify-content: center; color: #6366f1; transition: transform 0.2s; font-size: 0.75rem; }
.sam-suggestion:hover .sam-suggestion-icon { transform: scale(1.1); }
/* Error Toast */
.sam-toast { position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%); background-color: #dc2626; color: white; padding: 0.75rem 1.5rem; border-radius: 9999px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); z-index: 50; animation: bounce 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
@keyframes bounce { 0%, 100% { transform: translateX(-50%) translateY(0); } 50% { transform: translateX(-50%) translateY(-10px); } }
</style>
@endpush
@section('content')
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100">
<div class="container mx-auto px-4 py-12">
<div class="placeholder-container">
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
<h1 class="placeholder-title">SAM AI 메뉴 이동</h1>
<p class="placeholder-subtitle">
SAM 시스템의 AI 관련 기능들을 통합 관리하고
메뉴 구조를 재편성하는 관리 기능입니다.
</p>
<div class="placeholder-badge">AI/Automation</div>
<div class="sam-container">
<!-- Sidebar -->
<div class="sam-sidebar">
<div class="sam-sidebar-header">
<div class="sam-sidebar-logo">S</div>
<span class="sam-sidebar-title">Sam AI</span>
</div>
<div class="max-w-4xl mx-auto mt-12">
<div class="bg-white rounded-2xl shadow-lg p-8">
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
예정 기능
</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="p-4 bg-violet-50 rounded-lg">
<h3 class="font-semibold text-violet-800 mb-2">메뉴 관리</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li> AI 메뉴 통합</li>
<li> 권한별 메뉴 노출</li>
<li> 메뉴 순서 설정</li>
</ul>
</div>
<div class="p-4 bg-blue-50 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">기능 라우팅</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li> 기능 활성화/비활성화</li>
<li> 테넌트별 기능 설정</li>
<li> 사용량 통계</li>
</ul>
</div>
<nav class="sam-sidebar-nav" id="sam-nav">
<div class="sam-menu-item active" data-page="dashboard">
<svg class="sam-menu-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><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>
<span class="sam-menu-label">Dashboard</span>
<div class="sam-menu-indicator"></div>
</div>
<div class="sam-menu-item" data-page="files">
<svg class="sam-menu-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><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>
<span class="sam-menu-label">My Files</span>
<div class="sam-menu-indicator"></div>
</div>
<div class="sam-menu-item" data-page="analytics">
<svg class="sam-menu-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
<span class="sam-menu-label">Analytics</span>
<div class="sam-menu-indicator"></div>
</div>
<div class="sam-menu-item" data-page="settings">
<svg class="sam-menu-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><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>
<span class="sam-menu-label">Settings</span>
<div class="sam-menu-indicator"></div>
</div>
<div class="sam-menu-item" data-page="team">
<svg class="sam-menu-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><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>
<span class="sam-menu-label">Team Directory</span>
<div class="sam-menu-indicator"></div>
</div>
<div class="sam-menu-item" data-page="calendar">
<svg class="sam-menu-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><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>
<span class="sam-menu-label">Calendar</span>
<div class="sam-menu-indicator"></div>
</div>
</nav>
<div class="sam-sidebar-footer">
<div class="sam-user-info">
<div class="sam-user-avatar">{{ mb_substr(auth()->user()->name ?? 'U', 0, 1) }}</div>
<div>
<div class="sam-user-name">{{ auth()->user()->name ?? 'User' }}</div>
<div class="sam-user-role">Pro Member</div>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<main class="sam-main">
<header class="sam-header">
<h1 class="sam-header-title" id="page-title">Dashboard</h1>
<div class="sam-header-actions">
<span class="sam-error-msg" id="error-msg" style="display: none;"></span>
<button class="sam-btn sam-btn-danger" id="btn-disconnect" style="display: none;">
<span class="sam-status-dot connected"></span>
<span>Disconnect Sam</span>
</button>
<button class="sam-btn sam-btn-primary" id="btn-connect">
<svg class="sam-btn-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><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 id="btn-connect-text">Call Sam</span>
</button>
</div>
</header>
<div class="sam-content">
<div class="sam-content-main">
<!-- Dashboard View -->
<div id="view-dashboard" class="sam-dashboard">
<div class="sam-card">
<div class="sam-card-label">Total Revenue</div>
<div class="sam-card-value">$124,500</div>
<div class="sam-card-change positive">+12.5% from last month</div>
</div>
<div class="sam-card">
<div class="sam-card-label">Active Projects</div>
<div class="sam-card-value">8</div>
<div class="sam-card-change neutral">2 pending approval</div>
</div>
<div class="sam-card">
<div class="sam-card-label">Team Efficiency</div>
<div class="sam-card-value">94%</div>
<div class="sam-card-change highlight">Top 5% in sector</div>
</div>
<div class="sam-card sam-chart-placeholder">
Analytics Chart Placeholder
</div>
</div>
<!-- Files View -->
<div id="view-files" class="sam-file-list" style="display: none;">
<div class="sam-file-header">
<h2 class="sam-file-title">Recent Files</h2>
<span class="sam-filter-badge" id="filter-badge" style="display: none;"></span>
</div>
<div class="sam-file-body">
<table class="sam-file-table">
<thead>
<tr>
<th>Name</th>
<th>Date</th>
<th>Size</th>
<th>Tags</th>
</tr>
</thead>
<tbody id="file-list-body"></tbody>
</table>
</div>
</div>
</div>
<div class="sam-content-sidebar">
<!-- Sam Panel -->
<div class="sam-panel idle" id="sam-panel">
<canvas class="sam-visualizer" id="visualizer" width="300" height="100"></canvas>
<div class="sam-panel-content">
<div class="sam-panel-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><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 class="sam-panel-title" id="panel-title">Sam is Idle</h3>
<p class="sam-panel-subtitle" id="panel-subtitle">Click "Call Sam" to start</p>
</div>
</div>
<!-- Suggestions -->
<div class="sam-suggestions">
<h4 class="sam-suggestions-title">Try Asking</h4>
<div class="sam-suggestions-list">
<button class="sam-suggestion">
<span class="sam-suggestion-icon">?</span>
"Show me the finance dashboard"
</button>
<button class="sam-suggestion">
<span class="sam-suggestion-icon">?</span>
"Find the Q3 report from October"
</button>
<button class="sam-suggestion">
<span class="sam-suggestion-icon">?</span>
"Settings 화면으로 이동해줘"
</button>
</div>
</div>
</div>
</div>
<!-- Error Toast -->
<div class="sam-toast" id="error-toast" style="display: none;"></div>
</main>
</div>
@endsection
@push('scripts')
<!-- Google GenAI SDK -->
<script type="module">
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>
<script type="module">
import { LiveManager, AudioVisualizer, ConnectionStatus } from '/js/sam-ai-live.js';
// ========================================================================
// Mock Data
// ========================================================================
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 MENU_LABELS = {
dashboard: 'Dashboard',
files: 'My Files',
analytics: 'Analytics',
settings: 'Settings',
team: 'Team Directory',
calendar: 'Calendar',
};
// ========================================================================
// State
// ========================================================================
let activeMenuId = 'dashboard';
let fileFilter = '';
let apiKey = null;
let liveManager = null;
let visualizer = null;
// ========================================================================
// DOM Elements
// ========================================================================
const elements = {
nav: document.getElementById('sam-nav'),
pageTitle: document.getElementById('page-title'),
btnConnect: document.getElementById('btn-connect'),
btnConnectText: document.getElementById('btn-connect-text'),
btnDisconnect: document.getElementById('btn-disconnect'),
errorMsg: document.getElementById('error-msg'),
errorToast: document.getElementById('error-toast'),
viewDashboard: document.getElementById('view-dashboard'),
viewFiles: document.getElementById('view-files'),
filterBadge: document.getElementById('filter-badge'),
fileListBody: document.getElementById('file-list-body'),
samPanel: document.getElementById('sam-panel'),
panelTitle: document.getElementById('panel-title'),
panelSubtitle: document.getElementById('panel-subtitle'),
visualizerCanvas: document.getElementById('visualizer'),
};
// ========================================================================
// UI Functions
// ========================================================================
function updateActiveMenu(pageId) {
activeMenuId = pageId;
document.querySelectorAll('.sam-menu-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === pageId);
});
elements.pageTitle.textContent = MENU_LABELS[pageId] || 'Dashboard';
// Show/hide views
elements.viewDashboard.style.display = pageId === 'dashboard' ? 'grid' : 'none';
elements.viewFiles.style.display = pageId !== 'dashboard' ? 'flex' : 'none';
}
function updateFileList(filter = '') {
fileFilter = filter;
const filteredFiles = MOCK_FILES.filter(f =>
f.name.toLowerCase().includes(filter.toLowerCase()) ||
f.tags.some(t => t.toLowerCase().includes(filter.toLowerCase()))
);
// Update filter badge
if (filter) {
elements.filterBadge.textContent = `Filtering by: "${filter}"`;
elements.filterBadge.style.display = 'inline-block';
} else {
elements.filterBadge.style.display = 'none';
}
// Render file list
if (filteredFiles.length === 0) {
elements.fileListBody.innerHTML = `
<tr>
<td colspan="4" class="sam-file-empty">No files found matching "${filter}"</td>
</tr>
`;
} else {
elements.fileListBody.innerHTML = filteredFiles.map(file => `
<tr>
<td class="sam-file-name">
<span class="sam-file-icon ${file.type}">${file.type.toUpperCase().slice(0, 3)}</span>
${file.name}
</td>
<td>${file.date}</td>
<td>${file.size}</td>
<td>
<div class="sam-file-tags">
${file.tags.map(tag => `<span class="sam-file-tag">#${tag}</span>`).join('')}
</div>
</td>
</tr>
`).join('');
}
}
function updateConnectionUI(status) {
const isConnected = status === ConnectionStatus.CONNECTED;
const isConnecting = status === ConnectionStatus.CONNECTING;
const isError = status === ConnectionStatus.ERROR;
elements.btnConnect.style.display = isConnected ? 'none' : 'flex';
elements.btnDisconnect.style.display = isConnected ? 'flex' : 'none';
elements.btnConnect.disabled = isConnecting;
elements.btnConnectText.textContent = isConnecting ? 'Connecting...' : 'Call Sam';
elements.samPanel.classList.toggle('active', isConnected);
elements.samPanel.classList.toggle('idle', !isConnected);
elements.panelTitle.textContent = isConnected ? 'Sam is Listening' : 'Sam is Idle';
elements.panelSubtitle.textContent = isConnected ? 'Say "Find the quarterly report"' : 'Click "Call Sam" to start';
if (isError) {
elements.errorMsg.textContent = 'Connection Error';
elements.errorMsg.style.display = 'inline';
} else {
elements.errorMsg.style.display = 'none';
}
// Visualizer
if (visualizer) {
visualizer.setActive(isConnected);
if (isConnected && liveManager) {
visualizer.setAnalyser(liveManager.getOutputAnalyser());
}
}
}
function showError(message) {
elements.errorToast.textContent = message;
elements.errorToast.style.display = 'block';
setTimeout(() => {
elements.errorToast.style.display = 'none';
}, 5000);
}
// ========================================================================
// Tool Handler
// ========================================================================
async function handleToolCall(name, args) {
console.log("Tool Call:", name, args);
if (name === 'navigateToPage') {
const pageId = args.pageId.toLowerCase();
const exists = Object.keys(MENU_LABELS).includes(pageId);
if (exists) {
updateActiveMenu(pageId);
if (pageId !== 'dashboard') {
updateFileList(fileFilter);
}
return { success: true, message: `Navigated to ${pageId}` };
} else {
return { success: false, message: `Page ${pageId} not found` };
}
} else if (name === 'searchDocuments') {
updateFileList(args.query);
updateActiveMenu('files');
return { success: true, count: 5 };
}
return { error: 'Unknown tool' };
}
// ========================================================================
// Connection Functions
// ========================================================================
async function connectToSam() {
if (!apiKey) {
showError("API Key가 설정되지 않았습니다.");
return;
}
if (liveManager) {
liveManager.disconnect();
}
liveManager = new LiveManager(
apiKey,
handleToolCall,
(status) => {
console.log('Status changed:', status);
updateConnectionUI(status);
},
(level) => {
// Audio level callback (for future use)
}
);
try {
await liveManager.connect();
} catch (err) {
console.error('Connection failed:', err);
showError('연결에 실패했습니다: ' + err.message);
}
}
function disconnect() {
if (liveManager) {
liveManager.disconnect();
liveManager = null;
}
}
// ========================================================================
// Initialize
// ========================================================================
async function init() {
// Initialize visualizer
visualizer = new AudioVisualizer(elements.visualizerCanvas);
visualizer.start();
// Load API key
try {
const response = await fetch('/api/gemini/api-key');
const data = await response.json();
if (data.success && data.apiKey) {
apiKey = data.apiKey;
console.log('API Key loaded successfully');
} else {
showError(data.error || 'API Key를 가져올 수 없습니다.');
}
} catch (err) {
console.error('API Key fetch error:', err);
showError('API Key를 가져오는 중 오류가 발생했습니다.');
}
// Initial file list
updateFileList('');
// Event listeners
elements.nav.addEventListener('click', (e) => {
const menuItem = e.target.closest('.sam-menu-item');
if (menuItem) {
const pageId = menuItem.dataset.page;
updateActiveMenu(pageId);
if (pageId !== 'dashboard') {
updateFileList(fileFilter);
}
}
});
elements.btnConnect.addEventListener('click', connectToSam);
elements.btnDisconnect.addEventListener('click', disconnect);
}
// Start
init();
</script>
@endpush

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\GeminiController;
use App\Http\Controllers\Api\Admin\BoardController;
use App\Http\Controllers\Api\Admin\DailyLogController;
use App\Http\Controllers\Api\Admin\DepartmentController;
@@ -510,3 +511,16 @@
});
});
});
/*
|--------------------------------------------------------------------------
| Gemini AI API
|--------------------------------------------------------------------------
|
| SAM AI 음성 어시스턴트용 Gemini API 키 제공
| 인증된 사용자만 접근 가능
|
*/
Route::middleware(['web', 'auth'])->prefix('gemini')->name('api.gemini.')->group(function () {
Route::get('/api-key', [GeminiController::class, 'getApiKey'])->name('api-key');
});