diff --git a/app/Http/Controllers/Juil/ConstructionSitePhotoController.php b/app/Http/Controllers/Juil/ConstructionSitePhotoController.php index f48a99b0..5a2a05a8 100644 --- a/app/Http/Controllers/Juil/ConstructionSitePhotoController.php +++ b/app/Http/Controllers/Juil/ConstructionSitePhotoController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Juil; +use App\Helpers\AiTokenHelper; use App\Http\Controllers\Controller; use App\Models\Juil\ConstructionSitePhoto; use App\Services\ConstructionSitePhotoService; @@ -233,4 +234,15 @@ public function downloadPhoto(Request $request, int $id, string $type): Response ->header('Content-Disposition', "{$disposition}; filename=\"{$encodedFilename}\"; filename*=UTF-8''{$encodedFilename}") ->header('Cache-Control', 'private, max-age=3600'); } + + public function logSttUsage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'duration_seconds' => 'required|integer|min:1', + ]); + + AiTokenHelper::saveSttUsage('공사현장사진대지-음성입력', $validated['duration_seconds']); + + return response()->json(['success' => true]); + } } diff --git a/app/Http/Controllers/Juil/MeetingMinuteController.php b/app/Http/Controllers/Juil/MeetingMinuteController.php new file mode 100644 index 00000000..3aece9e3 --- /dev/null +++ b/app/Http/Controllers/Juil/MeetingMinuteController.php @@ -0,0 +1,273 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('juil.meeting-minutes.index')); + } + + return view('juil.meeting-minutes'); + } + + public function list(Request $request): JsonResponse + { + $params = $request->only(['search', 'date_from', 'date_to', 'status', 'per_page']); + $meetings = $this->service->getList($params); + + return response()->json([ + 'success' => true, + 'data' => $meetings, + ]); + } + + public function show(int $id): JsonResponse + { + $meeting = MeetingMinute::with(['user', 'segments'])->find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $meeting, + ]); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'nullable|string|max:300', + 'folder' => 'nullable|string|max:100', + 'participants' => 'nullable|array', + 'meeting_date' => 'nullable|date', + 'meeting_time' => 'nullable', + 'stt_language' => 'nullable|string|max:10', + ]); + + $meeting = $this->service->create($validated); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 생성되었습니다.', + 'data' => $meeting, + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'title' => 'nullable|string|max:300', + 'folder' => 'nullable|string|max:100', + 'participants' => 'nullable|array', + 'meeting_date' => 'nullable|date', + 'meeting_time' => 'nullable', + ]); + + $meeting = $this->service->update($meeting, $validated); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 수정되었습니다.', + 'data' => $meeting, + ]); + } + + public function destroy(int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $this->service->delete($meeting); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 삭제되었습니다.', + ]); + } + + public function saveSegments(Request $request, int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'segments' => 'required|array', + 'segments.*.speaker_name' => 'required|string|max:100', + 'segments.*.speaker_label' => 'nullable|string|max:20', + 'segments.*.text' => 'required|string', + 'segments.*.start_time_ms' => 'nullable|integer|min:0', + 'segments.*.end_time_ms' => 'nullable|integer|min:0', + 'segments.*.is_manual_speaker' => 'nullable|boolean', + ]); + + $meeting = $this->service->saveSegments($meeting, $validated['segments']); + + return response()->json([ + 'success' => true, + 'message' => '세그먼트가 저장되었습니다.', + 'data' => $meeting, + ]); + } + + public function uploadAudio(Request $request, int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'audio' => 'required|file|max:102400', + 'duration_seconds' => 'required|integer|min:0', + ]); + + $result = $this->service->uploadAudio($meeting, $request->file('audio'), $validated['duration_seconds']); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '오디오 업로드에 실패했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '오디오가 업로드되었습니다.', + 'data' => $meeting->fresh(), + ]); + } + + public function summarize(int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + if (empty($meeting->full_transcript)) { + return response()->json([ + 'success' => false, + 'message' => '요약할 텍스트가 없습니다. 먼저 녹음을 진행해주세요.', + ], 422); + } + + $result = $this->service->generateSummary($meeting); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => 'AI 요약에 실패했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => 'AI 요약이 완료되었습니다.', + 'data' => $meeting->fresh(), + ]); + } + + public function downloadAudio(Request $request, int $id): Response|JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + if (! $meeting->audio_file_path) { + return response()->json([ + 'success' => false, + 'message' => '오디오 파일이 없습니다.', + ], 404); + } + + $googleCloudService = app(GoogleCloudService::class); + $content = $googleCloudService->downloadFromStorage($meeting->audio_file_path); + + if (! $content) { + return response()->json([ + 'success' => false, + 'message' => '파일 다운로드에 실패했습니다.', + ], 500); + } + + $extension = pathinfo($meeting->audio_file_path, PATHINFO_EXTENSION) ?: 'webm'; + $mimeType = 'audio/' . $extension; + + $safeTitle = preg_replace('/[\/\\\\:*?"<>|]/', '_', $meeting->title); + $filename = "{$safeTitle}.{$extension}"; + $encodedFilename = rawurlencode($filename); + + return response($content) + ->header('Content-Type', $mimeType) + ->header('Content-Length', strlen($content)) + ->header('Content-Disposition', "attachment; filename=\"{$encodedFilename}\"; filename*=UTF-8''{$encodedFilename}") + ->header('Cache-Control', 'private, max-age=3600'); + } + + public function logSttUsage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'duration_seconds' => 'required|integer|min:1', + ]); + + $this->service->logSttUsage($validated['duration_seconds']); + + return response()->json(['success' => true]); + } +} diff --git a/app/Models/Juil/MeetingMinute.php b/app/Models/Juil/MeetingMinute.php new file mode 100644 index 00000000..b2b405c1 --- /dev/null +++ b/app/Models/Juil/MeetingMinute.php @@ -0,0 +1,79 @@ + 'array', + 'decisions' => 'array', + 'action_items' => 'array', + 'meeting_date' => 'date', + 'duration_seconds' => 'integer', + 'audio_file_size' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function segments(): HasMany + { + return $this->hasMany(MeetingMinuteSegment::class)->orderBy('segment_order'); + } + + public function getFormattedDurationAttribute(): string + { + $seconds = $this->duration_seconds; + $hours = floor($seconds / 3600); + $minutes = floor(($seconds % 3600) / 60); + $secs = $seconds % 60; + + if ($hours > 0) { + return sprintf('%d:%02d:%02d', $hours, $minutes, $secs); + } + + return sprintf('%02d:%02d', $minutes, $secs); + } +} diff --git a/app/Models/Juil/MeetingMinuteSegment.php b/app/Models/Juil/MeetingMinuteSegment.php new file mode 100644 index 00000000..e2ffc8ad --- /dev/null +++ b/app/Models/Juil/MeetingMinuteSegment.php @@ -0,0 +1,36 @@ + 'integer', + 'start_time_ms' => 'integer', + 'end_time_ms' => 'integer', + 'is_manual_speaker' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function meetingMinute(): BelongsTo + { + return $this->belongsTo(MeetingMinute::class); + } +} diff --git a/app/Services/ConstructionSitePhotoService.php b/app/Services/ConstructionSitePhotoService.php index 9dc562b7..ac4aaf52 100644 --- a/app/Services/ConstructionSitePhotoService.php +++ b/app/Services/ConstructionSitePhotoService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Helpers\AiTokenHelper; use App\Models\Juil\ConstructionSitePhoto; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; @@ -86,6 +87,8 @@ public function uploadPhoto(ConstructionSitePhoto $photo, $file, string $type): $type . '_photo_size' => $result['size'], ]); + AiTokenHelper::saveGcsStorageUsage('공사현장사진대지-GCS저장', $result['size']); + return true; } diff --git a/app/Services/MeetingMinuteService.php b/app/Services/MeetingMinuteService.php new file mode 100644 index 00000000..17964a68 --- /dev/null +++ b/app/Services/MeetingMinuteService.php @@ -0,0 +1,423 @@ +orderBy('meeting_date', 'desc') + ->orderBy('id', 'desc'); + + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('full_transcript', 'like', "%{$search}%"); + }); + } + + if (! empty($params['date_from'])) { + $query->where('meeting_date', '>=', $params['date_from']); + } + + if (! empty($params['date_to'])) { + $query->where('meeting_date', '<=', $params['date_to']); + } + + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + $perPage = (int) ($params['per_page'] ?? 12); + + return $query->paginate($perPage); + } + + public function create(array $data): MeetingMinute + { + return MeetingMinute::create([ + 'tenant_id' => session('selected_tenant_id'), + 'user_id' => Auth::id(), + 'title' => $data['title'] ?? '무제 회의록', + 'folder' => $data['folder'] ?? null, + 'participants' => $data['participants'] ?? null, + 'meeting_date' => $data['meeting_date'] ?? now()->toDateString(), + 'meeting_time' => $data['meeting_time'] ?? now()->format('H:i'), + 'status' => MeetingMinute::STATUS_DRAFT, + 'stt_language' => $data['stt_language'] ?? 'ko-KR', + ]); + } + + public function update(MeetingMinute $meeting, array $data): MeetingMinute + { + $meeting->update(array_filter([ + 'title' => $data['title'] ?? null, + 'folder' => array_key_exists('folder', $data) ? $data['folder'] : null, + 'participants' => array_key_exists('participants', $data) ? $data['participants'] : null, + 'meeting_date' => $data['meeting_date'] ?? null, + 'meeting_time' => $data['meeting_time'] ?? null, + ], fn ($v) => $v !== null)); + + return $meeting->fresh(); + } + + public function delete(MeetingMinute $meeting): bool + { + if ($meeting->audio_file_path) { + $this->googleCloudService->deleteFromStorage($meeting->audio_file_path); + } + + return $meeting->delete(); + } + + public function saveSegments(MeetingMinute $meeting, array $segments): MeetingMinute + { + // 기존 세그먼트 삭제 후 새로 생성 + $meeting->segments()->delete(); + + $fullTranscript = ''; + + foreach ($segments as $index => $segment) { + MeetingMinuteSegment::create([ + 'meeting_minute_id' => $meeting->id, + 'segment_order' => $index, + 'speaker_name' => $segment['speaker_name'] ?? '화자 1', + 'speaker_label' => $segment['speaker_label'] ?? null, + 'text' => $segment['text'] ?? '', + 'start_time_ms' => $segment['start_time_ms'] ?? 0, + 'end_time_ms' => $segment['end_time_ms'] ?? null, + 'is_manual_speaker' => $segment['is_manual_speaker'] ?? true, + ]); + + $speakerName = $segment['speaker_name'] ?? '화자 1'; + $text = $segment['text'] ?? ''; + $fullTranscript .= "[{$speakerName}] {$text}\n"; + } + + $meeting->update([ + 'full_transcript' => trim($fullTranscript), + 'status' => MeetingMinute::STATUS_DRAFT, + ]); + + return $meeting->fresh()->load('segments'); + } + + public function uploadAudio(MeetingMinute $meeting, $file, int $durationSeconds): bool + { + $extension = $file->getClientOriginalExtension() ?: 'webm'; + $objectName = sprintf( + 'meeting-minutes/%d/%d/%s.%s', + $meeting->tenant_id, + $meeting->id, + now()->format('YmdHis'), + $extension + ); + + $tempPath = $file->getRealPath(); + $result = $this->googleCloudService->uploadToStorage($tempPath, $objectName); + + if (! $result) { + Log::error('MeetingMinute: GCS 오디오 업로드 실패', [ + 'meeting_id' => $meeting->id, + ]); + + return false; + } + + $meeting->update([ + 'audio_file_path' => $objectName, + 'audio_gcs_uri' => $result['uri'], + 'audio_file_size' => $result['size'] ?? $file->getSize(), + 'duration_seconds' => $durationSeconds, + ]); + + AiTokenHelper::saveGcsStorageUsage('회의록-GCS저장', $result['size'] ?? $file->getSize()); + + return true; + } + + public function generateSummary(MeetingMinute $meeting): ?array + { + if (empty($meeting->full_transcript)) { + return null; + } + + $meeting->update(['status' => MeetingMinute::STATUS_PROCESSING]); + + $config = AiConfig::getActiveGemini(); + + if (! $config) { + Log::warning('Gemini API 설정이 없습니다.'); + $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); + + return null; + } + + $prompt = $this->buildSummaryPrompt($meeting->full_transcript); + + try { + $result = $this->callGeminiForSummary($config, $prompt); + + if (! $result) { + $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); + + return null; + } + + $meeting->update([ + 'summary' => $result['summary'] ?? null, + 'decisions' => $result['decisions'] ?? [], + 'action_items' => $result['action_items'] ?? [], + 'status' => MeetingMinute::STATUS_COMPLETED, + ]); + + return $result; + } catch (\Exception $e) { + Log::error('MeetingMinute: Gemini 요약 실패', [ + 'meeting_id' => $meeting->id, + 'error' => $e->getMessage(), + ]); + $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); + + return null; + } + } + + public function logSttUsage(int $durationSeconds): void + { + AiTokenHelper::saveSttUsage('회의록-음성인식', $durationSeconds); + } + + private function buildSummaryPrompt(string $transcript): string + { + return <<isVertexAi()) { + $responseText = $this->callVertexAiApi($config, $prompt); + } else { + $responseText = $this->callGoogleAiStudioApi($config, $prompt); + } + + if (! $responseText) { + return null; + } + + // JSON 파싱 (코드블록 제거) + $cleaned = preg_replace('/```json\s*/', '', $responseText); + $cleaned = preg_replace('/```\s*/', '', $cleaned); + $cleaned = trim($cleaned); + + $parsed = json_decode($cleaned, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Log::warning('MeetingMinute: Gemini JSON 파싱 실패', [ + 'response' => mb_substr($responseText, 0, 500), + ]); + + return [ + 'summary' => $responseText, + 'decisions' => [], + 'action_items' => [], + 'keywords' => [], + ]; + } + + return $parsed; + } + + private function callGoogleAiStudioApi(AiConfig $config, string $prompt): ?string + { + $model = $config->model; + $apiKey = $config->api_key; + $baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; + + $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; + + return $this->callGeminiApi($url, $prompt, [ + 'Content-Type' => 'application/json', + ], false); + } + + private function callVertexAiApi(AiConfig $config, string $prompt): ?string + { + $model = $config->model; + $projectId = $config->getProjectId(); + $region = $config->getRegion(); + + if (! $projectId) { + Log::error('Vertex AI 프로젝트 ID가 설정되지 않았습니다.'); + + return null; + } + + $accessToken = $this->getAccessToken($config); + if (! $accessToken) { + Log::error('Google Cloud 인증 실패'); + + return null; + } + + $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; + + return $this->callGeminiApi($url, $prompt, [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + ], true); + } + + private function getAccessToken(AiConfig $config): ?string + { + $configuredPath = $config->getServiceAccountPath(); + + $possiblePaths = array_filter([ + $configuredPath, + '/var/www/sales/apikey/google_service_account.json', + storage_path('app/google_service_account.json'), + ]); + + $serviceAccountPath = null; + foreach ($possiblePaths as $path) { + if ($path && file_exists($path)) { + $serviceAccountPath = $path; + break; + } + } + + if (! $serviceAccountPath) { + Log::error('Service account file not found', ['tried_paths' => $possiblePaths]); + + return null; + } + + $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); + if (! $serviceAccount) { + return null; + } + + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/cloud-platform', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ])); + + $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); + if (! $privateKey) { + return null; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + + try { + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + if ($response->successful()) { + return $response->json()['access_token'] ?? null; + } + + return null; + } catch (\Exception $e) { + Log::error('OAuth token request exception', ['error' => $e->getMessage()]); + + return null; + } + } + + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function callGeminiApi(string $url, string $prompt, array $headers, bool $isVertexAi = false): ?string + { + $content = [ + 'parts' => [ + ['text' => $prompt], + ], + ]; + + if ($isVertexAi) { + $content['role'] = 'user'; + } + + try { + $response = Http::timeout(120) + ->withHeaders($headers) + ->post($url, [ + 'contents' => [$content], + 'generationConfig' => [ + 'temperature' => 0.3, + 'topK' => 40, + 'topP' => 0.95, + 'maxOutputTokens' => 4096, + ], + ]); + + if (! $response->successful()) { + Log::error('MeetingMinute Gemini API error', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + $result = $response->json(); + + AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', '회의록-AI요약'); + + return $result['candidates'][0]['content']['parts'][0]['text'] ?? null; + } catch (\Exception $e) { + Log::error('MeetingMinute Gemini API 예외', ['error' => $e->getMessage()]); + + return null; + } + } +} diff --git a/resources/views/juil/construction-photos.blade.php b/resources/views/juil/construction-photos.blade.php index dd7e0bb5..4555092c 100644 --- a/resources/views/juil/construction-photos.blade.php +++ b/resources/views/juil/construction-photos.blade.php @@ -25,6 +25,7 @@ deletePhoto: (id, type) => `/juil/construction-photos/${id}/photo/${type}`, downloadPhoto: (id, type) => `/juil/construction-photos/${id}/download/${type}`, photoUrl: (id, type) => `/juil/construction-photos/${id}/download/${type}?inline=1`, + logSttUsage: '/juil/construction-photos/log-stt-usage', }; const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || ''; @@ -53,6 +54,188 @@ return res.json(); } +// --- VoiceInputButton (Web Speech API STT) --- +// 규칙: 미확정=이탤릭+회색, 확정=일반체+진한색, 삭제금지, 교정허용, 부드러운 전환 +function VoiceInputButton({ onResult, disabled }) { + const [recording, setRecording] = useState(false); + const [finalizedSegments, setFinalizedSegments] = useState([]); + const [interimText, setInterimText] = useState(''); + const recognitionRef = useRef(null); + const startTimeRef = useRef(null); + const dismissTimerRef = useRef(null); + const previewRef = useRef(null); + + const isSupported = typeof window !== 'undefined' && + (window.SpeechRecognition || window.webkitSpeechRecognition); + + const logUsage = useCallback((startTime) => { + const duration = Math.max(1, Math.round((Date.now() - startTime) / 1000)); + apiFetch(API.logSttUsage, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ duration_seconds: duration }), + }).catch(() => {}); + }, []); + + // 프리뷰 자동 스크롤 + useEffect(() => { + if (previewRef.current) { + previewRef.current.scrollTop = previewRef.current.scrollHeight; + } + }, [finalizedSegments, interimText]); + + const stopRecording = useCallback(() => { + recognitionRef.current?.stop(); + recognitionRef.current = null; + if (startTimeRef.current) { + logUsage(startTimeRef.current); + startTimeRef.current = null; + } + setRecording(false); + setInterimText(''); + // 녹음 종료 후 2초 뒤 프리뷰 닫기 + dismissTimerRef.current = setTimeout(() => { + setFinalizedSegments([]); + }, 2000); + }, [logUsage]); + + const startRecording = useCallback(() => { + // 이전 타이머 정리 + if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current); dismissTimerRef.current = null; } + + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + const recognition = new SR(); + recognition.lang = 'ko-KR'; + recognition.continuous = true; + recognition.interimResults = true; + recognition.maxAlternatives = 1; + + recognition.onresult = (event) => { + if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current); dismissTimerRef.current = null; } + + let currentInterim = ''; + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + // 확정: input에 반영 + 로그에 영구 저장 + onResult(transcript); + setFinalizedSegments(prev => [...prev, transcript]); + currentInterim = ''; + } else { + // 미확정: 교정은 허용하되 이전 확정분은 보존 + currentInterim = transcript; + } + } + setInterimText(currentInterim); + }; + recognition.onerror = () => stopRecording(); + recognition.onend = () => { + if (startTimeRef.current) { + logUsage(startTimeRef.current); + startTimeRef.current = null; + } + setRecording(false); + setInterimText(''); + recognitionRef.current = null; + dismissTimerRef.current = setTimeout(() => { + setFinalizedSegments([]); + }, 2000); + }; + + recognitionRef.current = recognition; + startTimeRef.current = Date.now(); + setFinalizedSegments([]); + setInterimText(''); + recognition.start(); + setRecording(true); + }, [onResult, stopRecording, logUsage]); + + const toggle = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + if (disabled || !isSupported) return; + recording ? stopRecording() : startRecording(); + }, [disabled, isSupported, recording, stopRecording, startRecording]); + + useEffect(() => { + return () => { + recognitionRef.current?.stop(); + if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current); + }; + }, []); + + if (!isSupported) return null; + + const hasContent = finalizedSegments.length > 0 || interimText; + + return ( +
+ + + {/* 스트리밍 프리뷰 패널 */} + {(recording || hasContent) && ( +
+ {/* 확정 텍스트: 일반체 + 흰색 - 삭제되지 않음 */} + {finalizedSegments.map((seg, i) => ( + + {seg} + + ))} + + {/* 미확정 텍스트: 이탤릭 + 연한 회색 - 교정 가능 */} + {interimText && ( + + {interimText} + + )} + + {/* 녹음 중 + 텍스트 없음: 대기 표시 */} + {recording && !hasContent && ( + + + 말씀하세요... + + )} + + {/* 녹음 종료 후 확정 텍스트만 남아있을 때 */} + {!recording && finalizedSegments.length > 0 && !interimText && ( + + )} +
+ )} +
+ ); +} + // --- ToastNotification --- function ToastNotification({ message, type, onClose }) { useEffect(() => { @@ -234,14 +417,17 @@ function CreateModal({ show, onClose, onCreate }) {
- setSiteName(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm" - placeholder="공사 현장명을 입력하세요" - autoFocus - /> +
+ setSiteName(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm" + placeholder="공사 현장명을 입력하세요" + autoFocus + /> + setSiteName(prev => prev ? prev + ' ' + text : text)} /> +
@@ -254,13 +440,16 @@ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus
-