feat(V2-5차): pgvector 시맨틱 검색 + chat 스트리밍 + doc 편집 + VoiceAction + Realtime + storage + 메모
묶음 Q — pgvector + 시맨틱 검색:
- migrations/20260410000004_pgvector_knowledge.sql
- vector extension, embedding vector(1536) 컬럼
- ivfflat cosine 인덱스
- match_knowledge_chunks(query_embedding, match_count, similarity_threshold) RPC
- RLS: user_id 또는 소속 팀 기준
- functions/embed-chunks: 문서 소유권 확인 후 OpenAI text-embedding-3-small 배치 호출 → knowledge_chunks.embedding 업데이트
- functions/search-knowledge: 쿼리 텍스트 → OpenAI 임베딩 → user 권한 RPC 호출 → 상위 청크 반환
- config.toml에 embed-chunks/search-knowledge 등록
- components/knowledge/knowledge-search.tsx: 검색창 + 결과 카드(유사도 %)
- /knowledge 페이지에 검색 UI 추가
묶음 R — /chat SSE 스트리밍:
- functions/llm-proxy: Anthropic Messages API stream 지원
- ANTHROPIC_API_KEY 없으면 SSE placeholder 스트림
- stream=true일 때 response.body 그대로 프록시 (text/event-stream)
- stream=false는 JSON 응답
- components/chat/chat-panel.tsx:
- stream=true로 요청
- ReadableStream 파싱 (SSE: data: {type:content_block_delta, delta:{text_delta}})
- assistantId 메시지를 progressive 업데이트, scrollToBottom
- 불필요한 LlmResponse 인터페이스 제거
묶음 S — DocumentEditor:
- components/meetings/document-editor.tsx
- 문서 박스 클릭 → MUI Dialog (fullWidth, maxWidth md)
- TextField multiline 20~40 rows, monospace
- 제목 편집 + 저장/삭제 버튼
- meetings/[id] 페이지 Documents 섹션을 DocumentEditor로 교체 (+ typoSx 미사용 import 제거)
묶음 T — /actions (VoiceAction 이식):
- components/actions/action-runner.tsx
- SYSTEM_PROMPT로 JSON 스키마 강제 (create_meeting/search_knowledge/create_memo/send_team_invite/unknown)
- LLM 응답에서 JSON 추출 → 파싱 → 확인 후 실행
- 각 type별 실행 로직 (meetings/memos INSERT, 검색은 안내만)
- app/(app)/actions/page.tsx + Sidebar Actions 메뉴 + AutoAwesomeIcon
묶음 U — Realtime + Storage + 메모 UI:
- CloudSyncService:
- RealtimeChannel import 추가
- startRealtime(): meetings/history/dictionary 변경 구독, debounce 후 pullAll 자동 트리거
- stopRealtime(), signIn 직후/세션 복원 시 자동 시작, signOut 시 종료
- apps/web/components/meetings/memo-form.tsx
- TextField + 저장 버튼, 회의 시작 기준 경과 ms 자동 계산
- Realtime 구독자에게 자동 전파
- meetings/[id] MEMOS 섹션에 MemoForm 렌더
- apps/web/components/record/mic-recorder.tsx
- STT 성공 후 Supabase Storage 'audio' 버킷에 {user_id}/{ts}.webm 업로드
- meetings 테이블에 INSERT (raw_transcript, audio_storage_key, duration_ms, ended_at)
- Storage/meetings 실패는 전사 결과는 유지하며 경고
묶음 V — 11개 locale nav 키:
- en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th 에 nav.chat/nav.knowledge/nav.actions 추가
- ko.json에 nav.actions 추가
- Sidebar에 Actions 메뉴(AutoAwesomeIcon) 등록
검증:
- desktop typecheck + build OK
- web typecheck + build OK (15 라우트: 기존 14 + /actions)
- api-client test 19 passed
- 회귀 없음
통계:
- 총 Edge Functions 12개 (embed-chunks/search-knowledge 추가)
- 총 SQL 마이그레이션 8개
- 웹 라우트 15개 (accept-invite/actions/billing/chat/dashboard/knowledge/login/meetings/meetings[id]/record/teams/teams[id]/auth-callback/root/_not-found)
This commit is contained in:
parent
1fa24ce3c9
commit
b386733d1e
29 changed files with 1363 additions and 81 deletions
22
apps/web/src/app/(app)/actions/page.tsx
Normal file
22
apps/web/src/app/(app)/actions/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// apps/web/src/app/(app)/actions/page.tsx
|
||||
// 음성 액션 (텍스트 명령 → LLM 파싱 → 실행)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
import { ActionRunner } from '@/components/actions/action-runner'
|
||||
|
||||
export default function ActionsPage(): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<PhosphorText variant="title" sx={{ mb: 1 }}>
|
||||
ACTIONS
|
||||
</PhosphorText>
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13, mb: 4 }}>
|
||||
자연어 명령을 입력하면 LLM이 파싱하여 실행 가능한 액션으로 변환합니다. (V1
|
||||
VoiceActionService의 web 포트)
|
||||
</Box>
|
||||
<ActionRunner />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { Box, Stack } from '@mui/material'
|
|||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { AddKnowledgeForm } from '@/components/knowledge/add-knowledge-form'
|
||||
import { KnowledgeSearch } from '@/components/knowledge/knowledge-search'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
|
||||
interface KnowledgeDoc {
|
||||
|
|
@ -48,6 +49,10 @@ export default async function KnowledgePage(): Promise<React.ReactElement> {
|
|||
<AddKnowledgeForm />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<KnowledgeSearch />
|
||||
</Box>
|
||||
|
||||
{docs.length === 0 ? (
|
||||
<MetalCard sx={{ p: 6, textAlign: 'center', color: d3roPalette.text.muted }}>
|
||||
아직 지식 문서가 없습니다. 위에서 추가하세요.
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import { Box, Stack } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import {
|
||||
LiveTranscriptList,
|
||||
type TranscriptRow
|
||||
} from '@/components/meetings/live-transcript-list'
|
||||
import { GenerateDocumentButton } from '@/components/meetings/generate-document-button'
|
||||
import { DocumentEditor } from '@/components/meetings/document-editor'
|
||||
import { MemoForm } from '@/components/meetings/memo-form'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -91,6 +93,7 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
|
|||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<MemoForm meetingId={id} meetingStartedAt={meeting.started_at} />
|
||||
</MetalCard>
|
||||
|
||||
{/* Documents */}
|
||||
|
|
@ -111,21 +114,16 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
|
|||
) : (
|
||||
<Stack spacing={1}>
|
||||
{(documents ?? []).map((doc) => (
|
||||
<Box
|
||||
<DocumentEditor
|
||||
key={doc.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 1
|
||||
doc={{
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
content: doc.content,
|
||||
template_type: doc.template_type,
|
||||
created_at: doc.created_at
|
||||
}}
|
||||
>
|
||||
<Box sx={{ ...typoSx("body"), color: d3roPalette.text.primary, mb: 0.5 }}>
|
||||
{doc.title}
|
||||
</Box>
|
||||
<Box sx={{ color: d3roPalette.text.label, fontSize: 11 }}>
|
||||
{doc.template_type} · {new Date(doc.created_at).toLocaleDateString('ko-KR')}
|
||||
</Box>
|
||||
</Box>
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
|
|
|||
264
apps/web/src/components/actions/action-runner.tsx
Normal file
264
apps/web/src/components/actions/action-runner.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/actions/action-runner.tsx
|
||||
// 텍스트 명령 → LLM 파싱 (function-like JSON) → 동작 시뮬레이션.
|
||||
// V1 VoiceActionService의 간이 web 포트.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, TextField, Stack, Alert, CircularProgress, Chip } from '@mui/material'
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
type ActionType =
|
||||
| 'create_meeting'
|
||||
| 'search_knowledge'
|
||||
| 'create_memo'
|
||||
| 'send_team_invite'
|
||||
| 'unknown'
|
||||
|
||||
interface ActionResult {
|
||||
type: ActionType
|
||||
args: Record<string, unknown>
|
||||
rationale: string
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 D3RO Voice의 음성 액션 파서입니다.
|
||||
사용자의 자연어 명령을 다음 JSON 스키마로 변환하세요:
|
||||
|
||||
{
|
||||
"type": "create_meeting" | "search_knowledge" | "create_memo" | "send_team_invite" | "unknown",
|
||||
"args": { ... },
|
||||
"rationale": "왜 이 액션을 선택했는지 한 줄"
|
||||
}
|
||||
|
||||
각 type의 args 스키마:
|
||||
- create_meeting: { title: string }
|
||||
- search_knowledge: { query: string }
|
||||
- create_memo: { meeting_id?: string, content: string }
|
||||
- send_team_invite: { team_id: string, email: string, role?: "admin" | "member" }
|
||||
- unknown: {}
|
||||
|
||||
JSON만 출력하세요. 설명 불필요.`
|
||||
|
||||
interface LlmResponse {
|
||||
content: Array<{ type: string; text: string }>
|
||||
}
|
||||
|
||||
export function ActionRunner(): React.ReactElement {
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<ActionResult | null>(null)
|
||||
const [executed, setExecuted] = useState<string | null>(null)
|
||||
|
||||
async function handleParse(): Promise<void> {
|
||||
if (!input.trim()) return
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setExecuted(null)
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
if (!session) {
|
||||
throw new Error('로그인이 필요합니다')
|
||||
}
|
||||
|
||||
const resp = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/llm-proxy`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: 'user', content: input.trim() }],
|
||||
system: SYSTEM_PROMPT,
|
||||
max_tokens: 512
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`LLM 호출 실패: ${resp.status}`)
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as LlmResponse
|
||||
const text = data.content?.[0]?.text ?? ''
|
||||
|
||||
// JSON 추출 (LLM 응답이 ```json ... ``` 블록에 싸일 수도)
|
||||
const jsonMatch = /\{[\s\S]*\}/.exec(text)
|
||||
if (!jsonMatch) {
|
||||
throw new Error('LLM 응답에서 JSON을 찾지 못했습니다')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]) as ActionResult
|
||||
setResult(parsed)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecute(): Promise<void> {
|
||||
if (!result) return
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
throw new Error('로그인이 필요합니다')
|
||||
}
|
||||
|
||||
switch (result.type) {
|
||||
case 'create_meeting': {
|
||||
const title = (result.args.title as string | undefined) ?? '(제목 없음)'
|
||||
const { error: err } = await supabase
|
||||
.from('meetings')
|
||||
.insert({ user_id: user.id, title, team_id: null, status: 'completed' })
|
||||
if (err) throw new Error(err.message)
|
||||
setExecuted(`회의 "${title}" 생성됨`)
|
||||
break
|
||||
}
|
||||
case 'search_knowledge': {
|
||||
const query = (result.args.query as string | undefined) ?? ''
|
||||
setExecuted(`knowledge 검색: "${query}" — /knowledge 페이지에서 결과 확인`)
|
||||
break
|
||||
}
|
||||
case 'create_memo': {
|
||||
const content = (result.args.content as string | undefined) ?? ''
|
||||
const meetingId = result.args.meeting_id as string | undefined
|
||||
if (!meetingId) {
|
||||
throw new Error('meeting_id가 필요합니다')
|
||||
}
|
||||
const { error: err } = await supabase.from('meeting_memos').insert({
|
||||
meeting_id: meetingId,
|
||||
user_id: user.id,
|
||||
content,
|
||||
timestamp_ms: 0
|
||||
})
|
||||
if (err) throw new Error(err.message)
|
||||
setExecuted(`메모 생성됨: "${content}"`)
|
||||
break
|
||||
}
|
||||
case 'send_team_invite':
|
||||
setExecuted(`초대는 /teams/[id] 페이지에서 직접 진행하세요 (MVP 안전 장치)`)
|
||||
break
|
||||
default:
|
||||
setExecuted('알 수 없는 액션 — 실행 불가')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<MetalCard sx={{ p: 3 }}>
|
||||
<PhosphorText variant="heading" sx={{ mb: 2 }}>
|
||||
COMMAND
|
||||
</PhosphorText>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={4}
|
||||
size="small"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="예: '회의록 정리 — 프로젝트 킥오프 생성', '지난주 브레인스토밍 관련 검색'"
|
||||
disabled={busy}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleParse()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={() => void handleParse()}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
파싱
|
||||
</Button>
|
||||
</Stack>
|
||||
</MetalCard>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<MetalCard sx={{ p: 3 }}>
|
||||
<PhosphorText variant="heading" sx={{ mb: 2 }}>
|
||||
PARSED ACTION
|
||||
</PhosphorText>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>TYPE</Box>
|
||||
<Chip
|
||||
label={result.type}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: d3roPalette.tag.purpleBg,
|
||||
color: d3roPalette.tag.purple,
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 0.5 }}>ARGS</Box>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 1,
|
||||
fontSize: 12,
|
||||
color: d3roPalette.text.primary,
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(result.args, null, 2)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 0.5 }}>RATIONALE</Box>
|
||||
<Box sx={{ color: d3roPalette.text.secondary, fontSize: 13 }}>{result.rationale}</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => void handleExecute()}
|
||||
disabled={busy || result.type === 'unknown'}
|
||||
>
|
||||
실행
|
||||
</Button>
|
||||
</Stack>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{executed && (
|
||||
<Alert severity="success" variant="outlined">
|
||||
{executed}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,15 +18,6 @@ interface Message {
|
|||
content: string
|
||||
}
|
||||
|
||||
interface LlmResponse {
|
||||
id: string
|
||||
model: string
|
||||
role: string
|
||||
content: Array<{ type: string; text: string }>
|
||||
stop_reason: string
|
||||
usage: { input_tokens: number; output_tokens: number }
|
||||
}
|
||||
|
||||
export function ChatPanel(): React.ReactElement {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
|
|
@ -70,7 +61,8 @@ export function ChatPanel(): React.ReactElement {
|
|||
|
||||
const payload = {
|
||||
messages: nextMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
max_tokens: 1024
|
||||
max_tokens: 1024,
|
||||
stream: true
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
|
|
@ -90,15 +82,54 @@ export function ChatPanel(): React.ReactElement {
|
|||
throw new Error(`LLM 호출 실패: ${response.status} ${errTxt}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LlmResponse
|
||||
const assistantText = data.content?.[0]?.text ?? '[응답 없음]'
|
||||
const assistantMsg: Message = {
|
||||
id: data.id,
|
||||
role: 'assistant',
|
||||
content: assistantText
|
||||
if (!response.body) {
|
||||
throw new Error('응답 body가 없습니다')
|
||||
}
|
||||
|
||||
// SSE 스트림 파싱 — Anthropic content_block_delta 이벤트의 text_delta 누적
|
||||
const assistantId = `a_${Date.now()}`
|
||||
setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '' }])
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let accumulated = ''
|
||||
let streamDone = false
|
||||
|
||||
while (!streamDone) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
streamDone = true
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('data:')) continue
|
||||
const data = trimmed.slice(5).trim()
|
||||
if (data === '[DONE]' || data === '') continue
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data) as {
|
||||
type?: string
|
||||
delta?: { type?: string; text?: string }
|
||||
}
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
||||
accumulated += event.delta.text ?? ''
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: accumulated } : m))
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch {
|
||||
// 불완전한 JSON 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMsg])
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
|
|
|
|||
133
apps/web/src/components/knowledge/knowledge-search.tsx
Normal file
133
apps/web/src/components/knowledge/knowledge-search.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/knowledge/knowledge-search.tsx
|
||||
// 지식 베이스 시맨틱 검색 — search-knowledge Edge Function 호출
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, TextField, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
interface SearchResult {
|
||||
id: string
|
||||
document_id: string
|
||||
chunk_index: number
|
||||
content: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export function KnowledgeSearch(): React.ReactElement {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSearch(): Promise<void> {
|
||||
if (!query.trim()) return
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
if (!session) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/search-knowledge`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ query: query.trim(), count: 8 })
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = (await response.json()) as { error?: string; message?: string }
|
||||
throw new Error(errData.message ?? errData.error ?? `검색 실패: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { results: SearchResult[] }
|
||||
setResults(data.results)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSearch()
|
||||
}
|
||||
}}
|
||||
placeholder="지식 베이스에서 검색... (시맨틱)"
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <SearchIcon />}
|
||||
onClick={() => void handleSearch()}
|
||||
disabled={busy || !query.trim()}
|
||||
>
|
||||
검색
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<Stack spacing={1.5}>
|
||||
<PhosphorText variant="label" color="label">
|
||||
검색 결과 ({results.length})
|
||||
</PhosphorText>
|
||||
{results.map((r) => (
|
||||
<MetalCard key={r.id} sx={{ p: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>
|
||||
#{r.chunk_index}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
bgcolor: d3roPalette.tag.greenBg,
|
||||
color: d3roPalette.tag.green,
|
||||
fontSize: 10
|
||||
}}
|
||||
>
|
||||
{Math.round(r.similarity * 100)}%
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box sx={{ ...typoSx('body'), color: d3roPalette.text.primary, whiteSpace: 'pre-wrap' }}>
|
||||
{r.content}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'
|
|||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import ChatIcon from '@mui/icons-material/Chat'
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'
|
||||
import GroupsIcon from '@mui/icons-material/Groups'
|
||||
import PaymentIcon from '@mui/icons-material/Payment'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
|
|
@ -61,6 +62,12 @@ export function Sidebar(): React.ReactElement {
|
|||
label: t('nav.knowledge') ?? 'Knowledge',
|
||||
icon: <LibraryBooksIcon />
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
path: '/actions',
|
||||
label: t('nav.actions') ?? 'Actions',
|
||||
icon: <AutoAwesomeIcon />
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
path: '/teams',
|
||||
|
|
|
|||
171
apps/web/src/components/meetings/document-editor.tsx
Normal file
171
apps/web/src/components/meetings/document-editor.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/meetings/document-editor.tsx
|
||||
// 회의록 문서 편집 — MarkdownEditor 다이얼로그
|
||||
// 클릭 → 다이얼로그 열림 → textarea 편집 → 저장 시 meeting_documents UPDATE
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
Alert,
|
||||
IconButton,
|
||||
Stack
|
||||
} from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import SaveIcon from '@mui/icons-material/Save'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
interface DocumentEditorProps {
|
||||
doc: {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
template_type: string
|
||||
created_at: string
|
||||
}
|
||||
}
|
||||
|
||||
export function DocumentEditor({ doc }: DocumentEditorProps): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [title, setTitle] = useState(doc.title)
|
||||
const [content, setContent] = useState(doc.content)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { error: updateErr } = await supabase
|
||||
.from('meeting_documents')
|
||||
.update({ title, content })
|
||||
.eq('id', doc.id)
|
||||
|
||||
if (updateErr) {
|
||||
setError(updateErr.message)
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (!window.confirm('이 문서를 삭제하시겠습니까?')) return
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { error: deleteErr } = await supabase
|
||||
.from('meeting_documents')
|
||||
.delete()
|
||||
.eq('id', doc.id)
|
||||
|
||||
if (deleteErr) {
|
||||
setError(deleteErr.message)
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: d3roPalette.bg.cardHover }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 0.5 }}>{doc.title}</Box>
|
||||
<Box sx={{ color: d3roPalette.text.label, fontSize: 11 }}>
|
||||
{doc.template_type} · {new Date(doc.created_at).toLocaleDateString('ko-KR')}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<TextField
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
variant="standard"
|
||||
placeholder="제목"
|
||||
sx={{ flex: 1, mr: 2 }}
|
||||
/>
|
||||
<IconButton onClick={() => setOpen(false)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={20}
|
||||
maxRows={40}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
disabled={busy}
|
||||
placeholder="마크다운으로 편집..."
|
||||
variant="outlined"
|
||||
slotProps={{
|
||||
input: {
|
||||
style: { fontFamily: 'monospace', fontSize: 13 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={busy}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Button onClick={() => setOpen(false)} disabled={busy}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={busy}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
97
apps/web/src/components/meetings/memo-form.tsx
Normal file
97
apps/web/src/components/meetings/memo-form.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/meetings/memo-form.tsx
|
||||
// 회의 메모 작성 — meeting_memos INSERT + Realtime 구독 유도
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Box, TextField, Button, Stack, Alert } from '@mui/material'
|
||||
import NoteAddIcon from '@mui/icons-material/NoteAdd'
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
interface MemoFormProps {
|
||||
meetingId: string
|
||||
meetingStartedAt: string
|
||||
}
|
||||
|
||||
export function MemoForm({ meetingId, meetingStartedAt }: MemoFormProps): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const [content, setContent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!content.trim()) return
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
// 회의 시작 시간 대비 경과 밀리초
|
||||
const elapsedMs = Math.max(0, Date.now() - new Date(meetingStartedAt).getTime())
|
||||
|
||||
const { error: insertErr } = await supabase.from('meeting_memos').insert({
|
||||
meeting_id: meetingId,
|
||||
user_id: user.id,
|
||||
content: content.trim(),
|
||||
timestamp_ms: elapsedMs
|
||||
})
|
||||
|
||||
if (insertErr) {
|
||||
setError(insertErr.message)
|
||||
return
|
||||
}
|
||||
|
||||
setContent('')
|
||||
router.refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder="메모 입력... (Enter 저장)"
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<NoteAddIcon />}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={busy || !content.trim()}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</Stack>
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 10, mt: 0.5 }}>
|
||||
회의 시작 후 경과 시간으로 timestamp_ms가 자동 계산됩니다.
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -176,6 +176,43 @@ export function MicRecorder(): React.ReactElement {
|
|||
|
||||
const data = (await response.json()) as SttResponse
|
||||
setTranscript(data.transcript)
|
||||
|
||||
// 회의로 저장 + Supabase Storage에 오디오 업로드
|
||||
try {
|
||||
const {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (user) {
|
||||
const audioKey = `${user.id}/${Date.now()}.webm`
|
||||
const { error: uploadErr } = await supabase.storage
|
||||
.from('audio')
|
||||
.upload(audioKey, blob, { contentType: 'audio/webm' })
|
||||
|
||||
const storageKey: string | null = uploadErr ? null : audioKey
|
||||
|
||||
const { error: insertErr } = await supabase.from('meetings').insert({
|
||||
user_id: user.id,
|
||||
team_id: null,
|
||||
title: `녹음 ${new Date().toLocaleString('ko-KR')}`,
|
||||
status: 'completed',
|
||||
duration_ms: Math.round(data.duration_seconds * 1000),
|
||||
raw_transcript: data.transcript,
|
||||
audio_storage_key: storageKey,
|
||||
stt_model: 'google-stt',
|
||||
ended_at: new Date().toISOString()
|
||||
})
|
||||
if (insertErr) {
|
||||
// 회의 저장 실패해도 전사 결과는 유지
|
||||
setError(`회의 저장 실패 (전사는 성공): ${insertErr.message}`)
|
||||
}
|
||||
}
|
||||
} catch (saveErr) {
|
||||
setError(
|
||||
`저장 중 오류 (전사는 성공): ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`
|
||||
)
|
||||
}
|
||||
|
||||
setState('done')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'STT 처리 실패')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue