Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
273
app/Http/Controllers/Juil/MeetingMinuteController.php
Normal file
273
app/Http/Controllers/Juil/MeetingMinuteController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
79
app/Models/Juil/MeetingMinute.php
Normal file
79
app/Models/Juil/MeetingMinute.php
Normal 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);
|
||||
}
|
||||
}
|
||||
36
app/Models/Juil/MeetingMinuteSegment.php
Normal file
36
app/Models/Juil/MeetingMinuteSegment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
423
app/Services/MeetingMinuteService.php
Normal file
423
app/Services/MeetingMinuteService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">✓</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}
|
||||
|
||||
915
resources/views/juil/meeting-minutes.blade.php
Normal file
915
resources/views/juil/meeting-minutes.blade.php
Normal 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">×</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">✓</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
|
||||
@@ -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">✓ 완료</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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user