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:
@@ -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=
|
||||
|
||||
49
app/Http/Controllers/Api/GeminiController.php
Normal file
49
app/Http/Controllers/Api/GeminiController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
605
public/js/sam-ai-live.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user