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