Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-02-10 10:27:26 +09:00
11 changed files with 2074 additions and 85 deletions

View File

@@ -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]);
}
}

View File

@@ -0,0 +1,273 @@
<?php
namespace App\Http\Controllers\Juil;
use App\Helpers\AiTokenHelper;
use App\Http\Controllers\Controller;
use App\Models\Juil\MeetingMinute;
use App\Services\GoogleCloudService;
use App\Services\MeetingMinuteService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class MeetingMinuteController extends Controller
{
public function __construct(
private readonly MeetingMinuteService $service
) {}
public function index(Request $request): View|Response
{
if ($request->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]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Models\Juil;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class MeetingMinute extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'meeting_minutes';
const STATUS_DRAFT = 'DRAFT';
const STATUS_RECORDING = 'RECORDING';
const STATUS_PROCESSING = 'PROCESSING';
const STATUS_COMPLETED = 'COMPLETED';
const STATUS_FAILED = 'FAILED';
protected $fillable = [
'tenant_id',
'user_id',
'title',
'folder',
'participants',
'meeting_date',
'meeting_time',
'duration_seconds',
'audio_file_path',
'audio_gcs_uri',
'audio_file_size',
'full_transcript',
'summary',
'decisions',
'action_items',
'status',
'stt_language',
];
protected $casts = [
'participants' => '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);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\Juil;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MeetingMinuteSegment extends Model
{
protected $table = 'meeting_minute_segments';
protected $fillable = [
'meeting_minute_id',
'segment_order',
'speaker_name',
'speaker_label',
'text',
'start_time_ms',
'end_time_ms',
'is_manual_speaker',
];
protected $casts = [
'segment_order' => '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);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,423 @@
<?php
namespace App\Services;
use App\Helpers\AiTokenHelper;
use App\Models\Juil\MeetingMinute;
use App\Models\Juil\MeetingMinuteSegment;
use App\Models\System\AiConfig;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MeetingMinuteService
{
public function __construct(
private readonly GoogleCloudService $googleCloudService
) {}
public function getList(array $params): LengthAwarePaginator
{
$query = MeetingMinute::with('user:id,name')
->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 <<<PROMPT
다음은 회의 녹취록입니다. 이 내용을 분석하여 아래 JSON 형식으로 정확히 응답해주세요.
다른 텍스트 없이 JSON만 응답해주세요.
## 응답 형식 (JSON):
{
"summary": "회의 전체 요약 (3-5문장)",
"decisions": ["결정사항 1", "결정사항 2"],
"action_items": [
{"assignee": "담당자명", "task": "할일 내용", "deadline": "기한 또는 null"}
],
"keywords": ["핵심 키워드1", "핵심 키워드2"]
}
## 주의사항:
- summary는 회의의 핵심 내용을 간결하게 요약
- decisions는 회의에서 확정된 결정사항만 포함
- action_items의 assignee는 녹취록에서 파악 가능한 경우만 기입, 불명확하면 "미정"
- keywords는 3-5개의 핵심 키워드
## 녹취록:
{$transcript}
PROMPT;
}
private function callGeminiForSummary(AiConfig $config, string $prompt): ?array
{
if ($config->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;
}
}
}

View File

@@ -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 (
<div className="relative flex-shrink-0">
<button
type="button"
onClick={toggle}
disabled={disabled}
title={recording ? '녹음 중지 (클릭)' : '음성으로 입력'}
className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all
${recording
? 'bg-red-500 text-white shadow-lg shadow-red-200'
: 'bg-gray-100 text-gray-500 hover:bg-blue-100 hover:text-blue-600'}
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}`}
>
{recording ? (
<span className="relative flex items-center justify-center w-4 h-4">
<span className="absolute inset-0 rounded-full bg-white/30 animate-ping"></span>
<svg className="w-3.5 h-3.5 relative" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</span>
) : (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
)}
</button>
{/* 스트리밍 프리뷰 패널 */}
{(recording || hasContent) && (
<div
ref={previewRef}
className="absolute bottom-full mb-2 right-0 bg-gray-900 rounded-lg shadow-xl z-50
w-[300px] max-h-[120px] overflow-y-auto px-3 py-2"
style={{ lineHeight: '1.6' }}
>
{/* 확정 텍스트: 일반체 + 흰색 - 삭제되지 않음 */}
{finalizedSegments.map((seg, i) => (
<span key={i} className="text-white text-xs font-normal transition-colors duration-300">
{seg}
</span>
))}
{/* 미확정 텍스트: 이탤릭 + 연한 회색 - 교정 가능 */}
{interimText && (
<span className="text-gray-400 text-xs italic transition-colors duration-200">
{interimText}
</span>
)}
{/* 녹음 중 + 텍스트 없음: 대기 표시 */}
{recording && !hasContent && (
<span className="text-gray-500 text-xs flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 bg-red-400 rounded-full animate-pulse"></span>
말씀하세요...
</span>
)}
{/* 녹음 종료 후 확정 텍스트만 남아있을 때 */}
{!recording && finalizedSegments.length > 0 && !interimText && (
<span className="text-green-400 text-xs ml-1">&#10003;</span>
)}
</div>
)}
</div>
);
}
// --- ToastNotification ---
function ToastNotification({ message, type, onClose }) {
useEffect(() => {
@@ -234,14 +417,17 @@ function CreateModal({ show, onClose, onCreate }) {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">현장명 *</label>
<input
type="text"
value={siteName}
onChange={e => 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
/>
<div className="flex items-center gap-2">
<input
type="text"
value={siteName}
onChange={e => 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
/>
<VoiceInputButton onResult={(text) => setSiteName(prev => prev ? prev + ' ' + text : text)} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">작업일자 *</label>
@@ -254,13 +440,16 @@ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea
value={description}
onChange={e => setDescription(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"
rows={3}
placeholder="작업 내용을 간단히 기록하세요"
/>
<div className="flex items-start gap-2">
<textarea
value={description}
onChange={e => setDescription(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"
rows={3}
placeholder="작업 내용을 간단히 기록하세요"
/>
<VoiceInputButton onResult={(text) => setDescription(prev => prev ? prev + ' ' + text : text)} />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">
@@ -327,12 +516,15 @@ function DetailModal({ item, onClose, onUpload, onDeletePhoto, onUpdate, onDelet
{/* Header */}
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl z-10">
{editing ? (
<input
type="text"
value={siteName}
onChange={e => setSiteName(e.target.value)}
className="text-lg font-bold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
/>
<div className="flex items-center gap-2 flex-1 mr-3">
<input
type="text"
value={siteName}
onChange={e => setSiteName(e.target.value)}
className="text-lg font-bold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1"
/>
<VoiceInputButton onResult={(text) => setSiteName(prev => prev ? prev + ' ' + text : text)} />
</div>
) : (
<h2 className="text-lg font-bold text-gray-900">{item.site_name}</h2>
)}
@@ -351,8 +543,11 @@ className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-xs text-gray-500 mb-1">설명</label>
<textarea value={description} onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm" rows={2} />
<div className="flex items-start gap-2">
<textarea value={description} onChange={e => setDescription(e.target.value)}
className="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm" rows={2} />
<VoiceInputButton onResult={(text) => setDescription(prev => prev ? prev + ' ' + text : text)} />
</div>
</div>
</>
) : (
@@ -444,6 +639,7 @@ function App() {
const [showCreate, setShowCreate] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
const [toast, setToast] = useState(null);
const modalDirtyRef = useRef(false);
const showToast = (message, type = 'success') => setToast({ message, type });
@@ -480,7 +676,7 @@ function App() {
body: JSON.stringify(data),
});
showToast('사진대지가 등록되었습니다.');
fetchList();
modalDirtyRef.current = true;
// 생성 후 바로 상세 열기
setSelectedItem(res.data);
};
@@ -495,21 +691,21 @@ function App() {
body: formData,
});
showToast('사진이 업로드되었습니다.');
// 상세 모달 데이터 갱신
modalDirtyRef.current = true;
// 모달 데이터만 갱신 (배경 리스트는 모달 닫힐 때)
if (selectedItem?.id === id) {
setSelectedItem(res.data);
}
fetchList();
};
const handleDeletePhoto = async (id, type) => {
if (!confirm(`${TYPE_LABELS[type]} 사진을 삭제하시겠습니까?`)) return;
const res = await apiFetch(API.deletePhoto(id, type), { method: 'DELETE' });
showToast('사진이 삭제되었습니다.');
modalDirtyRef.current = true;
if (selectedItem?.id === id) {
setSelectedItem(res.data);
}
fetchList();
};
const handleUpdate = async (id, data) => {
@@ -519,16 +715,17 @@ function App() {
body: JSON.stringify(data),
});
showToast('사진대지가 수정되었습니다.');
fetchList();
modalDirtyRef.current = true;
};
const handleDelete = async (id) => {
await apiFetch(API.destroy(id), { method: 'DELETE' });
showToast('사진대지가 삭제되었습니다.');
fetchList();
modalDirtyRef.current = true;
};
const handleSelectItem = async (item) => {
modalDirtyRef.current = false;
try {
const res = await apiFetch(API.show(item.id));
setSelectedItem(res.data);
@@ -537,6 +734,14 @@ function App() {
}
};
const handleCloseDetail = useCallback(() => {
setSelectedItem(null);
if (modalDirtyRef.current) {
modalDirtyRef.current = false;
fetchList();
}
}, [fetchList]);
const refreshSelected = async () => {
if (!selectedItem) return;
try {
@@ -662,7 +867,7 @@ className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50
<CreateModal show={showCreate} onClose={() => setShowCreate(false)} onCreate={handleCreate} />
<DetailModal
item={selectedItem}
onClose={() => setSelectedItem(null)}
onClose={handleCloseDetail}
onUpload={handleUpload}
onDeletePhoto={handleDeletePhoto}
onUpdate={handleUpdate}

View File

@@ -0,0 +1,915 @@
@extends('layouts.app')
@section('title', '회의록 작성')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
@verbatim
const { useState, useEffect, useCallback, useRef, useMemo } = React;
const API = {
list: '/juil/meeting-minutes/list',
store: '/juil/meeting-minutes',
show: (id) => `/juil/meeting-minutes/${id}`,
update: (id) => `/juil/meeting-minutes/${id}`,
destroy: (id) => `/juil/meeting-minutes/${id}`,
saveSegments: (id) => `/juil/meeting-minutes/${id}/segments`,
uploadAudio: (id) => `/juil/meeting-minutes/${id}/upload-audio`,
summarize: (id) => `/juil/meeting-minutes/${id}/summarize`,
downloadAudio: (id) => `/juil/meeting-minutes/${id}/download-audio`,
logSttUsage: '/juil/meeting-minutes/log-stt-usage',
};
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
const SPEAKER_COLORS = [
{ name: '화자 1', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-800', dot: 'bg-blue-500' },
{ name: '화자 2', bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', badge: 'bg-green-100 text-green-800', dot: 'bg-green-500' },
{ name: '화자 3', bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', badge: 'bg-purple-100 text-purple-800', dot: 'bg-purple-500' },
{ name: '화자 4', bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', badge: 'bg-orange-100 text-orange-800', dot: 'bg-orange-500' },
];
const STATUS_LABELS = {
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-800' },
RECORDING: { label: '녹음중', color: 'bg-red-100 text-red-800' },
PROCESSING: { label: '처리중', color: 'bg-yellow-100 text-yellow-800' },
COMPLETED: { label: '완료', color: 'bg-green-100 text-green-800' },
FAILED: { label: '실패', color: 'bg-red-100 text-red-800' },
};
const LANGUAGES = [
{ code: 'ko-KR', label: '한국어' },
{ code: 'en-US', label: 'English' },
{ code: 'ja-JP', label: '日本語' },
{ code: 'zh-CN', label: '中文' },
];
async function apiFetch(url, options = {}) {
const { headers: optHeaders, ...restOptions } = options;
const res = await fetch(url, {
...restOptions,
headers: {
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json',
...optHeaders,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: '요청 처리 중 오류가 발생했습니다.' }));
throw new Error(err.message || `HTTP ${res.status}`);
}
return res.json();
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function formatMs(ms) {
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
// ========== Toast ==========
function ToastNotification({ toast, onClose }) {
useEffect(() => {
if (toast) {
const t = setTimeout(onClose, 3000);
return () => clearTimeout(t);
}
}, [toast]);
if (!toast) return null;
const colors = toast.type === 'error' ? 'bg-red-500' : toast.type === 'warning' ? 'bg-yellow-500' : 'bg-green-500';
return (
<div className={`fixed top-4 right-4 z-50 ${colors} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 max-w-sm`}>
<span className="flex-1 text-sm">{toast.message}</span>
<button onClick={onClose} className="text-white/80 hover:text-white">&times;</button>
</div>
);
}
// ========== AlertModal ==========
function AlertModal({ show, title, message, icon, onClose }) {
if (!show) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-xl shadow-2xl max-w-sm w-full mx-4 p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-col items-center text-center">
{icon === 'warning' && (
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
<svg className="w-7 h-7 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.072 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
</div>
)}
{icon === 'info' && (
<div className="w-14 h-14 rounded-full bg-blue-100 flex items-center justify-center mb-4">
<svg className="w-7 h-7 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
)}
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-sm text-gray-500 leading-relaxed">{message}</p>
<button onClick={onClose} className="mt-5 w-full bg-blue-600 text-white py-2.5 rounded-lg hover:bg-blue-700 transition text-sm font-medium">확인</button>
</div>
</div>
</div>
);
}
// ========== MeetingCard ==========
function MeetingCard({ meeting, onClick }) {
const st = STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT;
const date = meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString('ko-KR') : '';
const participants = meeting.participants || [];
return (
<div onClick={() => onClick(meeting.id)} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md hover:border-blue-300 transition-all cursor-pointer">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-gray-900 truncate flex-1">{meeting.title}</h3>
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs font-medium ${st.color}`}>{st.label}</span>
</div>
<div className="text-sm text-gray-500 space-y-1">
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span>{date}</span>
{meeting.duration_seconds > 0 && <span className="text-gray-400">| {formatDuration(meeting.duration_seconds)}</span>}
</div>
{participants.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{participants.slice(0, 3).map((p, i) => (
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-600">{p}</span>
))}
{participants.length > 3 && <span className="text-xs text-gray-400">+{participants.length - 3}</span>}
</div>
)}
{meeting.folder && <div className="text-xs text-gray-400 mt-1">📁 {meeting.folder}</div>}
</div>
</div>
);
}
// ========== MeetingList ==========
function MeetingList({ onSelect, onNew, showToast }) {
const [meetings, setMeetings] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [pagination, setPagination] = useState(null);
const loadMeetings = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ per_page: 12, page });
if (search) params.set('search', search);
const res = await apiFetch(`${API.list}?${params}`);
setMeetings(res.data.data || []);
setPagination({ current_page: res.data.current_page, last_page: res.data.last_page, total: res.data.total });
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading(false);
}
}, [search]);
useEffect(() => { loadMeetings(); }, [loadMeetings]);
const handleCreate = async () => {
try {
const res = await apiFetch(API.store, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '무제 회의록', meeting_date: new Date().toISOString().split('T')[0] }),
});
showToast('새 회의록이 생성되었습니다.');
onSelect(res.data.id);
} catch (e) {
showToast(e.message, 'error');
}
};
return (
<div className="max-w-6xl mx-auto p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">회의록</h1>
<p className="text-sm text-gray-500 mt-1">AI 음성 인식 + 자동 요약 회의록</p>
</div>
<button onClick={handleCreate} className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition flex items-center gap-2 text-sm font-medium">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
회의록
</button>
</div>
<div className="mb-4">
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="회의록 검색..." className="w-full max-w-md px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm" />
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : meetings.length === 0 ? (
<div className="text-center py-20 text-gray-500">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
<p className="text-lg font-medium">회의록이 없습니다</p>
<p className="text-sm mt-1"> 회의록을 만들어 음성 녹음을 시작하세요.</p>
<button onClick={handleCreate} className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm"> 회의록 만들기</button>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{meetings.map(m => <MeetingCard key={m.id} meeting={m} onClick={onSelect} />)}
</div>
{pagination && pagination.last_page > 1 && (
<div className="flex justify-center gap-2 mt-6">
{Array.from({ length: pagination.last_page }, (_, i) => i + 1).map(p => (
<button key={p} onClick={() => loadMeetings(p)} className={`px-3 py-1 rounded text-sm ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white border text-gray-700 hover:bg-gray-50'}`}>{p}</button>
))}
</div>
)}
</>
)}
</div>
);
}
// ========== MeetingDetail ==========
function MeetingDetail({ meetingId, onBack, showToast }) {
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('conversation');
const [showSummary, setShowSummary] = useState(true);
// 녹음 상태
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [localSegments, setLocalSegments] = useState([]);
const [interimText, setInterimText] = useState('');
const [currentSpeakerIdx, setCurrentSpeakerIdx] = useState(0);
const [speakers, setSpeakers] = useState([{ name: '화자 1' }, { name: '화자 2' }]);
const [sttLanguage, setSttLanguage] = useState('ko-KR');
// 편집 상태
const [editingTitle, setEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState('');
const [saving, setSaving] = useState(false);
const [summarizing, setSummarizing] = useState(false);
const [alertModal, setAlertModal] = useState(null);
// refs
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const recognitionRef = useRef(null);
const streamRef = useRef(null);
const timerRef = useRef(null);
const transcriptRef = useRef(null);
const startTimeRef = useRef(null);
const loadMeeting = useCallback(async () => {
try {
const res = await apiFetch(API.show(meetingId));
const data = res.data;
setMeeting(data);
setTitleValue(data.title);
setSttLanguage(data.stt_language || 'ko-KR');
if (data.segments && data.segments.length > 0) {
setLocalSegments(data.segments.map(s => ({
speaker_name: s.speaker_name,
text: s.text,
start_time_ms: s.start_time_ms,
end_time_ms: s.end_time_ms,
is_final: true,
})));
// 화자 목록 복원
const uniqueSpeakers = [...new Set(data.segments.map(s => s.speaker_name))];
if (uniqueSpeakers.length > 0) {
setSpeakers(uniqueSpeakers.map(n => ({ name: n })));
}
}
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading(false);
}
}, [meetingId]);
useEffect(() => { loadMeeting(); }, [loadMeeting]);
// 타이머
useEffect(() => {
if (isRecording) {
timerRef.current = setInterval(() => setRecordingTime(prev => prev + 1), 1000);
} else if (timerRef.current) {
clearInterval(timerRef.current);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [isRecording]);
// 자동 스크롤
useEffect(() => {
if (transcriptRef.current) {
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
}
}, [localSegments, interimText]);
const currentSpeaker = speakers[currentSpeakerIdx] || speakers[0];
const speakerColor = SPEAKER_COLORS[currentSpeakerIdx % SPEAKER_COLORS.length];
const getSpeakerColor = (name) => {
const idx = speakers.findIndex(s => s.name === name);
return SPEAKER_COLORS[idx >= 0 ? idx % SPEAKER_COLORS.length : 0];
};
// ===== 녹음 시작 =====
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
// MediaRecorder
const recorder = new MediaRecorder(stream);
audioChunksRef.current = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); };
recorder.start();
mediaRecorderRef.current = recorder;
// Web Speech API
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
const recognition = new SpeechRecognition();
recognition.lang = sttLanguage;
recognition.continuous = true;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onresult = (event) => {
let currentInterim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const text = event.results[i][0].transcript;
if (event.results[i].isFinal) {
const now = Date.now();
const startMs = startTimeRef.current ? now - startTimeRef.current : 0;
setLocalSegments(prev => [...prev, {
speaker_name: currentSpeaker.name,
text: text.trim(),
start_time_ms: startMs,
end_time_ms: null,
is_final: true,
}]);
currentInterim = '';
} else {
currentInterim = text;
}
}
setInterimText(currentInterim);
};
recognition.onerror = (event) => {
if (event.error === 'no-speech' || event.error === 'aborted') return;
console.warn('STT error:', event.error);
};
recognition.onend = () => {
if (isRecordingRef.current && recognitionRef.current) {
try { recognitionRef.current.start(); } catch (e) {}
}
};
recognition.start();
recognitionRef.current = recognition;
}
startTimeRef.current = Date.now();
setRecordingTime(0);
setIsRecording(true);
} catch (e) {
showToast('마이크 접근 권한이 필요합니다.', 'error');
}
};
// isRecording ref (onend에서 접근)
const isRecordingRef = useRef(false);
useEffect(() => { isRecordingRef.current = isRecording; }, [isRecording]);
// ===== 녹음 중지 =====
const stopRecording = async () => {
setIsRecording(false);
if (recognitionRef.current) {
recognitionRef.current.onend = null;
recognitionRef.current.stop();
recognitionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
// MediaRecorder 중지 → blob 생성
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
await new Promise(resolve => {
recorder.onstop = resolve;
recorder.stop();
});
}
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const duration = recordingTime;
// 1. 세그먼트 저장
setSaving(true);
try {
const segmentsToSave = localSegments.filter(s => s.is_final).map((s, i) => ({
speaker_name: s.speaker_name,
text: s.text,
start_time_ms: s.start_time_ms || 0,
end_time_ms: s.end_time_ms || null,
is_manual_speaker: true,
}));
if (segmentsToSave.length > 0) {
await apiFetch(API.saveSegments(meetingId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ segments: segmentsToSave }),
});
}
// 2. 오디오 업로드
if (audioBlob.size > 0 && duration > 0) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
formData.append('duration_seconds', duration);
await apiFetch(API.uploadAudio(meetingId), {
method: 'POST',
body: formData,
});
}
// 3. STT 사용량 로깅
if (duration > 0) {
await apiFetch(API.logSttUsage, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration_seconds: duration }),
}).catch(() => {});
}
showToast('녹음이 저장되었습니다.');
// 4. 자동 요약
if (segmentsToSave.length > 0) {
setSummarizing(true);
try {
await apiFetch(API.summarize(meetingId), { method: 'POST' });
showToast('AI 요약이 완료되었습니다.');
} catch (e) {
showToast('AI 요약 실패: ' + e.message, 'warning');
} finally {
setSummarizing(false);
}
}
// 새로고침
await loadMeeting();
} catch (e) {
showToast('저장 실패: ' + e.message, 'error');
} finally {
setSaving(false);
}
};
// ===== 화자 전환 =====
const switchSpeaker = (idx) => {
setCurrentSpeakerIdx(idx);
};
const addSpeaker = () => {
if (speakers.length >= 4) return;
const newIdx = speakers.length;
setSpeakers(prev => [...prev, { name: `화자 ${newIdx + 1}` }]);
};
// ===== 제목 인라인 편집 =====
const saveTitle = async () => {
if (!titleValue.trim()) return;
try {
await apiFetch(API.update(meetingId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titleValue.trim() }),
});
setMeeting(prev => ({ ...prev, title: titleValue.trim() }));
setEditingTitle(false);
} catch (e) {
showToast(e.message, 'error');
}
};
// ===== 삭제 =====
const handleDelete = async () => {
if (!confirm('이 회의록을 삭제하시겠습니까?')) return;
try {
await apiFetch(API.destroy(meetingId), { method: 'DELETE' });
showToast('회의록이 삭제되었습니다.');
onBack();
} catch (e) {
showToast(e.message, 'error');
}
};
// ===== 노트 복사 =====
const copyNotes = () => {
const lines = localSegments.filter(s => s.is_final).map(s => `[${s.speaker_name}] ${s.text}`);
const text = lines.join('\n');
navigator.clipboard.writeText(text).then(() => showToast('클립보드에 복사되었습니다.'));
};
// ===== 수동 요약 =====
const handleSummarize = async () => {
const hasContent = localSegments.some(s => s.is_final) || (meeting && meeting.full_transcript);
if (!hasContent) {
setAlertModal({
title: '대화 내용이 없습니다',
message: '요약을 실행하려면 먼저 녹음을 진행하여 대화 내용을 기록해주세요.',
icon: 'warning',
});
return;
}
setSummarizing(true);
try {
await apiFetch(API.summarize(meetingId), { method: 'POST' });
showToast('AI 요약이 완료되었습니다.');
await loadMeeting();
} catch (e) {
showToast(e.message, 'error');
} finally {
setSummarizing(false);
}
};
if (loading) {
return <div className="flex items-center justify-center h-96"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>;
}
if (!meeting) {
return <div className="text-center py-20 text-gray-500">회의록을 찾을 없습니다.</div>;
}
const segments = localSegments.filter(s => s.is_final);
return (
<div className="flex flex-col" style={{height: 'calc(100vh - 112px)', margin: '-24px'}}>
{/* Alert Modal */}
<AlertModal show={!!alertModal} title={alertModal?.title} message={alertModal?.message} icon={alertModal?.icon} onClose={() => setAlertModal(null)} />
{/* Header */}
<div className="bg-white border-b px-4 py-3 flex-shrink-0">
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-gray-500 hover:text-gray-700 p-1">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
{editingTitle ? (
<input value={titleValue} onChange={(e) => setTitleValue(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && saveTitle()} autoFocus className="text-lg font-bold border-b-2 border-blue-500 outline-none bg-transparent flex-1" />
) : (
<h1 onClick={() => setEditingTitle(true)} className="text-lg font-bold text-gray-900 cursor-pointer hover:text-blue-600 flex-1 truncate">{meeting.title}</h1>
)}
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={copyNotes} className="text-gray-500 hover:text-gray-700 text-sm flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100" title="노트 복사">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
복사
</button>
{meeting.audio_file_path && (
<a href={API.downloadAudio(meetingId)} className="text-gray-500 hover:text-gray-700 text-sm flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100" title="음성 다운로드">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
</a>
)}
<button onClick={handleDelete} className="text-red-400 hover:text-red-600 p-1" title="삭제">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
<div className="flex items-center gap-3 mt-2 text-sm text-gray-500 flex-wrap">
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString('ko-KR') : ''}
</span>
{meeting.duration_seconds > 0 && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{formatDuration(meeting.duration_seconds)}
</span>
)}
{meeting.participants && meeting.participants.length > 0 && (
<div className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
{meeting.participants.join(', ')}
</div>
)}
{meeting.status && <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${(STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT).color}`}>{(STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT).label}</span>}
</div>
</div>
{/* Tab Bar */}
<div className="bg-white border-b px-4 flex items-center gap-4 flex-shrink-0">
<button onClick={() => setActiveTab('conversation')} className={`py-2.5 text-sm font-medium border-b-2 transition ${activeTab === 'conversation' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>대화 기록</button>
<button onClick={() => setActiveTab('script')} className={`py-2.5 text-sm font-medium border-b-2 transition ${activeTab === 'script' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>스크립트</button>
<div className="flex-1"></div>
<button onClick={() => setShowSummary(!showSummary)} className={`py-2.5 text-sm font-medium flex items-center gap-1 ${showSummary ? 'text-blue-600' : 'text-gray-500'}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
AI 요약
</button>
</div>
{/* Main Content */}
<div className="flex flex-1 overflow-hidden">
{/* Left: Transcript */}
<div className="flex-1 overflow-y-auto p-4" ref={transcriptRef}>
{activeTab === 'conversation' ? (
<ConversationView segments={segments} interimText={interimText} isRecording={isRecording} currentSpeaker={currentSpeaker} getSpeakerColor={getSpeakerColor} />
) : (
<ScriptView segments={segments} interimText={interimText} isRecording={isRecording} />
)}
</div>
{/* Right: Summary Panel */}
{showSummary && (
<div className="w-80 border-l bg-gray-50 overflow-y-auto flex-shrink-0">
<SummaryPanel meeting={meeting} onSummarize={handleSummarize} summarizing={summarizing} />
</div>
)}
</div>
{/* Bottom: Recording Control Bar */}
<RecordingControlBar
isRecording={isRecording}
recordingTime={recordingTime}
currentSpeakerIdx={currentSpeakerIdx}
speakers={speakers}
sttLanguage={sttLanguage}
onStart={startRecording}
onStop={stopRecording}
onSwitchSpeaker={switchSpeaker}
onAddSpeaker={addSpeaker}
onLanguageChange={setSttLanguage}
onSummarize={handleSummarize}
saving={saving}
summarizing={summarizing}
hasSegments={segments.length > 0}
/>
</div>
);
}
// ========== ConversationView ==========
function ConversationView({ segments, interimText, isRecording, currentSpeaker, getSpeakerColor }) {
if (segments.length === 0 && !interimText) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
<p className="text-lg">녹음 버튼을 눌러 시작하세요</p>
<p className="text-sm mt-1">실시간 음성 인식이 대화를 기록합니다</p>
</div>
);
}
// 연속 같은 화자 세그먼트 그룹화
const groups = [];
segments.forEach((seg, i) => {
if (i === 0 || seg.speaker_name !== segments[i - 1].speaker_name) {
groups.push({ speaker_name: seg.speaker_name, texts: [seg] });
} else {
groups[groups.length - 1].texts.push(seg);
}
});
return (
<div className="space-y-3">
{groups.map((group, gi) => {
const color = getSpeakerColor(group.speaker_name);
return (
<div key={gi} className={`rounded-lg border p-3 ${color.bg} ${color.border}`}>
<div className="flex items-center gap-2 mb-1.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${color.badge}`}>
<span className={`w-2 h-2 rounded-full ${color.dot} mr-1`}></span>
{group.speaker_name}
</span>
{group.texts[0].start_time_ms > 0 && (
<span className="text-xs text-gray-400">{formatMs(group.texts[0].start_time_ms)}</span>
)}
</div>
<div className="text-sm text-gray-800 leading-relaxed">
{group.texts.map((t, ti) => <span key={ti}>{ti > 0 ? ' ' : ''}{t.text}</span>)}
</div>
</div>
);
})}
{/* Interim (미확정) */}
{isRecording && interimText && (
<div className="rounded-lg border-2 border-dashed border-gray-300 p-3 bg-white">
<div className="flex items-center gap-2 mb-1.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getSpeakerColor(currentSpeaker.name).badge}`}>
<span className={`w-2 h-2 rounded-full bg-red-500 mr-1 animate-pulse`}></span>
{currentSpeaker.name}
</span>
<span className="text-xs text-gray-400">인식 ...</span>
</div>
<div className="text-sm text-gray-400 italic leading-relaxed">{interimText}</div>
</div>
)}
</div>
);
}
// ========== ScriptView ==========
function ScriptView({ segments, interimText, isRecording }) {
if (segments.length === 0 && !interimText) {
return <div className="text-center py-20 text-gray-400">스크립트가 없습니다.</div>;
}
return (
<div className="prose prose-sm max-w-none">
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
{segments.map((s, i) => <span key={i}>{s.text}{i < segments.length - 1 ? ' ' : ''}</span>)}
{isRecording && interimText && <span className="text-gray-400 italic"> {interimText}</span>}
</div>
</div>
);
}
// ========== SummaryPanel ==========
function SummaryPanel({ meeting, onSummarize, summarizing }) {
const hasSummary = meeting.summary || (meeting.decisions && meeting.decisions.length > 0) || (meeting.action_items && meeting.action_items.length > 0);
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 text-sm">AI 요약</h3>
<button onClick={onSummarize} disabled={summarizing} className="text-xs text-blue-600 hover:text-blue-700 disabled:text-gray-400 flex items-center gap-1">
{summarizing ? (
<><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-600"></div> 요약 ...</>
) : (
<><svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg> 요약 실행</>
)}
</button>
</div>
{!hasSummary ? (
<div className="text-center py-8 text-gray-400">
<svg className="w-10 h-10 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
<p className="text-sm">녹음 완료 AI가 자동으로 요약합니다</p>
</div>
) : (
<div className="space-y-4">
{meeting.summary && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">회의 요약</h4>
<p className="text-sm text-gray-700 leading-relaxed">{meeting.summary}</p>
</div>
)}
{meeting.decisions && meeting.decisions.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">결정사항</h4>
<ul className="space-y-1">
{meeting.decisions.map((d, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
<span className="text-green-500 mt-0.5">&#10003;</span>
<span>{d}</span>
</li>
))}
</ul>
</div>
)}
{meeting.action_items && meeting.action_items.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">액션 아이템</h4>
<ul className="space-y-2">
{meeting.action_items.map((item, i) => (
<li key={i} className="bg-white rounded border p-2">
<div className="text-sm text-gray-800">{item.task || item}</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
{item.assignee && <span className="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">{item.assignee}</span>}
{item.deadline && <span className="bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded">{item.deadline}</span>}
</div>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}
// ========== RecordingControlBar ==========
function RecordingControlBar({ isRecording, recordingTime, currentSpeakerIdx, speakers, sttLanguage, onStart, onStop, onSwitchSpeaker, onAddSpeaker, onLanguageChange, onSummarize, saving, summarizing, hasSegments }) {
return (
<div className="bg-white border-t shadow-lg px-4 py-3 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
{/* Language */}
<div className="flex items-center gap-2">
<select value={sttLanguage} onChange={(e) => onLanguageChange(e.target.value)} disabled={isRecording} className="text-sm border rounded px-2 py-1.5 bg-white disabled:bg-gray-100 disabled:text-gray-400">
{LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
</select>
</div>
{/* Speaker Selection */}
<div className="flex items-center gap-1">
{speakers.map((sp, idx) => {
const c = SPEAKER_COLORS[idx % SPEAKER_COLORS.length];
return (
<button key={idx} onClick={() => onSwitchSpeaker(idx)} className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${currentSpeakerIdx === idx ? `${c.badge} ring-2 ring-offset-1 ring-blue-400` : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{sp.name}
</button>
);
})}
{speakers.length < 4 && (
<button onClick={onAddSpeaker} disabled={isRecording} className="px-2 py-1.5 rounded-full text-xs text-gray-400 hover:text-gray-600 hover:bg-gray-100 disabled:opacity-50" title="화자 추가">+</button>
)}
</div>
{/* Record Button */}
<div className="flex items-center gap-3">
{isRecording ? (
<button onClick={onStop} disabled={saving} className="bg-red-600 text-white px-5 py-2 rounded-full hover:bg-red-700 transition flex items-center gap-2 text-sm font-medium shadow-lg disabled:opacity-50">
<span className="w-3 h-3 bg-white rounded-sm"></span>
중지
</button>
) : (
<button onClick={onStart} disabled={saving} className="bg-red-500 text-white px-5 py-2 rounded-full hover:bg-red-600 transition flex items-center gap-2 text-sm font-medium shadow-lg disabled:opacity-50">
<span className="w-3 h-3 bg-white rounded-full"></span>
녹음
</button>
)}
<span className="text-sm font-mono text-gray-600 min-w-[60px]">{formatDuration(recordingTime)}</span>
{isRecording && <span className="flex items-center gap-1 text-xs text-red-500"><span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>REC</span>}
</div>
{/* AI Summary Button */}
<div className="flex items-center gap-2">
{saving && <span className="text-xs text-blue-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>저장 ...</span>}
{summarizing && <span className="text-xs text-purple-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-purple-500"></div>요약 ...</span>}
<button onClick={onSummarize} disabled={summarizing || isRecording || !hasSegments} className="bg-purple-600 text-white px-3 py-1.5 rounded-lg hover:bg-purple-700 transition text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
AI 요약
</button>
</div>
</div>
</div>
);
}
// ========== App ==========
function App() {
const [currentView, setCurrentView] = useState('list');
const [selectedId, setSelectedId] = useState(null);
const [toast, setToast] = useState(null);
const showToast = useCallback((message, type = 'success') => {
setToast({ message, type });
}, []);
const handleSelect = (id) => {
setSelectedId(id);
setCurrentView('detail');
};
const handleBack = () => {
setSelectedId(null);
setCurrentView('list');
};
return (
<>
<ToastNotification toast={toast} onClose={() => setToast(null)} />
{currentView === 'list' ? (
<MeetingList onSelect={handleSelect} showToast={showToast} />
) : (
<MeetingDetail meetingId={selectedId} onBack={handleBack} showToast={showToast} />
)}
</>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
@endverbatim
</script>
@endpush

View File

@@ -14,6 +14,7 @@
timer: 0,
transcript: '',
interimTranscript: '',
finalizedSegments: [],
status: '마이크 버튼을 눌러 녹음을 시작하세요',
saving: false,
saveProgress: 0,
@@ -127,27 +128,35 @@
this.transcript = '';
this.interimTranscript = '';
this.finalizedSegments = [];
let confirmedResults = [];
// 규칙: interim=이탤릭+회색(교정 허용), final=일반체+진한색(삭제 불가)
this.recognition.onresult = (event) => {
let interimTranscript = '';
let currentInterim = '';
for (let i = 0; i < event.results.length; i++) {
const result = event.results[i];
const text = result[0].transcript;
for (let i = event.resultIndex; i < event.results.length; i++) {
const text = event.results[i][0].transcript;
if (result.isFinal) {
if (!confirmedResults[i]) {
confirmedResults[i] = text;
}
if (event.results[i].isFinal) {
// 확정: finalizedSegments에 영구 저장 (삭제 불가)
this.finalizedSegments.push(text);
currentInterim = '';
} else {
interimTranscript += text;
// 미확정: 교정은 허용하되 이전 확정분은 보존
currentInterim = text;
}
}
this.transcript = confirmedResults.filter(Boolean).join(' ');
this.interimTranscript = interimTranscript;
// transcript 합산 (서버 저장용)
this.transcript = this.finalizedSegments.join(' ');
this.interimTranscript = currentInterim;
// 자동 스크롤
this.$nextTick(() => {
if (this.$refs.transcriptContainer) {
this.$refs.transcriptContainer.scrollTop = this.$refs.transcriptContainer.scrollHeight;
}
});
};
this.recognition.onerror = (event) => {
@@ -287,6 +296,7 @@
this.timer = 0;
this.transcript = '';
this.interimTranscript = '';
this.finalizedSegments = [];
this.saving = false;
this.saveProgress = 0;
this.status = '마이크 버튼을 눌러 녹음을 시작하세요';
@@ -373,17 +383,37 @@ class="w-full h-20 bg-gray-50 rounded-lg border border-gray-200"
</div>
</div>
{{-- 실시간 텍스트 변환 표시 --}}
<div x-show="transcript || interimTranscript" class="bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<p class="text-xs font-medium text-gray-500">음성 인식 결과</p>
<p class="text-xs text-gray-400" x-text="transcript.length + ' 자'"></p>
{{-- 실시간 텍스트 변환 표시 (interim=이탤릭+회색, final=일반체+진한색) --}}
<div x-show="finalizedSegments.length > 0 || interimTranscript" class="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-700">
<div class="flex items-center gap-2">
<p class="text-xs font-medium text-gray-400">음성 인식 결과</p>
<template x-if="isRecording">
<span class="flex items-center gap-1 text-xs text-red-400">
<span class="w-1.5 h-1.5 bg-red-400 rounded-full animate-pulse"></span>
인식
</span>
</template>
<template x-if="!isRecording && finalizedSegments.length > 0 && !interimTranscript">
<span class="text-green-400 text-xs">&#10003; 완료</span>
</template>
</div>
<p class="text-xs text-gray-500" x-text="transcript.length + ' 자'"></p>
</div>
<div class="p-3 max-h-32 overflow-y-auto" x-ref="transcriptContainer" x-effect="if(transcript || interimTranscript) { $nextTick(() => $refs.transcriptContainer.scrollTop = $refs.transcriptContainer.scrollHeight) }">
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">
<span x-text="transcript"></span>
<span class="text-gray-400 italic" x-text="interimTranscript"></span>
</p>
<div class="p-3 max-h-32 overflow-y-auto" x-ref="transcriptContainer" style="line-height: 1.6;">
{{-- 확정 텍스트: 일반체 + 흰색 (삭제 불가) --}}
<template x-for="(seg, i) in finalizedSegments" :key="i">
<span class="text-white text-sm font-normal transition-colors duration-300" x-text="seg"></span>
</template>
{{-- 미확정 텍스트: 이탤릭 + 연한 회색 (교정 가능) --}}
<span x-show="interimTranscript" class="text-gray-400 text-sm italic transition-colors duration-200" x-text="interimTranscript"></span>
{{-- 녹음 + 텍스트 없음: 대기 표시 --}}
<span x-show="isRecording && finalizedSegments.length === 0 && !interimTranscript" class="text-gray-500 text-sm flex items-center gap-1.5">
<span class="inline-block w-1.5 h-1.5 bg-red-400 rounded-full animate-pulse"></span>
말씀하세요...
</span>
</div>
</div>

View File

@@ -65,46 +65,28 @@
@endpush
@section('content')
<div class="space-y-6" x-data="{ activeTab: 'ai' }">
<div class="space-y-6">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">AI 스토리지 설정</h1>
<button type="button" @click="activeTab === 'ai' ? openModal() : openGcsModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition inline-flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
설정 추가
</button>
</div>
<!-- 네비게이 -->
<div class="flex border-b border-gray-200">
<button type="button"
@click="activeTab = 'ai'"
:class="activeTab === 'ai' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-6 py-3 border-b-2 font-medium text-sm transition-colors">
<!-- AI 설정 -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
AI 설정
<h2 class="text-lg font-semibold text-gray-800">AI 설정</h2>
</div>
</button>
<button type="button"
@click="activeTab = 'storage'"
:class="activeTab === 'storage' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-6 py-3 border-b-2 font-medium text-sm transition-colors">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
<button type="button" onclick="openModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition inline-flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
스토리지 설정 (GCS)
</div>
</button>
</div>
<!-- AI 설정 콘텐츠 -->
<div x-show="activeTab === 'ai'" x-cloak>
AI 설정 추가
</button>
</div>
<!-- AI 설정 목록 -->
<div id="config-list" class="space-y-4">
@forelse($configs as $config)
@@ -172,8 +154,22 @@ class="px-6 py-3 border-b-2 font-medium text-sm transition-colors">
</div>
</div>
<!-- 스토리지 설정 (GCS) 콘텐츠 -->
<div x-show="activeTab === 'storage'" x-cloak>
<!-- 스토리지 설정 (GCS) 섹션 -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<h2 class="text-lg font-semibold text-gray-800">스토리지 설정 (GCS)</h2>
</div>
<button type="button" onclick="openGcsModal()" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition inline-flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
GCS 설정 추가
</button>
</div>
<!-- GCS 설정 목록 -->
<div id="storage-config-list" class="space-y-4">
@forelse($storageConfigs as $config)

View File

@@ -32,6 +32,7 @@
use App\Http\Controllers\RolePermissionController;
use App\Http\Controllers\Sales\SalesProductController;
use App\Http\Controllers\Juil\ConstructionSitePhotoController;
use App\Http\Controllers\Juil\MeetingMinuteController;
use App\Http\Controllers\Juil\PlanningController;
use App\Http\Controllers\Stats\StatDashboardController;
use App\Http\Controllers\System\AiConfigController;
@@ -1325,5 +1326,21 @@
Route::delete('/{id}', [ConstructionSitePhotoController::class, 'destroy'])->name('destroy');
Route::delete('/{id}/photo/{type}', [ConstructionSitePhotoController::class, 'deletePhoto'])->name('delete-photo');
Route::get('/{id}/download/{type}', [ConstructionSitePhotoController::class, 'downloadPhoto'])->name('download');
Route::post('/log-stt-usage', [ConstructionSitePhotoController::class, 'logSttUsage'])->name('log-stt-usage');
});
// 회의록 작성
Route::prefix('meeting-minutes')->name('meeting-minutes.')->group(function () {
Route::get('/', [MeetingMinuteController::class, 'index'])->name('index');
Route::get('/list', [MeetingMinuteController::class, 'list'])->name('list');
Route::post('/', [MeetingMinuteController::class, 'store'])->name('store');
Route::post('/log-stt-usage', [MeetingMinuteController::class, 'logSttUsage'])->name('log-stt-usage');
Route::get('/{id}', [MeetingMinuteController::class, 'show'])->name('show');
Route::put('/{id}', [MeetingMinuteController::class, 'update'])->name('update');
Route::delete('/{id}', [MeetingMinuteController::class, 'destroy'])->name('destroy');
Route::post('/{id}/segments', [MeetingMinuteController::class, 'saveSegments'])->name('save-segments');
Route::post('/{id}/upload-audio', [MeetingMinuteController::class, 'uploadAudio'])->name('upload-audio');
Route::post('/{id}/summarize', [MeetingMinuteController::class, 'summarize'])->name('summarize');
Route::get('/{id}/download-audio', [MeetingMinuteController::class, 'downloadAudio'])->name('download-audio');
});
});