Merge pull request #2 from vasceannie/codex/refactor-tauri-adapter-files-into-structure
refactor(api): split Tauri adapter into modular package
This commit is contained in:
@@ -1,813 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() }));
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import {
|
||||
createTauriAPI,
|
||||
initializeTauriAPI,
|
||||
isTauriEnvironment,
|
||||
type TauriInvoke,
|
||||
type TauriListen,
|
||||
} from './tauri-adapter';
|
||||
import type { NoteFlowAPI, TranscriptionStream } from './interface';
|
||||
import type { AudioChunk, Meeting, Summary, TranscriptUpdate, UserPreferences } from './types';
|
||||
import { meetingCache } from '@/lib/cache/meeting-cache';
|
||||
import { defaultPreferences } from '@/lib/preferences/constants';
|
||||
import { clonePreferences } from '@/lib/preferences/core';
|
||||
|
||||
type InvokeMock = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
type ListenMock = (
|
||||
event: string,
|
||||
handler: (event: { payload: unknown }) => void
|
||||
) => Promise<() => void>;
|
||||
|
||||
function createMocks() {
|
||||
const invoke = vi.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>();
|
||||
const listen = vi
|
||||
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
|
||||
.mockResolvedValue(() => {});
|
||||
return { invoke, listen };
|
||||
}
|
||||
|
||||
function assertTranscriptionStream(value: unknown): asserts value is TranscriptionStream {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('Expected transcription stream');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.send !== 'function' || typeof record.onUpdate !== 'function') {
|
||||
throw new Error('Expected transcription stream');
|
||||
}
|
||||
}
|
||||
|
||||
function buildMeeting(id: string): Meeting {
|
||||
return {
|
||||
id,
|
||||
title: `Meeting ${id}`,
|
||||
state: 'created',
|
||||
created_at: Date.now() / 1000,
|
||||
duration_seconds: 0,
|
||||
segments: [],
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary(meetingId: string): Summary {
|
||||
return {
|
||||
meeting_id: meetingId,
|
||||
executive_summary: 'Test summary',
|
||||
key_points: [],
|
||||
action_items: [],
|
||||
model_version: 'test-v1',
|
||||
generated_at: Date.now() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreferences(aiTemplate?: UserPreferences['ai_template']): UserPreferences {
|
||||
const prefs = clonePreferences(defaultPreferences);
|
||||
return {
|
||||
...prefs,
|
||||
ai_template: aiTemplate ?? prefs.ai_template,
|
||||
};
|
||||
}
|
||||
|
||||
describe('tauri-adapter mapping', () => {
|
||||
it('maps listMeetings args to snake_case', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValue({ meetings: [], total_count: 0 });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.listMeetings({
|
||||
states: ['recording'],
|
||||
limit: 5,
|
||||
offset: 10,
|
||||
sort_order: 'newest',
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('list_meetings', {
|
||||
states: [2],
|
||||
limit: 5,
|
||||
offset: 10,
|
||||
sort_order: 1,
|
||||
project_id: undefined,
|
||||
project_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps identity commands with expected payloads', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValueOnce({ user_id: 'u1', display_name: 'Local User' });
|
||||
invoke.mockResolvedValueOnce({ workspaces: [] });
|
||||
invoke.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.getCurrentUser();
|
||||
await api.listWorkspaces();
|
||||
await api.switchWorkspace('w1');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('get_current_user');
|
||||
expect(invoke).toHaveBeenCalledWith('list_workspaces');
|
||||
expect(invoke).toHaveBeenCalledWith('switch_workspace', { workspace_id: 'w1' });
|
||||
});
|
||||
|
||||
it('maps auth login commands with expected payloads', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state123' });
|
||||
invoke.mockResolvedValueOnce({
|
||||
success: true,
|
||||
user_id: 'u1',
|
||||
workspace_id: 'w1',
|
||||
display_name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
|
||||
const authResult = await api.initiateAuthLogin('google', 'noteflow://callback');
|
||||
expect(authResult).toEqual({ auth_url: 'https://auth.example.com', state: 'state123' });
|
||||
expect(invoke).toHaveBeenCalledWith('initiate_auth_login', {
|
||||
provider: 'google',
|
||||
redirect_uri: 'noteflow://callback',
|
||||
});
|
||||
|
||||
const completeResult = await api.completeAuthLogin('google', 'auth-code', 'state123');
|
||||
expect(completeResult.success).toBe(true);
|
||||
expect(completeResult.user_id).toBe('u1');
|
||||
expect(invoke).toHaveBeenCalledWith('complete_auth_login', {
|
||||
provider: 'google',
|
||||
code: 'auth-code',
|
||||
state: 'state123',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps initiateAuthLogin without redirect_uri', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state456' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.initiateAuthLogin('outlook');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('initiate_auth_login', {
|
||||
provider: 'outlook',
|
||||
redirect_uri: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps logout command with optional provider', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke
|
||||
.mockResolvedValueOnce({ success: true, tokens_revoked: true })
|
||||
.mockResolvedValueOnce({ success: true, tokens_revoked: false, revocation_error: 'timeout' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
|
||||
// Logout specific provider
|
||||
const result1 = await api.logout('google');
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result1.tokens_revoked).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith('logout', { provider: 'google' });
|
||||
|
||||
// Logout all providers
|
||||
const result2 = await api.logout();
|
||||
expect(result2.success).toBe(true);
|
||||
expect(result2.tokens_revoked).toBe(false);
|
||||
expect(result2.revocation_error).toBe('timeout');
|
||||
expect(invoke).toHaveBeenCalledWith('logout', { provider: undefined });
|
||||
});
|
||||
|
||||
it('handles completeAuthLogin failure response', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error_message: 'Invalid authorization code',
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.completeAuthLogin('google', 'bad-code', 'state');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_message).toBe('Invalid authorization code');
|
||||
expect(result.user_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps meeting and annotation args to snake_case', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
const meeting = buildMeeting('m1');
|
||||
invoke.mockResolvedValueOnce(meeting).mockResolvedValueOnce({ id: 'a1' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.getMeeting({ meeting_id: 'm1', include_segments: true, include_summary: true });
|
||||
await api.addAnnotation({
|
||||
meeting_id: 'm1',
|
||||
annotation_type: 'decision',
|
||||
text: 'Ship it',
|
||||
start_time: 1.25,
|
||||
end_time: 2.5,
|
||||
segment_ids: [1, 2],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('get_meeting', {
|
||||
meeting_id: 'm1',
|
||||
include_segments: true,
|
||||
include_summary: true,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('add_annotation', {
|
||||
meeting_id: 'm1',
|
||||
annotation_type: 2,
|
||||
text: 'Ship it',
|
||||
start_time: 1.25,
|
||||
end_time: 2.5,
|
||||
segment_ids: [1, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes delete responses', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValueOnce({ success: true }).mockResolvedValueOnce(true);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await expect(api.deleteMeeting('m1')).resolves.toBe(true);
|
||||
await expect(api.deleteAnnotation('a1')).resolves.toBe(true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('delete_meeting', { meeting_id: 'm1' });
|
||||
expect(invoke).toHaveBeenCalledWith('delete_annotation', { annotation_id: 'a1' });
|
||||
});
|
||||
|
||||
it('sends audio chunk with snake_case keys', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const chunk: AudioChunk = {
|
||||
meeting_id: 'm1',
|
||||
audio_data: new Float32Array([0.25, -0.25]),
|
||||
timestamp: 12.34,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
};
|
||||
|
||||
stream.send(chunk);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('start_recording', { meeting_id: 'm1' });
|
||||
expect(invoke).toHaveBeenCalledWith('send_audio_chunk', {
|
||||
meeting_id: 'm1',
|
||||
audio_data: [0.25, -0.25],
|
||||
timestamp: 12.34,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends audio chunk without optional fields', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m2');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const chunk: AudioChunk = {
|
||||
meeting_id: 'm2',
|
||||
audio_data: new Float32Array([0.1]),
|
||||
timestamp: 1.23,
|
||||
};
|
||||
|
||||
stream.send(chunk);
|
||||
|
||||
const call = invoke.mock.calls.find((item) => item[0] === 'send_audio_chunk');
|
||||
expect(call).toBeDefined();
|
||||
const args = call?.[1] as Record<string, unknown>;
|
||||
expect(args).toMatchObject({
|
||||
meeting_id: 'm2',
|
||||
timestamp: 1.23,
|
||||
});
|
||||
const audioData = args.audio_data as number[] | undefined;
|
||||
expect(audioData).toHaveLength(1);
|
||||
expect(audioData?.[0]).toBeCloseTo(0.1, 5);
|
||||
});
|
||||
|
||||
it('forwards transcript updates with full segment payload', async () => {
|
||||
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
|
||||
const invoke = vi
|
||||
.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const listen = vi
|
||||
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
|
||||
.mockImplementation((_event, handler) => {
|
||||
capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const callback = vi.fn();
|
||||
await stream.onUpdate(callback);
|
||||
|
||||
const payload: TranscriptUpdate = {
|
||||
meeting_id: 'm1',
|
||||
update_type: 'final',
|
||||
partial_text: undefined,
|
||||
segment: {
|
||||
segment_id: 12,
|
||||
text: 'Hello world',
|
||||
start_time: 1.2,
|
||||
end_time: 2.3,
|
||||
words: [
|
||||
{ word: 'Hello', start_time: 1.2, end_time: 1.6, probability: 0.9 },
|
||||
{ word: 'world', start_time: 1.6, end_time: 2.3, probability: 0.92 },
|
||||
],
|
||||
language: 'en',
|
||||
language_confidence: 0.99,
|
||||
avg_logprob: -0.2,
|
||||
no_speech_prob: 0.01,
|
||||
speaker_id: 'SPEAKER_00',
|
||||
speaker_confidence: 0.95,
|
||||
},
|
||||
server_timestamp: 123.45,
|
||||
};
|
||||
|
||||
if (!capturedHandler) {
|
||||
throw new Error('Transcript update handler not registered');
|
||||
}
|
||||
|
||||
capturedHandler({ payload });
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it('ignores transcript updates for other meetings', async () => {
|
||||
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
|
||||
const invoke = vi
|
||||
.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const listen = vi
|
||||
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
|
||||
.mockImplementation((_event, handler) => {
|
||||
capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
const callback = vi.fn();
|
||||
await stream.onUpdate(callback);
|
||||
|
||||
capturedHandler?.({
|
||||
payload: {
|
||||
meeting_id: 'other',
|
||||
update_type: 'partial',
|
||||
partial_text: 'nope',
|
||||
server_timestamp: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps connection and export commands with snake_case args', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValue({ version: '1.0.0' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.connect('localhost:50051');
|
||||
await api.saveExportFile('content', 'Meeting Notes', 'md');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('connect', { server_url: 'localhost:50051' });
|
||||
expect(invoke).toHaveBeenCalledWith('save_export_file', {
|
||||
content: 'content',
|
||||
default_name: 'Meeting Notes',
|
||||
extension: 'md',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps audio device selection with snake_case args', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValue([]);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.listAudioDevices();
|
||||
await api.selectAudioDevice('input:0:Mic', true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('list_audio_devices');
|
||||
expect(invoke).toHaveBeenCalledWith('select_audio_device', {
|
||||
device_id: 'input:0:Mic',
|
||||
is_input: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps playback commands with snake_case args', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValue({
|
||||
meeting_id: 'm1',
|
||||
position: 0,
|
||||
duration: 0,
|
||||
is_playing: true,
|
||||
is_paused: false,
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.startPlayback('m1', 12.5);
|
||||
await api.seekPlayback(30);
|
||||
await api.getPlaybackState();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('start_playback', {
|
||||
meeting_id: 'm1',
|
||||
start_time: 12.5,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('seek_playback', { position: 30 });
|
||||
expect(invoke).toHaveBeenCalledWith('get_playback_state');
|
||||
});
|
||||
|
||||
it('stops transcription stream on close', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
const unlisten = vi.fn();
|
||||
listen.mockResolvedValueOnce(unlisten);
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
await stream.onUpdate(() => {});
|
||||
stream.close();
|
||||
|
||||
expect(unlisten).toHaveBeenCalled();
|
||||
expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });
|
||||
});
|
||||
|
||||
it('cleans up pending transcript listener when closed before listen resolves', async () => {
|
||||
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
|
||||
let resolveListen: ((fn: () => void) => void) | null = null;
|
||||
const unlisten = vi.fn();
|
||||
const invoke = vi
|
||||
.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const listen = vi
|
||||
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
|
||||
.mockImplementation((_event, handler) => {
|
||||
capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;
|
||||
return new Promise<() => void>((resolve) => {
|
||||
resolveListen = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const callback = vi.fn();
|
||||
const onUpdatePromise = stream.onUpdate(callback);
|
||||
|
||||
stream.close();
|
||||
resolveListen?.(unlisten);
|
||||
await onUpdatePromise;
|
||||
|
||||
expect(unlisten).toHaveBeenCalled();
|
||||
|
||||
if (!capturedHandler) {
|
||||
throw new Error('Transcript update handler not registered');
|
||||
}
|
||||
|
||||
capturedHandler({
|
||||
payload: {
|
||||
meeting_id: 'm1',
|
||||
update_type: 'partial',
|
||||
partial_text: 'late update',
|
||||
server_timestamp: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops transcription stream even without listeners', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
stream.close();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });
|
||||
});
|
||||
|
||||
it('only caches meetings when list includes items', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
const cacheSpy = vi.spyOn(meetingCache, 'cacheMeetings');
|
||||
|
||||
invoke.mockResolvedValueOnce({ meetings: [], total_count: 0 });
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.listMeetings({});
|
||||
expect(cacheSpy).not.toHaveBeenCalled();
|
||||
|
||||
invoke.mockResolvedValueOnce({ meetings: [buildMeeting('m1')], total_count: 1 });
|
||||
await api.listMeetings({});
|
||||
expect(cacheSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when delete meeting fails', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
invoke.mockResolvedValueOnce({ success: false });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.deleteMeeting('m1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('generates summary with template options when available', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
const summary = buildSummary('m1');
|
||||
|
||||
invoke
|
||||
.mockResolvedValueOnce(
|
||||
buildPreferences({ tone: 'casual', format: 'narrative', verbosity: 'balanced' })
|
||||
)
|
||||
.mockResolvedValueOnce(summary);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.generateSummary('m1', true);
|
||||
|
||||
expect(result).toEqual(summary);
|
||||
expect(invoke).toHaveBeenCalledWith('generate_summary', {
|
||||
meeting_id: 'm1',
|
||||
force_regenerate: true,
|
||||
options: { tone: 'casual', format: 'narrative', verbosity: 'balanced' },
|
||||
});
|
||||
});
|
||||
|
||||
it('generates summary even if preferences lookup fails', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
const summary = buildSummary('m2');
|
||||
|
||||
invoke.mockRejectedValueOnce(new Error('no prefs')).mockResolvedValueOnce(summary);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.generateSummary('m2');
|
||||
|
||||
expect(result).toEqual(summary);
|
||||
expect(invoke).toHaveBeenCalledWith('generate_summary', {
|
||||
meeting_id: 'm2',
|
||||
force_regenerate: false,
|
||||
options: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('covers additional adapter commands', async () => {
|
||||
const { invoke, listen } = createMocks();
|
||||
|
||||
const annotation = {
|
||||
id: 'a1',
|
||||
meeting_id: 'm1',
|
||||
annotation_type: 'note',
|
||||
text: 'Note',
|
||||
start_time: 0,
|
||||
end_time: 1,
|
||||
segment_ids: [],
|
||||
created_at: 1,
|
||||
};
|
||||
|
||||
const annotationResponses: Array<
|
||||
(typeof annotation)[] | { annotations: (typeof annotation)[] }
|
||||
> = [{ annotations: [annotation] }, [annotation]];
|
||||
|
||||
invoke.mockImplementation(async (cmd) => {
|
||||
switch (cmd) {
|
||||
case 'list_annotations':
|
||||
return annotationResponses.shift();
|
||||
case 'get_annotation':
|
||||
return annotation;
|
||||
case 'update_annotation':
|
||||
return annotation;
|
||||
case 'export_transcript':
|
||||
return { content: 'data', format_name: 'Markdown', file_extension: '.md' };
|
||||
case 'save_export_file':
|
||||
return true;
|
||||
case 'list_audio_devices':
|
||||
return [];
|
||||
case 'get_default_audio_device':
|
||||
return null;
|
||||
case 'get_preferences':
|
||||
return buildPreferences();
|
||||
case 'get_cloud_consent_status':
|
||||
return { consent_granted: true };
|
||||
case 'get_trigger_status':
|
||||
return {
|
||||
enabled: false,
|
||||
is_snoozed: false,
|
||||
snooze_remaining_secs: 0,
|
||||
pending_trigger: null,
|
||||
};
|
||||
case 'accept_trigger':
|
||||
return buildMeeting('m9');
|
||||
case 'extract_entities':
|
||||
return { entities: [], total_count: 0, cached: false };
|
||||
case 'update_entity':
|
||||
return { id: 'e1', text: 'Entity', category: 'other', segment_ids: [], confidence: 1 };
|
||||
case 'delete_entity':
|
||||
return true;
|
||||
case 'list_calendar_events':
|
||||
return { events: [], total_count: 0 };
|
||||
case 'get_calendar_providers':
|
||||
return { providers: [] };
|
||||
case 'initiate_oauth':
|
||||
return { auth_url: 'https://auth', state: 'state' };
|
||||
case 'complete_oauth':
|
||||
return { success: true, error_message: '', integration_id: 'int-123' };
|
||||
case 'get_oauth_connection_status':
|
||||
return {
|
||||
connection: {
|
||||
provider: 'google',
|
||||
status: 'disconnected',
|
||||
email: '',
|
||||
expires_at: 0,
|
||||
error_message: '',
|
||||
integration_type: 'calendar',
|
||||
},
|
||||
};
|
||||
case 'disconnect_oauth':
|
||||
return { success: true };
|
||||
case 'register_webhook':
|
||||
return {
|
||||
id: 'w1',
|
||||
workspace_id: 'w1',
|
||||
name: 'Webhook',
|
||||
url: 'https://example.com',
|
||||
events: ['meeting.completed'],
|
||||
enabled: true,
|
||||
timeout_ms: 1000,
|
||||
max_retries: 3,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
case 'list_webhooks':
|
||||
return { webhooks: [], total_count: 0 };
|
||||
case 'update_webhook':
|
||||
return {
|
||||
id: 'w1',
|
||||
workspace_id: 'w1',
|
||||
name: 'Webhook',
|
||||
url: 'https://example.com',
|
||||
events: ['meeting.completed'],
|
||||
enabled: false,
|
||||
timeout_ms: 1000,
|
||||
max_retries: 3,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
};
|
||||
case 'delete_webhook':
|
||||
return { success: true };
|
||||
case 'get_webhook_deliveries':
|
||||
return { deliveries: [], total_count: 0 };
|
||||
case 'start_integration_sync':
|
||||
return { sync_run_id: 's1', status: 'running' };
|
||||
case 'get_sync_status':
|
||||
return { status: 'success', items_synced: 1, items_total: 1, error_message: '' };
|
||||
case 'list_sync_history':
|
||||
return { runs: [], total_count: 0 };
|
||||
case 'get_recent_logs':
|
||||
return { logs: [], total_count: 0 };
|
||||
case 'get_performance_metrics':
|
||||
return {
|
||||
current: {
|
||||
timestamp: 1,
|
||||
cpu_percent: 0,
|
||||
memory_percent: 0,
|
||||
memory_mb: 0,
|
||||
disk_percent: 0,
|
||||
network_bytes_sent: 0,
|
||||
network_bytes_recv: 0,
|
||||
process_memory_mb: 0,
|
||||
active_connections: 0,
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
case 'refine_speakers':
|
||||
return { job_id: 'job', status: 'queued', segments_updated: 0, speaker_ids: [] };
|
||||
case 'get_diarization_status':
|
||||
return { job_id: 'job', status: 'completed', segments_updated: 1, speaker_ids: [] };
|
||||
case 'rename_speaker':
|
||||
return { success: true };
|
||||
case 'cancel_diarization':
|
||||
return { success: true, error_message: '', status: 'cancelled' };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
|
||||
const list1 = await api.listAnnotations('m1');
|
||||
const list2 = await api.listAnnotations('m1');
|
||||
expect(list1).toHaveLength(1);
|
||||
expect(list2).toHaveLength(1);
|
||||
|
||||
await api.getAnnotation('a1');
|
||||
await api.updateAnnotation({ annotation_id: 'a1', text: 'Updated' });
|
||||
await api.exportTranscript('m1', 'markdown');
|
||||
await api.saveExportFile('content', 'Meeting', 'md');
|
||||
await api.listAudioDevices();
|
||||
await api.getDefaultAudioDevice(true);
|
||||
await api.selectAudioDevice('mic', true);
|
||||
await api.getPreferences();
|
||||
await api.savePreferences(buildPreferences());
|
||||
await api.grantCloudConsent();
|
||||
await api.revokeCloudConsent();
|
||||
await api.getCloudConsentStatus();
|
||||
await api.pausePlayback();
|
||||
await api.stopPlayback();
|
||||
await api.setTriggerEnabled(true);
|
||||
await api.snoozeTriggers(5);
|
||||
await api.resetSnooze();
|
||||
await api.getTriggerStatus();
|
||||
await api.dismissTrigger();
|
||||
await api.acceptTrigger('Title');
|
||||
await api.extractEntities('m1', true);
|
||||
await api.updateEntity('m1', 'e1', 'Entity', 'other');
|
||||
await api.deleteEntity('m1', 'e1');
|
||||
await api.listCalendarEvents(2, 5, 'google');
|
||||
await api.getCalendarProviders();
|
||||
await api.initiateCalendarAuth('google', 'redirect');
|
||||
await api.completeCalendarAuth('google', 'code', 'state');
|
||||
await api.getOAuthConnectionStatus('google');
|
||||
await api.disconnectCalendar('google');
|
||||
await api.registerWebhook({
|
||||
workspace_id: 'w1',
|
||||
name: 'Webhook',
|
||||
url: 'https://example.com',
|
||||
events: ['meeting.completed'],
|
||||
});
|
||||
await api.listWebhooks();
|
||||
await api.updateWebhook({ webhook_id: 'w1', name: 'Webhook' });
|
||||
await api.deleteWebhook('w1');
|
||||
await api.getWebhookDeliveries('w1', 10);
|
||||
await api.startIntegrationSync('int-1');
|
||||
await api.getSyncStatus('sync');
|
||||
await api.listSyncHistory('int-1', 10, 0);
|
||||
await api.getRecentLogs({ limit: 10 });
|
||||
await api.getPerformanceMetrics({ history_limit: 5 });
|
||||
await api.refineSpeakers('m1', 2);
|
||||
await api.getDiarizationJobStatus('job');
|
||||
await api.renameSpeaker('m1', 'old', 'new');
|
||||
await api.cancelDiarization('job');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tauri-adapter environment', () => {
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
const listenMock = vi.mocked(listen);
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
listenMock.mockReset();
|
||||
});
|
||||
|
||||
it('detects tauri environment flags', () => {
|
||||
vi.stubGlobal('window', undefined as unknown as Window);
|
||||
expect(isTauriEnvironment()).toBe(false);
|
||||
vi.unstubAllGlobals();
|
||||
expect(isTauriEnvironment()).toBe(false);
|
||||
|
||||
window.__TAURI__ = {};
|
||||
expect(isTauriEnvironment()).toBe(true);
|
||||
delete window.__TAURI__;
|
||||
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
expect(isTauriEnvironment()).toBe(true);
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
|
||||
window.isTauri = true;
|
||||
expect(isTauriEnvironment()).toBe(true);
|
||||
delete window.isTauri;
|
||||
});
|
||||
|
||||
it('initializes tauri api when available', async () => {
|
||||
invokeMock.mockResolvedValueOnce(true);
|
||||
listenMock.mockResolvedValue(() => {});
|
||||
|
||||
const api = await initializeTauriAPI();
|
||||
expect(api).toBeDefined();
|
||||
expect(invokeMock).toHaveBeenCalledWith('is_connected');
|
||||
});
|
||||
|
||||
it('throws when tauri api is unavailable', async () => {
|
||||
invokeMock.mockRejectedValueOnce(new Error('no tauri'));
|
||||
|
||||
await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment');
|
||||
});
|
||||
|
||||
it('throws a helpful error when invoke rejects with non-Error', async () => {
|
||||
invokeMock.mockRejectedValueOnce('no tauri');
|
||||
await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
170
client/src/api/tauri-adapter/__tests__/core-mapping.test.ts
Normal file
170
client/src/api/tauri-adapter/__tests__/core-mapping.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createTauriAPI, type TauriInvoke, type TauriListen } from '@/api/tauri-adapter';
|
||||
import { buildMeeting, createMocks } from './test-utils';
|
||||
|
||||
function createInvokeListenMocks() {
|
||||
return createMocks();
|
||||
}
|
||||
|
||||
describe('tauri-adapter mapping (core)', () => {
|
||||
it('maps listMeetings args to snake_case', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValue({ meetings: [], total_count: 0 });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.listMeetings({
|
||||
states: ['recording'],
|
||||
limit: 5,
|
||||
offset: 10,
|
||||
sort_order: 'newest',
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('list_meetings', {
|
||||
states: [2],
|
||||
limit: 5,
|
||||
offset: 10,
|
||||
sort_order: 1,
|
||||
project_id: undefined,
|
||||
project_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps identity commands with expected payloads', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValueOnce({ user_id: 'u1', display_name: 'Local User' });
|
||||
invoke.mockResolvedValueOnce({ workspaces: [] });
|
||||
invoke.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.getCurrentUser();
|
||||
await api.listWorkspaces();
|
||||
await api.switchWorkspace('w1');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('get_current_user');
|
||||
expect(invoke).toHaveBeenCalledWith('list_workspaces');
|
||||
expect(invoke).toHaveBeenCalledWith('switch_workspace', { workspace_id: 'w1' });
|
||||
});
|
||||
|
||||
it('maps auth login commands with expected payloads', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state123' });
|
||||
invoke.mockResolvedValueOnce({
|
||||
success: true,
|
||||
user_id: 'u1',
|
||||
workspace_id: 'w1',
|
||||
display_name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
|
||||
const authResult = await api.initiateAuthLogin('google', 'noteflow://callback');
|
||||
expect(authResult).toEqual({ auth_url: 'https://auth.example.com', state: 'state123' });
|
||||
expect(invoke).toHaveBeenCalledWith('initiate_auth_login', {
|
||||
provider: 'google',
|
||||
redirect_uri: 'noteflow://callback',
|
||||
});
|
||||
|
||||
const completeResult = await api.completeAuthLogin('google', 'auth-code', 'state123');
|
||||
expect(completeResult.success).toBe(true);
|
||||
expect(completeResult.user_id).toBe('u1');
|
||||
expect(invoke).toHaveBeenCalledWith('complete_auth_login', {
|
||||
provider: 'google',
|
||||
code: 'auth-code',
|
||||
state: 'state123',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps initiateAuthLogin without redirect_uri', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state456' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.initiateAuthLogin('outlook');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('initiate_auth_login', {
|
||||
provider: 'outlook',
|
||||
redirect_uri: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps logout command with optional provider', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke
|
||||
.mockResolvedValueOnce({ success: true, tokens_revoked: true })
|
||||
.mockResolvedValueOnce({ success: true, tokens_revoked: false, revocation_error: 'timeout' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
|
||||
// Logout specific provider
|
||||
const result1 = await api.logout('google');
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result1.tokens_revoked).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith('logout', { provider: 'google' });
|
||||
|
||||
// Logout all providers
|
||||
const result2 = await api.logout();
|
||||
expect(result2.success).toBe(true);
|
||||
expect(result2.tokens_revoked).toBe(false);
|
||||
expect(result2.revocation_error).toBe('timeout');
|
||||
expect(invoke).toHaveBeenCalledWith('logout', { provider: undefined });
|
||||
});
|
||||
|
||||
it('handles completeAuthLogin failure response', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error_message: 'Invalid authorization code',
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.completeAuthLogin('google', 'bad-code', 'state');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_message).toBe('Invalid authorization code');
|
||||
expect(result.user_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps meeting and annotation args to snake_case', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
const meeting = buildMeeting('m1');
|
||||
invoke.mockResolvedValueOnce(meeting).mockResolvedValueOnce({ id: 'a1' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.getMeeting({ meeting_id: 'm1', include_segments: true, include_summary: true });
|
||||
await api.addAnnotation({
|
||||
meeting_id: 'm1',
|
||||
annotation_type: 'decision',
|
||||
text: 'Ship it',
|
||||
start_time: 1.25,
|
||||
end_time: 2.5,
|
||||
segment_ids: [1, 2],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('get_meeting', {
|
||||
meeting_id: 'm1',
|
||||
include_segments: true,
|
||||
include_summary: true,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('add_annotation', {
|
||||
meeting_id: 'm1',
|
||||
annotation_type: 2,
|
||||
text: 'Ship it',
|
||||
start_time: 1.25,
|
||||
end_time: 2.5,
|
||||
segment_ids: [1, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes delete responses', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValueOnce({ success: true }).mockResolvedValueOnce(true);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await expect(api.deleteMeeting('m1')).resolves.toBe(true);
|
||||
await expect(api.deleteAnnotation('a1')).resolves.toBe(true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('delete_meeting', { meeting_id: 'm1' });
|
||||
expect(invoke).toHaveBeenCalledWith('delete_annotation', { annotation_id: 'a1' });
|
||||
});
|
||||
});
|
||||
58
client/src/api/tauri-adapter/__tests__/environment.test.ts
Normal file
58
client/src/api/tauri-adapter/__tests__/environment.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() }));
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import { initializeTauriAPI, isTauriEnvironment } from '@/api/tauri-adapter';
|
||||
|
||||
describe('tauri-adapter environment', () => {
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
const listenMock = vi.mocked(listen);
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
listenMock.mockReset();
|
||||
});
|
||||
|
||||
it('detects tauri environment flags', () => {
|
||||
vi.stubGlobal('window', undefined as unknown as Window);
|
||||
expect(isTauriEnvironment()).toBe(false);
|
||||
vi.unstubAllGlobals();
|
||||
expect(isTauriEnvironment()).toBe(false);
|
||||
|
||||
window.__TAURI__ = {};
|
||||
expect(isTauriEnvironment()).toBe(true);
|
||||
delete window.__TAURI__;
|
||||
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
expect(isTauriEnvironment()).toBe(true);
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
|
||||
window.isTauri = true;
|
||||
expect(isTauriEnvironment()).toBe(true);
|
||||
delete window.isTauri;
|
||||
});
|
||||
|
||||
it('initializes tauri api when available', async () => {
|
||||
invokeMock.mockResolvedValueOnce(true);
|
||||
listenMock.mockResolvedValue(() => {});
|
||||
|
||||
const api = await initializeTauriAPI();
|
||||
expect(api).toBeDefined();
|
||||
expect(invokeMock).toHaveBeenCalledWith('is_connected');
|
||||
});
|
||||
|
||||
it('throws when tauri api is unavailable', async () => {
|
||||
invokeMock.mockRejectedValueOnce(new Error('no tauri'));
|
||||
|
||||
await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment');
|
||||
});
|
||||
|
||||
it('throws a helpful error when invoke rejects with non-Error', async () => {
|
||||
invokeMock.mockRejectedValueOnce('no tauri');
|
||||
await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment');
|
||||
});
|
||||
});
|
||||
348
client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts
Normal file
348
client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createTauriAPI, type TauriInvoke, type TauriListen } from '@/api/tauri-adapter';
|
||||
import { meetingCache } from '@/lib/cache/meeting-cache';
|
||||
import { buildMeeting, buildPreferences, buildSummary, createMocks } from './test-utils';
|
||||
|
||||
function createInvokeListenMocks() {
|
||||
return createMocks();
|
||||
}
|
||||
|
||||
describe('tauri-adapter mapping (misc)', () => {
|
||||
it('maps connection and export commands with snake_case args', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValue({ version: '1.0.0' });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.connect('localhost:50051');
|
||||
await api.saveExportFile('content', 'Meeting Notes', 'md');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('connect', { server_url: 'localhost:50051' });
|
||||
expect(invoke).toHaveBeenCalledWith('save_export_file', {
|
||||
content: 'content',
|
||||
default_name: 'Meeting Notes',
|
||||
extension: 'md',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps audio device selection with snake_case args', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValue([]);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.listAudioDevices();
|
||||
await api.selectAudioDevice('input:0:Mic', true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('list_audio_devices');
|
||||
expect(invoke).toHaveBeenCalledWith('select_audio_device', {
|
||||
device_id: 'input:0:Mic',
|
||||
is_input: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps playback commands with snake_case args', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValue({
|
||||
meeting_id: 'm1',
|
||||
position: 0,
|
||||
duration: 0,
|
||||
is_playing: true,
|
||||
is_paused: false,
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.startPlayback('m1', 12.5);
|
||||
await api.seekPlayback(30);
|
||||
await api.getPlaybackState();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('start_playback', {
|
||||
meeting_id: 'm1',
|
||||
start_time: 12.5,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('seek_playback', { position: 30 });
|
||||
expect(invoke).toHaveBeenCalledWith('get_playback_state');
|
||||
});
|
||||
|
||||
it('only caches meetings when list includes items', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
const cacheSpy = vi.spyOn(meetingCache, 'cacheMeetings');
|
||||
|
||||
invoke.mockResolvedValueOnce({ meetings: [], total_count: 0 });
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
await api.listMeetings({});
|
||||
expect(cacheSpy).not.toHaveBeenCalled();
|
||||
|
||||
invoke.mockResolvedValueOnce({ meetings: [buildMeeting('m1')], total_count: 1 });
|
||||
await api.listMeetings({});
|
||||
expect(cacheSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when delete meeting fails', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValueOnce({ success: false });
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.deleteMeeting('m1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('generates summary with template options when available', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
const summary = buildSummary('m1');
|
||||
|
||||
invoke
|
||||
.mockResolvedValueOnce(
|
||||
buildPreferences({ tone: 'casual', format: 'narrative', verbosity: 'balanced' })
|
||||
)
|
||||
.mockResolvedValueOnce(summary);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.generateSummary('m1', true);
|
||||
|
||||
expect(result).toEqual(summary);
|
||||
expect(invoke).toHaveBeenCalledWith('generate_summary', {
|
||||
meeting_id: 'm1',
|
||||
force_regenerate: true,
|
||||
options: { tone: 'casual', format: 'narrative', verbosity: 'balanced' },
|
||||
});
|
||||
});
|
||||
|
||||
it('generates summary even if preferences lookup fails', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
const summary = buildSummary('m2');
|
||||
|
||||
invoke.mockRejectedValueOnce(new Error('no prefs')).mockResolvedValueOnce(summary);
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const result = await api.generateSummary('m2');
|
||||
|
||||
expect(result).toEqual(summary);
|
||||
expect(invoke).toHaveBeenCalledWith('generate_summary', {
|
||||
meeting_id: 'm2',
|
||||
force_regenerate: false,
|
||||
options: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('covers additional adapter commands with payload assertions', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
|
||||
const annotation = {
|
||||
id: 'a1',
|
||||
meeting_id: 'm1',
|
||||
annotation_type: 'note',
|
||||
text: 'Note',
|
||||
start_time: 0,
|
||||
end_time: 1,
|
||||
segment_ids: [],
|
||||
created_at: 1,
|
||||
};
|
||||
|
||||
const annotationResponses: Array<
|
||||
(typeof annotation)[] | { annotations: (typeof annotation)[] }
|
||||
> = [{ annotations: [annotation] }, [annotation]];
|
||||
|
||||
invoke.mockImplementation(async (cmd) => {
|
||||
switch (cmd) {
|
||||
case 'list_annotations':
|
||||
return annotationResponses.shift();
|
||||
case 'get_annotation':
|
||||
return annotation;
|
||||
case 'update_annotation':
|
||||
return annotation;
|
||||
case 'export_transcript':
|
||||
return { content: 'data', format_name: 'Markdown', file_extension: '.md' };
|
||||
case 'save_export_file':
|
||||
return true;
|
||||
case 'list_audio_devices':
|
||||
return [];
|
||||
case 'get_default_audio_device':
|
||||
return null;
|
||||
case 'get_preferences':
|
||||
return buildPreferences();
|
||||
case 'get_cloud_consent_status':
|
||||
return { consent_granted: true };
|
||||
case 'get_trigger_status':
|
||||
return {
|
||||
enabled: false,
|
||||
is_snoozed: false,
|
||||
snooze_remaining_secs: 0,
|
||||
pending_trigger: null,
|
||||
};
|
||||
case 'accept_trigger':
|
||||
return buildMeeting('m9');
|
||||
case 'extract_entities':
|
||||
return { entities: [], total_count: 0, cached: false };
|
||||
case 'update_entity':
|
||||
return { id: 'e1', text: 'Entity', category: 'other', segment_ids: [], confidence: 1 };
|
||||
case 'delete_entity':
|
||||
return true;
|
||||
case 'list_calendar_events':
|
||||
return { events: [], total_count: 0 };
|
||||
case 'get_calendar_providers':
|
||||
return { providers: [] };
|
||||
case 'initiate_oauth':
|
||||
return { auth_url: 'https://auth', state: 'state' };
|
||||
case 'complete_oauth':
|
||||
return { success: true, error_message: '', integration_id: 'int-123' };
|
||||
case 'get_oauth_connection_status':
|
||||
return {
|
||||
connection: {
|
||||
provider: 'google',
|
||||
status: 'disconnected',
|
||||
email: '',
|
||||
expires_at: 0,
|
||||
error_message: '',
|
||||
integration_type: 'calendar',
|
||||
},
|
||||
};
|
||||
case 'disconnect_oauth':
|
||||
return { success: true };
|
||||
case 'register_webhook':
|
||||
return {
|
||||
id: 'w1',
|
||||
workspace_id: 'w1',
|
||||
name: 'Webhook',
|
||||
url: 'https://example.com',
|
||||
events: ['meeting.completed'],
|
||||
enabled: true,
|
||||
timeout_ms: 1000,
|
||||
max_retries: 3,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
case 'list_webhooks':
|
||||
return { webhooks: [], total_count: 0 };
|
||||
case 'update_webhook':
|
||||
return {
|
||||
id: 'w1',
|
||||
workspace_id: 'w1',
|
||||
name: 'Webhook',
|
||||
url: 'https://example.com',
|
||||
events: ['meeting.completed'],
|
||||
enabled: false,
|
||||
timeout_ms: 1000,
|
||||
max_retries: 3,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
};
|
||||
case 'delete_webhook':
|
||||
return { success: true };
|
||||
case 'get_webhook_deliveries':
|
||||
return { deliveries: [], total_count: 0 };
|
||||
case 'start_integration_sync':
|
||||
return { sync_run_id: 's1', status: 'running' };
|
||||
case 'get_sync_status':
|
||||
return { status: 'success', items_synced: 1, items_total: 1, error_message: '' };
|
||||
case 'list_sync_history':
|
||||
return { runs: [], total_count: 0 };
|
||||
case 'get_recent_logs':
|
||||
return { logs: [], total_count: 0 };
|
||||
case 'get_performance_metrics':
|
||||
return {
|
||||
current: {
|
||||
timestamp: 1,
|
||||
cpu_percent: 0,
|
||||
memory_percent: 0,
|
||||
memory_mb: 0,
|
||||
disk_percent: 0,
|
||||
network_bytes_sent: 0,
|
||||
network_bytes_recv: 0,
|
||||
process_memory_mb: 0,
|
||||
active_connections: 0,
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
case 'refine_speakers':
|
||||
return { job_id: 'job', status: 'queued', segments_updated: 0, speaker_ids: [] };
|
||||
case 'get_diarization_status':
|
||||
return { job_id: 'job', status: 'completed', segments_updated: 1, speaker_ids: [] };
|
||||
case 'rename_speaker':
|
||||
return { success: true };
|
||||
case 'cancel_diarization':
|
||||
return { success: true, error_message: '', status: 'cancelled' };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
|
||||
const list1 = await api.listAnnotations('m1');
|
||||
const list2 = await api.listAnnotations('m1');
|
||||
expect(list1).toHaveLength(1);
|
||||
expect(list2).toHaveLength(1);
|
||||
|
||||
await api.getAnnotation('a1');
|
||||
await api.updateAnnotation({ annotation_id: 'a1', text: 'Updated' });
|
||||
await api.exportTranscript('m1', 'markdown');
|
||||
await api.saveExportFile('content', 'Meeting', 'md');
|
||||
await api.listAudioDevices();
|
||||
await api.getDefaultAudioDevice(true);
|
||||
await api.selectAudioDevice('mic', true);
|
||||
await api.getPreferences();
|
||||
await api.savePreferences(buildPreferences());
|
||||
await api.grantCloudConsent();
|
||||
await api.revokeCloudConsent();
|
||||
await api.getCloudConsentStatus();
|
||||
await api.pausePlayback();
|
||||
await api.stopPlayback();
|
||||
await api.setTriggerEnabled(true);
|
||||
await api.snoozeTriggers(5);
|
||||
await api.resetSnooze();
|
||||
await api.getTriggerStatus();
|
||||
await api.dismissTrigger();
|
||||
await api.acceptTrigger('Title');
|
||||
await api.extractEntities('m1', true);
|
||||
await api.updateEntity('m1', 'e1', 'Entity', 'other');
|
||||
await api.deleteEntity('m1', 'e1');
|
||||
await api.listCalendarEvents(2, 5, 'google');
|
||||
await api.getCalendarProviders();
|
||||
await api.initiateCalendarAuth('google', 'redirect');
|
||||
await api.completeCalendarAuth('google', 'code', 'state');
|
||||
await api.getOAuthConnectionStatus('google');
|
||||
await api.disconnectCalendar('google');
|
||||
await api.registerWebhook({
|
||||
workspace_id: 'w1',
|
||||
name: 'Webhook',
|
||||
url: 'https://example.com',
|
||||
events: ['meeting.completed'],
|
||||
});
|
||||
await api.listWebhooks();
|
||||
await api.updateWebhook({ webhook_id: 'w1', name: 'Webhook' });
|
||||
await api.deleteWebhook('w1');
|
||||
await api.getWebhookDeliveries('w1', 10);
|
||||
await api.startIntegrationSync('int-1');
|
||||
await api.getSyncStatus('sync');
|
||||
await api.listSyncHistory('int-1', 10, 0);
|
||||
await api.getRecentLogs({ limit: 10 });
|
||||
await api.getPerformanceMetrics({ history_limit: 5 });
|
||||
await api.refineSpeakers('m1', 2);
|
||||
await api.getDiarizationJobStatus('job');
|
||||
await api.renameSpeaker('m1', 'old', 'new');
|
||||
await api.cancelDiarization('job');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('export_transcript', {
|
||||
meeting_id: 'm1',
|
||||
format: 1,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('set_trigger_enabled', { enabled: true });
|
||||
expect(invoke).toHaveBeenCalledWith('extract_entities', {
|
||||
meeting_id: 'm1',
|
||||
force_refresh: true,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('register_webhook', {
|
||||
request: {
|
||||
workspace_id: 'w1',
|
||||
name: 'Webhook',
|
||||
url: 'https://example.com',
|
||||
events: ['meeting.completed'],
|
||||
},
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('list_calendar_events', {
|
||||
hours_ahead: 2,
|
||||
limit: 5,
|
||||
provider: 'google',
|
||||
});
|
||||
});
|
||||
});
|
||||
60
client/src/api/tauri-adapter/__tests__/test-utils.ts
Normal file
60
client/src/api/tauri-adapter/__tests__/test-utils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { Meeting, Summary, UserPreferences } from '../../types';
|
||||
import { defaultPreferences } from '@/lib/preferences/constants';
|
||||
import { clonePreferences } from '@/lib/preferences/core';
|
||||
import type { TranscriptionStream } from '../../interface';
|
||||
|
||||
export type InvokeMock = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
export type ListenMock = (
|
||||
event: string,
|
||||
handler: (event: { payload: unknown }) => void
|
||||
) => Promise<() => void>;
|
||||
|
||||
export function createMocks() {
|
||||
const invoke = vi.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>();
|
||||
const listen = vi
|
||||
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
|
||||
.mockResolvedValue(() => {});
|
||||
return { invoke, listen };
|
||||
}
|
||||
|
||||
export function assertTranscriptionStream(value: unknown): asserts value is TranscriptionStream {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('Expected transcription stream');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.send !== 'function' || typeof record.onUpdate !== 'function') {
|
||||
throw new Error('Expected transcription stream');
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMeeting(id: string): Meeting {
|
||||
return {
|
||||
id,
|
||||
title: `Meeting ${id}`,
|
||||
state: 'created',
|
||||
created_at: Date.now() / 1000,
|
||||
duration_seconds: 0,
|
||||
segments: [],
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSummary(meetingId: string): Summary {
|
||||
return {
|
||||
meeting_id: meetingId,
|
||||
executive_summary: 'Test summary',
|
||||
key_points: [],
|
||||
action_items: [],
|
||||
model_version: 'test-v1',
|
||||
generated_at: Date.now() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPreferences(aiTemplate?: UserPreferences['ai_template']): UserPreferences {
|
||||
const prefs = clonePreferences(defaultPreferences);
|
||||
return {
|
||||
...prefs,
|
||||
ai_template: aiTemplate ?? prefs.ai_template,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createTauriAPI, type TauriInvoke, type TauriListen } from '@/api/tauri-adapter';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import type { AudioChunk, TranscriptUpdate } from '../../types';
|
||||
import { assertTranscriptionStream, createMocks } from './test-utils';
|
||||
|
||||
function createInvokeListenMocks() {
|
||||
return createMocks();
|
||||
}
|
||||
|
||||
describe('tauri-adapter mapping (transcription)', () => {
|
||||
it('sends audio chunk with snake_case keys', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const chunk: AudioChunk = {
|
||||
meeting_id: 'm1',
|
||||
audio_data: new Float32Array([0.25, -0.25]),
|
||||
timestamp: 12.34,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
};
|
||||
|
||||
stream.send(chunk);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('start_recording', { meeting_id: 'm1' });
|
||||
expect(invoke).toHaveBeenCalledWith('send_audio_chunk', {
|
||||
meeting_id: 'm1',
|
||||
audio_data: chunk.audio_data,
|
||||
timestamp: 12.34,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends audio chunk without optional fields', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m2');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const chunk: AudioChunk = {
|
||||
meeting_id: 'm2',
|
||||
audio_data: new Float32Array([0.1]),
|
||||
timestamp: 1.23,
|
||||
};
|
||||
|
||||
stream.send(chunk);
|
||||
|
||||
const call = invoke.mock.calls.find((item) => item[0] === 'send_audio_chunk');
|
||||
expect(call).toBeDefined();
|
||||
const args = call?.[1] as Record<string, unknown>;
|
||||
expect(args).toMatchObject({
|
||||
meeting_id: 'm2',
|
||||
timestamp: 1.23,
|
||||
});
|
||||
const audioData = args.audio_data as Float32Array | undefined;
|
||||
expect(audioData).toBeInstanceOf(Float32Array);
|
||||
expect(audioData).toHaveLength(1);
|
||||
expect(audioData?.[0]).toBeCloseTo(0.1, 5);
|
||||
});
|
||||
|
||||
it('forwards transcript updates with full segment payload', async () => {
|
||||
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
|
||||
const invoke = vi
|
||||
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const listen = vi
|
||||
.fn<Parameters<TauriListen>, ReturnType<TauriListen>>()
|
||||
.mockImplementation((_event, handler) => {
|
||||
capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const callback = vi.fn();
|
||||
await stream.onUpdate(callback);
|
||||
|
||||
const payload: TranscriptUpdate = {
|
||||
meeting_id: 'm1',
|
||||
update_type: 'final',
|
||||
partial_text: undefined,
|
||||
segment: {
|
||||
segment_id: 12,
|
||||
text: 'Hello world',
|
||||
start_time: 1.2,
|
||||
end_time: 2.3,
|
||||
words: [
|
||||
{ word: 'Hello', start_time: 1.2, end_time: 1.6, probability: 0.9 },
|
||||
{ word: 'world', start_time: 1.6, end_time: 2.3, probability: 0.92 },
|
||||
],
|
||||
language: 'en',
|
||||
language_confidence: 0.99,
|
||||
avg_logprob: -0.2,
|
||||
no_speech_prob: 0.01,
|
||||
speaker_id: 'SPEAKER_00',
|
||||
speaker_confidence: 0.95,
|
||||
},
|
||||
server_timestamp: 123.45,
|
||||
};
|
||||
|
||||
if (!capturedHandler) {
|
||||
throw new Error('Transcript update handler not registered');
|
||||
}
|
||||
|
||||
capturedHandler({ payload });
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it('ignores transcript updates for other meetings', async () => {
|
||||
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
|
||||
const invoke = vi
|
||||
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const listen = vi
|
||||
.fn<Parameters<TauriListen>, ReturnType<TauriListen>>()
|
||||
.mockImplementation((_event, handler) => {
|
||||
capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
const callback = vi.fn();
|
||||
await stream.onUpdate(callback);
|
||||
|
||||
capturedHandler?.({
|
||||
payload: {
|
||||
meeting_id: 'other',
|
||||
update_type: 'partial',
|
||||
partial_text: 'nope',
|
||||
server_timestamp: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops transcription stream on close', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
const unlisten = vi.fn();
|
||||
listen.mockResolvedValueOnce(unlisten);
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
await stream.onUpdate(() => {});
|
||||
await stream.close();
|
||||
|
||||
expect(unlisten).toHaveBeenCalled();
|
||||
expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });
|
||||
});
|
||||
|
||||
it('propagates errors when closing a transcription stream fails', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
const unlisten = vi.fn();
|
||||
listen.mockResolvedValueOnce(unlisten);
|
||||
invoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
const stopError = new Error('STOP_RECORDING failed');
|
||||
invoke.mockRejectedValueOnce(stopError);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const onError = vi.fn();
|
||||
stream.onError(onError);
|
||||
|
||||
await stream.onUpdate(() => {});
|
||||
|
||||
await expect(stream.close()).rejects.toBe(stopError);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'stream_close_failed',
|
||||
message: expect.stringContaining('Failed to stop recording'),
|
||||
})
|
||||
);
|
||||
expect(unlisten).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up pending transcript listener when closed before listen resolves', async () => {
|
||||
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
|
||||
let resolveListen: ((fn: () => void) => void) | null = null;
|
||||
const unlisten = vi.fn();
|
||||
const invoke = vi
|
||||
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const listen = vi
|
||||
.fn<Parameters<TauriListen>, ReturnType<TauriListen>>()
|
||||
.mockImplementation((_event, handler) => {
|
||||
capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;
|
||||
return new Promise<() => void>((resolve) => {
|
||||
resolveListen = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
|
||||
const callback = vi.fn();
|
||||
const onUpdatePromise = stream.onUpdate(callback);
|
||||
|
||||
stream.close();
|
||||
resolveListen?.(unlisten);
|
||||
await onUpdatePromise;
|
||||
|
||||
expect(unlisten).toHaveBeenCalled();
|
||||
|
||||
if (!capturedHandler) {
|
||||
throw new Error('Transcript update handler not registered');
|
||||
}
|
||||
|
||||
capturedHandler({
|
||||
payload: {
|
||||
meeting_id: 'm1',
|
||||
update_type: 'partial',
|
||||
partial_text: 'late update',
|
||||
server_timestamp: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops transcription stream even without listeners', async () => {
|
||||
const { invoke, listen } = createInvokeListenMocks();
|
||||
invoke.mockResolvedValue(undefined);
|
||||
|
||||
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
|
||||
const stream: unknown = await api.startTranscription('m1');
|
||||
assertTranscriptionStream(stream);
|
||||
stream.close();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });
|
||||
});
|
||||
});
|
||||
46
client/src/api/tauri-adapter/api.ts
Normal file
46
client/src/api/tauri-adapter/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { NoteFlowAPI } from '../interface';
|
||||
import type { TauriInvoke, TauriListen } from './types';
|
||||
import { createAnnotationApi } from './sections/annotations';
|
||||
import { createAppsApi } from './sections/apps';
|
||||
import { createAsrApi } from './sections/asr';
|
||||
import { createAudioApi } from './sections/audio';
|
||||
import { createCalendarApi } from './sections/calendar';
|
||||
import { createCoreApi } from './sections/core';
|
||||
import { createDiarizationApi } from './sections/diarization';
|
||||
import { createEntityApi } from './sections/entities';
|
||||
import { createExportApi } from './sections/exporting';
|
||||
import { createIntegrationApi } from './sections/integrations';
|
||||
import { createMeetingApi } from './sections/meetings';
|
||||
import { createObservabilityApi } from './sections/observability';
|
||||
import { createOidcApi } from './sections/oidc';
|
||||
import { createPlaybackApi } from './sections/playback';
|
||||
import { createPreferencesApi } from './sections/preferences';
|
||||
import { createProjectApi } from './sections/projects';
|
||||
import { createSummarizationApi } from './sections/summarization';
|
||||
import { createTriggerApi } from './sections/triggers';
|
||||
import { createWebhookApi } from './sections/webhooks';
|
||||
|
||||
/** Creates a Tauri API adapter instance. */
|
||||
export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFlowAPI {
|
||||
return {
|
||||
...createCoreApi(invoke),
|
||||
...createProjectApi(invoke),
|
||||
...createMeetingApi(invoke, listen),
|
||||
...createSummarizationApi(invoke),
|
||||
...createAsrApi(invoke),
|
||||
...createAnnotationApi(invoke),
|
||||
...createExportApi(invoke),
|
||||
...createPlaybackApi(invoke),
|
||||
...createDiarizationApi(invoke),
|
||||
...createPreferencesApi(invoke),
|
||||
...createAudioApi(invoke),
|
||||
...createAppsApi(invoke),
|
||||
...createTriggerApi(invoke),
|
||||
...createEntityApi(invoke),
|
||||
...createCalendarApi(invoke),
|
||||
...createWebhookApi(invoke),
|
||||
...createIntegrationApi(invoke),
|
||||
...createObservabilityApi(invoke),
|
||||
...createOidcApi(invoke),
|
||||
};
|
||||
}
|
||||
42
client/src/api/tauri-adapter/environment.ts
Normal file
42
client/src/api/tauri-adapter/environment.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { NoteFlowAPI } from '../interface';
|
||||
import { extractErrorMessage } from '../helpers';
|
||||
import { createTauriAPI } from './api';
|
||||
import { addClientLog } from '@/lib/client-logs';
|
||||
|
||||
/** Check if running in a Tauri environment (synchronous hint). */
|
||||
export function isTauriEnvironment(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
// Tauri 2.x injects __TAURI_INTERNALS__ into the window
|
||||
// Only check for Tauri-injected globals, not our own globals like __NOTEFLOW_API__
|
||||
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window || 'isTauri' in window;
|
||||
}
|
||||
|
||||
/** Dynamically import Tauri APIs and create the adapter. */
|
||||
export async function initializeTauriAPI(): Promise<NoteFlowAPI> {
|
||||
const [core, event] = await Promise.all([
|
||||
import('@tauri-apps/api/core'),
|
||||
import('@tauri-apps/api/event'),
|
||||
]).catch((error) => {
|
||||
addClientLog({
|
||||
level: 'debug',
|
||||
source: 'api',
|
||||
message: 'Tauri adapter initialization: import failed',
|
||||
details: extractErrorMessage(error, 'unknown error'),
|
||||
});
|
||||
throw new Error('Not running in Tauri environment.');
|
||||
});
|
||||
|
||||
try {
|
||||
return createTauriAPI(core.invoke, event.listen);
|
||||
} catch (error) {
|
||||
addClientLog({
|
||||
level: 'error',
|
||||
source: 'api',
|
||||
message: 'Tauri adapter initialization failed',
|
||||
details: extractErrorMessage(error, 'unknown error'),
|
||||
});
|
||||
throw new Error('Failed to initialize Tauri API.');
|
||||
}
|
||||
}
|
||||
15
client/src/api/tauri-adapter/index.ts
Normal file
15
client/src/api/tauri-adapter/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export { createTauriAPI } from './api';
|
||||
export { initializeTauriAPI, isTauriEnvironment } from './environment';
|
||||
export {
|
||||
CONGESTION_DISPLAY_THRESHOLD_MS,
|
||||
CONSECUTIVE_FAILURE_THRESHOLD,
|
||||
TauriTranscriptionStream,
|
||||
} from './stream';
|
||||
export type {
|
||||
CongestionCallback,
|
||||
CongestionState,
|
||||
StreamErrorCallback,
|
||||
TauriInvoke,
|
||||
TauriListen,
|
||||
} from './types';
|
||||
export { TauriEvents } from '../tauri-constants';
|
||||
66
client/src/api/tauri-adapter/sections/annotations.ts
Normal file
66
client/src/api/tauri-adapter/sections/annotations.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
AddAnnotationRequest,
|
||||
Annotation,
|
||||
UpdateAnnotationRequest,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { annotationTypeToGrpc, normalizeAnnotationList, normalizeSuccessResponse } from '../../helpers';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createAnnotationApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'listAnnotations'
|
||||
| 'addAnnotation'
|
||||
| 'getAnnotation'
|
||||
| 'updateAnnotation'
|
||||
| 'deleteAnnotation'
|
||||
> {
|
||||
return {
|
||||
async listAnnotations(
|
||||
meetingId: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<Annotation[]> {
|
||||
return normalizeAnnotationList(
|
||||
await invoke<Annotation[] | { annotations: Annotation[] }>(TauriCommands.LIST_ANNOTATIONS, {
|
||||
meeting_id: meetingId,
|
||||
start_time: startTime ?? 0,
|
||||
end_time: endTime ?? 0,
|
||||
})
|
||||
);
|
||||
},
|
||||
async addAnnotation(request: AddAnnotationRequest): Promise<Annotation> {
|
||||
return invoke<Annotation>(TauriCommands.ADD_ANNOTATION, {
|
||||
meeting_id: request.meeting_id,
|
||||
annotation_type: annotationTypeToGrpc(request.annotation_type),
|
||||
text: request.text,
|
||||
start_time: request.start_time,
|
||||
end_time: request.end_time,
|
||||
segment_ids: request.segment_ids ?? [],
|
||||
});
|
||||
},
|
||||
async getAnnotation(annotationId: string): Promise<Annotation> {
|
||||
return invoke<Annotation>(TauriCommands.GET_ANNOTATION, { annotation_id: annotationId });
|
||||
},
|
||||
async updateAnnotation(request: UpdateAnnotationRequest): Promise<Annotation> {
|
||||
return invoke<Annotation>(TauriCommands.UPDATE_ANNOTATION, {
|
||||
annotation_id: request.annotation_id,
|
||||
annotation_type: request.annotation_type
|
||||
? annotationTypeToGrpc(request.annotation_type)
|
||||
: undefined,
|
||||
text: request.text,
|
||||
start_time: request.start_time,
|
||||
end_time: request.end_time,
|
||||
segment_ids: request.segment_ids,
|
||||
});
|
||||
},
|
||||
async deleteAnnotation(annotationId: string): Promise<boolean> {
|
||||
return normalizeSuccessResponse(
|
||||
await invoke<boolean | { success: boolean }>(TauriCommands.DELETE_ANNOTATION, {
|
||||
annotation_id: annotationId,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
25
client/src/api/tauri-adapter/sections/apps.ts
Normal file
25
client/src/api/tauri-adapter/sections/apps.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ListInstalledAppsRequest, ListInstalledAppsResponse } from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createAppsApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'listInstalledApps' | 'invalidateAppCache'
|
||||
> {
|
||||
return {
|
||||
async listInstalledApps(
|
||||
options?: ListInstalledAppsRequest
|
||||
): Promise<ListInstalledAppsResponse> {
|
||||
return invoke<ListInstalledAppsResponse>(TauriCommands.LIST_INSTALLED_APPS, {
|
||||
common_only: options?.commonOnly ?? false,
|
||||
page: options?.page ?? 0,
|
||||
page_size: options?.pageSize ?? 50,
|
||||
force_refresh: options?.forceRefresh ?? false,
|
||||
});
|
||||
},
|
||||
async invalidateAppCache(): Promise<void> {
|
||||
await invoke(TauriCommands.INVALIDATE_APP_CACHE);
|
||||
},
|
||||
};
|
||||
}
|
||||
80
client/src/api/tauri-adapter/sections/asr.ts
Normal file
80
client/src/api/tauri-adapter/sections/asr.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ASRConfiguration,
|
||||
ASRConfigurationJobStatus,
|
||||
HuggingFaceTokenStatus,
|
||||
SetHuggingFaceTokenRequest,
|
||||
SetHuggingFaceTokenResult,
|
||||
StreamingConfiguration,
|
||||
UpdateASRConfigurationRequest,
|
||||
UpdateASRConfigurationResult,
|
||||
UpdateStreamingConfigurationRequest,
|
||||
ValidateHuggingFaceTokenResult,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createAsrApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'getAsrConfiguration'
|
||||
| 'updateAsrConfiguration'
|
||||
| 'getAsrJobStatus'
|
||||
| 'getStreamingConfiguration'
|
||||
| 'updateStreamingConfiguration'
|
||||
| 'setHuggingFaceToken'
|
||||
| 'getHuggingFaceTokenStatus'
|
||||
| 'deleteHuggingFaceToken'
|
||||
| 'validateHuggingFaceToken'
|
||||
> {
|
||||
return {
|
||||
async getAsrConfiguration(): Promise<ASRConfiguration> {
|
||||
return invoke<ASRConfiguration>(TauriCommands.GET_ASR_CONFIGURATION);
|
||||
},
|
||||
|
||||
async updateAsrConfiguration(
|
||||
request: UpdateASRConfigurationRequest
|
||||
): Promise<UpdateASRConfigurationResult> {
|
||||
return invoke<UpdateASRConfigurationResult>(TauriCommands.UPDATE_ASR_CONFIGURATION, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async getAsrJobStatus(jobId: string): Promise<ASRConfigurationJobStatus> {
|
||||
return invoke<ASRConfigurationJobStatus>(TauriCommands.GET_ASR_JOB_STATUS, {
|
||||
job_id: jobId,
|
||||
});
|
||||
},
|
||||
|
||||
async getStreamingConfiguration(): Promise<StreamingConfiguration> {
|
||||
return invoke<StreamingConfiguration>(TauriCommands.GET_STREAMING_CONFIGURATION);
|
||||
},
|
||||
|
||||
async updateStreamingConfiguration(
|
||||
request: UpdateStreamingConfigurationRequest
|
||||
): Promise<StreamingConfiguration> {
|
||||
return invoke<StreamingConfiguration>(TauriCommands.UPDATE_STREAMING_CONFIGURATION, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async setHuggingFaceToken(
|
||||
request: SetHuggingFaceTokenRequest
|
||||
): Promise<SetHuggingFaceTokenResult> {
|
||||
return invoke<SetHuggingFaceTokenResult>(TauriCommands.SET_HUGGINGFACE_TOKEN, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async getHuggingFaceTokenStatus(): Promise<HuggingFaceTokenStatus> {
|
||||
return invoke<HuggingFaceTokenStatus>(TauriCommands.GET_HUGGINGFACE_TOKEN_STATUS);
|
||||
},
|
||||
|
||||
async deleteHuggingFaceToken(): Promise<boolean> {
|
||||
return invoke<boolean>(TauriCommands.DELETE_HUGGINGFACE_TOKEN);
|
||||
},
|
||||
|
||||
async validateHuggingFaceToken(): Promise<ValidateHuggingFaceTokenResult> {
|
||||
return invoke<ValidateHuggingFaceTokenResult>(TauriCommands.VALIDATE_HUGGINGFACE_TOKEN);
|
||||
},
|
||||
};
|
||||
}
|
||||
117
client/src/api/tauri-adapter/sections/audio.ts
Normal file
117
client/src/api/tauri-adapter/sections/audio.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
AudioDeviceInfo,
|
||||
DualCaptureConfigInfo,
|
||||
TestAudioConfig,
|
||||
TestAudioResult,
|
||||
TestEnvironmentInfo,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createAudioApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'listAudioDevices'
|
||||
| 'getDefaultAudioDevice'
|
||||
| 'selectAudioDevice'
|
||||
| 'listLoopbackDevices'
|
||||
| 'setSystemAudioDevice'
|
||||
| 'setDualCaptureEnabled'
|
||||
| 'setAudioMixLevels'
|
||||
| 'getDualCaptureConfig'
|
||||
| 'checkTestEnvironment'
|
||||
| 'injectTestAudio'
|
||||
| 'injectTestTone'
|
||||
> {
|
||||
return {
|
||||
async listAudioDevices(): Promise<AudioDeviceInfo[]> {
|
||||
return invoke<AudioDeviceInfo[]>(TauriCommands.LIST_AUDIO_DEVICES);
|
||||
},
|
||||
async getDefaultAudioDevice(isInput: boolean): Promise<AudioDeviceInfo | null> {
|
||||
return invoke<AudioDeviceInfo | null>(TauriCommands.GET_DEFAULT_AUDIO_DEVICE, {
|
||||
is_input: isInput,
|
||||
});
|
||||
},
|
||||
async selectAudioDevice(deviceId: string, isInput: boolean): Promise<void> {
|
||||
await invoke(TauriCommands.SELECT_AUDIO_DEVICE, { device_id: deviceId, is_input: isInput });
|
||||
},
|
||||
|
||||
// Dual capture (system audio)
|
||||
async listLoopbackDevices(): Promise<AudioDeviceInfo[]> {
|
||||
return invoke<AudioDeviceInfo[]>(TauriCommands.LIST_LOOPBACK_DEVICES);
|
||||
},
|
||||
async setSystemAudioDevice(deviceId: string | null): Promise<void> {
|
||||
await invoke(TauriCommands.SET_SYSTEM_AUDIO_DEVICE, { device_id: deviceId });
|
||||
},
|
||||
async setDualCaptureEnabled(enabled: boolean): Promise<void> {
|
||||
await invoke(TauriCommands.SET_DUAL_CAPTURE_ENABLED, { enabled });
|
||||
},
|
||||
async setAudioMixLevels(micGain: number, systemGain: number): Promise<void> {
|
||||
await invoke(TauriCommands.SET_AUDIO_MIX_LEVELS, {
|
||||
mic_gain: micGain,
|
||||
system_gain: systemGain,
|
||||
});
|
||||
},
|
||||
async getDualCaptureConfig(): Promise<DualCaptureConfigInfo> {
|
||||
return invoke<DualCaptureConfigInfo>(TauriCommands.GET_DUAL_CAPTURE_CONFIG);
|
||||
},
|
||||
|
||||
async checkTestEnvironment(): Promise<TestEnvironmentInfo> {
|
||||
const result = await invoke<{
|
||||
has_input_devices: boolean;
|
||||
has_virtual_device: boolean;
|
||||
input_devices: string[];
|
||||
is_server_connected: boolean;
|
||||
can_run_audio_tests: boolean;
|
||||
}>(TauriCommands.CHECK_TEST_ENVIRONMENT);
|
||||
return {
|
||||
hasInputDevices: result.has_input_devices,
|
||||
hasVirtualDevice: result.has_virtual_device,
|
||||
inputDevices: result.input_devices,
|
||||
isServerConnected: result.is_server_connected,
|
||||
canRunAudioTests: result.can_run_audio_tests,
|
||||
};
|
||||
},
|
||||
async injectTestAudio(meetingId: string, config: TestAudioConfig): Promise<TestAudioResult> {
|
||||
const result = await invoke<{
|
||||
chunks_sent: number;
|
||||
duration_seconds: number;
|
||||
sample_rate: number;
|
||||
}>(TauriCommands.INJECT_TEST_AUDIO, {
|
||||
meeting_id: meetingId,
|
||||
config: {
|
||||
wav_path: config.wavPath,
|
||||
speed: config.speed ?? 1.0,
|
||||
chunk_ms: config.chunkMs ?? 100,
|
||||
},
|
||||
});
|
||||
return {
|
||||
chunksSent: result.chunks_sent,
|
||||
durationSeconds: result.duration_seconds,
|
||||
sampleRate: result.sample_rate,
|
||||
};
|
||||
},
|
||||
async injectTestTone(
|
||||
meetingId: string,
|
||||
frequencyHz: number,
|
||||
durationSeconds: number,
|
||||
sampleRate?: number
|
||||
): Promise<TestAudioResult> {
|
||||
const result = await invoke<{
|
||||
chunks_sent: number;
|
||||
duration_seconds: number;
|
||||
sample_rate: number;
|
||||
}>(TauriCommands.INJECT_TEST_TONE, {
|
||||
meeting_id: meetingId,
|
||||
frequency_hz: frequencyHz,
|
||||
duration_seconds: durationSeconds,
|
||||
sample_rate: sampleRate,
|
||||
});
|
||||
return {
|
||||
chunksSent: result.chunks_sent,
|
||||
durationSeconds: result.duration_seconds,
|
||||
sampleRate: result.sample_rate,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
98
client/src/api/tauri-adapter/sections/calendar.ts
Normal file
98
client/src/api/tauri-adapter/sections/calendar.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
CompleteCalendarAuthResponse,
|
||||
DisconnectOAuthResponse,
|
||||
GetCalendarProvidersResponse,
|
||||
GetOAuthClientConfigRequest,
|
||||
GetOAuthClientConfigResponse,
|
||||
GetOAuthConnectionStatusResponse,
|
||||
InitiateCalendarAuthResponse,
|
||||
ListCalendarEventsResponse,
|
||||
SetOAuthClientConfigRequest,
|
||||
SetOAuthClientConfigResponse,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createCalendarApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'listCalendarEvents'
|
||||
| 'getCalendarProviders'
|
||||
| 'initiateCalendarAuth'
|
||||
| 'completeCalendarAuth'
|
||||
| 'getOAuthConnectionStatus'
|
||||
| 'getOAuthClientConfig'
|
||||
| 'setOAuthClientConfig'
|
||||
| 'disconnectCalendar'
|
||||
> {
|
||||
return {
|
||||
async listCalendarEvents(
|
||||
hoursAhead?: number,
|
||||
limit?: number,
|
||||
provider?: string
|
||||
): Promise<ListCalendarEventsResponse> {
|
||||
return invoke<ListCalendarEventsResponse>(TauriCommands.LIST_CALENDAR_EVENTS, {
|
||||
hours_ahead: hoursAhead,
|
||||
limit,
|
||||
provider,
|
||||
});
|
||||
},
|
||||
async getCalendarProviders(): Promise<GetCalendarProvidersResponse> {
|
||||
return invoke<GetCalendarProvidersResponse>(TauriCommands.GET_CALENDAR_PROVIDERS);
|
||||
},
|
||||
async initiateCalendarAuth(
|
||||
provider: string,
|
||||
redirectUri?: string
|
||||
): Promise<InitiateCalendarAuthResponse> {
|
||||
return invoke<InitiateCalendarAuthResponse>(TauriCommands.INITIATE_OAUTH, {
|
||||
provider,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
},
|
||||
async completeCalendarAuth(
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<CompleteCalendarAuthResponse> {
|
||||
const response = await invoke<CompleteCalendarAuthResponse>(TauriCommands.COMPLETE_OAUTH, {
|
||||
provider,
|
||||
code,
|
||||
state,
|
||||
});
|
||||
clientLog.calendarConnected(provider);
|
||||
return response;
|
||||
},
|
||||
async getOAuthConnectionStatus(provider: string): Promise<GetOAuthConnectionStatusResponse> {
|
||||
return invoke<GetOAuthConnectionStatusResponse>(TauriCommands.GET_OAUTH_CONNECTION_STATUS, {
|
||||
provider,
|
||||
});
|
||||
},
|
||||
async getOAuthClientConfig(
|
||||
request: GetOAuthClientConfigRequest
|
||||
): Promise<GetOAuthClientConfigResponse> {
|
||||
return invoke<GetOAuthClientConfigResponse>(TauriCommands.GET_OAUTH_CLIENT_CONFIG, {
|
||||
provider: request.provider,
|
||||
workspace_id: request.workspace_id,
|
||||
integration_type: request.integration_type,
|
||||
});
|
||||
},
|
||||
async setOAuthClientConfig(
|
||||
request: SetOAuthClientConfigRequest
|
||||
): Promise<SetOAuthClientConfigResponse> {
|
||||
return invoke<SetOAuthClientConfigResponse>(TauriCommands.SET_OAUTH_CLIENT_CONFIG, {
|
||||
provider: request.provider,
|
||||
workspace_id: request.workspace_id,
|
||||
integration_type: request.integration_type,
|
||||
config: request.config,
|
||||
});
|
||||
},
|
||||
async disconnectCalendar(provider: string): Promise<DisconnectOAuthResponse> {
|
||||
const response = await invoke<DisconnectOAuthResponse>(TauriCommands.DISCONNECT_OAUTH, {
|
||||
provider,
|
||||
});
|
||||
clientLog.calendarDisconnected(provider);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
116
client/src/api/tauri-adapter/sections/core.ts
Normal file
116
client/src/api/tauri-adapter/sections/core.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
CompleteAuthLoginResponse,
|
||||
EffectiveServerUrl,
|
||||
GetCurrentUserResponse,
|
||||
GetWorkspaceSettingsRequest,
|
||||
GetWorkspaceSettingsResponse,
|
||||
InitiateAuthLoginResponse,
|
||||
ListWorkspacesResponse,
|
||||
LogoutResponse,
|
||||
ServerInfo,
|
||||
SwitchWorkspaceResponse,
|
||||
UpdateWorkspaceSettingsRequest,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { extractErrorMessage } from '../../helpers';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createCoreApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'getServerInfo'
|
||||
| 'connect'
|
||||
| 'disconnect'
|
||||
| 'isConnected'
|
||||
| 'getEffectiveServerUrl'
|
||||
| 'getCurrentUser'
|
||||
| 'listWorkspaces'
|
||||
| 'switchWorkspace'
|
||||
| 'getWorkspaceSettings'
|
||||
| 'updateWorkspaceSettings'
|
||||
| 'initiateAuthLogin'
|
||||
| 'completeAuthLogin'
|
||||
| 'logout'
|
||||
> {
|
||||
return {
|
||||
async getServerInfo(): Promise<ServerInfo> {
|
||||
return invoke<ServerInfo>(TauriCommands.GET_SERVER_INFO);
|
||||
},
|
||||
async connect(serverUrl?: string): Promise<ServerInfo> {
|
||||
try {
|
||||
const info = await invoke<ServerInfo>(TauriCommands.CONNECT, { server_url: serverUrl });
|
||||
clientLog.connected(serverUrl);
|
||||
return info;
|
||||
} catch (error) {
|
||||
clientLog.connectionFailed(extractErrorMessage(error, 'Connection failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async disconnect(): Promise<void> {
|
||||
await invoke(TauriCommands.DISCONNECT);
|
||||
clientLog.disconnected();
|
||||
},
|
||||
async isConnected(): Promise<boolean> {
|
||||
return invoke<boolean>(TauriCommands.IS_CONNECTED);
|
||||
},
|
||||
async getEffectiveServerUrl(): Promise<EffectiveServerUrl> {
|
||||
return invoke<EffectiveServerUrl>(TauriCommands.GET_EFFECTIVE_SERVER_URL);
|
||||
},
|
||||
async getCurrentUser(): Promise<GetCurrentUserResponse> {
|
||||
return invoke<GetCurrentUserResponse>(TauriCommands.GET_CURRENT_USER);
|
||||
},
|
||||
async listWorkspaces(): Promise<ListWorkspacesResponse> {
|
||||
return invoke<ListWorkspacesResponse>(TauriCommands.LIST_WORKSPACES);
|
||||
},
|
||||
async switchWorkspace(workspaceId: string): Promise<SwitchWorkspaceResponse> {
|
||||
return invoke<SwitchWorkspaceResponse>(TauriCommands.SWITCH_WORKSPACE, {
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
},
|
||||
async getWorkspaceSettings(
|
||||
request: GetWorkspaceSettingsRequest
|
||||
): Promise<GetWorkspaceSettingsResponse> {
|
||||
return invoke<GetWorkspaceSettingsResponse>(TauriCommands.GET_WORKSPACE_SETTINGS, {
|
||||
workspace_id: request.workspace_id,
|
||||
});
|
||||
},
|
||||
async updateWorkspaceSettings(
|
||||
request: UpdateWorkspaceSettingsRequest
|
||||
): Promise<GetWorkspaceSettingsResponse> {
|
||||
return invoke<GetWorkspaceSettingsResponse>(TauriCommands.UPDATE_WORKSPACE_SETTINGS, {
|
||||
workspace_id: request.workspace_id,
|
||||
settings: request.settings,
|
||||
});
|
||||
},
|
||||
async initiateAuthLogin(
|
||||
provider: string,
|
||||
redirectUri?: string
|
||||
): Promise<InitiateAuthLoginResponse> {
|
||||
return invoke<InitiateAuthLoginResponse>(TauriCommands.INITIATE_AUTH_LOGIN, {
|
||||
provider,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
},
|
||||
async completeAuthLogin(
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<CompleteAuthLoginResponse> {
|
||||
const response = await invoke<CompleteAuthLoginResponse>(TauriCommands.COMPLETE_AUTH_LOGIN, {
|
||||
provider,
|
||||
code,
|
||||
state,
|
||||
});
|
||||
clientLog.loginCompleted(provider);
|
||||
return response;
|
||||
},
|
||||
async logout(provider?: string): Promise<LogoutResponse> {
|
||||
const response = await invoke<LogoutResponse>(TauriCommands.LOGOUT, {
|
||||
provider,
|
||||
});
|
||||
clientLog.loggedOut(provider);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
51
client/src/api/tauri-adapter/sections/diarization.ts
Normal file
51
client/src/api/tauri-adapter/sections/diarization.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { CancelDiarizationResult, DiarizationJobStatus } from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createDiarizationApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'refineSpeakers'
|
||||
| 'getDiarizationJobStatus'
|
||||
| 'renameSpeaker'
|
||||
| 'cancelDiarization'
|
||||
| 'getActiveDiarizationJobs'
|
||||
> {
|
||||
return {
|
||||
async refineSpeakers(meetingId: string, numSpeakers?: number): Promise<DiarizationJobStatus> {
|
||||
const status = await invoke<DiarizationJobStatus>(TauriCommands.REFINE_SPEAKERS, {
|
||||
meeting_id: meetingId,
|
||||
num_speakers: numSpeakers ?? 0,
|
||||
});
|
||||
if (status?.job_id) {
|
||||
clientLog.diarizationStarted(meetingId, status.job_id);
|
||||
}
|
||||
return status;
|
||||
},
|
||||
async getDiarizationJobStatus(jobId: string): Promise<DiarizationJobStatus> {
|
||||
return invoke<DiarizationJobStatus>(TauriCommands.GET_DIARIZATION_STATUS, { job_id: jobId });
|
||||
},
|
||||
async renameSpeaker(
|
||||
meetingId: string,
|
||||
oldSpeakerId: string,
|
||||
newName: string
|
||||
): Promise<boolean> {
|
||||
const result = await invoke<{ success: boolean }>(TauriCommands.RENAME_SPEAKER, {
|
||||
meeting_id: meetingId,
|
||||
old_speaker_id: oldSpeakerId,
|
||||
new_speaker_name: newName,
|
||||
});
|
||||
if (result.success) {
|
||||
clientLog.speakerRenamed(meetingId, oldSpeakerId, newName);
|
||||
}
|
||||
return result.success;
|
||||
},
|
||||
async cancelDiarization(jobId: string): Promise<CancelDiarizationResult> {
|
||||
return invoke<CancelDiarizationResult>(TauriCommands.CANCEL_DIARIZATION, { job_id: jobId });
|
||||
},
|
||||
async getActiveDiarizationJobs(): Promise<DiarizationJobStatus[]> {
|
||||
return invoke<DiarizationJobStatus[]>(TauriCommands.GET_ACTIVE_DIARIZATION_JOBS);
|
||||
},
|
||||
};
|
||||
}
|
||||
43
client/src/api/tauri-adapter/sections/entities.ts
Normal file
43
client/src/api/tauri-adapter/sections/entities.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ExtractedEntity, ExtractEntitiesResponse } from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createEntityApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'extractEntities' | 'updateEntity' | 'deleteEntity'
|
||||
> {
|
||||
return {
|
||||
async extractEntities(
|
||||
meetingId: string,
|
||||
forceRefresh?: boolean
|
||||
): Promise<ExtractEntitiesResponse> {
|
||||
const response = await invoke<ExtractEntitiesResponse>(TauriCommands.EXTRACT_ENTITIES, {
|
||||
meeting_id: meetingId,
|
||||
force_refresh: forceRefresh ?? false,
|
||||
});
|
||||
clientLog.entitiesExtracted(meetingId, response.entities?.length ?? 0);
|
||||
return response;
|
||||
},
|
||||
async updateEntity(
|
||||
meetingId: string,
|
||||
entityId: string,
|
||||
text?: string,
|
||||
category?: string
|
||||
): Promise<ExtractedEntity> {
|
||||
return invoke<ExtractedEntity>(TauriCommands.UPDATE_ENTITY, {
|
||||
meeting_id: meetingId,
|
||||
entity_id: entityId,
|
||||
text,
|
||||
category,
|
||||
});
|
||||
},
|
||||
async deleteEntity(meetingId: string, entityId: string): Promise<boolean> {
|
||||
return invoke<boolean>(TauriCommands.DELETE_ENTITY, {
|
||||
meeting_id: meetingId,
|
||||
entity_id: entityId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
39
client/src/api/tauri-adapter/sections/exporting.ts
Normal file
39
client/src/api/tauri-adapter/sections/exporting.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ExportFormat, ExportResult } from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { exportFormatToGrpc, extractErrorMessage } from '../../helpers';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createExportApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'exportTranscript' | 'saveExportFile'
|
||||
> {
|
||||
return {
|
||||
async exportTranscript(meetingId: string, format: ExportFormat): Promise<ExportResult> {
|
||||
clientLog.exportStarted(meetingId, format);
|
||||
try {
|
||||
const result = await invoke<ExportResult>(TauriCommands.EXPORT_TRANSCRIPT, {
|
||||
meeting_id: meetingId,
|
||||
format: exportFormatToGrpc(format),
|
||||
});
|
||||
clientLog.exportCompleted(meetingId, format);
|
||||
return result;
|
||||
} catch (error) {
|
||||
clientLog.exportFailed(meetingId, format, extractErrorMessage(error, 'Export failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async saveExportFile(
|
||||
content: string,
|
||||
defaultName: string,
|
||||
extension: string
|
||||
): Promise<boolean> {
|
||||
return invoke<boolean>(TauriCommands.SAVE_EXPORT_FILE, {
|
||||
content,
|
||||
default_name: defaultName,
|
||||
extension,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
41
client/src/api/tauri-adapter/sections/integrations.ts
Normal file
41
client/src/api/tauri-adapter/sections/integrations.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
GetSyncStatusResponse,
|
||||
GetUserIntegrationsResponse,
|
||||
ListSyncHistoryResponse,
|
||||
StartIntegrationSyncResponse,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createIntegrationApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'startIntegrationSync' | 'getSyncStatus' | 'listSyncHistory' | 'getUserIntegrations'
|
||||
> {
|
||||
return {
|
||||
async startIntegrationSync(integrationId: string): Promise<StartIntegrationSyncResponse> {
|
||||
return invoke<StartIntegrationSyncResponse>(TauriCommands.START_INTEGRATION_SYNC, {
|
||||
integration_id: integrationId,
|
||||
});
|
||||
},
|
||||
async getSyncStatus(syncRunId: string): Promise<GetSyncStatusResponse> {
|
||||
return invoke<GetSyncStatusResponse>(TauriCommands.GET_SYNC_STATUS, {
|
||||
sync_run_id: syncRunId,
|
||||
});
|
||||
},
|
||||
async listSyncHistory(
|
||||
integrationId: string,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<ListSyncHistoryResponse> {
|
||||
return invoke<ListSyncHistoryResponse>(TauriCommands.LIST_SYNC_HISTORY, {
|
||||
integration_id: integrationId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
async getUserIntegrations(): Promise<GetUserIntegrationsResponse> {
|
||||
return invoke<GetUserIntegrationsResponse>(TauriCommands.GET_USER_INTEGRATIONS);
|
||||
},
|
||||
};
|
||||
}
|
||||
181
client/src/api/tauri-adapter/sections/meetings.ts
Normal file
181
client/src/api/tauri-adapter/sections/meetings.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
CreateMeetingRequest,
|
||||
GetMeetingRequest,
|
||||
ListMeetingsRequest,
|
||||
ListMeetingsResponse,
|
||||
Meeting,
|
||||
StreamStateInfo,
|
||||
Summary,
|
||||
SummarizationOptions,
|
||||
UserPreferences,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI, TranscriptionStream } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import {
|
||||
extractErrorDetails,
|
||||
extractErrorMessage,
|
||||
normalizeSuccessResponse,
|
||||
sortOrderToGrpcEnum,
|
||||
stateToGrpcEnum,
|
||||
} from '../../helpers';
|
||||
import { meetingCache } from '@/lib/cache/meeting-cache';
|
||||
import { addClientLog } from '@/lib/client-logs';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke, TauriListen } from '../types';
|
||||
import { TauriTranscriptionStream } from '../stream';
|
||||
import {
|
||||
normalizeProjectId,
|
||||
normalizeProjectIds,
|
||||
recordingBlockedDetails,
|
||||
RECORDING_BLOCKED_PREFIX,
|
||||
} from '../utils';
|
||||
|
||||
export function createMeetingApi(
|
||||
invoke: TauriInvoke,
|
||||
listen: TauriListen
|
||||
): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'createMeeting'
|
||||
| 'listMeetings'
|
||||
| 'getMeeting'
|
||||
| 'stopMeeting'
|
||||
| 'deleteMeeting'
|
||||
| 'startTranscription'
|
||||
| 'getStreamState'
|
||||
| 'resetStreamState'
|
||||
| 'generateSummary'
|
||||
> {
|
||||
return {
|
||||
async createMeeting(request: CreateMeetingRequest): Promise<Meeting> {
|
||||
const meeting = await invoke<Meeting>(TauriCommands.CREATE_MEETING, {
|
||||
title: request.title,
|
||||
metadata: request.metadata ?? {},
|
||||
project_id: normalizeProjectId(request.project_id),
|
||||
});
|
||||
meetingCache.cacheMeeting(meeting);
|
||||
clientLog.meetingCreated(meeting.id, meeting.title);
|
||||
return meeting;
|
||||
},
|
||||
async listMeetings(request: ListMeetingsRequest): Promise<ListMeetingsResponse> {
|
||||
const response = await invoke<ListMeetingsResponse>(TauriCommands.LIST_MEETINGS, {
|
||||
states: request.states?.map(stateToGrpcEnum) ?? [],
|
||||
limit: request.limit ?? 50,
|
||||
offset: request.offset ?? 0,
|
||||
sort_order: sortOrderToGrpcEnum(request.sort_order),
|
||||
project_id: normalizeProjectId(request.project_id),
|
||||
project_ids: normalizeProjectIds(request.project_ids ?? []),
|
||||
});
|
||||
if (response.meetings?.length) {
|
||||
meetingCache.cacheMeetings(response.meetings);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async getMeeting(request: GetMeetingRequest): Promise<Meeting> {
|
||||
const meeting = await invoke<Meeting>(TauriCommands.GET_MEETING, {
|
||||
meeting_id: request.meeting_id,
|
||||
include_segments: request.include_segments ?? false,
|
||||
include_summary: request.include_summary ?? false,
|
||||
});
|
||||
meetingCache.cacheMeeting(meeting);
|
||||
return meeting;
|
||||
},
|
||||
async stopMeeting(meetingId: string): Promise<Meeting> {
|
||||
const meeting = await invoke<Meeting>(TauriCommands.STOP_MEETING, {
|
||||
meeting_id: meetingId,
|
||||
});
|
||||
meetingCache.cacheMeeting(meeting);
|
||||
clientLog.meetingStopped(meeting.id, meeting.title);
|
||||
return meeting;
|
||||
},
|
||||
async deleteMeeting(meetingId: string): Promise<boolean> {
|
||||
const result = normalizeSuccessResponse(
|
||||
await invoke<boolean | { success: boolean }>(TauriCommands.DELETE_MEETING, {
|
||||
meeting_id: meetingId,
|
||||
})
|
||||
);
|
||||
if (result) {
|
||||
meetingCache.removeMeeting(meetingId);
|
||||
clientLog.meetingDeleted(meetingId);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async startTranscription(meetingId: string): Promise<TranscriptionStream> {
|
||||
try {
|
||||
await invoke(TauriCommands.START_RECORDING, { meeting_id: meetingId });
|
||||
return new TauriTranscriptionStream(meetingId, invoke, listen);
|
||||
} catch (error) {
|
||||
const details = extractErrorDetails(error, 'Failed to start recording');
|
||||
clientLog.recordingStartFailed(
|
||||
meetingId,
|
||||
details.message,
|
||||
details.grpcStatus,
|
||||
details.category,
|
||||
details.retryable
|
||||
);
|
||||
const blocked = recordingBlockedDetails(error);
|
||||
if (blocked) {
|
||||
addClientLog({
|
||||
level: 'warning',
|
||||
source: 'system',
|
||||
message: RECORDING_BLOCKED_PREFIX,
|
||||
metadata: {
|
||||
rule_id: blocked.ruleId ?? '',
|
||||
rule_label: blocked.ruleLabel ?? '',
|
||||
app_name: blocked.appName ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getStreamState(): Promise<StreamStateInfo> {
|
||||
return invoke<StreamStateInfo>(TauriCommands.GET_STREAM_STATE);
|
||||
},
|
||||
|
||||
async resetStreamState(): Promise<StreamStateInfo> {
|
||||
const info = await invoke<StreamStateInfo>(TauriCommands.RESET_STREAM_STATE);
|
||||
addClientLog({
|
||||
level: 'warning',
|
||||
source: 'system',
|
||||
message: `Stream state force-reset from ${info.state}${info.meeting_id ? ` (meeting: ${info.meeting_id})` : ''}`,
|
||||
metadata: {
|
||||
previous_state: info.state,
|
||||
meeting_id: info.meeting_id ?? '',
|
||||
started_at_secs_ago: String(info.started_at_secs_ago ?? 0),
|
||||
},
|
||||
});
|
||||
return info;
|
||||
},
|
||||
|
||||
async generateSummary(meetingId: string, forceRegenerate?: boolean): Promise<Summary> {
|
||||
let options: SummarizationOptions | undefined;
|
||||
try {
|
||||
const prefs = await invoke<UserPreferences>(TauriCommands.GET_PREFERENCES);
|
||||
if (prefs?.ai_template) {
|
||||
options = {
|
||||
tone: prefs.ai_template.tone,
|
||||
format: prefs.ai_template.format,
|
||||
verbosity: prefs.ai_template.verbosity,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* Preferences unavailable */
|
||||
}
|
||||
clientLog.summarizing(meetingId);
|
||||
try {
|
||||
const summary = await invoke<Summary>(TauriCommands.GENERATE_SUMMARY, {
|
||||
meeting_id: meetingId,
|
||||
force_regenerate: forceRegenerate ?? false,
|
||||
options,
|
||||
});
|
||||
clientLog.summaryGenerated(meetingId, summary.model_version);
|
||||
return summary;
|
||||
} catch (error) {
|
||||
clientLog.summaryFailed(meetingId, extractErrorMessage(error, 'Summary generation failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
35
client/src/api/tauri-adapter/sections/observability.ts
Normal file
35
client/src/api/tauri-adapter/sections/observability.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
ConnectionDiagnostics,
|
||||
GetPerformanceMetricsRequest,
|
||||
GetPerformanceMetricsResponse,
|
||||
GetRecentLogsRequest,
|
||||
GetRecentLogsResponse,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createObservabilityApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'getRecentLogs' | 'getPerformanceMetrics' | 'runConnectionDiagnostics'
|
||||
> {
|
||||
return {
|
||||
async getRecentLogs(request?: GetRecentLogsRequest): Promise<GetRecentLogsResponse> {
|
||||
return invoke<GetRecentLogsResponse>(TauriCommands.GET_RECENT_LOGS, {
|
||||
limit: request?.limit,
|
||||
level: request?.level,
|
||||
source: request?.source,
|
||||
});
|
||||
},
|
||||
async getPerformanceMetrics(
|
||||
request?: GetPerformanceMetricsRequest
|
||||
): Promise<GetPerformanceMetricsResponse> {
|
||||
return invoke<GetPerformanceMetricsResponse>(TauriCommands.GET_PERFORMANCE_METRICS, {
|
||||
history_limit: request?.history_limit,
|
||||
});
|
||||
},
|
||||
async runConnectionDiagnostics(): Promise<ConnectionDiagnostics> {
|
||||
return invoke<ConnectionDiagnostics>(TauriCommands.RUN_CONNECTION_DIAGNOSTICS);
|
||||
},
|
||||
};
|
||||
}
|
||||
76
client/src/api/tauri-adapter/sections/oidc.ts
Normal file
76
client/src/api/tauri-adapter/sections/oidc.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
DeleteOidcProviderResponse,
|
||||
ListOidcPresetsResponse,
|
||||
ListOidcProvidersResponse,
|
||||
OidcProviderApi,
|
||||
RefreshOidcDiscoveryResponse,
|
||||
RegisterOidcProviderRequest,
|
||||
UpdateOidcProviderRequest,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createOidcApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'registerOidcProvider'
|
||||
| 'listOidcProviders'
|
||||
| 'getOidcProvider'
|
||||
| 'updateOidcProvider'
|
||||
| 'deleteOidcProvider'
|
||||
| 'refreshOidcDiscovery'
|
||||
| 'testOidcConnection'
|
||||
| 'listOidcPresets'
|
||||
> {
|
||||
return {
|
||||
async registerOidcProvider(request: RegisterOidcProviderRequest): Promise<OidcProviderApi> {
|
||||
return invoke<OidcProviderApi>(TauriCommands.REGISTER_OIDC_PROVIDER, { request });
|
||||
},
|
||||
|
||||
async listOidcProviders(
|
||||
workspaceId?: string,
|
||||
enabledOnly?: boolean
|
||||
): Promise<ListOidcProvidersResponse> {
|
||||
return invoke<ListOidcProvidersResponse>(TauriCommands.LIST_OIDC_PROVIDERS, {
|
||||
workspace_id: workspaceId,
|
||||
enabled_only: enabledOnly ?? false,
|
||||
});
|
||||
},
|
||||
|
||||
async getOidcProvider(providerId: string): Promise<OidcProviderApi> {
|
||||
return invoke<OidcProviderApi>(TauriCommands.GET_OIDC_PROVIDER, {
|
||||
provider_id: providerId,
|
||||
});
|
||||
},
|
||||
|
||||
async updateOidcProvider(request: UpdateOidcProviderRequest): Promise<OidcProviderApi> {
|
||||
return invoke<OidcProviderApi>(TauriCommands.UPDATE_OIDC_PROVIDER, { request });
|
||||
},
|
||||
|
||||
async deleteOidcProvider(providerId: string): Promise<DeleteOidcProviderResponse> {
|
||||
return invoke<DeleteOidcProviderResponse>(TauriCommands.DELETE_OIDC_PROVIDER, {
|
||||
provider_id: providerId,
|
||||
});
|
||||
},
|
||||
|
||||
async refreshOidcDiscovery(
|
||||
providerId?: string,
|
||||
workspaceId?: string
|
||||
): Promise<RefreshOidcDiscoveryResponse> {
|
||||
return invoke<RefreshOidcDiscoveryResponse>(TauriCommands.REFRESH_OIDC_DISCOVERY, {
|
||||
provider_id: providerId,
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
},
|
||||
|
||||
async testOidcConnection(providerId: string): Promise<RefreshOidcDiscoveryResponse> {
|
||||
return invoke<RefreshOidcDiscoveryResponse>(TauriCommands.TEST_OIDC_CONNECTION, {
|
||||
provider_id: providerId,
|
||||
});
|
||||
},
|
||||
|
||||
async listOidcPresets(): Promise<ListOidcPresetsResponse> {
|
||||
return invoke<ListOidcPresetsResponse>(TauriCommands.LIST_OIDC_PRESETS);
|
||||
},
|
||||
};
|
||||
}
|
||||
27
client/src/api/tauri-adapter/sections/playback.ts
Normal file
27
client/src/api/tauri-adapter/sections/playback.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PlaybackInfo } from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createPlaybackApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'startPlayback' | 'pausePlayback' | 'stopPlayback' | 'seekPlayback' | 'getPlaybackState'
|
||||
> {
|
||||
return {
|
||||
async startPlayback(meetingId: string, startTime?: number): Promise<void> {
|
||||
await invoke(TauriCommands.START_PLAYBACK, { meeting_id: meetingId, start_time: startTime });
|
||||
},
|
||||
async pausePlayback(): Promise<void> {
|
||||
await invoke(TauriCommands.PAUSE_PLAYBACK);
|
||||
},
|
||||
async stopPlayback(): Promise<void> {
|
||||
await invoke(TauriCommands.STOP_PLAYBACK);
|
||||
},
|
||||
async seekPlayback(position: number): Promise<PlaybackInfo> {
|
||||
return invoke<PlaybackInfo>(TauriCommands.SEEK_PLAYBACK, { position });
|
||||
},
|
||||
async getPlaybackState(): Promise<PlaybackInfo> {
|
||||
return invoke<PlaybackInfo>(TauriCommands.GET_PLAYBACK_STATE);
|
||||
},
|
||||
};
|
||||
}
|
||||
34
client/src/api/tauri-adapter/sections/preferences.ts
Normal file
34
client/src/api/tauri-adapter/sections/preferences.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { UserPreferences } from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { addClientLog } from '@/lib/client-logs';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createPreferencesApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'getPreferences' | 'savePreferences'
|
||||
> {
|
||||
return {
|
||||
async getPreferences(): Promise<UserPreferences> {
|
||||
addClientLog({
|
||||
level: 'debug',
|
||||
source: 'api',
|
||||
message: 'TauriAdapter.getPreferences: calling invoke',
|
||||
});
|
||||
const prefs = await invoke<UserPreferences>(TauriCommands.GET_PREFERENCES);
|
||||
addClientLog({
|
||||
level: 'debug',
|
||||
source: 'api',
|
||||
message: 'TauriAdapter.getPreferences: received',
|
||||
metadata: {
|
||||
input: prefs.audio_devices?.input_device_id ? 'SET' : 'UNSET',
|
||||
output: prefs.audio_devices?.output_device_id ? 'SET' : 'UNSET',
|
||||
},
|
||||
});
|
||||
return prefs;
|
||||
},
|
||||
async savePreferences(preferences: UserPreferences): Promise<void> {
|
||||
await invoke(TauriCommands.SAVE_PREFERENCES, { preferences });
|
||||
},
|
||||
};
|
||||
}
|
||||
142
client/src/api/tauri-adapter/sections/projects.ts
Normal file
142
client/src/api/tauri-adapter/sections/projects.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
AddProjectMemberRequest,
|
||||
GetActiveProjectRequest,
|
||||
GetActiveProjectResponse,
|
||||
GetProjectBySlugRequest,
|
||||
GetProjectRequest,
|
||||
ListProjectMembersRequest,
|
||||
ListProjectMembersResponse,
|
||||
ListProjectsRequest,
|
||||
ListProjectsResponse,
|
||||
Project,
|
||||
ProjectMembership,
|
||||
RemoveProjectMemberRequest,
|
||||
RemoveProjectMemberResponse,
|
||||
SetActiveProjectRequest,
|
||||
UpdateProjectMemberRoleRequest,
|
||||
UpdateProjectRequest,
|
||||
CreateProjectRequest,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { normalizeSuccessResponse } from '../../helpers';
|
||||
import type { TauriInvoke } from '../types';
|
||||
import { normalizeProjectId } from '../utils';
|
||||
|
||||
export function createProjectApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'createProject'
|
||||
| 'getProject'
|
||||
| 'getProjectBySlug'
|
||||
| 'listProjects'
|
||||
| 'updateProject'
|
||||
| 'archiveProject'
|
||||
| 'restoreProject'
|
||||
| 'deleteProject'
|
||||
| 'setActiveProject'
|
||||
| 'getActiveProject'
|
||||
| 'addProjectMember'
|
||||
| 'updateProjectMemberRole'
|
||||
| 'removeProjectMember'
|
||||
| 'listProjectMembers'
|
||||
> {
|
||||
return {
|
||||
async createProject(request: CreateProjectRequest): Promise<Project> {
|
||||
return invoke<Project>(TauriCommands.CREATE_PROJECT, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async getProject(request: GetProjectRequest): Promise<Project> {
|
||||
return invoke<Project>(TauriCommands.GET_PROJECT, {
|
||||
project_id: request.project_id,
|
||||
});
|
||||
},
|
||||
|
||||
async getProjectBySlug(request: GetProjectBySlugRequest): Promise<Project> {
|
||||
return invoke<Project>(TauriCommands.GET_PROJECT_BY_SLUG, {
|
||||
workspace_id: request.workspace_id,
|
||||
slug: request.slug,
|
||||
});
|
||||
},
|
||||
|
||||
async listProjects(request: ListProjectsRequest): Promise<ListProjectsResponse> {
|
||||
return invoke<ListProjectsResponse>(TauriCommands.LIST_PROJECTS, {
|
||||
workspace_id: request.workspace_id,
|
||||
include_archived: request.include_archived ?? false,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
});
|
||||
},
|
||||
|
||||
async updateProject(request: UpdateProjectRequest): Promise<Project> {
|
||||
return invoke<Project>(TauriCommands.UPDATE_PROJECT, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async archiveProject(projectId: string): Promise<Project> {
|
||||
return invoke<Project>(TauriCommands.ARCHIVE_PROJECT, {
|
||||
project_id: projectId,
|
||||
});
|
||||
},
|
||||
|
||||
async restoreProject(projectId: string): Promise<Project> {
|
||||
return invoke<Project>(TauriCommands.RESTORE_PROJECT, {
|
||||
project_id: projectId,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteProject(projectId: string): Promise<boolean> {
|
||||
const response = await invoke<{ success: boolean }>(TauriCommands.DELETE_PROJECT, {
|
||||
project_id: projectId,
|
||||
});
|
||||
return normalizeSuccessResponse(response);
|
||||
},
|
||||
|
||||
async setActiveProject(request: SetActiveProjectRequest): Promise<void> {
|
||||
await invoke(TauriCommands.SET_ACTIVE_PROJECT, {
|
||||
workspace_id: request.workspace_id,
|
||||
project_id: normalizeProjectId(request.project_id),
|
||||
});
|
||||
},
|
||||
|
||||
async getActiveProject(request: GetActiveProjectRequest): Promise<GetActiveProjectResponse> {
|
||||
return invoke<GetActiveProjectResponse>(TauriCommands.GET_ACTIVE_PROJECT, {
|
||||
workspace_id: request.workspace_id,
|
||||
});
|
||||
},
|
||||
|
||||
async addProjectMember(request: AddProjectMemberRequest): Promise<ProjectMembership> {
|
||||
return invoke<ProjectMembership>(TauriCommands.ADD_PROJECT_MEMBER, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async updateProjectMemberRole(
|
||||
request: UpdateProjectMemberRoleRequest
|
||||
): Promise<ProjectMembership> {
|
||||
return invoke<ProjectMembership>(TauriCommands.UPDATE_PROJECT_MEMBER_ROLE, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async removeProjectMember(
|
||||
request: RemoveProjectMemberRequest
|
||||
): Promise<RemoveProjectMemberResponse> {
|
||||
return invoke<RemoveProjectMemberResponse>(TauriCommands.REMOVE_PROJECT_MEMBER, {
|
||||
request,
|
||||
});
|
||||
},
|
||||
|
||||
async listProjectMembers(
|
||||
request: ListProjectMembersRequest
|
||||
): Promise<ListProjectMembersResponse> {
|
||||
return invoke<ListProjectMembersResponse>(TauriCommands.LIST_PROJECT_MEMBERS, {
|
||||
project_id: request.project_id,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
132
client/src/api/tauri-adapter/sections/summarization.ts
Normal file
132
client/src/api/tauri-adapter/sections/summarization.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
ArchiveSummarizationTemplateRequest,
|
||||
CreateSummarizationTemplateRequest,
|
||||
GetSummarizationTemplateRequest,
|
||||
GetSummarizationTemplateResponse,
|
||||
ListSummarizationTemplateVersionsRequest,
|
||||
ListSummarizationTemplateVersionsResponse,
|
||||
ListSummarizationTemplatesRequest,
|
||||
ListSummarizationTemplatesResponse,
|
||||
RestoreSummarizationTemplateVersionRequest,
|
||||
SummarizationTemplate,
|
||||
SummarizationTemplateMutationResponse,
|
||||
UpdateSummarizationTemplateRequest,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createSummarizationApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'listSummarizationTemplates'
|
||||
| 'getSummarizationTemplate'
|
||||
| 'createSummarizationTemplate'
|
||||
| 'updateSummarizationTemplate'
|
||||
| 'archiveSummarizationTemplate'
|
||||
| 'listSummarizationTemplateVersions'
|
||||
| 'restoreSummarizationTemplateVersion'
|
||||
| 'grantCloudConsent'
|
||||
| 'revokeCloudConsent'
|
||||
| 'getCloudConsentStatus'
|
||||
> {
|
||||
return {
|
||||
async listSummarizationTemplates(
|
||||
request: ListSummarizationTemplatesRequest
|
||||
): Promise<ListSummarizationTemplatesResponse> {
|
||||
return invoke<ListSummarizationTemplatesResponse>(
|
||||
TauriCommands.LIST_SUMMARIZATION_TEMPLATES,
|
||||
{
|
||||
workspace_id: request.workspace_id,
|
||||
include_system: request.include_system ?? true,
|
||||
include_archived: request.include_archived ?? false,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async getSummarizationTemplate(
|
||||
request: GetSummarizationTemplateRequest
|
||||
): Promise<GetSummarizationTemplateResponse> {
|
||||
return invoke<GetSummarizationTemplateResponse>(TauriCommands.GET_SUMMARIZATION_TEMPLATE, {
|
||||
template_id: request.template_id,
|
||||
include_current_version: request.include_current_version ?? true,
|
||||
});
|
||||
},
|
||||
|
||||
async createSummarizationTemplate(
|
||||
request: CreateSummarizationTemplateRequest
|
||||
): Promise<SummarizationTemplateMutationResponse> {
|
||||
return invoke<SummarizationTemplateMutationResponse>(
|
||||
TauriCommands.CREATE_SUMMARIZATION_TEMPLATE,
|
||||
{
|
||||
workspace_id: request.workspace_id,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
content: request.content,
|
||||
change_note: request.change_note,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async updateSummarizationTemplate(
|
||||
request: UpdateSummarizationTemplateRequest
|
||||
): Promise<SummarizationTemplateMutationResponse> {
|
||||
return invoke<SummarizationTemplateMutationResponse>(
|
||||
TauriCommands.UPDATE_SUMMARIZATION_TEMPLATE,
|
||||
{
|
||||
template_id: request.template_id,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
content: request.content,
|
||||
change_note: request.change_note,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async archiveSummarizationTemplate(
|
||||
request: ArchiveSummarizationTemplateRequest
|
||||
): Promise<SummarizationTemplate> {
|
||||
return invoke<SummarizationTemplate>(TauriCommands.ARCHIVE_SUMMARIZATION_TEMPLATE, {
|
||||
template_id: request.template_id,
|
||||
});
|
||||
},
|
||||
|
||||
async listSummarizationTemplateVersions(
|
||||
request: ListSummarizationTemplateVersionsRequest
|
||||
): Promise<ListSummarizationTemplateVersionsResponse> {
|
||||
return invoke<ListSummarizationTemplateVersionsResponse>(
|
||||
TauriCommands.LIST_SUMMARIZATION_TEMPLATE_VERSIONS,
|
||||
{
|
||||
template_id: request.template_id,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async restoreSummarizationTemplateVersion(
|
||||
request: RestoreSummarizationTemplateVersionRequest
|
||||
): Promise<SummarizationTemplate> {
|
||||
return invoke<SummarizationTemplate>(TauriCommands.RESTORE_SUMMARIZATION_TEMPLATE_VERSION, {
|
||||
template_id: request.template_id,
|
||||
version_id: request.version_id,
|
||||
});
|
||||
},
|
||||
|
||||
async grantCloudConsent(): Promise<void> {
|
||||
await invoke(TauriCommands.GRANT_CLOUD_CONSENT);
|
||||
clientLog.cloudConsentGranted();
|
||||
},
|
||||
async revokeCloudConsent(): Promise<void> {
|
||||
await invoke(TauriCommands.REVOKE_CLOUD_CONSENT);
|
||||
clientLog.cloudConsentRevoked();
|
||||
},
|
||||
async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> {
|
||||
return invoke<{ consent_granted: boolean }>(TauriCommands.GET_CLOUD_CONSENT_STATUS).then(
|
||||
(r) => ({ consentGranted: r.consent_granted })
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
38
client/src/api/tauri-adapter/sections/triggers.ts
Normal file
38
client/src/api/tauri-adapter/sections/triggers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Meeting, TriggerStatus } from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
export function createTriggerApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
| 'setTriggerEnabled'
|
||||
| 'snoozeTriggers'
|
||||
| 'resetSnooze'
|
||||
| 'getTriggerStatus'
|
||||
| 'dismissTrigger'
|
||||
| 'acceptTrigger'
|
||||
> {
|
||||
return {
|
||||
async setTriggerEnabled(enabled: boolean): Promise<void> {
|
||||
await invoke(TauriCommands.SET_TRIGGER_ENABLED, { enabled });
|
||||
},
|
||||
async snoozeTriggers(minutes?: number): Promise<void> {
|
||||
await invoke(TauriCommands.SNOOZE_TRIGGERS, { minutes });
|
||||
clientLog.triggersSnoozed(minutes);
|
||||
},
|
||||
async resetSnooze(): Promise<void> {
|
||||
await invoke(TauriCommands.RESET_SNOOZE);
|
||||
clientLog.triggerSnoozeCleared();
|
||||
},
|
||||
async getTriggerStatus(): Promise<TriggerStatus> {
|
||||
return invoke<TriggerStatus>(TauriCommands.GET_TRIGGER_STATUS);
|
||||
},
|
||||
async dismissTrigger(): Promise<void> {
|
||||
await invoke(TauriCommands.DISMISS_TRIGGER);
|
||||
},
|
||||
async acceptTrigger(title?: string): Promise<Meeting> {
|
||||
return invoke<Meeting>(TauriCommands.ACCEPT_TRIGGER, { title });
|
||||
},
|
||||
};
|
||||
}
|
||||
76
client/src/api/tauri-adapter/sections/webhooks.ts
Normal file
76
client/src/api/tauri-adapter/sections/webhooks.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
DeleteWebhookResponse,
|
||||
GetWebhookDeliveriesResponse,
|
||||
ListWebhooksResponse,
|
||||
RegisteredWebhook,
|
||||
RegisterWebhookRequest,
|
||||
UpdateWebhookRequest,
|
||||
} from '../../types';
|
||||
import type { NoteFlowAPI } from '../../interface';
|
||||
import { TauriCommands } from '../../tauri-constants';
|
||||
import { clientLog } from '@/lib/client-log-events';
|
||||
import type { TauriInvoke } from '../types';
|
||||
|
||||
function sanitizeWebhookRequest<T extends {
|
||||
url?: string;
|
||||
name?: string;
|
||||
secret?: string;
|
||||
events?: unknown[];
|
||||
}>(request: T): T {
|
||||
const url = request.url?.trim();
|
||||
if (request.url !== undefined && !url) {
|
||||
throw new Error('Webhook URL is required.');
|
||||
}
|
||||
if (request.events !== undefined && request.events.length === 0) {
|
||||
throw new Error('Webhook events are required.');
|
||||
}
|
||||
const name = request.name?.trim();
|
||||
const secret = request.secret?.trim();
|
||||
return {
|
||||
...request,
|
||||
...(url ? { url } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(secret ? { secret } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createWebhookApi(invoke: TauriInvoke): Pick<
|
||||
NoteFlowAPI,
|
||||
'registerWebhook' | 'listWebhooks' | 'updateWebhook' | 'deleteWebhook' | 'getWebhookDeliveries'
|
||||
> {
|
||||
return {
|
||||
async registerWebhook(r: RegisterWebhookRequest): Promise<RegisteredWebhook> {
|
||||
const request = sanitizeWebhookRequest(r);
|
||||
const webhook = await invoke<RegisteredWebhook>(TauriCommands.REGISTER_WEBHOOK, {
|
||||
request,
|
||||
});
|
||||
clientLog.webhookRegistered(webhook.id, webhook.name);
|
||||
return webhook;
|
||||
},
|
||||
async listWebhooks(enabledOnly?: boolean): Promise<ListWebhooksResponse> {
|
||||
return invoke<ListWebhooksResponse>(TauriCommands.LIST_WEBHOOKS, {
|
||||
enabled_only: enabledOnly ?? false,
|
||||
});
|
||||
},
|
||||
async updateWebhook(r: UpdateWebhookRequest): Promise<RegisteredWebhook> {
|
||||
const request = sanitizeWebhookRequest(r);
|
||||
return invoke<RegisteredWebhook>(TauriCommands.UPDATE_WEBHOOK, { request });
|
||||
},
|
||||
async deleteWebhook(webhookId: string): Promise<DeleteWebhookResponse> {
|
||||
const response = await invoke<DeleteWebhookResponse>(TauriCommands.DELETE_WEBHOOK, {
|
||||
webhook_id: webhookId,
|
||||
});
|
||||
clientLog.webhookDeleted(webhookId);
|
||||
return response;
|
||||
},
|
||||
async getWebhookDeliveries(
|
||||
webhookId: string,
|
||||
limit?: number
|
||||
): Promise<GetWebhookDeliveriesResponse> {
|
||||
return invoke<GetWebhookDeliveriesResponse>(TauriCommands.GET_WEBHOOK_DELIVERIES, {
|
||||
webhook_id: webhookId,
|
||||
limit: limit ?? 50,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
273
client/src/api/tauri-adapter/stream.ts
Normal file
273
client/src/api/tauri-adapter/stream.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { TranscriptUpdate } from '../types';
|
||||
import { Timing } from '../constants';
|
||||
import { TauriCommands, TauriEvents } from '../tauri-constants';
|
||||
import { extractErrorMessage } from '../helpers';
|
||||
import { addClientLog } from '@/lib/client-logs';
|
||||
import { errorLog } from '@/lib/debug';
|
||||
import { StreamingQueue } from '@/lib/async-utils';
|
||||
import type { AudioChunk } from '../types';
|
||||
import type { CongestionCallback, StreamErrorCallback, TauriInvoke, TauriListen } from './types';
|
||||
|
||||
const logError = errorLog('TauriTranscriptionStream');
|
||||
|
||||
/** Consecutive failure threshold before emitting stream error. */
|
||||
export const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
||||
|
||||
/** Threshold in milliseconds before showing buffering indicator (2 seconds). */
|
||||
export const CONGESTION_DISPLAY_THRESHOLD_MS = Timing.TWO_SECONDS_MS;
|
||||
|
||||
/** Real-time transcription stream using Tauri events. */
|
||||
export class TauriTranscriptionStream {
|
||||
private unlistenFn: (() => void) | null = null;
|
||||
private healthUnlistenFn: (() => void) | null = null;
|
||||
private healthListenerPending = false;
|
||||
private errorCallback: StreamErrorCallback | null = null;
|
||||
private congestionCallback: CongestionCallback | null = null;
|
||||
|
||||
/** Latest ack_sequence received from server (for debugging/monitoring). */
|
||||
private lastAckedSequence = 0;
|
||||
|
||||
/** Timestamp when congestion started (null if not congested). */
|
||||
private congestionStartTime: number | null = null;
|
||||
|
||||
/** Whether buffering indicator is currently shown. */
|
||||
private isShowingBuffering = false;
|
||||
|
||||
/** Whether the stream has been closed (prevents late listeners). */
|
||||
private isClosed = false;
|
||||
|
||||
/** Queue for ordered, backpressure-aware chunk transmission. */
|
||||
private readonly sendQueue: StreamingQueue;
|
||||
private readonly drainTimeoutMs = 5000;
|
||||
|
||||
constructor(
|
||||
private meetingId: string,
|
||||
private invoke: TauriInvoke,
|
||||
private listen: TauriListen
|
||||
) {
|
||||
this.sendQueue = new StreamingQueue({
|
||||
label: `audio-stream-${meetingId}`,
|
||||
maxDepth: 50, // ~5 seconds of audio at 100ms chunks
|
||||
failureThreshold: CONSECUTIVE_FAILURE_THRESHOLD,
|
||||
onThresholdReached: (failures) => {
|
||||
if (this.errorCallback) {
|
||||
this.errorCallback({
|
||||
code: 'stream_send_failed',
|
||||
message: `Audio streaming interrupted after ${failures} consecutive failures`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onOverflow: () => {
|
||||
// Queue full = severe backpressure, notify via congestion callback
|
||||
if (this.congestionCallback) {
|
||||
this.congestionCallback({ isBuffering: true, duration: 0 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the last acknowledged chunk sequence number. */
|
||||
getLastAckedSequence(): number {
|
||||
return this.lastAckedSequence;
|
||||
}
|
||||
|
||||
/** Get current queue depth (for monitoring). */
|
||||
getQueueDepth(): number {
|
||||
return this.sendQueue.currentDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an audio chunk to the transcription service.
|
||||
*
|
||||
* Chunks are queued and sent in order with backpressure protection.
|
||||
* Returns false if the queue is full (severe backpressure).
|
||||
*/
|
||||
send(chunk: AudioChunk): boolean {
|
||||
if (this.isClosed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const args: Record<string, unknown> = {
|
||||
meeting_id: chunk.meeting_id,
|
||||
audio_data: chunk.audio_data,
|
||||
timestamp: chunk.timestamp,
|
||||
};
|
||||
if (typeof chunk.sample_rate === 'number') {
|
||||
args.sample_rate = chunk.sample_rate;
|
||||
}
|
||||
if (typeof chunk.channels === 'number') {
|
||||
args.channels = chunk.channels;
|
||||
}
|
||||
|
||||
return this.sendQueue.enqueueWithSuccessReset(async () => {
|
||||
await this.invoke(TauriCommands.SEND_AUDIO_CHUNK, args);
|
||||
});
|
||||
}
|
||||
|
||||
async onUpdate(callback: (update: TranscriptUpdate) => void): Promise<void> {
|
||||
// Clean up any existing listener to prevent memory leaks
|
||||
// (calling onUpdate() multiple times should replace, not accumulate listeners)
|
||||
if (this.unlistenFn) {
|
||||
this.unlistenFn();
|
||||
this.unlistenFn = null;
|
||||
}
|
||||
|
||||
const unlisten = await this.listen<TranscriptUpdate>(TauriEvents.TRANSCRIPT_UPDATE, (event) => {
|
||||
if (this.isClosed) {
|
||||
return;
|
||||
}
|
||||
if (event.payload.meeting_id === this.meetingId) {
|
||||
// Track latest ack_sequence for monitoring
|
||||
if (
|
||||
typeof event.payload.ack_sequence === 'number' &&
|
||||
event.payload.ack_sequence > this.lastAckedSequence
|
||||
) {
|
||||
this.lastAckedSequence = event.payload.ack_sequence;
|
||||
}
|
||||
callback(event.payload);
|
||||
}
|
||||
});
|
||||
if (this.isClosed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
this.unlistenFn = unlisten;
|
||||
}
|
||||
|
||||
/** Register callback for stream errors (connection failures, etc.). */
|
||||
onError(callback: StreamErrorCallback): void {
|
||||
this.errorCallback = callback;
|
||||
}
|
||||
|
||||
/** Register callback for congestion state updates (buffering indicator). */
|
||||
onCongestion(callback: CongestionCallback): void {
|
||||
this.congestionCallback = callback;
|
||||
// Start listening for stream_health events
|
||||
this.startHealthListener();
|
||||
}
|
||||
|
||||
/** Start listening for stream_health events from the Rust backend. */
|
||||
private startHealthListener(): void {
|
||||
if (this.isClosed) {
|
||||
return;
|
||||
}
|
||||
if (this.healthUnlistenFn || this.healthListenerPending) {
|
||||
return;
|
||||
} // Already listening or setup in progress
|
||||
|
||||
this.healthListenerPending = true;
|
||||
|
||||
this.listen<{
|
||||
meeting_id: string;
|
||||
is_congested: boolean;
|
||||
processing_delay_ms: number;
|
||||
queue_depth: number;
|
||||
congested_duration_ms: number;
|
||||
}>(TauriEvents.STREAM_HEALTH, (event) => {
|
||||
if (this.isClosed) {
|
||||
return;
|
||||
}
|
||||
if (event.payload.meeting_id !== this.meetingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { is_congested } = event.payload;
|
||||
|
||||
if (is_congested) {
|
||||
// Start tracking congestion if not already
|
||||
this.congestionStartTime ??= Date.now();
|
||||
const duration = Date.now() - this.congestionStartTime;
|
||||
|
||||
// Only show buffering after threshold is exceeded
|
||||
if (duration >= CONGESTION_DISPLAY_THRESHOLD_MS && !this.isShowingBuffering) {
|
||||
this.isShowingBuffering = true;
|
||||
this.congestionCallback?.({ isBuffering: true, duration });
|
||||
} else if (this.isShowingBuffering) {
|
||||
// Update duration while showing
|
||||
this.congestionCallback?.({ isBuffering: true, duration });
|
||||
}
|
||||
} else {
|
||||
// Congestion cleared
|
||||
if (this.isShowingBuffering) {
|
||||
this.isShowingBuffering = false;
|
||||
this.congestionCallback?.({ isBuffering: false, duration: 0 });
|
||||
}
|
||||
this.congestionStartTime = null;
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (this.isClosed) {
|
||||
unlisten();
|
||||
this.healthListenerPending = false;
|
||||
return;
|
||||
}
|
||||
this.healthUnlistenFn = unlisten;
|
||||
this.healthListenerPending = false;
|
||||
})
|
||||
.catch(() => {
|
||||
// Stream health listener failed - non-critical, monitoring degraded
|
||||
this.healthListenerPending = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream and stop recording.
|
||||
*
|
||||
* Drains the send queue first to ensure all pending chunks are transmitted,
|
||||
* then stops recording on the backend.
|
||||
*
|
||||
* Returns a Promise that resolves when the backend has confirmed the
|
||||
* recording has stopped. The Promise rejects if stopping fails.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.isClosed = true;
|
||||
|
||||
// Drain the send queue to ensure all pending chunks are transmitted
|
||||
try {
|
||||
const drainPromise = this.sendQueue.drain();
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('Queue drain timeout'));
|
||||
}, this.drainTimeoutMs);
|
||||
drainPromise.finally(() => clearTimeout(timeoutId));
|
||||
});
|
||||
await Promise.race([drainPromise, timeoutPromise]);
|
||||
} catch {
|
||||
// Queue drain failed or timed out - continue with cleanup anyway
|
||||
}
|
||||
|
||||
if (this.unlistenFn) {
|
||||
this.unlistenFn();
|
||||
this.unlistenFn = null;
|
||||
}
|
||||
if (this.healthUnlistenFn) {
|
||||
this.healthUnlistenFn();
|
||||
this.healthUnlistenFn = null;
|
||||
}
|
||||
// Reset congestion state
|
||||
this.congestionStartTime = null;
|
||||
this.isShowingBuffering = false;
|
||||
|
||||
try {
|
||||
await this.invoke(TauriCommands.STOP_RECORDING, { meeting_id: this.meetingId });
|
||||
} catch (err: unknown) {
|
||||
const message = extractErrorMessage(err, 'Failed to stop recording');
|
||||
logError('stop_recording failed', message);
|
||||
addClientLog({
|
||||
level: 'error',
|
||||
source: 'api',
|
||||
message: 'Tauri stream stop_recording failed',
|
||||
details: message,
|
||||
metadata: { context: 'tauri_stream_stop', meeting_id: this.meetingId },
|
||||
});
|
||||
// Emit error so UI can show notification
|
||||
if (this.errorCallback) {
|
||||
this.errorCallback({
|
||||
code: 'stream_close_failed',
|
||||
message: `Failed to stop recording: ${message}`,
|
||||
});
|
||||
}
|
||||
throw err; // Re-throw so callers can handle if they await
|
||||
}
|
||||
}
|
||||
}
|
||||
21
client/src/api/tauri-adapter/types.ts
Normal file
21
client/src/api/tauri-adapter/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Type-safe wrapper for Tauri's invoke function. */
|
||||
export type TauriInvoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
|
||||
/** Type-safe wrapper for Tauri's event system. */
|
||||
export type TauriListen = <T>(
|
||||
event: string,
|
||||
handler: (event: { payload: T }) => void
|
||||
) => Promise<() => void>;
|
||||
|
||||
/** Error callback type for stream errors. */
|
||||
export type StreamErrorCallback = (error: { code: string; message: string }) => void;
|
||||
|
||||
/** Congestion state for UI feedback. */
|
||||
export interface CongestionState {
|
||||
/** Whether the stream is currently showing congestion to the user. */
|
||||
isBuffering: boolean;
|
||||
/** Duration of congestion in milliseconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** Congestion callback type for stream health updates. */
|
||||
export type CongestionCallback = (state: CongestionState) => void;
|
||||
62
client/src/api/tauri-adapter/utils.ts
Normal file
62
client/src/api/tauri-adapter/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { IdentityDefaults } from '../constants';
|
||||
|
||||
const { DEFAULT_PROJECT_ID } = IdentityDefaults;
|
||||
|
||||
export const RECORDING_BLOCKED_PREFIX = 'Recording blocked by app policy';
|
||||
|
||||
export function normalizeProjectId(projectId?: string): string | undefined {
|
||||
const trimmed = projectId?.trim();
|
||||
if (!trimmed || trimmed === DEFAULT_PROJECT_ID) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function normalizeProjectIds(projectIds: string[]): string[] {
|
||||
return projectIds
|
||||
.map((projectId) => projectId.trim())
|
||||
.filter((projectId) => projectId && projectId !== DEFAULT_PROJECT_ID);
|
||||
}
|
||||
|
||||
export function recordingBlockedDetails(error: unknown): {
|
||||
ruleId?: string;
|
||||
ruleLabel?: string;
|
||||
appName?: string;
|
||||
} | null {
|
||||
let message: string;
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else if (typeof error === 'string') {
|
||||
message = error;
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(error);
|
||||
} catch {
|
||||
message = String(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.includes(RECORDING_BLOCKED_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = message.split(RECORDING_BLOCKED_PREFIX)[1] ?? '';
|
||||
const cleaned = details.replace(/^\s*:\s*/, '');
|
||||
const parts = cleaned
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const extracted: { ruleId?: string; ruleLabel?: string; appName?: string } = {};
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('rule_id=')) {
|
||||
extracted.ruleId = part.replace('rule_id=', '').trim();
|
||||
} else if (part.startsWith('rule_label=')) {
|
||||
extracted.ruleLabel = part.replace('rule_label=', '').trim();
|
||||
} else if (part.startsWith('app_name=')) {
|
||||
extracted.appName = part.replace('app_name=', '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return extracted;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ describe('TauriTranscriptionStream', () => {
|
||||
|
||||
const expectedPayload: Record<string, unknown> = {
|
||||
meeting_id: 'meeting-123',
|
||||
audio_data: expect.arrayContaining([expect.any(Number), expect.any(Number)]),
|
||||
audio_data: expect.any(Float32Array),
|
||||
timestamp: 1.5,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
|
||||
Reference in New Issue
Block a user