feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -2,23 +2,27 @@
|
|||
// createD3roSupabaseClient 팩토리 유닛 테스트
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createD3roSupabaseClient, isClientConfigured } from '../src/client'
|
||||
import {
|
||||
createD3roSupabaseClient,
|
||||
isClientConfigured,
|
||||
SupabaseConfigurationError,
|
||||
} from '../src/client'
|
||||
|
||||
describe('createD3roSupabaseClient', () => {
|
||||
it('URL과 key가 없으면 placeholder 클라이언트를 반환한다', () => {
|
||||
const client = createD3roSupabaseClient({ url: undefined, anonKey: undefined })
|
||||
expect(client).toBeDefined()
|
||||
// auth, from 등 메서드가 존재해야 함
|
||||
expect(typeof client.auth.getSession).toBe('function')
|
||||
expect(typeof client.from).toBe('function')
|
||||
it('URL과 key가 없으면 설정 오류로 즉시 실패한다', () => {
|
||||
expect(() => createD3roSupabaseClient({ url: undefined, anonKey: undefined }))
|
||||
.toThrow(SupabaseConfigurationError)
|
||||
})
|
||||
|
||||
it('URL만 있고 key가 없으면 placeholder 클라이언트를 반환한다', () => {
|
||||
const client = createD3roSupabaseClient({
|
||||
it('URL이나 key 중 하나만 있거나 공백이면 설정 오류로 즉시 실패한다', () => {
|
||||
expect(() => createD3roSupabaseClient({
|
||||
url: 'https://real.supabase.co',
|
||||
anonKey: undefined
|
||||
})
|
||||
expect(client).toBeDefined()
|
||||
anonKey: undefined,
|
||||
})).toThrow(SupabaseConfigurationError)
|
||||
expect(() => createD3roSupabaseClient({
|
||||
url: ' ',
|
||||
anonKey: 'anon-key',
|
||||
})).toThrow(SupabaseConfigurationError)
|
||||
})
|
||||
|
||||
it('URL과 key가 모두 있으면 실제 클라이언트를 생성한다', () => {
|
||||
|
|
@ -60,5 +64,6 @@ describe('isClientConfigured', () => {
|
|||
it('빈 문자열은 false로 처리된다', () => {
|
||||
expect(isClientConfigured({ url: '', anonKey: 'k' })).toBe(false)
|
||||
expect(isClientConfigured({ url: 'https://x.supabase.co', anonKey: '' })).toBe(false)
|
||||
expect(isClientConfigured({ url: ' ', anonKey: 'k' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,108 +1,82 @@
|
|||
// packages/api-client/__tests__/transcribe.test.ts
|
||||
// transcribeAudio 함수 단위 및 통합 인터페이스 테스트
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { transcribeAudio } from '../src/transcribe'
|
||||
|
||||
describe('transcribeAudio', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
const session = {
|
||||
supabaseUrl: 'https://project.supabase.co',
|
||||
anonKey: 'anon-key',
|
||||
token: 'user-token',
|
||||
}
|
||||
|
||||
it('Blob 입력 시 multipart/form-data로 /api/stt/transcribe를 호출하여 전사 결과를 반환한다', async () => {
|
||||
const mockAudioBlob = new Blob(['mock-audio-content'], { type: 'audio/webm' })
|
||||
const mockResponse = {
|
||||
text: '안녕하세요, 클라우드 음성 전사 테스트입니다.',
|
||||
function payload(transcript = '안녕하세요') {
|
||||
return {
|
||||
transcript,
|
||||
confidence: 0.99,
|
||||
language_code: 'ko',
|
||||
duration_seconds: 2.5,
|
||||
provider: 'groq',
|
||||
}
|
||||
}
|
||||
|
||||
describe('transcribeAudio', () => {
|
||||
beforeEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('uses only the authenticated Supabase quota gateway', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => payload() }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await transcribeAudio({
|
||||
...session,
|
||||
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
||||
language: 'ko',
|
||||
})
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://project.supabase.co/functions/v1/stt-proxy')
|
||||
expect(options.headers).toEqual({ Authorization: 'Bearer user-token', apikey: 'anon-key' })
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
expect((options.body as FormData).get('language_code')).toBe('ko')
|
||||
expect(result).toEqual({
|
||||
text: '안녕하세요',
|
||||
confidence: 0.99,
|
||||
language: 'ko',
|
||||
durationSeconds: 2.5,
|
||||
provider: 'groq',
|
||||
modelId: 'whisper-large-v3-turbo',
|
||||
latencyMs: 145,
|
||||
cost: 0.000021,
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockResponse,
|
||||
latencyMs: expect.any(Number),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await transcribeAudio({
|
||||
audio: mockAudioBlob,
|
||||
language: 'ko',
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
token: 'test-token',
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://localhost:5000/api/stt/transcribe')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.headers['Authorization']).toBe('Bearer test-token')
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
|
||||
expect(result.text).toBe('안녕하세요, 클라우드 음성 전사 테스트입니다.')
|
||||
expect(result.provider).toBe('groq')
|
||||
expect(result.durationSeconds).toBe(2.5)
|
||||
expect(result.latencyMs).toBe(145)
|
||||
})
|
||||
|
||||
it('Base64 문자열 입력 시 JSON 페이로드로 전송한다', async () => {
|
||||
const mockBase64 = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||||
const mockResponse = {
|
||||
text: 'Base64 전사 성공',
|
||||
confidence: 0.98,
|
||||
language: 'ko',
|
||||
durationSeconds: 1.0,
|
||||
provider: 'openai',
|
||||
modelId: 'whisper-1',
|
||||
latencyMs: 320,
|
||||
cost: 0.0001,
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockResponse,
|
||||
})
|
||||
it('converts binary and base64 audio to multipart without a JSON bypass', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => payload('binary') }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await transcribeAudio({
|
||||
audio: mockBase64,
|
||||
language: 'ko',
|
||||
prompt: '의료 용어 가이드',
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
})
|
||||
await transcribeAudio({ ...session, audio: Uint8Array.from([0, 1, 2, 253, 254, 255]) })
|
||||
await transcribeAudio({ ...session, audio: 'AAEC/f7/' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://localhost:5000/api/stt/transcribe')
|
||||
expect(options.headers['Content-Type']).toBe('application/json')
|
||||
const body = JSON.parse(options.body)
|
||||
expect(body.audioBase64).toBe(mockBase64)
|
||||
expect(body.language).toBe('ko')
|
||||
expect(body.initialPrompt).toBe('의료 용어 가이드')
|
||||
|
||||
expect(result.text).toBe('Base64 전사 성공')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
for (const [, options] of fetchMock.mock.calls) {
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
expect((options.body as FormData).get('audio')).toBeInstanceOf(Blob)
|
||||
}
|
||||
})
|
||||
|
||||
it('서버 응답이 4xx/5xx 실패 시 에러를 throw한다', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal STT Proxy Failure',
|
||||
})
|
||||
)
|
||||
it('requires token, anon key and a safe gateway origin before network access', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
transcribeAudio({
|
||||
audio: new Blob(['data']),
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
})
|
||||
).rejects.toThrow('STT transcription failed (500): Internal STT Proxy Failure')
|
||||
await expect(transcribeAudio({ ...session, token: '', audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('authenticated Supabase session')
|
||||
await expect(transcribeAudio({ ...session, supabaseUrl: 'http://public.example.com', audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('configuration is invalid')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not expose upstream bodies and rejects malformed success payloads', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, text: async () => 'secret upstream body' })))
|
||||
await expect(transcribeAudio({ ...session, audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('STT gateway request failed (503).')
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ transcript: 'x' }) })))
|
||||
await expect(transcribeAudio({ ...session, audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('invalid response')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/api-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -20,25 +20,37 @@ export interface CreateClientOptions {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase 클라이언트 생성.
|
||||
* url 또는 anonKey가 비어있으면 "dummy" 클라이언트를 반환 — 모든 쿼리는 빈 결과.
|
||||
* 이렇게 해야 env가 세팅되지 않은 상태에서도 빌드/SSR이 실패하지 않는다.
|
||||
*/
|
||||
export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupabaseClient {
|
||||
const { url, anonKey, auth } = options
|
||||
export class SupabaseConfigurationError extends Error {
|
||||
readonly code = 'SUPABASE_CONFIGURATION_MISSING'
|
||||
|
||||
constructor() {
|
||||
super('Supabase public URL and anonymous key are required.')
|
||||
this.name = 'SupabaseConfigurationError'
|
||||
}
|
||||
}
|
||||
|
||||
export function requireSupabasePublicConfig(options: CreateClientOptions): {
|
||||
url: string
|
||||
anonKey: string
|
||||
} {
|
||||
const url = options.url?.trim()
|
||||
const anonKey = options.anonKey?.trim()
|
||||
|
||||
if (!url || !anonKey) {
|
||||
// Env가 없으면 fallback URL로 생성. 실제 호출은 런타임에 실패하지만
|
||||
// 빌드/타입체크는 통과. 앱 상단에서 isClientConfigured()로 체크해야 함.
|
||||
return createClient<Database>('https://placeholder.supabase.co', 'placeholder-anon-key', {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false
|
||||
}
|
||||
})
|
||||
throw new SupabaseConfigurationError()
|
||||
}
|
||||
|
||||
return { url, anonKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase 클라이언트 생성.
|
||||
* 필수 공개 설정이 없으면 가짜 백엔드로 진행하지 않고 즉시 실패한다.
|
||||
*/
|
||||
export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupabaseClient {
|
||||
const { auth } = options
|
||||
const { url, anonKey } = requireSupabasePublicConfig(options)
|
||||
|
||||
return createClient<Database>(url, anonKey, {
|
||||
auth: {
|
||||
persistSession: auth?.persistSession ?? true,
|
||||
|
|
@ -52,5 +64,5 @@ export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupa
|
|||
* Env가 실제로 세팅되었는지 확인. UI에서 "Supabase 미설정" 경고 표시용.
|
||||
*/
|
||||
export function isClientConfigured(options: CreateClientOptions): boolean {
|
||||
return Boolean(options.url && options.anonKey)
|
||||
return Boolean(options.url?.trim() && options.anonKey?.trim())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,25 @@
|
|||
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import type { Database } from './types'
|
||||
import { requireSupabasePublicConfig } from './client'
|
||||
|
||||
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
|
||||
|
||||
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
|
||||
if (cachedClient) return cachedClient
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
const { url, anonKey } = requireSupabasePublicConfig({
|
||||
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
})
|
||||
|
||||
cachedClient = createBrowserClient<Database>(url, key)
|
||||
cachedClient = createBrowserClient<Database>(url, anonKey)
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
export function isSupabaseConfigured(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
return Boolean(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
&& process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||
import type { Database } from './types'
|
||||
import { requireSupabasePublicConfig } from './client'
|
||||
|
||||
/** next/headers cookies()가 반환하는 객체의 최소 인터페이스 */
|
||||
export interface CookieStore {
|
||||
|
|
@ -15,10 +16,12 @@ export interface CookieStore {
|
|||
export function createSupabaseServerClient(
|
||||
cookieStore: CookieStore,
|
||||
): ReturnType<typeof createServerClient<Database>> {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
const { url, anonKey } = requireSupabasePublicConfig({
|
||||
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
})
|
||||
|
||||
return createServerClient<Database>(url, key, {
|
||||
return createServerClient<Database>(url, anonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
|
|
@ -37,5 +40,8 @@ export function createSupabaseServerClient(
|
|||
}
|
||||
|
||||
export function isSupabaseConfiguredServer(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
return Boolean(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
&& process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
// packages/api-client/src/transcribe.ts
|
||||
// D3RO Cloud STT Transcription Unified Client
|
||||
// D3RO Cloud STT client. User-facing transcription always passes through the
|
||||
// Supabase Edge gateway that owns authentication, quota and usage accounting.
|
||||
|
||||
export interface TranscribeAudioParams {
|
||||
audio: Blob | ArrayBuffer | Uint8Array | string // Blob, Binary, or Base64 string
|
||||
audio: Blob | ArrayBuffer | Uint8Array | string
|
||||
language?: string
|
||||
prompt?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
apiBaseUrl?: string
|
||||
supabaseUrl?: string
|
||||
anonKey?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
|
|
@ -17,75 +15,108 @@ export interface TranscribeAudioResult {
|
|||
language: string
|
||||
durationSeconds: number
|
||||
provider: string
|
||||
modelId: string
|
||||
latencyMs: number
|
||||
cost: number
|
||||
modelId?: string
|
||||
cost?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 전사(STT) API를 호출합니다.
|
||||
* 사용자는 클라우드 서비스를 통해 자연스럽게 음성을 전사할 수 있으며,
|
||||
* 관리자에서 설정된 기본 프로바이더(Groq, OpenAI, Deepgram, Google 등)를 통해 자동으로 처리됩니다.
|
||||
*/
|
||||
export async function transcribeAudio(params: TranscribeAudioParams): Promise<TranscribeAudioResult> {
|
||||
const apiBase = (params.apiBaseUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000').replace(/\/$/, '')
|
||||
const url = `${apiBase}/api/stt/transcribe`
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const normalized = value.replace(/^data:[^,]*,/, '').replace(/\s+/g, '')
|
||||
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
||||
throw new Error('Unsupported audio payload format.')
|
||||
}
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
const output: number[] = []
|
||||
for (let offset = 0; offset < normalized.length; offset += 4) {
|
||||
const chars = normalized.slice(offset, offset + 4)
|
||||
const values = [...chars].map((char) => char === '=' ? 0 : alphabet.indexOf(char))
|
||||
if (values.some((item) => item < 0)) throw new Error('Unsupported audio payload format.')
|
||||
const combined = (values[0] << 18) | (values[1] << 12) | (values[2] << 6) | values[3]
|
||||
output.push((combined >>> 16) & 0xff)
|
||||
if (chars[2] !== '=') output.push((combined >>> 8) & 0xff)
|
||||
if (chars[3] !== '=') output.push(combined & 0xff)
|
||||
}
|
||||
return Uint8Array.from(output)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (params.token) {
|
||||
headers['Authorization'] = `Bearer ${params.token}`
|
||||
function gatewayUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL('/functions/v1/stt-proxy', value)
|
||||
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)
|
||||
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password) throw new Error('invalid')
|
||||
return url.toString()
|
||||
} catch {
|
||||
throw new Error('STT gateway configuration is invalid.')
|
||||
}
|
||||
}
|
||||
|
||||
function parseResult(value: unknown, latencyMs: number): TranscribeAudioResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('STT gateway returned an invalid response.')
|
||||
}
|
||||
const payload = value as Record<string, unknown>
|
||||
if (
|
||||
typeof payload.transcript !== 'string'
|
||||
|| !payload.transcript.trim()
|
||||
|| typeof payload.confidence !== 'number'
|
||||
|| !Number.isFinite(payload.confidence)
|
||||
|| payload.confidence < 0
|
||||
|| payload.confidence > 1
|
||||
|| typeof payload.language_code !== 'string'
|
||||
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(payload.language_code)
|
||||
|| typeof payload.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(payload.duration_seconds)
|
||||
|| payload.duration_seconds < 0
|
||||
|| typeof payload.provider !== 'string'
|
||||
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
|
||||
) {
|
||||
throw new Error('STT gateway returned an invalid response.')
|
||||
}
|
||||
return {
|
||||
text: payload.transcript.trim(),
|
||||
confidence: payload.confidence,
|
||||
language: payload.language_code,
|
||||
durationSeconds: payload.duration_seconds,
|
||||
provider: payload.provider,
|
||||
latencyMs,
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcribeAudio(params: TranscribeAudioParams): Promise<TranscribeAudioResult> {
|
||||
const supabaseUrl = params.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const anonKey = params.anonKey ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
const token = params.token ?? ''
|
||||
if (!supabaseUrl || !anonKey || !token) {
|
||||
throw new Error('STT gateway requires an authenticated Supabase session.')
|
||||
}
|
||||
|
||||
// 1. If audio is Blob or ArrayBuffer, send via FormData
|
||||
let audio: Blob
|
||||
const makeBlob = Blob as unknown as new (parts: unknown[], options?: { type?: string }) => Blob
|
||||
if (params.audio instanceof Blob) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', params.audio, 'recording.webm')
|
||||
if (params.language) formData.append('language', params.language)
|
||||
if (params.prompt) formData.append('prompt', params.prompt)
|
||||
if (params.model) formData.append('model', params.model)
|
||||
if (params.provider) formData.append('provider', params.provider)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`STT transcription failed (${res.status}): ${errText}`)
|
||||
}
|
||||
|
||||
return (await res.json()) as TranscribeAudioResult
|
||||
} else if (params.audio instanceof ArrayBuffer || params.audio instanceof Uint8Array) {
|
||||
const blob = new Blob([params.audio as BlobPart], { type: 'audio/webm' })
|
||||
return transcribeAudio({ ...params, audio: blob })
|
||||
audio = params.audio
|
||||
} else if (params.audio instanceof ArrayBuffer) {
|
||||
audio = new makeBlob([params.audio], { type: 'audio/wav' })
|
||||
} else if (params.audio instanceof Uint8Array) {
|
||||
audio = new makeBlob([params.audio.slice().buffer], { type: 'audio/wav' })
|
||||
} else if (typeof params.audio === 'string') {
|
||||
// 2. If audio is Base64 string, send JSON payload
|
||||
const payload = {
|
||||
audioBase64: params.audio,
|
||||
language: params.language || 'ko',
|
||||
initialPrompt: params.prompt,
|
||||
modelId: params.model,
|
||||
provider: params.provider,
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`STT transcription failed (${res.status}): ${errText}`)
|
||||
}
|
||||
|
||||
return (await res.json()) as TranscribeAudioResult
|
||||
audio = new makeBlob([decodeBase64(params.audio).buffer], { type: 'audio/wav' })
|
||||
} else {
|
||||
throw new Error('Unsupported audio payload format.')
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('audio', audio)
|
||||
if (params.language && params.language !== 'auto') formData.append('language_code', params.language)
|
||||
|
||||
const startedAt = Date.now()
|
||||
const response = await fetch(gatewayUrl(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: anonKey,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) throw new Error(`STT gateway request failed (${response.status}).`)
|
||||
return parseResult(await response.json().catch(() => null), Date.now() - startedAt)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type TeamMember = {
|
|||
}
|
||||
|
||||
export type MeetingStatus = 'recording' | 'processing' | 'completed' | 'error'
|
||||
export type MeetingLanguage = 'ko' | 'en' | 'ja' | 'zh-cn'
|
||||
|
||||
export type Meeting = {
|
||||
id: string
|
||||
|
|
@ -52,6 +53,11 @@ export type Meeting = {
|
|||
llm_latency_ms: number | null
|
||||
error_message: string | null
|
||||
audio_storage_key: string | null
|
||||
language?: MeetingLanguage | null
|
||||
attendees?: string[]
|
||||
template_id?: string | null
|
||||
creation_idempotency_key?: string | null
|
||||
creation_request_hash?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -77,6 +83,8 @@ export type MeetingDocument = {
|
|||
prompt_used: string | null
|
||||
llm_model: string | null
|
||||
llm_latency_ms: number | null
|
||||
template_id?: string | null
|
||||
generation_idempotency_key?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -120,10 +128,191 @@ export type HistoryEntry = {
|
|||
llm_latency_ms: number | null
|
||||
app_version: string
|
||||
summary_text: string | null
|
||||
is_favorite: boolean
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MemoTag = {
|
||||
id: string
|
||||
user_id: string
|
||||
history_id: string
|
||||
tag: string
|
||||
normalized_tag: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type TemplateKind = 'dictation' | 'meeting_document'
|
||||
|
||||
export type DictationTemplateField = {
|
||||
id: string
|
||||
name: string
|
||||
label: string
|
||||
promptText: string
|
||||
required: boolean
|
||||
maxDurationSec: number
|
||||
}
|
||||
|
||||
export type UserTemplate = {
|
||||
id: string
|
||||
user_id: string
|
||||
template_kind: TemplateKind
|
||||
builtin_key: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
fields: DictationTemplateField[]
|
||||
output_format: string | null
|
||||
template_type: DocumentTemplateType | null
|
||||
system_prompt: string | null
|
||||
is_builtin: boolean
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type UserTemplateSelection = {
|
||||
user_id: string
|
||||
template_kind: TemplateKind
|
||||
template_id: string
|
||||
revision: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MeetingDocumentGenerationRequest = {
|
||||
user_id: string
|
||||
idempotency_key: string
|
||||
meeting_id: string
|
||||
template_id: string
|
||||
request_hash: string
|
||||
document_title: string
|
||||
model: string
|
||||
quota_feature: 'llm_haiku' | 'llm_sonnet' | 'llm_opus'
|
||||
quota_limit: number
|
||||
quota_period: 'daily' | 'weekly'
|
||||
template_revision: number
|
||||
template_type: DocumentTemplateType
|
||||
transcript_hash: string
|
||||
status: 'processing' | 'succeeded' | 'failed'
|
||||
document_id: string | null
|
||||
error_code: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export type AudioFileSource = 'recording' | 'file-picker' | 'share-intent'
|
||||
export type AudioFileUploadStatus = 'pending' | 'uploading' | 'uploaded' | 'failed' | 'deleted'
|
||||
|
||||
export type AudioFile = {
|
||||
id: string
|
||||
user_id: string
|
||||
history_id: string | null
|
||||
meeting_id: string | null
|
||||
source: AudioFileSource
|
||||
original_name: string | null
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
duration_ms: number | null
|
||||
sha256: string
|
||||
upload_status: AudioFileUploadStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ProcessingJobKind = 'transcription' | 'summary' | 'minutes' | 'diarization' | 'export'
|
||||
export type ProcessingJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
|
||||
export type ProcessingJob = {
|
||||
id: string
|
||||
user_id: string
|
||||
audio_file_id: string | null
|
||||
history_id: string | null
|
||||
meeting_id: string | null
|
||||
kind: ProcessingJobKind
|
||||
status: ProcessingJobStatus
|
||||
progress: number
|
||||
attempt_count: number
|
||||
idempotency_key: string
|
||||
error_code: string | null
|
||||
error_message: string | null
|
||||
result: Record<string, unknown> | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
user_id: string
|
||||
theme_mode: 'system' | 'light' | 'dark'
|
||||
locale: string
|
||||
haptic_enabled: boolean
|
||||
auto_polish_enabled: boolean
|
||||
preferred_stt_model: string | null
|
||||
preferred_llm_model: string | null
|
||||
onboarding_version: number
|
||||
tutorial_completed_at: string | null
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type DevicePlatform = 'android' | 'ios' | 'web' | 'windows' | 'macos'
|
||||
|
||||
export type Device = {
|
||||
id: string
|
||||
user_id: string
|
||||
installation_id: string
|
||||
platform: DevicePlatform
|
||||
device_name: string
|
||||
app_version: string
|
||||
os_version: string | null
|
||||
push_token: string | null
|
||||
last_seen_at: string
|
||||
revoked_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type StorePurchaseState =
|
||||
| 'pending'
|
||||
| 'purchased'
|
||||
| 'cancelled'
|
||||
| 'expired'
|
||||
| 'refunded'
|
||||
| 'on_hold'
|
||||
| 'paused'
|
||||
|
||||
export type IapPurchase = {
|
||||
id: string
|
||||
user_id: string
|
||||
platform: 'google_play' | 'app_store'
|
||||
product_id: string
|
||||
store_transaction_id: string | null
|
||||
token_hash: string
|
||||
purchase_state: StorePurchaseState
|
||||
purchase_at: string | null
|
||||
expires_at: string | null
|
||||
auto_renewing: boolean | null
|
||||
acknowledged_at: string | null
|
||||
verified_at: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AdRewardClaim = {
|
||||
id: string
|
||||
user_id: string
|
||||
network: string
|
||||
placement: string
|
||||
ad_unit_id: string
|
||||
transaction_id: string
|
||||
reward_tokens: number
|
||||
verified_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type DictionaryEntry = {
|
||||
id: string
|
||||
user_id: string
|
||||
|
|
@ -154,6 +343,15 @@ export type Subscription = {
|
|||
stripe_customer_id: string | null
|
||||
/** @deprecated Phase 3.2-B (Payple 이관 예정) */
|
||||
stripe_subscription_id: string | null
|
||||
payment_provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store'
|
||||
payple_payer_id: string | null
|
||||
payple_pay_oid: string | null
|
||||
renewal_failures: number
|
||||
admin_note: string | null
|
||||
provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store' | 'admin'
|
||||
store_product_id: string | null
|
||||
store_purchase_id: string | null
|
||||
auto_renewing: boolean | null
|
||||
status: string | null
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
|
|
@ -247,6 +445,61 @@ export type Database = {
|
|||
Partial<HistoryEntry> & Pick<HistoryEntry, 'user_id' | 'original_text' | 'duration'>,
|
||||
Partial<HistoryEntry>
|
||||
>
|
||||
memo_tags: TypedTable<
|
||||
MemoTag,
|
||||
Partial<MemoTag> & Pick<MemoTag, 'user_id' | 'history_id' | 'tag'>,
|
||||
Partial<MemoTag>
|
||||
>
|
||||
user_templates: TypedTable<
|
||||
UserTemplate,
|
||||
Partial<UserTemplate> & Pick<UserTemplate, 'user_id' | 'template_kind' | 'name'>,
|
||||
Partial<UserTemplate>
|
||||
>
|
||||
user_template_selections: TypedTable<
|
||||
UserTemplateSelection,
|
||||
Partial<UserTemplateSelection> & Pick<UserTemplateSelection, 'user_id' | 'template_kind' | 'template_id'>,
|
||||
Partial<UserTemplateSelection>
|
||||
>
|
||||
meeting_document_generation_requests: TypedTable<
|
||||
MeetingDocumentGenerationRequest,
|
||||
MeetingDocumentGenerationRequest,
|
||||
Partial<MeetingDocumentGenerationRequest>
|
||||
>
|
||||
audio_files: TypedTable<
|
||||
AudioFile,
|
||||
Partial<AudioFile> &
|
||||
Pick<AudioFile, 'user_id' | 'source' | 'storage_key' | 'mime_type' | 'size_bytes' | 'sha256'>,
|
||||
Partial<AudioFile>
|
||||
>
|
||||
processing_jobs: TypedTable<
|
||||
ProcessingJob,
|
||||
Partial<ProcessingJob> &
|
||||
Pick<ProcessingJob, 'user_id' | 'kind' | 'idempotency_key'>,
|
||||
Partial<ProcessingJob>
|
||||
>
|
||||
user_settings: TypedTable<
|
||||
UserSettings,
|
||||
Partial<UserSettings> & Pick<UserSettings, 'user_id'>,
|
||||
Partial<UserSettings>
|
||||
>
|
||||
devices: TypedTable<
|
||||
Device,
|
||||
Partial<Device> &
|
||||
Pick<Device, 'user_id' | 'installation_id' | 'platform' | 'device_name' | 'app_version'>,
|
||||
Partial<Device>
|
||||
>
|
||||
iap_purchases: TypedTable<
|
||||
IapPurchase,
|
||||
Partial<IapPurchase> &
|
||||
Pick<IapPurchase, 'user_id' | 'platform' | 'product_id' | 'token_hash' | 'purchase_state'>,
|
||||
Partial<IapPurchase>
|
||||
>
|
||||
ad_reward_claims: TypedTable<
|
||||
AdRewardClaim,
|
||||
Partial<AdRewardClaim> &
|
||||
Pick<AdRewardClaim, 'user_id' | 'network' | 'placement' | 'ad_unit_id' | 'transaction_id' | 'reward_tokens'>,
|
||||
Partial<AdRewardClaim>
|
||||
>
|
||||
dictionary: TypedTable<
|
||||
DictionaryEntry,
|
||||
Partial<DictionaryEntry> & Pick<DictionaryEntry, 'user_id' | 'word'>,
|
||||
|
|
@ -354,6 +607,82 @@ export type Database = {
|
|||
}
|
||||
Returns: number
|
||||
}
|
||||
bootstrap_user_templates_v1: {
|
||||
Args: Record<string, never>
|
||||
Returns: UserTemplate[]
|
||||
}
|
||||
create_user_template_v1: {
|
||||
Args: {
|
||||
p_template_kind: TemplateKind
|
||||
p_name: string
|
||||
p_description?: string | null
|
||||
p_fields?: DictationTemplateField[]
|
||||
p_output_format?: string | null
|
||||
p_system_prompt?: string | null
|
||||
}
|
||||
Returns: UserTemplate
|
||||
}
|
||||
update_user_template_v1: {
|
||||
Args: {
|
||||
p_template_id: string
|
||||
p_expected_revision: number
|
||||
p_name: string
|
||||
p_description?: string | null
|
||||
p_fields?: DictationTemplateField[]
|
||||
p_output_format?: string | null
|
||||
p_system_prompt?: string | null
|
||||
}
|
||||
Returns: UserTemplate
|
||||
}
|
||||
delete_user_template_v1: {
|
||||
Args: { p_template_id: string; p_expected_revision: number }
|
||||
Returns: boolean
|
||||
}
|
||||
select_user_template_v1: {
|
||||
Args: { p_template_id: string; p_expected_revision?: number | null }
|
||||
Returns: UserTemplateSelection
|
||||
}
|
||||
mobile_add_memo_tag_v1: {
|
||||
Args: { p_history_id: string; p_tag: string }
|
||||
Returns: MemoTag
|
||||
}
|
||||
mobile_remove_memo_tag_v1: {
|
||||
Args: { p_history_id: string; p_tag: string }
|
||||
Returns: boolean
|
||||
}
|
||||
mobile_rename_memo_tag_v1: {
|
||||
Args: { p_old_tag: string; p_new_tag: string }
|
||||
Returns: number
|
||||
}
|
||||
mobile_list_memo_tags_v1: {
|
||||
Args: Record<string, never>
|
||||
Returns: Array<{ tag: string; normalized_tag: string; history_count: number }>
|
||||
}
|
||||
mobile_search_memos_v1: {
|
||||
Args: { p_query?: string; p_tag?: string | null; p_limit?: number; p_offset?: number }
|
||||
Returns: Array<{ history_row: HistoryEntry; tags: string[] }>
|
||||
}
|
||||
mobile_create_meeting_workspace_v2: {
|
||||
Args: {
|
||||
p_title: string
|
||||
p_attendees: string[]
|
||||
p_language: string
|
||||
p_template_id: string | null
|
||||
p_idempotency_key: string
|
||||
}
|
||||
Returns: Meeting
|
||||
}
|
||||
grant_verified_ad_reward: {
|
||||
Args: {
|
||||
p_user_id: string
|
||||
p_network: string
|
||||
p_placement: string
|
||||
p_ad_unit_id: string
|
||||
p_transaction_id: string
|
||||
p_reward_tokens: number
|
||||
}
|
||||
Returns: Record<string, unknown>
|
||||
}
|
||||
admin_usage_by_feature: {
|
||||
Args: {
|
||||
p_from: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue