You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

108 lines
3.4 KiB
TypeScript

// frontend/components/inbox/CaptureComposer.tsx
"use client";
import { useRef, useState } from "react";
import { Icon } from "@/components/Icon";
import { transcribeAudio, captionImage } from "@/lib/inbox/multimodal";
// 멀티모달 미가용/거부 시 phase-4 톤 폴백 (graceful degrade)
const VOICE_FALLBACK = "음성 메모 — (텍스트로 적어주세요)";
const IMAGE_FALLBACK = "이미지 캡처 — (자동 인식 결과 없음)";
export default function CaptureComposer({
onSubmit,
}: {
onSubmit: (raw: string, kind: "text" | "voice" | "image") => void;
}) {
const [input, setInput] = useState("");
const [recording, setRecording] = useState(false);
const recRef = useRef<MediaRecorder | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
const submitText = () => {
const t = input.trim();
if (!t) return;
onSubmit(t, "text");
setInput("");
};
/* 음성: 실제 녹음 → /inbox/transcribe → raw 텍스트로 capture */
const toggleMic = async () => {
if (recording) {
recRef.current?.stop();
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
onSubmit(VOICE_FALLBACK, "voice");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const rec = new MediaRecorder(stream);
const chunks: Blob[] = [];
rec.ondataavailable = (e) => chunks.push(e.data);
rec.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
setRecording(false);
const blob = new Blob(chunks, { type: "audio/webm" });
try {
const { text } = await transcribeAudio(blob);
onSubmit(text || VOICE_FALLBACK, "voice");
} catch {
onSubmit(VOICE_FALLBACK, "voice");
}
};
recRef.current = rec;
rec.start();
setRecording(true);
} catch {
onSubmit(VOICE_FALLBACK, "voice"); // 권한 거부 → 폴백
}
};
/* 이미지: 파일 선택 → /inbox/caption → raw 캡션으로 capture */
const onImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
try {
const { text } = await captionImage(f);
onSubmit(text || IMAGE_FALLBACK, "image");
} catch {
onSubmit(IMAGE_FALLBACK, "image");
} finally {
e.target.value = "";
}
};
return (
<div className="sb-cmd">
<Icon name="spark" />
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitText()}
placeholder="갑자기 생각난 것 아무거나… 예) 다음 주 한국 가는 비행기 티켓 사기"
aria-label="인박스에 빠르게 캡처"
/>
<button
className={"sb-mode" + (recording ? " rec" : "")}
onClick={toggleMic}
aria-label={recording ? "녹음 중지" : "음성으로 캡처"}
aria-pressed={recording}
>
<Icon name="mic" />
</button>
<button
className="sb-mode"
onClick={() => fileRef.current?.click()}
aria-label="이미지로 캡처"
>
<Icon name="image" />
</button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onImage} />
<button className="sb-send" onClick={submitText} aria-label="보내기">
<Icon name="arrow" />
</button>
</div>
);
}