feat: 웹 녹음 AI 요약 기능 구현
- MeetingLog 모델 (BelongsToTenant, SoftDeletes) - GoogleCloudService (GCS 업로드, STT API) - MeetingLogService (Claude API 요약) - MeetingLogController (HTMX/JSON 듀얼 응답) - 순수 Tailwind CSS UI 구현 - API 라우트 8개 엔드포인트 등록
This commit is contained in:
217
app/Http/Controllers/Api/Admin/MeetingLogController.php
Normal file
217
app/Http/Controllers/Api/Admin/MeetingLogController.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MeetingLog;
|
||||||
|
use App\Services\MeetingLogService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 API 컨트롤러 (웹 녹음 AI 요약)
|
||||||
|
*/
|
||||||
|
class MeetingLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MeetingLogService $meetingLogService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 목록 (HTMX용)
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View|JsonResponse
|
||||||
|
{
|
||||||
|
$params = $request->only(['search', 'status', 'per_page']);
|
||||||
|
$meetings = $this->meetingLogService->getList($params);
|
||||||
|
|
||||||
|
if ($request->header('HX-Request')) {
|
||||||
|
return view('lab.ai.web-recording.partials.list', compact('meetings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $meetings,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 상세 조회
|
||||||
|
*/
|
||||||
|
public function show(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$meeting = $this->meetingLogService->getById($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:200',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$meeting = $this->meetingLogService->create($validated);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '회의록이 생성되었습니다.',
|
||||||
|
'data' => $meeting,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 업로드 및 처리
|
||||||
|
*/
|
||||||
|
public function processAudio(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$meeting = MeetingLog::find($id);
|
||||||
|
|
||||||
|
if (! $meeting) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '회의록을 찾을 수 없습니다.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'audio' => 'required|string', // Base64 인코딩된 오디오
|
||||||
|
'duration' => 'required|integer|min:1', // 녹음 시간(초)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->meetingLogService->processAudio(
|
||||||
|
$meeting,
|
||||||
|
$validated['audio'],
|
||||||
|
$validated['duration']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $result['ok']) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '회의록이 생성되었습니다.',
|
||||||
|
'data' => $result['meeting'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 업데이트
|
||||||
|
*/
|
||||||
|
public function updateTitle(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$meeting = MeetingLog::find($id);
|
||||||
|
|
||||||
|
if (! $meeting) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '회의록을 찾을 수 없습니다.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:200',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$meeting = $this->meetingLogService->updateTitle($meeting, $validated['title']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '제목이 업데이트되었습니다.',
|
||||||
|
'data' => $meeting,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 삭제
|
||||||
|
*/
|
||||||
|
public function destroy(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$meeting = MeetingLog::find($id);
|
||||||
|
|
||||||
|
if (! $meeting) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '회의록을 찾을 수 없습니다.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->meetingLogService->delete($meeting);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '회의록이 삭제되었습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 상태 확인 (폴링용)
|
||||||
|
*/
|
||||||
|
public function status(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$meeting = MeetingLog::find($id);
|
||||||
|
|
||||||
|
if (! $meeting) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '회의록을 찾을 수 없습니다.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'id' => $meeting->id,
|
||||||
|
'status' => $meeting->status,
|
||||||
|
'status_label' => $meeting->status_label,
|
||||||
|
'is_completed' => $meeting->isCompleted(),
|
||||||
|
'is_processing' => $meeting->isProcessing(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요약 결과 조회 (HTMX용)
|
||||||
|
*/
|
||||||
|
public function summary(int $id): View|JsonResponse
|
||||||
|
{
|
||||||
|
$meeting = $this->meetingLogService->getById($id);
|
||||||
|
|
||||||
|
if (! $meeting) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '회의록을 찾을 수 없습니다.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request()->header('HX-Request')) {
|
||||||
|
return view('lab.ai.web-recording.partials.summary', compact('meeting'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'transcript' => $meeting->transcript_text,
|
||||||
|
'summary' => $meeting->summary_text,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/Models/MeetingLog.php
Normal file
119
app/Models/MeetingLog.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Traits\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 모델 (웹 녹음 AI 요약)
|
||||||
|
*
|
||||||
|
* admin_meeting_logs 테이블을 사용합니다.
|
||||||
|
*/
|
||||||
|
class MeetingLog extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'admin_meeting_logs';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'user_id',
|
||||||
|
'title',
|
||||||
|
'audio_file_path',
|
||||||
|
'audio_gcs_uri',
|
||||||
|
'transcript_text',
|
||||||
|
'summary_text',
|
||||||
|
'status',
|
||||||
|
'duration_seconds',
|
||||||
|
'file_expiry_date',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'duration_seconds' => 'integer',
|
||||||
|
'file_expiry_date' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
'deleted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 상수
|
||||||
|
*/
|
||||||
|
public const STATUS_PENDING = 'PENDING';
|
||||||
|
|
||||||
|
public const STATUS_PROCESSING = 'PROCESSING';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'COMPLETED';
|
||||||
|
|
||||||
|
public const STATUS_FAILED = 'FAILED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성자 관계
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 녹음 시간 포맷 (MM:SS)
|
||||||
|
*/
|
||||||
|
public function getFormattedDurationAttribute(): string
|
||||||
|
{
|
||||||
|
if (! $this->duration_seconds) {
|
||||||
|
return '00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
$minutes = floor($this->duration_seconds / 60);
|
||||||
|
$seconds = $this->duration_seconds % 60;
|
||||||
|
|
||||||
|
return sprintf('%02d:%02d', $minutes, $seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 레이블
|
||||||
|
*/
|
||||||
|
public function getStatusLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
self::STATUS_PENDING => '대기중',
|
||||||
|
self::STATUS_PROCESSING => '처리중',
|
||||||
|
self::STATUS_COMPLETED => '완료',
|
||||||
|
self::STATUS_FAILED => '실패',
|
||||||
|
default => $this->status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 색상 (Tailwind CSS)
|
||||||
|
*/
|
||||||
|
public function getStatusColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
self::STATUS_PENDING => 'badge-warning',
|
||||||
|
self::STATUS_PROCESSING => 'badge-info',
|
||||||
|
self::STATUS_COMPLETED => 'badge-success',
|
||||||
|
self::STATUS_FAILED => 'badge-error',
|
||||||
|
default => 'badge-ghost',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 완료 여부
|
||||||
|
*/
|
||||||
|
public function isCompleted(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 중 여부
|
||||||
|
*/
|
||||||
|
public function isProcessing(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_PROCESSING;
|
||||||
|
}
|
||||||
|
}
|
||||||
307
app/Services/GoogleCloudService.php
Normal file
307
app/Services/GoogleCloudService.php
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Cloud 서비스 (Storage, Speech-to-Text)
|
||||||
|
*/
|
||||||
|
class GoogleCloudService
|
||||||
|
{
|
||||||
|
private ?array $serviceAccount = null;
|
||||||
|
|
||||||
|
private ?string $accessToken = null;
|
||||||
|
|
||||||
|
private ?int $tokenExpiry = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->loadServiceAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 계정 로드
|
||||||
|
*/
|
||||||
|
private function loadServiceAccount(): void
|
||||||
|
{
|
||||||
|
$path = config('services.google.credentials_path');
|
||||||
|
|
||||||
|
if ($path && file_exists($path)) {
|
||||||
|
$this->serviceAccount = json_decode(file_get_contents($path), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth 토큰 발급
|
||||||
|
*/
|
||||||
|
private function getAccessToken(): ?string
|
||||||
|
{
|
||||||
|
// 캐시된 토큰이 유효하면 재사용
|
||||||
|
if ($this->accessToken && $this->tokenExpiry && time() < $this->tokenExpiry - 60) {
|
||||||
|
return $this->accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->serviceAccount) {
|
||||||
|
Log::error('Google Cloud: 서비스 계정 파일이 없습니다.');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$now = time();
|
||||||
|
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||||
|
$jwtClaim = base64_encode(json_encode([
|
||||||
|
'iss' => $this->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($this->serviceAccount['private_key']);
|
||||||
|
if (! $privateKey) {
|
||||||
|
Log::error('Google Cloud: 개인 키 읽기 실패');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||||
|
|
||||||
|
$jwt = $jwtHeader.'.'.$jwtClaim.'.'.base64_encode($signature);
|
||||||
|
|
||||||
|
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||||
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
'assertion' => $jwt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$data = $response->json();
|
||||||
|
$this->accessToken = $data['access_token'];
|
||||||
|
$this->tokenExpiry = $now + ($data['expires_in'] ?? 3600);
|
||||||
|
|
||||||
|
return $this->accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Google Cloud: OAuth 토큰 발급 실패', ['response' => $response->body()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Google Cloud: OAuth 토큰 발급 예외', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GCS에 파일 업로드
|
||||||
|
*/
|
||||||
|
public function uploadToStorage(string $localPath, string $objectName): ?string
|
||||||
|
{
|
||||||
|
$token = $this->getAccessToken();
|
||||||
|
if (! $token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bucket = config('services.google.storage_bucket');
|
||||||
|
if (! $bucket) {
|
||||||
|
Log::error('Google Cloud: Storage 버킷 설정 없음');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fileContent = file_get_contents($localPath);
|
||||||
|
$mimeType = mime_content_type($localPath) ?: 'audio/webm';
|
||||||
|
|
||||||
|
$uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/'.
|
||||||
|
urlencode($bucket).'/o?uploadType=media&name='.
|
||||||
|
urlencode($objectName);
|
||||||
|
|
||||||
|
$response = Http::withToken($token)
|
||||||
|
->withHeaders(['Content-Type' => $mimeType])
|
||||||
|
->withBody($fileContent, $mimeType)
|
||||||
|
->post($uploadUrl);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return 'gs://'.$bucket.'/'.$objectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Google Cloud: Storage 업로드 실패', ['response' => $response->body()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Google Cloud: Storage 업로드 예외', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64 오디오를 GCS에 업로드
|
||||||
|
*/
|
||||||
|
public function uploadBase64Audio(string $base64Audio, string $objectName): ?string
|
||||||
|
{
|
||||||
|
// Base64 데이터 파싱
|
||||||
|
$audioData = $base64Audio;
|
||||||
|
if (preg_match('/^data:audio\/\w+;base64,(.+)$/', $base64Audio, $matches)) {
|
||||||
|
$audioData = $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 임시 파일 생성
|
||||||
|
$tempPath = storage_path('app/temp/'.uniqid('audio_').'.webm');
|
||||||
|
$tempDir = dirname($tempPath);
|
||||||
|
|
||||||
|
if (! is_dir($tempDir)) {
|
||||||
|
mkdir($tempDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($tempPath, base64_decode($audioData));
|
||||||
|
|
||||||
|
// GCS 업로드
|
||||||
|
$gcsUri = $this->uploadToStorage($tempPath, $objectName);
|
||||||
|
|
||||||
|
// 임시 파일 삭제
|
||||||
|
@unlink($tempPath);
|
||||||
|
|
||||||
|
return $gcsUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speech-to-Text API 호출
|
||||||
|
*/
|
||||||
|
public function speechToText(string $gcsUri, string $languageCode = 'ko-KR'): ?string
|
||||||
|
{
|
||||||
|
$token = $this->getAccessToken();
|
||||||
|
if (! $token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 긴 오디오는 비동기 처리 (LongRunningRecognize)
|
||||||
|
$response = Http::withToken($token)
|
||||||
|
->post('https://speech.googleapis.com/v1/speech:longrunningrecognize', [
|
||||||
|
'config' => [
|
||||||
|
'encoding' => 'WEBM_OPUS',
|
||||||
|
'sampleRateHertz' => 48000,
|
||||||
|
'languageCode' => $languageCode,
|
||||||
|
'enableAutomaticPunctuation' => true,
|
||||||
|
'model' => 'latest_long',
|
||||||
|
],
|
||||||
|
'audio' => [
|
||||||
|
'uri' => $gcsUri,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error('Google Cloud: STT 요청 실패', ['response' => $response->body()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation = $response->json();
|
||||||
|
$operationName = $operation['name'] ?? null;
|
||||||
|
|
||||||
|
if (! $operationName) {
|
||||||
|
Log::error('Google Cloud: STT 작업 이름 없음');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업 완료 대기 (폴링)
|
||||||
|
return $this->waitForSttOperation($operationName);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Google Cloud: STT 예외', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STT 작업 완료 대기
|
||||||
|
*/
|
||||||
|
private function waitForSttOperation(string $operationName, int $maxAttempts = 60): ?string
|
||||||
|
{
|
||||||
|
$token = $this->getAccessToken();
|
||||||
|
if (! $token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||||
|
sleep(5); // 5초 대기
|
||||||
|
|
||||||
|
$response = Http::withToken($token)
|
||||||
|
->get("https://speech.googleapis.com/v1/operations/{$operationName}");
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $response->json();
|
||||||
|
|
||||||
|
if (isset($result['done']) && $result['done']) {
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
Log::error('Google Cloud: STT 작업 실패', ['error' => $result['error']]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 텍스트 추출
|
||||||
|
$transcript = '';
|
||||||
|
$results = $result['response']['results'] ?? [];
|
||||||
|
|
||||||
|
foreach ($results as $res) {
|
||||||
|
$alternatives = $res['alternatives'] ?? [];
|
||||||
|
if (! empty($alternatives)) {
|
||||||
|
$transcript .= $alternatives[0]['transcript'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Google Cloud: STT 작업 타임아웃');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GCS 파일 삭제
|
||||||
|
*/
|
||||||
|
public function deleteFromStorage(string $objectName): bool
|
||||||
|
{
|
||||||
|
$token = $this->getAccessToken();
|
||||||
|
if (! $token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bucket = config('services.google.storage_bucket');
|
||||||
|
if (! $bucket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$deleteUrl = 'https://storage.googleapis.com/storage/v1/b/'.
|
||||||
|
urlencode($bucket).'/o/'.urlencode($objectName);
|
||||||
|
|
||||||
|
$response = Http::withToken($token)->delete($deleteUrl);
|
||||||
|
|
||||||
|
return $response->successful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Google Cloud: Storage 삭제 예외', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 사용 가능 여부
|
||||||
|
*/
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return $this->serviceAccount !== null && $this->getAccessToken() !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/Services/MeetingLogService.php
Normal file
249
app/Services/MeetingLogService.php
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\MeetingLog;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 서비스 (웹 녹음 AI 요약)
|
||||||
|
*/
|
||||||
|
class MeetingLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GoogleCloudService $googleCloudService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 목록 조회
|
||||||
|
*/
|
||||||
|
public function getList(array $params = []): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = MeetingLog::query()
|
||||||
|
->with('user:id,name')
|
||||||
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (! empty($params['status'])) {
|
||||||
|
$query->where('status', $params['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색어
|
||||||
|
if (! empty($params['search'])) {
|
||||||
|
$search = $params['search'];
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('title', 'like', "%{$search}%")
|
||||||
|
->orWhere('transcript_text', 'like', "%{$search}%")
|
||||||
|
->orWhere('summary_text', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = $params['per_page'] ?? 10;
|
||||||
|
|
||||||
|
return $query->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 상세 조회
|
||||||
|
*/
|
||||||
|
public function getById(int $id): ?MeetingLog
|
||||||
|
{
|
||||||
|
return MeetingLog::with('user:id,name')->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 생성 (녹음 시작)
|
||||||
|
*/
|
||||||
|
public function create(array $data): MeetingLog
|
||||||
|
{
|
||||||
|
return MeetingLog::create([
|
||||||
|
'tenant_id' => currentTenantId(),
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'title' => $data['title'] ?? '무제 회의록',
|
||||||
|
'status' => MeetingLog::STATUS_PENDING,
|
||||||
|
'file_expiry_date' => now()->addDays(7),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 업로드 및 처리 시작
|
||||||
|
*/
|
||||||
|
public function processAudio(MeetingLog $meeting, string $audioBase64, int $durationSeconds): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 상태 업데이트
|
||||||
|
$meeting->update([
|
||||||
|
'status' => MeetingLog::STATUS_PROCESSING,
|
||||||
|
'duration_seconds' => $durationSeconds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1. GCS에 오디오 업로드
|
||||||
|
$objectName = sprintf(
|
||||||
|
'meetings/%d/%d/%s.webm',
|
||||||
|
$meeting->tenant_id,
|
||||||
|
$meeting->id,
|
||||||
|
now()->format('YmdHis')
|
||||||
|
);
|
||||||
|
|
||||||
|
$gcsUri = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName);
|
||||||
|
|
||||||
|
if (! $gcsUri) {
|
||||||
|
throw new \Exception('오디오 파일 업로드 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
$meeting->update([
|
||||||
|
'audio_file_path' => $objectName,
|
||||||
|
'audio_gcs_uri' => $gcsUri,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Speech-to-Text 변환
|
||||||
|
$transcript = $this->googleCloudService->speechToText($gcsUri);
|
||||||
|
|
||||||
|
if (! $transcript) {
|
||||||
|
throw new \Exception('음성 인식 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
$meeting->update(['transcript_text' => $transcript]);
|
||||||
|
|
||||||
|
// 3. AI 요약 생성
|
||||||
|
$summary = $this->generateSummary($transcript);
|
||||||
|
|
||||||
|
$meeting->update([
|
||||||
|
'summary_text' => $summary,
|
||||||
|
'status' => MeetingLog::STATUS_COMPLETED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'meeting' => $meeting->fresh(),
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('MeetingLog 처리 실패', [
|
||||||
|
'meeting_id' => $meeting->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$meeting->update(['status' => MeetingLog::STATUS_FAILED]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 요약 생성 (Claude API)
|
||||||
|
*/
|
||||||
|
private function generateSummary(string $transcript): ?string
|
||||||
|
{
|
||||||
|
$apiKey = config('services.claude.api_key');
|
||||||
|
if (empty($apiKey)) {
|
||||||
|
Log::warning('Claude API 키 미설정');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'x-api-key' => $apiKey,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
])->post('https://api.anthropic.com/v1/messages', [
|
||||||
|
'model' => 'claude-3-haiku-20240307',
|
||||||
|
'max_tokens' => 4096,
|
||||||
|
'messages' => [
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $this->buildSummaryPrompt($transcript),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
return $data['content'][0]['text'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Claude API 요청 실패', ['response' => $response->body()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Claude API 예외', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요약 프롬프트 생성
|
||||||
|
*/
|
||||||
|
private function buildSummaryPrompt(string $transcript): string
|
||||||
|
{
|
||||||
|
return <<<PROMPT
|
||||||
|
다음은 회의 녹음을 텍스트로 변환한 내용입니다. 이 내용을 바탕으로 구조화된 회의록을 작성해주세요.
|
||||||
|
|
||||||
|
## 요청사항:
|
||||||
|
1. **회의 요약**: 핵심 내용을 3-5문장으로 요약
|
||||||
|
2. **주요 논의 사항**: 논의된 주요 주제들을 항목별로 정리
|
||||||
|
3. **결정 사항**: 회의에서 결정된 내용들
|
||||||
|
4. **액션 아이템**: 후속 조치가 필요한 항목들 (담당자, 기한 등이 언급되었다면 포함)
|
||||||
|
5. **참고 사항**: 기타 중요한 내용
|
||||||
|
|
||||||
|
## 회의 녹취록:
|
||||||
|
{$transcript}
|
||||||
|
|
||||||
|
## 회의록:
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의록 삭제
|
||||||
|
*/
|
||||||
|
public function delete(MeetingLog $meeting): bool
|
||||||
|
{
|
||||||
|
// GCS 파일 삭제
|
||||||
|
if ($meeting->audio_file_path) {
|
||||||
|
$this->googleCloudService->deleteFromStorage($meeting->audio_file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $meeting->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 업데이트
|
||||||
|
*/
|
||||||
|
public function updateTitle(MeetingLog $meeting, string $title): MeetingLog
|
||||||
|
{
|
||||||
|
$meeting->update(['title' => $title]);
|
||||||
|
|
||||||
|
return $meeting->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 만료된 파일 정리 (Cron Job용)
|
||||||
|
*/
|
||||||
|
public function cleanupExpiredFiles(): int
|
||||||
|
{
|
||||||
|
$expired = MeetingLog::where('file_expiry_date', '<=', now())
|
||||||
|
->whereNotNull('audio_file_path')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($expired as $meeting) {
|
||||||
|
if ($meeting->audio_file_path) {
|
||||||
|
$this->googleCloudService->deleteFromStorage($meeting->audio_file_path);
|
||||||
|
$meeting->update([
|
||||||
|
'audio_file_path' => null,
|
||||||
|
'audio_gcs_uri' => null,
|
||||||
|
]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,4 +44,9 @@
|
|||||||
'api_key' => env('CLAUDE_API_KEY'),
|
'api_key' => env('CLAUDE_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'google' => [
|
||||||
|
'credentials_path' => env('GOOGLE_APPLICATION_CREDENTIALS'),
|
||||||
|
'storage_bucket' => env('GOOGLE_STORAGE_BUCKET'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,59 +4,531 @@
|
|||||||
|
|
||||||
@push('styles')
|
@push('styles')
|
||||||
<style>
|
<style>
|
||||||
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
.recording-container {
|
||||||
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; }
|
max-width: 900px;
|
||||||
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; }
|
margin: 0 auto;
|
||||||
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; }
|
}
|
||||||
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
|
|
||||||
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; }
|
.record-button {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button.recording {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); }
|
||||||
|
50% { box-shadow: 0 4px 30px rgba(245, 87, 108, 0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
min-height: 50px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer.active { color: #f5576c; }
|
||||||
|
|
||||||
|
.waveform {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-bar {
|
||||||
|
width: 4px;
|
||||||
|
background: #667eea;
|
||||||
|
border-radius: 2px;
|
||||||
|
animation: wave 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0%, 100% { height: 10px; }
|
||||||
|
50% { height: 40px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-waiting { background: #f1f5f9; color: #64748b; }
|
||||||
|
.status-recording { background: #fee2e2; color: #dc2626; }
|
||||||
|
.status-processing { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.status-completed { background: #dcfce7; color: #16a34a; }
|
||||||
|
.status-error { background: #fee2e2; color: #dc2626; }
|
||||||
|
|
||||||
|
.meeting-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content.open {
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-icon {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-icon.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100">
|
<div class="recording-container">
|
||||||
<div class="container mx-auto px-4 py-12">
|
{{-- 헤더 --}}
|
||||||
<div class="placeholder-container">
|
<div class="text-center mb-8">
|
||||||
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<h1 class="text-2xl font-bold text-gray-800 mb-2">웹 녹음 AI 요약</h1>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" />
|
<p class="text-gray-500">브라우저에서 녹음하고 AI가 자동으로 회의록을 작성합니다</p>
|
||||||
</svg>
|
</div>
|
||||||
<h1 class="placeholder-title">웹 녹음 AI 요약</h1>
|
|
||||||
<p class="placeholder-subtitle">
|
|
||||||
웹 브라우저에서 직접 녹음하고 AI가 자동으로
|
|
||||||
음성을 텍스트로 변환하여 요약본을 생성합니다.
|
|
||||||
</p>
|
|
||||||
<div class="placeholder-badge">AI/Automation</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto mt-12">
|
{{-- 녹음 섹션 --}}
|
||||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
<div class="bg-white rounded-xl shadow-md mb-8 p-8">
|
||||||
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
|
<div class="flex flex-col items-center text-center">
|
||||||
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{{-- 타이머 --}}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
<div class="timer" id="timer">00:00</div>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
|
{{-- 파형 (녹음 중에만 표시) --}}
|
||||||
|
<div class="waveform hidden mt-4" id="waveform">
|
||||||
|
@for($i = 0; $i < 20; $i++)
|
||||||
|
<div class="waveform-bar" style="animation-delay: {{ $i * 0.05 }}s"></div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 상태 표시 --}}
|
||||||
|
<div class="my-4">
|
||||||
|
<span class="status-badge status-waiting" id="statusBadge">대기 중</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 녹음 버튼 --}}
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<button class="record-button" id="recordBtn" onclick="toggleRecording()">
|
||||||
|
<svg id="micIcon" class="w-10 h-10 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
</svg>
|
||||||
예정 기능
|
<svg id="stopIcon" class="w-10 h-10 mx-auto hidden" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 안내 텍스트 --}}
|
||||||
|
<p class="text-sm text-gray-500 mt-4" id="helpText">
|
||||||
|
버튼을 클릭하여 녹음을 시작하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 처리 중 상태 (숨김) --}}
|
||||||
|
<div class="bg-white rounded-xl shadow-md mb-8 p-8 hidden" id="processingCard">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h3 class="text-lg font-semibold mt-4 text-gray-800">AI가 회의록을 작성하고 있습니다</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-2" id="processingStatus">음성을 텍스트로 변환 중...</p>
|
||||||
|
<div class="w-56 h-2 bg-gray-200 rounded-full mt-4 overflow-hidden">
|
||||||
|
<div class="h-full bg-blue-500 rounded-full transition-all" id="progressBar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 결과 섹션 (숨김) --}}
|
||||||
|
<div class="hidden" id="resultSection">
|
||||||
|
<div class="bg-white rounded-xl shadow-md mb-6 p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||||
|
<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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
AI 요약 결과
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" onclick="newRecording()">새 녹음</button>
|
||||||
<div class="p-4 bg-violet-50 rounded-lg">
|
</div>
|
||||||
<h3 class="font-semibold text-violet-800 mb-2">녹음 기능</h3>
|
|
||||||
<ul class="text-sm text-gray-600 space-y-1">
|
{{-- 제목 입력 --}}
|
||||||
<li>• 브라우저 내 실시간 녹음</li>
|
<div class="mb-4">
|
||||||
<li>• 일시정지/재개 지원</li>
|
<input type="text" id="meetingTitle" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="회의록 제목을 입력하세요" />
|
||||||
<li>• 녹음 파일 다운로드</li>
|
</div>
|
||||||
</ul>
|
|
||||||
</div>
|
{{-- 요약 내용 --}}
|
||||||
<div class="p-4 bg-blue-50 rounded-lg">
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||||
<h3 class="font-semibold text-blue-800 mb-2">AI 처리</h3>
|
<h3 class="font-semibold text-gray-800 mb-2">요약</h3>
|
||||||
<ul class="text-sm text-gray-600 space-y-1">
|
<div id="summaryContent" class="text-gray-700 prose max-w-none"></div>
|
||||||
<li>• Speech-to-Text 변환</li>
|
</div>
|
||||||
<li>• 핵심 내용 자동 요약</li>
|
|
||||||
<li>• 키워드 추출</li>
|
{{-- 원본 텍스트 (아코디언) --}}
|
||||||
</ul>
|
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||||
</div>
|
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||||
|
<span class="font-medium text-gray-800">원본 텍스트 보기</span>
|
||||||
|
<svg class="accordion-icon w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="accordion-content">
|
||||||
|
<div id="transcriptContent" class="px-4 pb-4 text-sm text-gray-600 whitespace-pre-wrap"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 최근 회의록 목록 --}}
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
최근 회의록
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div id="meetingList" hx-get="{{ route('api.admin.meeting-logs.index') }}" hx-trigger="load" hx-swap="innerHTML">
|
||||||
|
<div class="flex justify-center py-8">
|
||||||
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let audioChunks = [];
|
||||||
|
let timerInterval = null;
|
||||||
|
let startTime = null;
|
||||||
|
let currentMeetingId = null;
|
||||||
|
|
||||||
|
// 아코디언 토글
|
||||||
|
function toggleAccordion(header) {
|
||||||
|
const content = header.nextElementSibling;
|
||||||
|
const icon = header.querySelector('.accordion-icon');
|
||||||
|
content.classList.toggle('open');
|
||||||
|
icon.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 녹음 토글
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
|
stopRecording();
|
||||||
|
} else {
|
||||||
|
await startRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 녹음 시작
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
||||||
|
audioChunks = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||||
|
await processAudio(audioBlob);
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 회의록 생성
|
||||||
|
const response = await fetch('{{ route("api.admin.meeting-logs.store") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title: '무제 회의록' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
currentMeetingId = result.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.start(1000);
|
||||||
|
startTime = Date.now();
|
||||||
|
updateUI('recording');
|
||||||
|
startTimer();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('녹음 시작 실패:', error);
|
||||||
|
showToast('마이크 접근 권한이 필요합니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 녹음 중지
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
stopTimer();
|
||||||
|
updateUI('processing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오디오 처리
|
||||||
|
async function processAudio(audioBlob) {
|
||||||
|
const duration = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
|
||||||
|
// Base64 변환
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = async () => {
|
||||||
|
const base64Audio = reader.result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateProgress(10);
|
||||||
|
document.getElementById('processingStatus').textContent = '서버에 업로드 중...';
|
||||||
|
|
||||||
|
const response = await fetch(`/api/meeting-logs/${currentMeetingId}/process`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
audio: base64Audio,
|
||||||
|
duration: duration
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
updateProgress(100);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showResult(result.data);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || '처리 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('처리 실패:', error);
|
||||||
|
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||||
|
updateUI('ready');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(audioBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 진행률 업데이트
|
||||||
|
function updateProgress(percent) {
|
||||||
|
document.getElementById('progressBar').style.width = percent + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 표시
|
||||||
|
function showResult(meeting) {
|
||||||
|
document.getElementById('meetingTitle').value = meeting.title || '';
|
||||||
|
document.getElementById('summaryContent').innerHTML = marked.parse(meeting.summary_text || '요약 내용이 없습니다.');
|
||||||
|
document.getElementById('transcriptContent').textContent = meeting.transcript_text || '';
|
||||||
|
|
||||||
|
updateUI('result');
|
||||||
|
|
||||||
|
// 목록 새로고침
|
||||||
|
htmx.trigger('#meetingList', 'load');
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI 업데이트
|
||||||
|
function updateUI(state) {
|
||||||
|
const recordBtn = document.getElementById('recordBtn');
|
||||||
|
const micIcon = document.getElementById('micIcon');
|
||||||
|
const stopIcon = document.getElementById('stopIcon');
|
||||||
|
const waveform = document.getElementById('waveform');
|
||||||
|
const statusBadge = document.getElementById('statusBadge');
|
||||||
|
const helpText = document.getElementById('helpText');
|
||||||
|
const processingCard = document.getElementById('processingCard');
|
||||||
|
const resultSection = document.getElementById('resultSection');
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'recording':
|
||||||
|
recordBtn.classList.add('recording');
|
||||||
|
micIcon.classList.add('hidden');
|
||||||
|
stopIcon.classList.remove('hidden');
|
||||||
|
waveform.classList.remove('hidden');
|
||||||
|
statusBadge.textContent = '녹음 중';
|
||||||
|
statusBadge.className = 'status-badge status-recording';
|
||||||
|
helpText.textContent = '버튼을 클릭하여 녹음을 종료하세요';
|
||||||
|
processingCard.classList.add('hidden');
|
||||||
|
resultSection.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'processing':
|
||||||
|
recordBtn.classList.remove('recording');
|
||||||
|
recordBtn.disabled = true;
|
||||||
|
micIcon.classList.remove('hidden');
|
||||||
|
stopIcon.classList.add('hidden');
|
||||||
|
waveform.classList.add('hidden');
|
||||||
|
statusBadge.textContent = '처리 중';
|
||||||
|
statusBadge.className = 'status-badge status-processing';
|
||||||
|
helpText.textContent = 'AI가 회의록을 작성하고 있습니다...';
|
||||||
|
processingCard.classList.remove('hidden');
|
||||||
|
resultSection.classList.add('hidden');
|
||||||
|
updateProgress(0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'result':
|
||||||
|
recordBtn.classList.remove('recording');
|
||||||
|
recordBtn.disabled = false;
|
||||||
|
micIcon.classList.remove('hidden');
|
||||||
|
stopIcon.classList.add('hidden');
|
||||||
|
waveform.classList.add('hidden');
|
||||||
|
statusBadge.textContent = '완료';
|
||||||
|
statusBadge.className = 'status-badge status-completed';
|
||||||
|
helpText.textContent = '버튼을 클릭하여 새로운 녹음을 시작하세요';
|
||||||
|
processingCard.classList.add('hidden');
|
||||||
|
resultSection.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // ready
|
||||||
|
recordBtn.classList.remove('recording');
|
||||||
|
recordBtn.disabled = false;
|
||||||
|
micIcon.classList.remove('hidden');
|
||||||
|
stopIcon.classList.add('hidden');
|
||||||
|
waveform.classList.add('hidden');
|
||||||
|
statusBadge.textContent = '대기 중';
|
||||||
|
statusBadge.className = 'status-badge status-waiting';
|
||||||
|
helpText.textContent = '버튼을 클릭하여 녹음을 시작하세요';
|
||||||
|
processingCard.classList.add('hidden');
|
||||||
|
resultSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타이머 시작
|
||||||
|
function startTimer() {
|
||||||
|
const timerEl = document.getElementById('timer');
|
||||||
|
timerEl.classList.add('active');
|
||||||
|
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
|
||||||
|
const seconds = (elapsed % 60).toString().padStart(2, '0');
|
||||||
|
timerEl.textContent = `${minutes}:${seconds}`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타이머 중지
|
||||||
|
function stopTimer() {
|
||||||
|
if (timerInterval) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
timerInterval = null;
|
||||||
|
}
|
||||||
|
document.getElementById('timer').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 녹음
|
||||||
|
function newRecording() {
|
||||||
|
currentMeetingId = null;
|
||||||
|
document.getElementById('timer').textContent = '00:00';
|
||||||
|
updateUI('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회의록 삭제
|
||||||
|
async function deleteMeeting(id) {
|
||||||
|
showDeleteConfirm('이 회의록', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/meeting-logs/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
showToast('삭제되었습니다.', 'success');
|
||||||
|
htmx.trigger('#meetingList', 'load');
|
||||||
|
} else {
|
||||||
|
showToast(result.message || '삭제 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('삭제 실패:', error);
|
||||||
|
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회의록 상세 보기
|
||||||
|
async function viewMeeting(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/meeting-logs/${id}/summary`, {
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
document.getElementById('meetingTitle').value = result.data.transcript ? '회의록' : '';
|
||||||
|
document.getElementById('summaryContent').innerHTML = marked.parse(result.data.summary || '요약 내용이 없습니다.');
|
||||||
|
document.getElementById('transcriptContent').textContent = result.data.transcript || '';
|
||||||
|
updateUI('result');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('조회 실패:', error);
|
||||||
|
showToast('조회 중 오류가 발생했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{-- Markdown 파서 --}}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
@endpush
|
||||||
|
|||||||
60
resources/views/lab/ai/web-recording/partials/list.blade.php
Normal file
60
resources/views/lab/ai/web-recording/partials/list.blade.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{{-- 회의록 목록 (HTMX partial) --}}
|
||||||
|
@forelse($meetings as $meeting)
|
||||||
|
<div class="meeting-card bg-white border border-gray-200 rounded-lg p-4 mb-3 hover:shadow-md"
|
||||||
|
onclick="viewMeeting({{ $meeting->id }})">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold text-gray-800">{{ $meeting->title }}</h3>
|
||||||
|
<div class="flex items-center gap-2 mt-1 text-sm text-gray-500">
|
||||||
|
<span>{{ $meeting->user?->name ?? '알 수 없음' }}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>{{ $meeting->created_at->format('Y-m-d H:i') }}</span>
|
||||||
|
@if($meeting->duration_seconds)
|
||||||
|
<span>|</span>
|
||||||
|
<span>{{ $meeting->formatted_duration }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@php
|
||||||
|
$statusClass = match($meeting->status) {
|
||||||
|
'PENDING' => 'bg-yellow-100 text-yellow-700',
|
||||||
|
'PROCESSING' => 'bg-blue-100 text-blue-700',
|
||||||
|
'COMPLETED' => 'bg-green-100 text-green-700',
|
||||||
|
'FAILED' => 'bg-red-100 text-red-700',
|
||||||
|
default => 'bg-gray-100 text-gray-700',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full {{ $statusClass }}">
|
||||||
|
{{ $meeting->status_label }}
|
||||||
|
</span>
|
||||||
|
<button class="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
onclick="event.stopPropagation(); deleteMeeting({{ $meeting->id }})">
|
||||||
|
<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="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>
|
||||||
|
@if($meeting->transcript_text)
|
||||||
|
<p class="text-sm text-gray-600 mt-2 line-clamp-2">
|
||||||
|
{{ Str::limit($meeting->transcript_text, 100) }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-center py-12 text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>저장된 회의록이 없습니다.</p>
|
||||||
|
<p class="text-sm mt-1">새 녹음을 시작해보세요!</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
{{-- 페이지네이션 --}}
|
||||||
|
@if($meetings->hasPages())
|
||||||
|
<div class="mt-4 flex justify-center">
|
||||||
|
{{ $meetings->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{{-- 회의록 요약 결과 (HTMX partial) --}}
|
||||||
|
@if($meeting->isProcessing())
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="spinner mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">회의록을 생성하고 있습니다...</p>
|
||||||
|
<p class="text-sm text-gray-400">음성 인식 및 AI 요약 중</p>
|
||||||
|
</div>
|
||||||
|
@elseif($meeting->isCompleted())
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- 제목 편집 --}}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text"
|
||||||
|
id="meeting-title-{{ $meeting->id }}"
|
||||||
|
value="{{ $meeting->title }}"
|
||||||
|
class="flex-1 px-4 py-3 border border-gray-300 rounded-lg font-semibold text-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="회의록 제목">
|
||||||
|
<button class="px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||||
|
onclick="updateMeetingTitle({{ $meeting->id }})">
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 메타 정보 --}}
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span>작성자: {{ $meeting->user?->name ?? '알 수 없음' }}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>녹음 시간: {{ $meeting->formatted_duration }}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>{{ $meeting->created_at->format('Y-m-d H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 음성 인식 결과 --}}
|
||||||
|
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||||
|
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||||
|
<span class="font-medium text-gray-800">음성 인식 결과</span>
|
||||||
|
<svg class="accordion-icon w-5 h-5 text-gray-500 open" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-content open">
|
||||||
|
<div class="px-4 pb-4">
|
||||||
|
<p class="whitespace-pre-wrap text-gray-600 text-sm">{{ $meeting->transcript_text ?: '음성 인식 결과가 없습니다.' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- AI 요약 결과 --}}
|
||||||
|
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||||
|
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||||
|
<span class="font-medium text-gray-800">AI 요약</span>
|
||||||
|
<svg class="accordion-icon w-5 h-5 text-gray-500 open" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-content open">
|
||||||
|
<div class="px-4 pb-4 prose max-w-none text-sm">
|
||||||
|
{!! nl2br(e($meeting->summary_text ?: 'AI 요약 결과가 없습니다.')) !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-8 text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<p>회의록 처리에 실패했습니다.</p>
|
||||||
|
<p class="text-sm mt-1">다시 시도해주세요.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function updateMeetingTitle(id) {
|
||||||
|
const title = document.getElementById('meeting-title-' + id).value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/meeting-logs/${id}/title`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title: title })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
showToast('제목이 저장되었습니다.', 'success');
|
||||||
|
htmx.trigger('#meetingList', 'load');
|
||||||
|
} else {
|
||||||
|
showToast(result.message || '저장 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 실패:', error);
|
||||||
|
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
use App\Http\Controllers\Api\Admin\RolePermissionController;
|
use App\Http\Controllers\Api\Admin\RolePermissionController;
|
||||||
use App\Http\Controllers\Api\Admin\TenantController;
|
use App\Http\Controllers\Api\Admin\TenantController;
|
||||||
use App\Http\Controllers\Api\Admin\ItemFieldController;
|
use App\Http\Controllers\Api\Admin\ItemFieldController;
|
||||||
|
use App\Http\Controllers\Api\Admin\MeetingLogController;
|
||||||
use App\Http\Controllers\Api\Admin\UserController;
|
use App\Http\Controllers\Api\Admin\UserController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -560,3 +561,38 @@
|
|||||||
Route::post('/', [\App\Http\Controllers\Api\BizCertController::class, 'store'])->name('store');
|
Route::post('/', [\App\Http\Controllers\Api\BizCertController::class, 'store'])->name('store');
|
||||||
Route::delete('/{id}', [\App\Http\Controllers\Api\BizCertController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [\App\Http\Controllers\Api\BizCertController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 웹 녹음 AI 요약 API
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Lab > AI > 웹 녹음 AI 요약 기능
|
||||||
|
| Google STT + Claude API를 사용한 회의록 생성
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
Route::middleware(['web', 'auth'])->prefix('meeting-logs')->name('api.admin.meeting-logs.')->group(function () {
|
||||||
|
// 목록 조회 (HTMX 지원)
|
||||||
|
Route::get('/', [MeetingLogController::class, 'index'])->name('index');
|
||||||
|
|
||||||
|
// 회의록 생성 (녹음 시작)
|
||||||
|
Route::post('/', [MeetingLogController::class, 'store'])->name('store');
|
||||||
|
|
||||||
|
// 상세 조회
|
||||||
|
Route::get('/{id}', [MeetingLogController::class, 'show'])->name('show');
|
||||||
|
|
||||||
|
// 오디오 업로드 및 처리
|
||||||
|
Route::post('/{id}/process', [MeetingLogController::class, 'processAudio'])->name('process');
|
||||||
|
|
||||||
|
// 제목 수정
|
||||||
|
Route::put('/{id}/title', [MeetingLogController::class, 'updateTitle'])->name('update-title');
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
Route::delete('/{id}', [MeetingLogController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
|
// 처리 상태 확인 (폴링용)
|
||||||
|
Route::get('/{id}/status', [MeetingLogController::class, 'status'])->name('status');
|
||||||
|
|
||||||
|
// 요약 결과 조회 (HTMX 지원)
|
||||||
|
Route::get('/{id}/summary', [MeetingLogController::class, 'summary'])->name('summary');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user