17 Commits
3.5 ... main

30 changed files with 5957 additions and 2256 deletions

0
.aidesigner/.gitkeep Normal file
View File

View File

@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../Speech2Text"
},
{
"path": "."
}
],
"settings": {}
}

0
.codex Normal file
View File

14
.gitignore vendored
View File

@@ -8,3 +8,17 @@ server/server/data/privatechat.sqlite-wal
server/server/data/master.key
client/dist/*
client/apple-client/WebApp/**
server/data/master.key
.vscode/extensions.json
.aidesigner/*
!.aidesigner/.gitkeep
CLAUDE.md
.mcp.json
.agents/skills/aidesigner-frontend/SKILL.md
.agents/skills/aidesigner-frontend/references/api.md
.agents/skills/aidesigner-frontend/references/frontend-rubric.md
.claude/agents/aidesigner-frontend.md
.claude/commands/aidesigner.md
.claude/skills/aidesigner-frontend/SKILL.md
.claude/skills/aidesigner-frontend/references/api.md
.claude/skills/aidesigner-frontend/references/frontend-rubric.md

View File

@@ -49,6 +49,16 @@
{
"glob": "**/*",
"input": "public"
},
{
"glob": "magick.wasm",
"input": "node_modules/@imagemagick/magick-wasm/dist",
"output": "/"
},
{
"glob": "ort-wasm-simd-threaded.jsep.*",
"input": "node_modules/@huggingface/transformers/dist",
"output": "/transformers-wasm"
}
],
"styles": [
@@ -63,8 +73,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "700kB",
"maximumError": "1MB"
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",

1763
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,23 +12,25 @@
"private": true,
"packageManager": "npm@11.10.1",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/common": "^21.2.8",
"@angular/compiler": "^21.2.8",
"@angular/core": "^21.2.8",
"@angular/forms": "^21.2.8",
"@angular/platform-browser": "^21.2.8",
"@angular/router": "^21.2.8",
"@huggingface/transformers": "^3.8.1",
"@imagemagick/magick-wasm": "^0.0.39",
"bootstrap": "^5.3.8",
"ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
},
"devDependencies": {
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.0",
"dotenv": "^17.3.1",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
"@angular/build": "^21.2.7",
"@angular/cli": "^21.2.7",
"@angular/compiler-cli": "^21.2.8",
"dotenv": "^17.4.2",
"prettier": "^3.8.2",
"typescript": "~5.9.3"
}
}

View File

@@ -1,3 +1,6 @@
window.__PRIVATECHAT_ENV__ = {
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr"
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr",
"PRIVATECHAT_CLIENT_WHISPER_MODEL": "Xenova/whisper-small",
"PRIVATECHAT_CLIENT_WHISPER_LANGUAGE": "auto",
"PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH": "/transformers-wasm/"
};

BIN
client/public/notif.mp3 Normal file

Binary file not shown.

View File

@@ -9,6 +9,10 @@ dotenv.config({ path: rootEnvPath });
const runtimeEnv = {
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
PRIVATECHAT_CLIENT_WHISPER_MODEL: process.env.PRIVATECHAT_CLIENT_WHISPER_MODEL ?? 'Xenova/whisper-small',
PRIVATECHAT_CLIENT_WHISPER_LANGUAGE: process.env.PRIVATECHAT_CLIENT_WHISPER_LANGUAGE ?? 'auto',
PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH:
process.env.PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH ?? '/transformers-wasm/',
};
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;

View File

@@ -1,5 +1,11 @@
.approval-card {
border: 1px solid var(--surface-border-soft);
border-radius: 1rem;
background: var(--panel-soft-background);
border-radius: 1.15rem;
background: var(--surface-background);
transition: border-color 160ms ease, transform 160ms ease;
}
.approval-card:hover {
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
transform: translateY(-1px);
}

View File

@@ -0,0 +1,273 @@
import { inject, Injectable } from '@angular/core';
import { ChatSessionService } from './chat-session.service';
import type { DictationLanguage } from './models';
type PrivateChatRuntimeEnv = {
PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH?: string;
PRIVATECHAT_CLIENT_WHISPER_LANGUAGE?: string;
PRIVATECHAT_CLIENT_WHISPER_MODEL?: string;
};
type AutomaticSpeechRecognitionOutput = {
text: string;
};
type AutomaticSpeechRecognitionPipeline = (
audio: Float32Array,
options?: {
chunk_length_s?: number;
stride_length_s?: number;
task?: 'transcribe';
language?: string;
},
) => Promise<AutomaticSpeechRecognitionOutput | AutomaticSpeechRecognitionOutput[]>;
type TransformersModule = {
env: {
backends: {
onnx: {
wasm?: {
wasmPaths?: string;
};
};
};
};
pipeline: (
task: string,
model: string,
options?: {
device?: 'wasm' | 'webgpu';
dtype?: 'fp32';
model_file_name?: string;
subfolder?: string;
},
) => Promise<unknown>;
};
const whisperTargetSampleRate = 16_000;
const defaultWhisperModel = 'Xenova/whisper-small';
const defaultTransformersWasmPath = '/transformers-wasm/';
const defaultChunkLengthSeconds = 30;
const defaultStrideLengthSeconds = 5;
const whisperLanguageNames: Record<DictationLanguage, string> = {
en: 'english',
fr: 'french',
es: 'spanish',
};
function readRuntimeEnv(): PrivateChatRuntimeEnv {
if (typeof window === 'undefined') {
return {};
}
return (window as typeof window & { __PRIVATECHAT_ENV__?: PrivateChatRuntimeEnv }).__PRIVATECHAT_ENV__ ?? {};
}
function resolveAudioContextConstructor(): typeof AudioContext | null {
if (typeof window === 'undefined') {
return null;
}
return window.AudioContext
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
?? null;
}
@Injectable({ providedIn: 'root' })
export class BrowserSpeechTranscriberService {
private readonly session = inject(ChatSessionService);
private readonly runtimeEnv = readRuntimeEnv();
private readonly modelId = this.runtimeEnv.PRIVATECHAT_CLIENT_WHISPER_MODEL?.trim() || defaultWhisperModel;
private readonly fallbackLanguage = this.normalizeLanguage(
this.runtimeEnv.PRIVATECHAT_CLIENT_WHISPER_LANGUAGE,
);
private transformersModulePromise: Promise<TransformersModule> | null = null;
private pipelinePromise: Promise<AutomaticSpeechRecognitionPipeline> | null = null;
async preload(): Promise<void> {
await this.getPipeline();
}
async transcribe(audioBlob: Blob): Promise<string> {
if (audioBlob.size === 0) {
return '';
}
const waveform = await this.decodeToWhisperWaveform(audioBlob);
const transcriber = await this.getPipeline();
const inputLanguage = this.session.currentUser()
? this.session.dictationLanguage()
: this.resolveFallbackInputLanguage();
const output = await transcriber(waveform, {
chunk_length_s: defaultChunkLengthSeconds,
stride_length_s: defaultStrideLengthSeconds,
task: 'transcribe',
language: whisperLanguageNames[inputLanguage],
});
const transcription = Array.isArray(output) ? output[0] : output;
return transcription.text.trim();
}
private async getPipeline(): Promise<AutomaticSpeechRecognitionPipeline> {
if (!this.pipelinePromise) {
this.pipelinePromise = this.createPreferredPipeline<AutomaticSpeechRecognitionPipeline>(
'automatic-speech-recognition',
this.modelId,
);
}
return await this.pipelinePromise!;
}
private async getTransformersModule(): Promise<TransformersModule> {
if (!this.transformersModulePromise) {
this.transformersModulePromise = import('@huggingface/transformers') as Promise<TransformersModule>;
}
const transformersModule = await this.transformersModulePromise;
const onnxWasmEnv = transformersModule.env.backends.onnx.wasm;
if (onnxWasmEnv && !onnxWasmEnv.wasmPaths) {
onnxWasmEnv.wasmPaths =
this.runtimeEnv.PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH?.trim() || defaultTransformersWasmPath;
}
return transformersModule;
}
private async createPreferredPipeline<T>(
task: string,
model: string,
options?: {
dtype?: 'fp32';
model_file_name?: string;
subfolder?: string;
},
): Promise<T> {
const transformersModule = await this.getTransformersModule();
const candidateDevices: Array<'webgpu' | 'wasm'> = this.browserSupportsWebGpu()
? ['webgpu', 'wasm']
: ['wasm'];
let lastError: unknown = null;
for (const device of candidateDevices) {
try {
const pipeline = await transformersModule.pipeline(task, model, {
...options,
device,
});
console.info(`[dictation] Loaded ${task} pipeline for ${model} on ${device}.`);
return pipeline as T;
} catch (error) {
lastError = error;
console.warn(`[dictation] Could not load ${task} pipeline for ${model} on ${device}.`, error);
}
}
throw lastError instanceof Error ? lastError : new Error(`Could not load ${task} pipeline for ${model}.`);
}
private async decodeToWhisperWaveform(audioBlob: Blob): Promise<Float32Array> {
const audioContextConstructor = resolveAudioContextConstructor();
if (!audioContextConstructor) {
throw new Error('This browser cannot decode recorded audio for dictation.');
}
const arrayBuffer = await audioBlob.arrayBuffer();
const audioContext = new audioContextConstructor();
try {
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
const monoChannel = this.mixToMono(audioBuffer);
if (audioBuffer.sampleRate === whisperTargetSampleRate) {
return monoChannel;
}
return this.resampleMonoChannel(monoChannel, audioBuffer.sampleRate, whisperTargetSampleRate);
} catch (error) {
throw error instanceof Error
? error
: new Error('Could not decode the recorded dictation audio.');
} finally {
await audioContext.close().catch(() => undefined);
}
}
private mixToMono(audioBuffer: AudioBuffer): Float32Array {
const mixed = new Float32Array(audioBuffer.length);
for (let channelIndex = 0; channelIndex < audioBuffer.numberOfChannels; channelIndex += 1) {
const channel = audioBuffer.getChannelData(channelIndex);
for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {
mixed[sampleIndex] += channel[sampleIndex];
}
}
if (audioBuffer.numberOfChannels > 1) {
for (let sampleIndex = 0; sampleIndex < mixed.length; sampleIndex += 1) {
mixed[sampleIndex] /= audioBuffer.numberOfChannels;
}
}
return mixed;
}
private resampleMonoChannel(
monoChannel: Float32Array,
sourceSampleRate: number,
targetSampleRate: number,
): Float32Array {
if (sourceSampleRate === targetSampleRate) {
return monoChannel;
}
const targetLength = Math.max(1, Math.round(monoChannel.length * targetSampleRate / sourceSampleRate));
const resampled = new Float32Array(targetLength);
const positionRatio = sourceSampleRate / targetSampleRate;
for (let sampleIndex = 0; sampleIndex < targetLength; sampleIndex += 1) {
const sourcePosition = sampleIndex * positionRatio;
const sourceIndex = Math.floor(sourcePosition);
const nextSourceIndex = Math.min(sourceIndex + 1, monoChannel.length - 1);
const interpolationWeight = sourcePosition - sourceIndex;
const currentValue = monoChannel[sourceIndex] ?? 0;
const nextValue = monoChannel[nextSourceIndex] ?? currentValue;
resampled[sampleIndex] = currentValue + ((nextValue - currentValue) * interpolationWeight);
}
return resampled;
}
private normalizeLanguage(language: string | undefined): string | null {
const trimmedLanguage = language?.trim();
if (!trimmedLanguage || trimmedLanguage.toLowerCase() === 'auto') {
return null;
}
return trimmedLanguage;
}
private browserSupportsWebGpu(): boolean {
return typeof navigator !== 'undefined' && 'gpu' in navigator;
}
private resolveFallbackInputLanguage(): DictationLanguage {
switch (this.fallbackLanguage?.toLowerCase()) {
case 'french':
case 'fr':
return 'fr';
case 'spanish':
case 'es':
return 'es';
default:
return 'en';
}
}
}

View File

@@ -40,12 +40,129 @@
</div>
}
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
@if (conversationModalOpen()) {
<div class="conversation-modal-backdrop" (click)="closeConversationModal()">
<section class="conversation-modal panel p-3 p-lg-4" (click)="$event.stopPropagation()">
<header class="conversation-modal-header">
<div>
<a class="back-link" routerLink="/">← Back to dashboard</a>
<p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p>
<h2 class="h5 mb-0">{{ displayedPeer()?.displayName ?? 'Conversation' }}</h2>
</div>
<button
class="conversation-modal-close"
type="button"
(click)="closeConversationModal()"
aria-label="Close fullscreen conversation"
>
×
</button>
</header>
<div #fullscreenConversationContainer class="conversation conversation-modal-body">
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
</div>
</section>
</div>
}
<div class="chat-header mb-4">
@if (currentUser(); as connectedUser) {
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
<div class="status-indicators mt-2">
<div class="chat-header-main">
<a class="back-link" routerLink="/" aria-label="Back to dashboard"></a>
<h1 class="chat-header-title mb-0">{{ connectedUser.displayName }}</h1>
@if (displayedPeer(); as selectedPeer) {
<div class="peer-dropdown" (click)="$event.stopPropagation()">
<button
class="peer-dropdown-trigger peer-tile"
type="button"
[class.peer-tile-active]="true"
[class.peer-tile-unread]="isPeerUnread(selectedPeer.id)"
(click)="togglePeerDropdown()"
[attr.aria-expanded]="peerDropdownOpen()"
aria-haspopup="listbox"
aria-label="Choose peer"
>
<span class="peer-tile-main text-start">
<span class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ selectedPeer.displayName }}</span>
@if (isPeerTyping(selectedPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing">
<span></span>
<span></span>
<span></span>
</span>
}
</span>
<span class="peer-tile-indicators">
<span
class="status-led peer-tile-status"
[class.status-led-ok]="selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'"
[class.status-led-offline]="selectedPeer.channelState !== 'open' && selectedPeer.connectionState !== 'connected'"
[attr.aria-label]="
selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
></span>
<span class="peer-dropdown-caret" [class.peer-dropdown-caret-open]="peerDropdownOpen()"></span>
</span>
</span>
</span>
</button>
@if (peerDropdownOpen()) {
<div class="peer-dropdown-menu" role="listbox">
@for (dropdownPeer of dropdownPeers(); track dropdownPeer.id) {
<article
class="peer-tile"
[class.peer-tile-active]="dropdownPeer.id === peerId()"
[class.peer-tile-unread]="isPeerUnread(dropdownPeer.id)"
>
<button
class="peer-tile-main text-start"
type="button"
(click)="selectPeerFromDropdown(dropdownPeer.id)"
>
<div class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ dropdownPeer.displayName }}</span>
@if (isPeerTyping(dropdownPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing">
<span></span>
<span></span>
<span></span>
</span>
}
</span>
<span
class="status-led peer-tile-status"
[class.status-led-ok]="dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'"
[class.status-led-offline]="dropdownPeer.channelState !== 'open' && dropdownPeer.connectionState !== 'connected'"
[attr.aria-label]="
dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
></span>
</div>
</button>
<button
class="peer-tile-delete"
type="button"
title="Delete conversation"
aria-label="Delete conversation"
(click)="deleteConversation(dropdownPeer.id, $event)"
>
🗑️
</button>
</article>
}
</div>
}
</div>
}
<div class="status-indicators">
<div class="status-indicator">
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
<span>Signaling</span>
@@ -61,78 +178,199 @@
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
<span>WebRTC</span>
</button>
<button
class="status-indicator status-indicator-action"
type="button"
[disabled]="conversation().length === 0"
aria-label="Open fullscreen conversation"
title="Open fullscreen conversation"
(click)="openConversationModal()"
>
<span class="expand-action-icon" aria-hidden="true"></span>
</button>
</div>
</div>
} @else {
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
}
<div class="chat-header-main">
<a class="back-link" routerLink="/" aria-label="Back to dashboard"></a>
<h1 class="chat-header-title mb-0">Not signed in</h1>
</div>
}
</div>
<div class="chat-layout">
<aside class="peer-sidebar">
<div class="peer-list">
@if (session.peers().length === 0) {
<div class="empty-chat empty-peers">
No peers are currently connected.
</div>
}
@for (connectedPeer of session.peers(); track connectedPeer.id) {
<article
class="peer-tile"
[class.peer-tile-active]="connectedPeer.id === peerId()"
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
>
<button
class="peer-tile-main text-start"
type="button"
(click)="switchPeer(connectedPeer.id)"
>
<div class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
@if (isPeerTyping(connectedPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing">
<span></span>
<span></span>
<span></span>
</span>
}
</span>
<span
class="status-led peer-tile-status"
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
[attr.aria-label]="
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
></span>
</div>
</button>
<button
class="peer-tile-delete"
type="button"
title="Delete conversation"
aria-label="Delete conversation"
(click)="deleteConversation(connectedPeer.id, $event)"
>
🗑️
</button>
</article>
}
</div>
</aside>
<div class="chat-main">
<div class="chat-main" (click)="closePeerDropdown()">
<div #conversationContainer class="conversation">
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
</div>
<div class="composer">
<textarea
#composerTextarea
class="form-control composer-textarea"
rows="2"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!peerId()"
placeholder="Write a text message to your peer, even if they are offline"
></textarea>
<div class="composer-toolbar">
<div class="composer-actions">
@if (peerId(); as selectedPeerId) {
@if (peer(); as livePeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="openCallChoice(livePeer.id)"
title="Start call"
aria-label="Start call"
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(livePeer.id)"
title="End call"
aria-label="End call"
>
🛑
</button>
}
<button
class="composer-voice"
type="button"
[disabled]="livePeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()"
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[class.composer-voice-recording]="isRecordingVoice()"
>
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
</button>
<button
class="composer-dictation"
type="button"
[disabled]="!selectedPeerId || isTranscribingDictation()"
(click)="toggleDictation(composerTextarea)"
[title]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[attr.aria-label]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
>
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
</button>
}
<input
#fileInput
class="composer-file-input"
type="file"
[disabled]="!selectedPeerId"
(change)="sendFile(selectedPeerId, fileInput)"
/>
<button
class="composer-plus"
type="button"
[disabled]="!selectedPeerId"
(click)="fileInput.click()"
title="Send file"
aria-label="Send file"
>
+
</button>
}
<button
class="composer-image-generate"
type="button"
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
(click)="requestGeneratedImage()"
title="Generate image from prompt"
aria-label="Generate image from prompt"
>
🖼️
</button>
<div class="composer-emoji-picker-shell">
@if (emojiPickerOpen()) {
<div class="composer-emoji-picker">
@for (emoji of emojiOptions; track emoji) {
<button
class="composer-emoji-option"
type="button"
(click)="insertEmoji(emoji, composerTextarea)"
[attr.aria-label]="'Insert ' + emoji"
[title]="'Insert ' + emoji"
>
{{ emoji }}
</button>
}
</div>
}
<button
class="composer-emoji-trigger"
type="button"
[disabled]="!peerId()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<button
class="send-emoji"
type="button"
[disabled]="!peerId()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</div>
@if (lastIncomingReceiveMetric(); as receiveMetric) {
<div class="composer-receive-speed" title="Receive speed of the last completed incoming WebRTC message">
<span class="composer-receive-speed-label">Rx</span>
<span class="composer-receive-speed-value">{{ receiveMetric.mbps | number: '1.2-2' }} Mbit/s</span>
</div>
}
</div>
</div>
</div>
</div>
</section>
</div>
</main>
<ng-template #conversationBubbles>
@if (conversation().length === 0) {
<div class="empty-chat">
No text messages yet. The chat page is ready as soon as the peer channel opens.
No text messages yet. Messages and files can be queued here and will send when the peer reconnects.
</div>
}
@@ -141,9 +379,11 @@
class="bubble"
[class.bubble-incoming]="entry.direction === 'incoming'"
[class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-pending]="isPendingOutgoingEntry(entry)"
[class.bubble-system]="entry.direction === 'system'"
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
>
@if (entry.direction !== 'system') {
@if (entry.direction !== 'system' && !isEmojiOnlyEntry(entry)) {
<div class="bubble-actions">
@if (isGeneratedImageEntry(entry)) {
<button
@@ -186,14 +426,19 @@
}
</div>
}
@if (!isEmojiOnlyEntry(entry)) {
<div class="bubble-meta">
<span class="bubble-author">{{ entry.authorLabel }}</span>
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
@if (isPendingOutgoingEntry(entry)) {
<span class="bubble-delivery-state">Queued</span>
}
</div>
}
@switch (entry.kind) {
@case ('text') {
<p class="mb-0">{{ entry.text }}</p>
<p class="mb-0" [class.emoji-only-text]="isEmojiOnlyEntry(entry)">{{ entry.text }}</p>
}
@case ('json') {
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
@@ -203,7 +448,7 @@
@if (isImageEntry(entry)) {
<img
class="bubble-image"
[src]="entry.downloadUrl"
[src]="imageDisplayUrl(entry)"
[alt]="entry.fileName || 'Shared image'"
/>
}
@@ -273,156 +518,4 @@
}
</article>
}
</div>
<div class="composer">
<textarea
#composerTextarea
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
></textarea>
<div class="composer-toolbar">
@if (peer(); as selectedPeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="openCallChoice(selectedPeer.id)"
title="Start call"
aria-label="Start call"
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(selectedPeer.id)"
title="End call"
aria-label="End call"
>
🛑
</button>
}
<button
class="composer-voice"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()"
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[class.composer-voice-recording]="isRecordingVoice()"
>
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
</button>
<button
class="composer-dictation"
type="button"
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
(click)="toggleDictation(composerTextarea)"
[title]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[attr.aria-label]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
>
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
</button>
<input
#fileInput
class="composer-file-input"
type="file"
[disabled]="selectedPeer.channelState !== 'open'"
(change)="sendFile(selectedPeer.id, fileInput)"
/>
<button
class="composer-plus"
type="button"
[disabled]="selectedPeer.channelState !== 'open'"
(click)="fileInput.click()"
title="Send file"
aria-label="Send file"
>
+
</button>
}
<button
class="composer-image-generate"
type="button"
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
(click)="requestGeneratedImage()"
title="Generate image from prompt"
aria-label="Generate image from prompt"
>
🖼️
</button>
<div class="composer-emoji-picker-shell">
@if (emojiPickerOpen()) {
<div class="composer-emoji-picker">
@for (emoji of emojiOptions; track emoji) {
<button
class="composer-emoji-option"
type="button"
(click)="insertEmoji(emoji, composerTextarea)"
[attr.aria-label]="'Insert ' + emoji"
[title]="'Insert ' + emoji"
>
{{ emoji }}
</button>
}
</div>
}
<button
class="composer-emoji-trigger"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</main>
</ng-template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,22 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, untracked, ViewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { BrowserSpeechTranscriberService } from './browser-speech-transcriber.service';
import { PeerCallModalComponent } from './peer-call-modal.component';
import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
type KnownPeerSummary = {
id: string;
displayName: string;
};
type DropdownPeerSummary = PeerSummary & { knownOnly: boolean };
@Component({
selector: 'app-chat-page',
imports: [
@@ -22,9 +30,14 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
styleUrl: './chat-page.component.scss',
})
export class ChatPageComponent implements OnDestroy {
private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers';
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly ngZone = inject(NgZone);
private readonly speechTranscriber = inject(BrowserSpeechTranscriberService);
private readonly routeParamMap = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
@@ -44,6 +57,7 @@ export class ChatPageComponent implements OnDestroy {
private resolveDictationCompletion: (() => void) | null = null;
private dictationApplyToken = 0;
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
private lastAutoConnectedPeerId: string | null = null;
@ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
@@ -55,26 +69,77 @@ export class ChatPageComponent implements OnDestroy {
this.conversationContainer = value;
}
private conversationContainer?: ElementRef<HTMLDivElement>;
@ViewChild('fullscreenConversationContainer')
set fullscreenConversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
this.fullscreenConversationContainer = value;
}
private fullscreenConversationContainer?: ElementRef<HTMLDivElement>;
messageText = '';
readonly forwardingEntryId = signal<string | null>(null);
readonly callChoicePeerId = signal<string | null>(null);
readonly conversationModalOpen = signal(false);
readonly peerDropdownOpen = signal(false);
readonly emojiPickerOpen = signal(false);
readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false);
readonly isTranscribingDictation = signal(false);
readonly knownPeers = signal<KnownPeerSummary[]>([]);
readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😊',
'😉', '😍', '😘', '😎', '🤔',
'😅', '😭', '😡', '😴', '🙃',
'👍', '👎', '👏', '🙏', '🤝',
'🎉', '🔥', '❤️', '💡', '',
'🚀', '👀', '📹', '📎', '💬',
'🌍', '', '', '🎵', '📷',
'🗑️', '', '🛑', '🙌', '👌',
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🫠', '🤑', '😲',
'☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧',
'😨', '😩', '🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪',
'😵', '🥴', '😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮',
'🤧', '😇', '🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐',
'🤓', '😈', '👿', '👹', '👺', '💀', '☠️', '👻', '👽', '🤖',
'💩', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾',
'🙈', '🙉', '🙊', '💋', '💌', '💘', '💝', '💖', '💗', '💓',
'💞', '💕', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
'🤎', '💔', '❤️‍🔥', '❤️‍🩹', '❣️', '💯', '💢', '💥', '💫', '💦',
'💨', '🕳️', '💬', '🗨️', '🗯️', '💭', '💤', '👋', '🤚', '🖐️',
'✋', '🖖', '🫱', '🫲', '🫳', '🫴', '👌', '🤌', '🤏', '✌️',
'🤞', '🫰', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝️',
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '🫶', '👐',
'🤲', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶',
'👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️',
'👅', '👄', '🫦', '🌍', '🌎', '🌏', '🌕', '⭐', '🌟', '✨',
'⚡', '🔥', '💧', '🌈', '☀️', '🌤️', '⛅', '🌧️', '⛈️', '🌩️',
'❄️', '☃️', '☔', '🍎', '🍊', '🍋', '🍉', '🍇', '🍓', '🍒',
'🍑', '🍍', '🥥', '🥑', '🍔', '🍕', '🌮', '🍣', '🍪', '🎂',
'☕', '🍵', '🍹', '🎉', '🎈', '🎁', '🏆', '🚀', '📷', '🎵',
];
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly dropdownPeers = computed<DropdownPeerSummary[]>(() => {
const connectedPeers = this.session.peers();
const connectedPeerIds = new Set(connectedPeers.map((peer) => peer.id));
const knownOnlyPeers = this.knownPeers()
.filter((peer) => !connectedPeerIds.has(peer.id))
.sort((left, right) => left.displayName.localeCompare(right.displayName))
.map<DropdownPeerSummary>((peer) => ({
id: peer.id,
username: peer.id,
displayName: peer.displayName,
connectionState: 'disconnected',
channelState: 'closed',
knownOnly: true,
}));
return [
...connectedPeers.map<DropdownPeerSummary>((peer) => ({ ...peer, knownOnly: false })),
...knownOnlyPeers,
];
});
readonly displayedPeer = computed<DropdownPeerSummary | null>(() => {
const selectedPeerId = this.peerId();
const peers = this.dropdownPeers();
return (selectedPeerId ? peers.find((peer) => peer.id === selectedPeerId) ?? null : null) ?? peers[0] ?? null;
});
readonly currentUser = computed(() => this.session.currentUser());
readonly callModalPeerId = computed(() =>
this.session.activeVoiceCallPeerId()
@@ -90,13 +155,18 @@ export class ChatPageComponent implements OnDestroy {
readonly callChoicePeer = computed(() => {
const peerId = this.callChoicePeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
return peerId ? this.dropdownPeers().find((peer) => peer.id === peerId) ?? null : null;
});
readonly conversation = computed(() =>
this.session
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
readonly lastIncomingReceiveMetric = computed(() => {
const metric = this.session.lastIncomingReceiveMetric();
return metric?.peerId === this.peerId() ? metric : null;
});
readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
);
@@ -206,14 +276,68 @@ export class ChatPageComponent implements OnDestroy {
void this.router.navigateByUrl('/');
}
queueMicrotask(() => {
void this.speechTranscriber.preload().catch(() => undefined);
});
effect(() => {
const currentUserId = this.currentUser()?.id ?? null;
this.knownPeers.set(this.readKnownPeers(currentUserId));
});
effect(() => {
const connectedPeers = this.session.peers();
if (connectedPeers.length === 0) {
return;
}
this.mergeKnownPeers(
connectedPeers.map((peer) => ({ id: peer.id, displayName: peer.displayName })),
);
});
effect(() => {
const currentUserId = this.currentUser()?.id ?? null;
const knownPeersFromMessages = this.session.messages()
.filter((entry) => entry.direction !== 'system' && entry.kind !== 'system')
.map((entry) => ({
id: entry.peerId,
displayName: entry.direction === 'incoming' && entry.authorLabel !== 'You'
? entry.authorLabel
: this.findKnownPeerDisplayName(entry.peerId) ?? entry.peerId,
}));
if (!currentUserId || knownPeersFromMessages.length === 0) {
return;
}
this.mergeKnownPeers(knownPeersFromMessages);
});
effect(() => {
const peerId = this.peerId();
const hasLivePeer = !!this.peer();
if (!peerId) {
this.lastAutoConnectedPeerId = null;
return;
}
this.session.selectPeer(peerId);
if (!hasLivePeer) {
if (this.lastAutoConnectedPeerId === peerId) {
this.lastAutoConnectedPeerId = null;
}
return;
}
if (this.lastAutoConnectedPeerId === peerId) {
return;
}
this.lastAutoConnectedPeerId = peerId;
void this.session.connectToPeer(peerId);
});
@@ -258,12 +382,12 @@ export class ChatPageComponent implements OnDestroy {
async ensureConnection(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
if (!peerId || !this.peer()) {
return;
}
this.session.selectPeer(peerId);
await this.session.connectToPeer(peerId);
await this.session.reconnectToPeer(peerId);
}
async sendMessage(): Promise<void> {
@@ -546,9 +670,21 @@ export class ChatPageComponent implements OnDestroy {
await this.session.deleteMessage(entry);
}
isEmojiOnlyEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'text' || entry.direction === 'system' || !entry.text?.trim()) {
return false;
}
return entry.text
.trim()
.split(/\s+/u)
.every((token) => this.isEmojiToken(token));
}
async deleteConversation(peerId: string, event?: Event): Promise<void> {
event?.stopPropagation();
await this.session.deleteConversation(peerId);
this.removeKnownPeer(peerId);
}
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
@@ -574,6 +710,33 @@ export class ChatPageComponent implements OnDestroy {
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
}
togglePeerDropdown(): void {
if (this.dropdownPeers().length === 0) {
return;
}
this.peerDropdownOpen.update((open) => !open);
}
closePeerDropdown(): void {
this.peerDropdownOpen.set(false);
}
openConversationModal(): void {
this.closePeerDropdown();
this.conversationModalOpen.set(true);
this.scrollConversationToBottom();
}
closeConversationModal(): void {
this.conversationModalOpen.set(false);
}
async selectPeerFromDropdown(peerId: string): Promise<void> {
this.closePeerDropdown();
await this.switchPeer(peerId);
}
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
if (!targetPeerId) {
return;
@@ -615,13 +778,21 @@ export class ChatPageComponent implements OnDestroy {
}
isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
return entry.kind === 'file' && !!this.imageDisplayUrl(entry);
}
isGeneratedImageEntry(entry: ChatEntry): boolean {
return this.isImageEntry(entry) && entry.generatedByAi === true;
}
imageDisplayUrl(entry: ChatEntry): string | null {
if (entry.kind !== 'file' || !(entry.fileMimeType?.startsWith('image/') ?? false)) {
return null;
}
return entry.previewDownloadUrl ?? entry.downloadUrl ?? null;
}
isVideoEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'file' || !entry.downloadUrl) {
return false;
@@ -647,6 +818,7 @@ export class ChatPageComponent implements OnDestroy {
hasDocumentPreviewImage(entry: ChatEntry): boolean {
return (
entry.kind === 'file' &&
!(entry.fileMimeType?.startsWith('image/') ?? false) &&
!!entry.previewDownloadUrl &&
(entry.previewMimeType?.startsWith('image/') ?? false)
);
@@ -668,6 +840,10 @@ export class ChatPageComponent implements OnDestroy {
return this.session.unreadPeerIds().includes(peerId);
}
isPendingOutgoingEntry(entry: ChatEntry): boolean {
return entry.direction === 'outgoing' && entry.deliveryState === 'pending';
}
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') {
return 'ok';
@@ -681,7 +857,7 @@ export class ChatPageComponent implements OnDestroy {
}
canReconnectWebRtc(): boolean {
return this.indicatorTone(this.webRtcState()) === 'offline';
return !!this.peerId() && !!this.peer() && this.indicatorTone(this.webRtcState()) !== 'ok';
}
async switchPeer(peerId: string): Promise<void> {
@@ -693,11 +869,162 @@ export class ChatPageComponent implements OnDestroy {
this.stopVoiceRecording(true);
this.forwardingEntryId.set(null);
this.callChoicePeerId.set(null);
this.conversationModalOpen.set(false);
this.peerDropdownOpen.set(false);
this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
private mergeKnownPeers(peers: Array<{ id: string; displayName?: string }>): void {
const currentUserId = this.currentUser()?.id;
if (!currentUserId) {
return;
}
const currentKnownPeers = untracked(this.knownPeers);
const nextPeers = this.normalizeKnownPeers([
...currentKnownPeers,
...peers,
], currentUserId);
if (this.areKnownPeersEqual(nextPeers, currentKnownPeers)) {
return;
}
this.knownPeers.set(nextPeers);
this.writeKnownPeers(currentUserId, nextPeers);
}
private removeKnownPeer(peerId: string): void {
const currentUserId = this.currentUser()?.id;
if (!currentUserId) {
return;
}
const currentKnownPeers = untracked(this.knownPeers);
const nextPeers = currentKnownPeers.filter((knownPeer) => knownPeer.id !== peerId);
if (nextPeers.length === currentKnownPeers.length) {
return;
}
this.knownPeers.set(nextPeers);
this.writeKnownPeers(currentUserId, nextPeers);
}
private readKnownPeers(currentUserId: string | null): KnownPeerSummary[] {
if (!currentUserId) {
return [];
}
const storedValue = this.readStorage(this.knownPeersStorageKey(currentUserId));
if (!storedValue) {
return [];
}
try {
const parsedValue = JSON.parse(storedValue);
if (!Array.isArray(parsedValue)) {
return [];
}
return this.normalizeKnownPeers(parsedValue, currentUserId);
} catch {
return [];
}
}
private writeKnownPeers(currentUserId: string, peers: KnownPeerSummary[]): void {
const storageKey = this.knownPeersStorageKey(currentUserId);
if (peers.length === 0) {
this.removeStorage(storageKey);
return;
}
this.writeStorage(storageKey, JSON.stringify(peers));
}
private normalizeKnownPeers(peers: unknown[], currentUserId: string): KnownPeerSummary[] {
const peerMap = new Map<string, KnownPeerSummary>();
for (const peer of peers) {
if (typeof peer === 'string') {
const id = peer.trim();
if (!id || id === currentUserId) {
continue;
}
peerMap.set(id, { id, displayName: id });
continue;
}
if (!peer || typeof peer !== 'object') {
continue;
}
const candidate = peer as Partial<KnownPeerSummary>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
if (!id || id === currentUserId) {
continue;
}
const displayName = typeof candidate.displayName === 'string' && candidate.displayName.trim()
? candidate.displayName.trim()
: peerMap.get(id)?.displayName ?? id;
peerMap.set(id, { id, displayName });
}
return Array.from(peerMap.values())
.sort((left, right) => left.displayName.localeCompare(right.displayName));
}
private areKnownPeersEqual(left: KnownPeerSummary[], right: KnownPeerSummary[]): boolean {
return left.length === right.length
&& left.every((peer, index) =>
peer.id === right[index]?.id && peer.displayName === right[index]?.displayName,
);
}
private findKnownPeerDisplayName(peerId: string): string | null {
return untracked(this.knownPeers).find((peer) => peer.id === peerId)?.displayName ?? null;
}
private knownPeersStorageKey(currentUserId: string): string {
return `${ChatPageComponent.knownPeersStoragePrefix}.${currentUserId}`;
}
private readStorage(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
private writeStorage(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Ignore storage errors in private browsing modes.
}
}
private removeStorage(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Ignore storage errors in private browsing modes.
}
}
private stopVoiceRecording(discard: boolean): void {
const recorder = this.voiceRecorder;
@@ -794,16 +1121,16 @@ export class ChatPageComponent implements OnDestroy {
private async transcribeDictation(blob: Blob, textarea: HTMLTextAreaElement, applyToken: number): Promise<void> {
try {
const transcript = await this.session.requestSpeechTranscription(blob);
const transcript = await this.speechTranscriber.transcribe(blob);
if (applyToken !== this.dictationApplyToken) {
return;
}
this.applyDictatedText(this.mergeDictatedText(this.dictationBaseText, transcript), textarea);
} catch {
} catch (error) {
if (applyToken === this.dictationApplyToken) {
this.session.error.set('Dictation transcription failed.');
this.session.error.set(error instanceof Error ? error.message : 'Dictation transcription failed.');
}
} finally {
if (applyToken === this.dictationApplyToken) {
@@ -874,16 +1201,34 @@ export class ChatPageComponent implements OnDestroy {
}
private scrollConversationToBottom(): void {
const container = this.conversationContainer?.nativeElement;
if (!container) {
return;
}
queueMicrotask(() => {
requestAnimationFrame(() => {
for (const container of [
this.conversationContainer?.nativeElement,
this.fullscreenConversationContainer?.nativeElement,
]) {
if (!container) {
continue;
}
container.scrollTop = container.scrollHeight;
}
});
});
}
private isEmojiToken(token: string): boolean {
if (!token) {
return false;
}
const graphemes = this.graphemeSegmenter
? Array.from(this.graphemeSegmenter.segment(token), ({ segment }) => segment)
: [token];
return graphemes.every((grapheme) =>
/[\p{Emoji}\p{Extended_Pictographic}\u20E3]/u.test(grapheme)
&& /^[\p{Emoji}\p{Emoji_Component}\p{Extended_Pictographic}\u200D\uFE0F\u20E3]+$/u.test(grapheme),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -53,22 +53,11 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
<p class="text-secondary mb-0">Use the Fastify server for authentication and peer discovery.</p>
<p class="text-secondary mb-0">Use the current browser host for authentication and peer discovery.</p>
</div>
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
</div>
<div class="mb-3">
<label class="form-label" for="serverUrl">Backend URL</label>
<input
id="serverUrl"
name="serverUrl"
class="form-control form-control-lg"
[(ngModel)]="serverUrl"
placeholder="http://localhost:3000"
/>
</div>
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
<button
class="btn"
@@ -177,49 +166,123 @@
}
} @else {
<section class="row g-4 align-items-stretch">
<div class="col-lg-5">
<div class="col-12">
<div class="panel p-4 h-100">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h3 mb-1">Connection settings</h2>
<p class="text-secondary mb-0">Manage the backend endpoint used for auth and signaling.</p>
<h2 class="h3 mb-1">Account tools</h2>
<p class="text-secondary mb-0">This session uses the current browser host for auth and signaling.</p>
</div>
@if (session.isApprovalAdmin()) {
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
}
</div>
@if (!embeddedMode) {
<label class="form-label" for="connectedServerUrl">Backend URL</label>
<div class="input-group mb-3">
<input
id="connectedServerUrl"
class="form-control"
[(ngModel)]="serverUrl"
(blur)="applyServerUrl()"
/>
<button class="btn btn-outline-secondary" type="button" (click)="applyServerUrl()">Apply</button>
</div>
} @else {
<div class="empty-state p-4 text-center text-secondary">
Backend settings are managed by the native app in embedded mode.
</div>
}
<div class="small status-pill mt-3">{{ session.status() }}</div>
<div class="small status-pill mb-3">{{ session.status() }}</div>
@if (session.error()) {
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
<div class="alert alert-danger mb-3">{{ session.error() }}</div>
}
@if (session.notice()) {
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
<div class="alert alert-success mb-4">{{ session.notice() }}</div>
}
</div>
<section class="access-key-panel mb-4">
<div class="dictation-language-panel">
<div>
<h3 class="h5 mb-1">Dictation language</h3>
<p class="small text-secondary mb-0">
Speech input and text output use the same selected language.
</p>
</div>
<div class="col-lg-7">
<div class="panel p-4 h-100">
<div class="dictation-language-select-shell mt-3">
<label class="form-label small mb-2" for="dictationLanguage">Language</label>
<select
id="dictationLanguage"
class="form-select"
[ngModel]="session.dictationLanguage()"
(ngModelChange)="setDictationLanguage($event)"
>
@for (option of dictationLanguageOptions; track option.value) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
</div>
</div>
</section>
<section class="access-key-panel mb-4">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3 class="h5 mb-1">Notifications</h3>
<p class="small text-secondary mb-0">Play a sound when any incoming message or file arrives.</p>
</div>
<div class="form-check form-switch m-0">
<input
id="incomingMessageSoundEnabled"
class="form-check-input"
type="checkbox"
[ngModel]="session.incomingMessageSoundEnabled()"
(ngModelChange)="setIncomingMessageSound($event)"
/>
<label class="form-check-label small" for="incomingMessageSoundEnabled">
{{ session.incomingMessageSoundEnabled() ? 'On' : 'Off' }}
</label>
</div>
</div>
</section>
<section class="access-key-panel user-search-panel mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Find people</h3>
<p class="small text-secondary mb-0">Search approved accounts and add them to the chat peer list.</p>
</div>
<button
class="btn btn-sm btn-outline-light"
type="button"
[disabled]="loadingKnownUsers()"
(click)="reloadKnownUsers()"
>
Refresh
</button>
</div>
<div class="user-search-shell">
<input
class="form-control"
[(ngModel)]="userSearch"
placeholder="Search by display name or username"
/>
@if (knownUsersError()) {
<div class="alert alert-danger mt-3 mb-0">{{ knownUsersError() }}</div>
} @else if (loadingKnownUsers()) {
<div class="empty-state p-3 text-center text-secondary mt-3">Loading users...</div>
} @else if (filteredKnownUsers().length === 0) {
<div class="empty-state p-3 text-center text-secondary mt-3">No matching users found.</div>
} @else {
<div class="user-search-results mt-3">
@for (user of filteredKnownUsers(); track user.id) {
<button class="user-search-result" type="button" (click)="addKnownPeer(user)">
<span class="user-search-result-copy">
<span class="fw-semibold">{{ user.displayName }}</span>
<span class="small text-secondary">@{{ user.username }}</span>
</span>
<span class="user-search-result-action">Add</span>
</button>
}
</div>
}
</div>
@if (knownUsersNotice()) {
<div class="small text-secondary mt-3">{{ knownUsersNotice() }}</div>
}
</section>
<section class="access-key-panel">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>

View File

@@ -8,144 +8,130 @@
min-height: 100dvh;
}
/* ── Panel primitives ───────────────────────────────────────────────────── */
.hero-panel,
.panel,
.session-card,
.empty-state {
border: 1px solid var(--surface-border);
background: var(--panel-background);
backdrop-filter: blur(18px);
box-shadow: 0 20px 60px var(--shadow-color);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow:
0 24px 64px var(--shadow-color),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.hero-panel {
border-radius: 2rem;
}
.panel,
.session-card {
border-radius: 1.5rem;
}
.hero-panel { border-radius: 2rem; }
.panel { border-radius: 1.75rem; }
.session-card { border-radius: 1.5rem; }
.panel-muted {
background: var(--panel-alt-background);
}
.hero-copy {
max-width: 52rem;
}
/* ── Hero header ────────────────────────────────────────────────────────── */
.hero-copy { max-width: 52rem; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
padding: 0.4rem 0.9rem;
border-radius: 999px;
margin-bottom: 1rem;
letter-spacing: 0.14em;
margin-bottom: 0.75rem;
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.72rem;
font-size: 0.68rem;
font-weight: 700;
font-family: var(--font-mono);
color: var(--accent-color);
background: var(--accent-color-soft);
border: 1px solid color-mix(in srgb, var(--accent-color) 25%, transparent);
}
.eyebrow::before {
content: '';
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--accent-color);
animation: glow-pulse 3s ease-in-out infinite;
}
/* ── Theme toggle ───────────────────────────────────────────────────────── */
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
gap: 0.5rem;
min-width: 7.5rem;
height: 3rem;
padding: 0 0.95rem;
height: 2.75rem;
padding: 0 1rem;
border: 1px solid var(--surface-border);
border-radius: 999px;
color: var(--page-text);
background: var(--panel-soft-background);
font-size: 0.95rem;
font-weight: 700;
text-transform: capitalize;
background: rgba(255, 255, 255, 0.04);
font-size: 0.88rem;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.theme-toggle-icon {
font-size: 1.25rem;
}
.theme-toggle-label {
letter-spacing: 0.03em;
}
.theme-toggle-icon { font-size: 1.15rem; }
.theme-toggle-label { letter-spacing: 0.06em; }
.theme-toggle:hover,
.theme-toggle:focus-visible {
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
border-color: color-mix(in srgb, var(--accent-color) 40%, transparent);
background: var(--surface-hover-background);
transform: translateY(-1px);
box-shadow: 0 0 0 3px var(--accent-color-soft);
}
/* ── Session card ───────────────────────────────────────────────────────── */
.session-card { min-width: min(100%, 18rem); }
.status-pill {
display: inline-flex;
padding: 0.45rem 0.8rem;
align-items: center;
gap: 0.5rem;
padding: 0.38rem 0.85rem;
border-radius: 999px;
background: var(--badge-background);
}
.btn-accent,
.btn-accent:hover,
.btn-accent:focus-visible {
color: #06111d;
border: 0;
}
.btn-accent {
background: var(--accent-gradient);
}
.btn-accent:hover,
.btn-accent:focus-visible {
background: var(--accent-gradient-hover);
}
.access-key-panel {
padding: 1rem;
border-radius: 1rem;
background: var(--panel-soft-background);
}
.access-key-card {
border-radius: 0.9rem;
border: 1px solid var(--surface-border-soft);
background: var(--surface-background);
}
.empty-state {
border-radius: 1.25rem;
}
.info-rail article {
padding: 1rem 1.1rem;
border-radius: 1rem;
background: var(--panel-soft-background);
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--page-text-soft);
}
/* ── Auth form card ─────────────────────────────────────────────────────── */
.form-control,
.form-control:focus {
.form-control:focus,
.form-select,
.form-select:focus {
color: var(--page-text);
background-color: var(--input-background);
border-color: var(--input-border);
border-radius: 0.85rem;
box-shadow: none;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.form-control::placeholder {
color: var(--placeholder-color);
.form-control:focus,
.form-select:focus {
border-color: color-mix(in srgb, var(--accent-color) 55%, transparent);
box-shadow: 0 0 0 3px var(--accent-color-soft);
}
.form-control::placeholder { color: var(--placeholder-color); }
.form-label,
.h3,
.h4,
.display-5,
.h3, .h4, .h5,
.fw-semibold,
.fw-bold {
color: var(--page-text);
@@ -156,3 +142,134 @@
.small {
color: var(--page-text-muted) !important;
}
/* Auth tab toggle */
.btn-group .btn {
border-radius: 0.75rem;
font-weight: 500;
font-size: 0.9rem;
}
.btn-group .btn-primary {
background: var(--accent-gradient);
border-color: transparent;
color: #fff;
box-shadow: 0 2px 12px rgba(13, 148, 136, 0.28);
}
.btn-group .btn-outline-primary {
color: var(--page-text-muted);
border-color: var(--surface-border);
background: transparent;
}
.btn-group .btn-outline-primary:hover {
background: var(--panel-soft-background);
color: var(--page-text);
}
/* ── Access key / settings panels ──────────────────────────────────────── */
.access-key-panel {
padding: 1.1rem 1.25rem;
border-radius: 1.25rem;
background: var(--panel-soft-background);
border: 1px solid var(--surface-border-soft);
transition: border-color 160ms ease;
}
.access-key-panel:hover {
border-color: var(--surface-border);
}
.access-key-card {
border-radius: 1rem;
border: 1px solid var(--surface-border-soft);
background: var(--surface-background);
transition: border-color 160ms ease;
}
.access-key-card:hover {
border-color: var(--surface-border);
}
/* ── User search ────────────────────────────────────────────────────────── */
.user-search-panel,
.user-search-shell,
.dictation-language-panel,
.dictation-language-select-shell {
display: grid;
gap: 0.75rem;
}
.user-search-results {
display: grid;
gap: 0.5rem;
}
.user-search-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: 100%;
padding: 0.85rem 1rem;
border: 1px solid var(--surface-border-soft);
border-radius: 1rem;
color: var(--page-text);
background: var(--surface-background);
text-align: left;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.user-search-result:hover,
.user-search-result:focus-visible {
border-color: color-mix(in srgb, var(--accent-color) 38%, transparent);
background: var(--surface-hover-background);
transform: translateY(-1px);
}
.user-search-result-copy {
display: grid;
gap: 0.08rem;
min-width: 0;
}
.user-search-result-action {
flex: 0 0 auto;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
font-family: var(--font-mono);
color: var(--accent-color);
}
/* ── How-it-works info rail ─────────────────────────────────────────────── */
.info-rail article {
padding: 1rem 1.15rem;
border-radius: 1rem;
background: var(--panel-soft-background);
border: 1px solid var(--surface-border-soft);
position: relative;
overflow: hidden;
}
.info-rail article::before {
content: attr(data-step);
position: absolute;
top: 0.65rem;
right: 0.9rem;
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-color);
opacity: 0.7;
}
/* ── Empty state ────────────────────────────────────────────────────────── */
.empty-state {
border-radius: 1.25rem;
border: 1px dashed var(--surface-border);
background: var(--panel-soft-background) !important;
}

View File

@@ -1,10 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject, signal } from '@angular/core';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import type { AdminUserSummary } from './models';
import type { AdminUserSummary, DictationLanguage, UserProfile } from './models';
import { ThemeService } from './theme.service';
@Component({
@@ -19,19 +19,41 @@ export class HomePageComponent {
authMode: 'login' | 'register' = 'login';
readonly embeddedMode =
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
serverUrl = '';
displayName = '';
username = '';
password = '';
accessKeyLabel = '';
userSearch = '';
readonly adminUsers = signal<AdminUserSummary[]>([]);
readonly knownUsers = signal<UserProfile[]>([]);
readonly loadingKnownUsers = signal(false);
readonly knownUsersError = signal<string | null>(null);
readonly knownUsersNotice = signal<string | null>(null);
readonly loadingAdminUsers = signal(false);
readonly deletingUserId = signal<string | null>(null);
readonly adminUsersError = signal<string | null>(null);
readonly dictationLanguageOptions: Array<{ value: DictationLanguage; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
];
readonly filteredKnownUsers = computed(() => {
const query = this.userSearch.trim().toLowerCase();
const users = this.knownUsers();
if (!query) {
return users.slice(0, 8);
}
return users
.filter((user) =>
user.displayName.toLowerCase().includes(query)
|| user.username.toLowerCase().includes(query),
)
.slice(0, 8);
});
constructor(readonly session: ChatSessionService) {
this.serverUrl = session.serverUrl();
if (this.embeddedMode) {
effect(() => {
const currentUser = this.session.currentUser();
@@ -45,6 +67,21 @@ export class HomePageComponent {
});
}
effect(() => {
const currentUser = this.session.currentUser();
if (!currentUser) {
this.knownUsers.set([]);
this.loadingKnownUsers.set(false);
this.knownUsersError.set(null);
this.knownUsersNotice.set(null);
this.userSearch = '';
return;
}
void this.reloadKnownUsers();
});
effect(() => {
const currentUser = this.session.currentUser();
@@ -60,8 +97,6 @@ export class HomePageComponent {
}
async submitAuth(): Promise<void> {
this.applyServerUrl();
if (this.authMode === 'register') {
const authenticated = await this.session.register(this.username, this.password, this.displayName);
this.password = '';
@@ -76,10 +111,6 @@ export class HomePageComponent {
await this.session.login(this.username, this.password);
}
applyServerUrl(): void {
this.session.setServerUrl(this.serverUrl);
}
async logout(): Promise<void> {
await this.session.logout();
this.authMode = 'login';
@@ -88,7 +119,6 @@ export class HomePageComponent {
}
async loginWithAccessKey(): Promise<void> {
this.applyServerUrl();
await this.session.loginWithAccessKey(this.username);
this.password = '';
}
@@ -98,6 +128,27 @@ export class HomePageComponent {
this.accessKeyLabel = '';
}
async reloadKnownUsers(): Promise<void> {
this.loadingKnownUsers.set(true);
this.knownUsersError.set(null);
try {
this.knownUsers.set(await this.session.loadKnownUsers());
} catch (error) {
this.knownUsersError.set(
error instanceof Error ? error.message : 'Could not load users.',
);
} finally {
this.loadingKnownUsers.set(false);
}
}
addKnownPeer(user: UserProfile): void {
this.session.rememberKnownPeer(user);
this.knownUsersNotice.set(`${user.displayName} was added to your chat peer list.`);
this.userSearch = user.displayName;
}
async reloadAdminUsers(): Promise<void> {
this.loadingAdminUsers.set(true);
this.adminUsersError.set(null);
@@ -152,4 +203,12 @@ export class HomePageComponent {
cycleTheme(): void {
this.theme.cycleMode();
}
setIncomingMessageSound(enabled: boolean): void {
this.session.setIncomingMessageSoundEnabled(enabled);
}
setDictationLanguage(language: string): void {
this.session.setDictationLanguage(language as DictationLanguage);
}
}

View File

@@ -55,6 +55,8 @@ export interface AccessKeySummary {
createdAt: string;
}
export type DeliveryState = 'pending' | 'sent';
export interface RegistrationOptionsResponse {
rp: PublicKeyCredentialRpEntity;
user: {
@@ -97,6 +99,7 @@ export interface ChatEntry {
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
createdAt: number;
authorLabel: string;
deliveryState?: DeliveryState;
generatedByAi?: boolean;
showSpinner?: boolean;
text?: string;
@@ -110,6 +113,7 @@ export interface ChatEntry {
}
export type CallMode = 'audio' | 'video';
export type DictationLanguage = 'en' | 'fr' | 'es';
export type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit }
@@ -135,16 +139,6 @@ export type ServerEvent =
peerId: string;
message: string;
}
| {
type: 'speech-transcribed';
requestId: string;
text: string;
}
| {
type: 'speech-transcription-error';
requestId: string;
message: string;
}
| { type: 'pong' }
| { type: 'error'; message: string };

View File

@@ -5,7 +5,7 @@
<title>PrivateChat</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#08111d">
<meta name="theme-color" content="#030712">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -14,7 +14,7 @@
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src="env.js"></script>
</head>

View File

@@ -1,108 +1,104 @@
@use 'bootstrap/scss/bootstrap';
/* ── Design Tokens ───────────────────────────────────────────────────────── */
:root {
--page-text: #142236;
--page-text-muted: rgba(39, 63, 91, 0.72);
--page-text-soft: rgba(39, 63, 91, 0.82);
/* Text */
--page-text: #e2e8f4;
--page-text-muted: rgba(180, 196, 224, 0.68);
--page-text-soft: rgba(180, 196, 224, 0.82);
/* Page background: deep obsidian with teal/blue aurora */
--page-background:
radial-gradient(circle at top left, rgba(81, 168, 255, 0.2), transparent 30%),
radial-gradient(circle at top right, rgba(129, 244, 215, 0.22), transparent 24%),
linear-gradient(180deg, #f6fbff 0%, #e8f1fb 100%);
radial-gradient(circle at 12% 8%, rgba(13, 148, 136, 0.22), transparent 28%),
radial-gradient(circle at 90% 5%, rgba(59, 130, 246, 0.18), transparent 24%),
radial-gradient(circle at 50% 95%, rgba(99, 102, 241, 0.12), transparent 30%),
linear-gradient(180deg, #030712 0%, #080f1e 100%);
/* Panels */
--panel-background: rgba(11, 17, 32, 0.72);
--panel-alt-background: rgba(15, 23, 42, 0.78);
--panel-soft-background: rgba(255, 255, 255, 0.04);
/* Surfaces */
--surface-background: rgba(8, 14, 24, 0.65);
--surface-hover-background: rgba(17, 28, 50, 0.92);
--surface-border: rgba(255, 255, 255, 0.08);
--surface-border-soft: rgba(255, 255, 255, 0.05);
/* Inputs */
--input-background: rgba(0, 0, 0, 0.35);
--input-border: rgba(255, 255, 255, 0.12);
--placeholder-color: rgba(148, 168, 210, 0.45);
/* Accent — teal-to-blue gradient */
--accent-color: #2dd4bf;
--accent-color-soft: rgba(45, 212, 191, 0.12);
--accent-gradient: linear-gradient(135deg, #0d9488, #3b82f6);
--accent-gradient-hover: linear-gradient(135deg, #14b8a6, #60a5fa);
/* Link */
--link-color: #60a5fa;
/* Badges / Pills */
--badge-background: rgba(255, 255, 255, 0.07);
/* Chat bubbles */
--incoming-bubble-background: rgba(30, 58, 138, 0.22);
--incoming-bubble-border: rgba(59, 130, 246, 0.28);
--incoming-bubble-text: #dbeafe;
--outgoing-bubble-background: rgba(6, 78, 59, 0.25);
--outgoing-bubble-border: rgba(45, 212, 191, 0.30);
--outgoing-bubble-text: #d1fae5;
/* Status LEDs */
--led-ok: #10b981;
--led-connecting: #f59e0b;
--led-offline: #ef4444;
/* Shadow */
--shadow-color: rgba(0, 0, 0, 0.55);
/* Typography */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
color-scheme: dark;
}
/* Light theme override */
:root[data-theme='light'] {
--page-text: #0f172a;
--page-text-muted: rgba(30, 50, 80, 0.66);
--page-text-soft: rgba(30, 50, 80, 0.82);
--page-background:
radial-gradient(circle at 12% 8%, rgba(13, 148, 136, 0.15), transparent 28%),
radial-gradient(circle at 90% 5%, rgba(59, 130, 246, 0.12), transparent 24%),
linear-gradient(180deg, #f0fafa 0%, #e8f1fb 100%);
--panel-background: rgba(255, 255, 255, 0.82);
--panel-alt-background: rgba(241, 247, 255, 0.9);
--panel-soft-background: rgba(20, 34, 54, 0.04);
--surface-background: rgba(255, 255, 255, 0.82);
--surface-hover-background: rgba(235, 244, 255, 0.98);
--surface-border: rgba(33, 62, 94, 0.12);
--surface-border-soft: rgba(33, 62, 94, 0.08);
--input-background: rgba(255, 255, 255, 0.92);
--input-border: rgba(77, 114, 154, 0.26);
--placeholder-color: rgba(55, 83, 118, 0.52);
--accent-color: #138a7b;
--accent-color-soft: rgba(19, 138, 123, 0.1);
--accent-gradient: linear-gradient(135deg, #8df0df, #6cb6ff);
--accent-gradient-hover: linear-gradient(135deg, #a6f5e8, #86c4ff);
--link-color: #2f7cd6;
--badge-background: rgba(20, 34, 54, 0.08);
--incoming-bubble-background: #d9ebff;
--incoming-bubble-text: #183759;
--outgoing-bubble-background: #d9f5df;
--outgoing-bubble-text: #1e4d2f;
--danger-background: #d94b53;
--shadow-color: rgba(41, 73, 110, 0.14);
--panel-alt-background: rgba(241, 248, 255, 0.88);
--panel-soft-background: rgba(15, 23, 42, 0.04);
--surface-background: rgba(255, 255, 255, 0.75);
--surface-hover-background: rgba(236, 248, 255, 0.98);
--surface-border: rgba(30, 60, 100, 0.12);
--surface-border-soft: rgba(30, 60, 100, 0.07);
--input-background: rgba(255, 255, 255, 0.9);
--input-border: rgba(100, 130, 180, 0.28);
--placeholder-color: rgba(60, 90, 130, 0.48);
--accent-color: #0d9488;
--accent-color-soft: rgba(13, 148, 136, 0.1);
--incoming-bubble-background: #dbeafe;
--incoming-bubble-border: rgba(59, 130, 246, 0.28);
--incoming-bubble-text: #1e3a5f;
--outgoing-bubble-background: #d1fae5;
--outgoing-bubble-border: rgba(13, 148, 136, 0.28);
--outgoing-bubble-text: #064e3b;
--shadow-color: rgba(30, 60, 100, 0.14);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--page-text: #eff3ff;
--page-text-muted: rgba(231, 238, 249, 0.72);
--page-text-soft: rgba(231, 238, 249, 0.84);
--page-background:
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
linear-gradient(180deg, #08111d 0%, #101d31 100%);
--panel-background: rgba(9, 16, 28, 0.78);
--panel-alt-background: rgba(15, 27, 44, 0.78);
--panel-soft-background: rgba(255, 255, 255, 0.04);
--surface-background: rgba(8, 14, 23, 0.7);
--surface-hover-background: rgba(16, 30, 49, 0.92);
--surface-border: rgba(255, 255, 255, 0.12);
--surface-border-soft: rgba(255, 255, 255, 0.08);
--input-background: rgba(255, 255, 255, 0.06);
--input-border: rgba(255, 255, 255, 0.16);
--placeholder-color: rgba(239, 243, 255, 0.5);
--accent-color: #81f4d7;
--accent-color-soft: rgba(129, 244, 215, 0.1);
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
--link-color: #9bd5ff;
--badge-background: rgba(255, 255, 255, 0.08);
--incoming-bubble-background: #dcefff;
--incoming-bubble-text: #0f2540;
--outgoing-bubble-background: #def7dd;
--outgoing-bubble-text: #153420;
--danger-background: #d94b53;
--shadow-color: rgba(0, 0, 0, 0.28);
color-scheme: dark;
}
}
/* ── Base ────────────────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root[data-theme='dark'] {
--page-text: #eff3ff;
--page-text-muted: rgba(231, 238, 249, 0.72);
--page-text-soft: rgba(231, 238, 249, 0.84);
--page-background:
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
linear-gradient(180deg, #08111d 0%, #101d31 100%);
--panel-background: rgba(9, 16, 28, 0.78);
--panel-alt-background: rgba(15, 27, 44, 0.78);
--panel-soft-background: rgba(255, 255, 255, 0.04);
--surface-background: rgba(8, 14, 23, 0.7);
--surface-hover-background: rgba(16, 30, 49, 0.92);
--surface-border: rgba(255, 255, 255, 0.12);
--surface-border-soft: rgba(255, 255, 255, 0.08);
--input-background: rgba(255, 255, 255, 0.06);
--input-border: rgba(255, 255, 255, 0.16);
--placeholder-color: rgba(239, 243, 255, 0.5);
--accent-color: #81f4d7;
--accent-color-soft: rgba(129, 244, 215, 0.1);
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
--link-color: #9bd5ff;
--badge-background: rgba(255, 255, 255, 0.08);
--incoming-bubble-background: #dcefff;
--incoming-bubble-text: #0f2540;
--outgoing-bubble-background: #def7dd;
--outgoing-bubble-text: #153420;
--danger-background: #d94b53;
--shadow-color: rgba(0, 0, 0, 0.28);
color-scheme: dark;
}
html,
body {
html, body {
min-height: 100dvh;
}
@@ -113,18 +109,28 @@ body {
background: var(--page-background);
background-attachment: fixed;
transition:
background 180ms ease,
color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
background 220ms ease,
color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
button,
input,
textarea {
button, input, textarea, select {
font: inherit;
}
/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(45, 212, 191, 0.35);
}
/* ── Bootstrap overrides ────────────────────────────────────────────────── */
.text-secondary {
color: var(--page-text-muted) !important;
}
@@ -176,3 +182,455 @@ textarea {
.alert-warning {
border: 1px solid var(--surface-border);
}
/* ── Modals (shared across pages) ───────────────────────────────────────── */
.call-choice-backdrop {
position: fixed;
inset: 0;
z-index: 1240;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
}
.call-choice-card {
width: min(100%, 25rem);
}
.conversation-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1230;
display: grid;
place-items: center;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(12px);
}
.conversation-modal {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(100%, 96rem);
height: min(100dvh - 1.5rem, 100%);
max-height: 100dvh;
border: 0;
}
.conversation-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
}
.conversation-modal-eyebrow,
.call-choice-eyebrow,
.bubble-delivery-state {
text-transform: uppercase;
}
.conversation-modal-eyebrow {
font-size: 0.72rem;
letter-spacing: 0.16em;
color: var(--accent-color);
font-family: var(--font-mono);
}
.conversation-modal-close {
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: 0;
border-radius: 999px;
color: var(--page-text);
background: var(--badge-background);
font-size: 1.35rem;
line-height: 1;
transition: background 160ms ease;
}
.conversation-modal-close:hover {
background: rgba(255, 255, 255, 0.12);
}
.conversation-modal-body {
min-height: 0;
max-height: none;
padding-top: 1rem;
}
.call-choice-eyebrow {
margin-bottom: 0.45rem;
font-size: 0.72rem;
letter-spacing: 0.18em;
color: var(--accent-color);
font-family: var(--font-mono);
}
.call-choice-actions {
display: grid;
gap: 0.85rem;
}
.call-choice-button {
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
padding: 1rem 1.1rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
color: var(--page-text);
background: var(--surface-background);
text-align: left;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.call-choice-button:hover,
.call-choice-button:focus-visible {
border-color: color-mix(in srgb, var(--accent-color) 40%, transparent);
background: var(--surface-hover-background);
transform: translateY(-1px);
}
.call-choice-icon {
display: inline-grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
background: var(--badge-background);
font-size: 1.2rem;
border: 1px solid var(--surface-border);
}
.status-indicator-action {
padding: 0;
border: 0;
background: transparent;
}
.status-indicator-action:not(:disabled) {
color: var(--page-text);
cursor: pointer;
}
.status-indicator-action:not(:disabled):hover,
.status-indicator-action:not(:disabled):focus-visible {
color: var(--accent-color);
}
.status-indicator-action:disabled {
cursor: default;
opacity: 1;
}
/* ── Status LEDs ─────────────────────────────────────────────────────────── */
.status-led,
.peer-tile-delete,
.bubble-spinner {
border-radius: 999px;
}
.status-led {
width: 0.55rem;
height: 0.55rem;
flex-shrink: 0;
box-shadow: 0 0 6px currentColor;
}
.status-led-ok {
color: var(--led-ok);
background: var(--led-ok);
}
.status-led-connecting {
color: var(--led-connecting);
background: var(--led-connecting);
}
.status-led-offline {
color: var(--led-offline);
background: var(--led-offline);
}
/* ── Peer dropdown ───────────────────────────────────────────────────────── */
.peer-dropdown {
position: relative;
min-width: min(18rem, 42vw);
}
.peer-dropdown-trigger {
width: 100%;
padding-top: 0.52rem;
padding-bottom: 0.52rem;
}
.peer-dropdown-menu {
position: absolute;
top: calc(100% + 0.65rem);
left: 0;
z-index: 4;
display: grid;
gap: 0.65rem;
width: 100%;
max-height: calc(3 * 4.55rem + 1.5rem);
overflow: auto;
padding: 0.75rem;
border: 1px solid var(--surface-border);
border-radius: 1.2rem;
background: var(--panel-background);
backdrop-filter: blur(20px);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.peer-tile {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
padding: 0.75rem 0.85rem 0.75rem 1rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
color: inherit;
background: var(--surface-background);
font-size: 1em;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.peer-tile-main {
display: block;
min-width: 0;
padding: 0;
border: 0;
color: inherit;
background: transparent;
}
.peer-tile-indicators,
.bubble-system-status {
display: inline-flex;
align-items: center;
}
.peer-tile-indicators {
gap: 0.38rem;
flex: 0 0 auto;
}
.peer-dropdown-caret {
font-size: 1.1rem;
line-height: 1;
transition: transform 160ms ease;
opacity: 0.6;
}
.peer-dropdown-caret-open {
transform: rotate(180deg);
}
.peer-tile-delete {
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
background: transparent;
font-size: 0.7rem;
line-height: 1;
opacity: 0.5;
transition: opacity 160ms ease, background 160ms ease;
}
.peer-tile-delete:hover,
.peer-tile-delete:focus-visible {
opacity: 1;
background: rgba(239, 68, 68, 0.15);
}
.peer-tile:hover,
.peer-tile:focus-visible,
.peer-tile-active {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent-color) 38%, transparent);
background: var(--surface-hover-background);
}
.peer-tile-unread {
border-color: #ef4444;
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.4), 0 0 12px rgba(239, 68, 68, 0.15);
}
.peer-tile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.53rem;
}
.peer-tile-title {
display: inline-flex;
align-items: center;
gap: 0.32rem;
min-width: 0;
}
/* Typing indicator */
.peer-typing-dots {
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-height: 0.9rem;
}
.peer-typing-dots span {
width: 0.27rem;
height: 0.27rem;
border-radius: 999px;
background: var(--accent-color);
opacity: 0.35;
animation: peer-typing-pulse 900ms infinite ease-in-out;
}
.peer-typing-dots span:nth-child(2) { animation-delay: 120ms; }
.peer-typing-dots span:nth-child(3) { animation-delay: 240ms; }
.peer-tile-status {
flex: 0 0 auto;
}
/* ── Chat Bubbles ────────────────────────────────────────────────────────── */
.bubble-incoming {
justify-self: start;
color: var(--incoming-bubble-text);
background: var(--incoming-bubble-background);
border: 1px solid var(--incoming-bubble-border);
border-radius: 1.25rem 1.25rem 1.25rem 0.35rem;
}
.bubble-outgoing {
justify-self: end;
color: var(--outgoing-bubble-text);
background: var(--outgoing-bubble-background);
border: 1px solid var(--outgoing-bubble-border);
border-radius: 1.25rem 1.25rem 0.35rem 1.25rem;
}
.bubble-pending {
opacity: 0.52;
filter: grayscale(0.32);
}
.bubble-system {
justify-self: center;
max-width: 90%;
color: var(--page-text-soft);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--surface-border-soft);
font-family: var(--font-mono);
font-size: 0.75rem;
}
.bubble-emoji-only {
max-width: none;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.bubble-meta {
display: flex;
align-items: baseline;
gap: 0.45rem;
margin-bottom: 0.3rem;
font-size: 0.72rem;
opacity: 0.65;
font-family: var(--font-mono);
}
.bubble-time { display: block; }
.bubble-delivery-state {
display: inline-block;
margin-top: 0.1rem;
font-size: 0.68rem;
letter-spacing: 0.06em;
color: var(--accent-color);
}
.bubble-system-status {
gap: 0.7rem;
}
.bubble-spinner {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
border: 0.15rem solid currentColor;
border-right-color: transparent;
opacity: 0.8;
animation: bubble-spin 700ms linear infinite;
}
/* ── Keyframes ───────────────────────────────────────────────────────────── */
@keyframes peer-typing-pulse {
0%, 80%, 100% { opacity: 0.35; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
@keyframes bubble-spin {
to { transform: rotate(360deg); }
}
@keyframes glow-pulse {
0%, 100% { opacity: 0.5; box-shadow: 0 0 6px currentColor; }
50% { opacity: 1; box-shadow: 0 0 14px currentColor; }
}
.status-led-ok { animation: glow-pulse 3s ease-in-out infinite; }
/* ── Global accent button (used across pages) ───────────────────────────── */
.btn-accent,
.btn-accent:hover,
.btn-accent:focus-visible {
color: #fff;
border: 0;
font-weight: 600;
letter-spacing: 0.02em;
}
.btn-accent {
background: var(--accent-gradient);
box-shadow: 0 4px 20px rgba(13, 148, 136, 0.3);
transition: opacity 160ms ease, transform 160ms ease, box-shadow 160ms ease;
}
.btn-accent:hover,
.btn-accent:focus-visible {
background: var(--accent-gradient-hover);
transform: translateY(-1px);
box-shadow: 0 8px 28px rgba(13, 148, 136, 0.4);
}
.btn-accent:active {
transform: scale(0.97);
}
/* ── Mobile ──────────────────────────────────────────────────────────────── */
@media (max-width: 767.98px) {
.peer-dropdown { min-width: min(100%, 18rem); }
.conversation-modal-backdrop { padding: 0; }
.conversation-modal {
width: 100vw;
height: 100dvh;
border-radius: 0;
}
}

1023
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,11 @@
"dev:server": "npm run dev --prefix server",
"dev:client": "npm run start --prefix client",
"build": "npm run build --prefix server && npm run build --prefix client",
"start": "npm run build && npm run start --prefix server"
"start": "npm run build && npm run start --prefix server",
"restart": "npm run build && sudo systemctl restart privatechat.service"
},
"devDependencies": {
"concurrently": "^9.2.1"
"concurrently": "^9.2.1",
"puppeteer": "^24.41.0"
}
}

65
server/dist/index.js vendored
View File

@@ -16,7 +16,6 @@ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthe
import Fastify from 'fastify';
import { Redis } from 'ioredis';
import { z } from 'zod';
import { SpeechTranscriber } from './speech-transcriber.js';
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
const registerSchema = z.object({
@@ -90,12 +89,6 @@ const signalMessageSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('ping'),
}),
z.object({
type: z.literal('speech-transcription'),
requestId: z.string().uuid(),
mimeType: z.string().trim().min(1).max(128),
audioBase64: z.string().min(1).max(32_000_000),
}),
]);
const app = Fastify({ logger: true, trustProxy: true });
const approvalAdminUsername = 'ladparis';
@@ -106,9 +99,6 @@ const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
@@ -121,11 +111,6 @@ const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
const speechTranscriber = new SpeechTranscriber({
serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage,
requestTimeoutMs: speechTranscriptionTimeoutMs,
}, app.log);
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
@@ -472,6 +457,15 @@ app.get('/api/auth/session', async (request, reply) => {
messageEncryptionKey: authContext.user.messageEncryptionKey,
};
});
app.get('/api/users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
return {
users: listDiscoverableUsers(authContext.user.id),
};
});
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
@@ -773,25 +767,6 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
}
return;
}
if (parsed.type === 'speech-transcription') {
try {
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
send(socket, {
type: 'speech-transcribed',
requestId: parsed.requestId,
text,
});
}
catch (error) {
app.log.warn({ err: error, userId }, 'Speech transcription failed');
send(socket, {
type: 'speech-transcription-error',
requestId: parsed.requestId,
message: error instanceof Error ? error.message : 'Speech transcription failed.',
});
}
return;
}
let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to);
if (recipientSockets) {
@@ -981,6 +956,17 @@ function listAdminUsers() {
approvedAt: row.approved_at,
}));
}
function listDiscoverableUsers(currentUserId) {
const rows = selectAllUsersStatement.all();
return rows
.filter((row) => row.is_active === 1 && row.id !== currentUserId)
.map((row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username));
}
function approveUser(userId) {
const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId);
@@ -1237,23 +1223,12 @@ function parseClientMessage(rawMessage) {
prompt: parsed.data.prompt,
};
}
if (parsed.data.type === 'speech-transcription') {
return {
type: 'speech-transcription',
requestId: parsed.data.requestId,
mimeType: parsed.data.mimeType,
audioBase64: parsed.data.audioBase64,
};
}
return {
type: 'signal',
to: parsed.data.to,
signal: normalizeSignal(parsed.data.signal),
};
}
async function transcribeAudioPayload(requestId, audioBase64, mimeType) {
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
}
async function generateImageFromPrompt(prompt) {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 120_000);

View File

@@ -1,124 +0,0 @@
import WebSocket from 'ws';
export class SpeechTranscriber {
options;
logger;
constructor(options, logger) {
this.options = options;
this.logger = logger;
}
async transcribe(requestId, audioBase64, mimeType) {
const audio = this.normalizeAudioPayload(audioBase64, mimeType);
return await new Promise((resolve, reject) => {
let settled = false;
const socket = new WebSocket(this.options.serviceUrl);
const finish = (handler) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
socket.removeAllListeners();
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
socket.close();
}
handler();
};
const timeout = setTimeout(() => {
finish(() => {
reject(new Error(`The transcription service timed out after ${this.options.requestTimeoutMs}ms.`));
});
}, this.options.requestTimeoutMs);
socket.on('open', () => {
try {
socket.send(JSON.stringify({
type: 'transcribe',
id: requestId,
language: this.options.language,
audio,
}));
}
catch (error) {
finish(() => {
reject(error instanceof Error ? error : new Error('Could not send transcription request.'));
});
}
});
socket.on('message', (payload) => {
const event = this.parseEvent(payload);
if (!event) {
return;
}
if (event.id && event.id !== requestId) {
this.logger.warn({ requestId, event }, 'Ignored transcription event for another request');
return;
}
if (event.type === 'start') {
this.logger.info({ requestId, model: event.model, language: event.language }, 'Speech transcription started');
return;
}
if (event.type === 'delta') {
return;
}
if (event.type === 'done') {
finish(() => {
resolve(event.text.trim());
});
return;
}
finish(() => {
reject(new Error(event.message));
});
});
socket.on('error', (error) => {
finish(() => {
reject(error instanceof Error ? error : new Error('The transcription service connection failed.'));
});
});
socket.on('close', (code, reasonBuffer) => {
if (settled) {
return;
}
const reason = reasonBuffer.toString().trim();
const detail = reason
? `The transcription service closed the connection unexpectedly (code=${code}, reason=${reason}).`
: `The transcription service closed the connection unexpectedly (code=${code}).`;
finish(() => {
reject(new Error(detail));
});
});
});
}
normalizeAudioPayload(audioBase64, mimeType) {
const trimmedAudio = audioBase64.trim();
if (trimmedAudio.startsWith('data:')) {
return trimmedAudio;
}
const normalizedMimeType = mimeType.trim() || 'audio/webm';
return `data:${normalizedMimeType};base64,${trimmedAudio}`;
}
parseEvent(payload) {
const message = this.rawDataToString(payload).trim();
if (!message) {
return null;
}
try {
return JSON.parse(message);
}
catch {
this.logger.warn({ transcriptionPayload: message }, 'Ignored non-JSON transcription event');
return null;
}
}
rawDataToString(payload) {
if (typeof payload === 'string') {
return payload;
}
if (payload instanceof ArrayBuffer) {
return Buffer.from(payload).toString('utf8');
}
if (Array.isArray(payload)) {
return Buffer.concat(payload).toString('utf8');
}
return payload.toString('utf8');
}
}

328
server/package-lock.json generated
View File

@@ -10,27 +10,27 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/static": "^9.0.0",
"@fastify/static": "^9.1.0",
"@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^13.2.3",
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"ioredis": "^5.10.0",
"@simplewebauthn/server": "^13.3.0",
"dotenv": "^17.4.2",
"fastify": "^5.8.4",
"ioredis": "^5.10.1",
"libreoffice-convert": "^1.8.1",
"ws": "^8.19.0",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.3.5",
"@types/node": "^25.6.0",
"@types/ws": "^8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
@@ -45,9 +45,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
@@ -62,9 +62,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
@@ -79,9 +79,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
@@ -96,9 +96,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
@@ -113,9 +113,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
@@ -130,9 +130,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
@@ -147,9 +147,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
@@ -164,9 +164,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
@@ -181,9 +181,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
@@ -198,9 +198,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
@@ -215,9 +215,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
@@ -232,9 +232,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
@@ -249,9 +249,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
@@ -266,9 +266,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
@@ -283,9 +283,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
@@ -300,9 +300,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
@@ -317,9 +317,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
@@ -334,9 +334,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
@@ -351,9 +351,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
@@ -368,9 +368,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
@@ -385,9 +385,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
@@ -402,9 +402,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
@@ -419,9 +419,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
@@ -436,9 +436,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
@@ -453,9 +453,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
@@ -663,9 +663,9 @@
}
},
"node_modules/@fastify/static": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
"integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==",
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.0.tgz",
"integrity": "sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==",
"funding": [
{
"type": "github",
@@ -900,9 +900,9 @@
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.2.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.3.tgz",
"integrity": "sha512-ZhcVBOw63birYx9jVfbhK6rTehckVes8PeWV324zpmdxr0BUfylospwMzcrxrdMcOi48MHWj2LCA+S528LnGvg==",
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.0.tgz",
"integrity": "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
@@ -919,13 +919,13 @@
}
},
"node_modules/@types/node": {
"version": "25.3.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@types/ws": {
@@ -1054,9 +1054,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -1075,9 +1075,9 @@
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -1145,9 +1145,9 @@
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -1187,9 +1187,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1200,32 +1200,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escape-html": {
@@ -1271,15 +1271,16 @@
}
},
"node_modules/fast-jwt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.2.tgz",
"integrity": "sha512-lzy+8JVyBOvwxjydFRBKLFVe1elRArL37pHRX1zHPt4T7FP7kNIpqauE1lOjZlD79DBzzRzQmp+28wbsY13weA==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0"
"mnemonist": "^0.40.0",
"safe-regex2": "^5.1.0"
},
"engines": {
"node": ">=20"
@@ -1323,9 +1324,9 @@
}
},
"node_modules/fastify": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
"funding": [
{
"type": "github",
@@ -1430,9 +1431,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1486,9 +1487,9 @@
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz",
"integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==",
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
@@ -1606,9 +1607,9 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"version": "11.3.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
"integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -1633,12 +1634,12 @@
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -1903,9 +1904,9 @@
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
"integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
"integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==",
"funding": [
{
"type": "github",
@@ -1919,6 +1920,9 @@
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safe-stable-stringify": {
@@ -2135,9 +2139,9 @@
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
@@ -2154,9 +2158,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -11,18 +11,18 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/static": "^9.0.0",
"@fastify/static": "^9.1.0",
"@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^13.2.3",
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"ioredis": "^5.10.0",
"@simplewebauthn/server": "^13.3.0",
"dotenv": "^17.4.2",
"fastify": "^5.8.4",
"ioredis": "^5.10.1",
"libreoffice-convert": "^1.8.1",
"ws": "^8.19.0",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.3.5",
"@types/node": "^25.6.0",
"@types/ws": "^8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"

View File

@@ -26,8 +26,6 @@ import { Redis } from 'ioredis';
import type WebSocket from 'ws';
import { z } from 'zod';
import { SpeechTranscriber } from './speech-transcriber.js';
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
@@ -125,12 +123,6 @@ type ClientMessage =
}
| {
type: 'ping';
}
| {
type: 'speech-transcription';
requestId: string;
mimeType: string;
audioBase64: string;
};
type ServerMessage =
@@ -153,16 +145,6 @@ type ServerMessage =
peerId: string;
message: string;
}
| {
type: 'speech-transcribed';
requestId: string;
text: string;
}
| {
type: 'speech-transcription-error';
requestId: string;
message: string;
}
| { type: 'pong' }
| { type: 'error'; message: string };
@@ -316,12 +298,6 @@ const signalMessageSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('ping'),
}),
z.object({
type: z.literal('speech-transcription'),
requestId: z.string().uuid(),
mimeType: z.string().trim().min(1).max(128),
audioBase64: z.string().min(1).max(32_000_000),
}),
]);
const app = Fastify({ logger: true, trustProxy: true });
@@ -340,9 +316,6 @@ const frontendDistPath = resolveProjectPath(
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
@@ -358,15 +331,6 @@ const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
const speechTranscriber = new SpeechTranscriber(
{
serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage,
requestTimeoutMs: speechTranscriptionTimeoutMs,
},
app.log,
);
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
@@ -806,6 +770,18 @@ app.get('/api/auth/session', async (request, reply) => {
};
});
app.get('/api/users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
return {
users: listDiscoverableUsers(authContext.user.id),
};
});
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
@@ -1213,27 +1189,6 @@ async function handleSocketMessage(
return;
}
if (parsed.type === 'speech-transcription') {
try {
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
send(socket, {
type: 'speech-transcribed',
requestId: parsed.requestId,
text,
});
} catch (error) {
app.log.warn({ err: error, userId }, 'Speech transcription failed');
send(socket, {
type: 'speech-transcription-error',
requestId: parsed.requestId,
message: error instanceof Error ? error.message : 'Speech transcription failed.',
});
}
return;
}
let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to);
@@ -1497,6 +1452,21 @@ function listAdminUsers(): AdminUserSummary[] {
}));
}
function listDiscoverableUsers(currentUserId: string): PublicUser[] {
const rows = selectAllUsersStatement.all() as DatabaseUserRow[];
return rows
.filter((row) => row.is_active === 1 && row.id !== currentUserId)
.map((row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
}))
.sort((left, right) =>
left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username),
);
}
function approveUser(userId: string): UserRecord | null {
const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId);
@@ -1870,15 +1840,6 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
};
}
if (parsed.data.type === 'speech-transcription') {
return {
type: 'speech-transcription',
requestId: parsed.data.requestId,
mimeType: parsed.data.mimeType,
audioBase64: parsed.data.audioBase64,
};
}
return {
type: 'signal',
to: parsed.data.to,
@@ -1886,10 +1847,6 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
};
}
async function transcribeAudioPayload(requestId: string, audioBase64: string, mimeType: string): Promise<string> {
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
}
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 120_000);

View File

@@ -1,173 +0,0 @@
import WebSocket, { type RawData } from 'ws';
type LoggerLike = {
info: (payload: unknown, message?: string) => void;
warn: (payload: unknown, message?: string) => void;
error: (payload: unknown, message?: string) => void;
};
type SpeechTranscriberOptions = {
serviceUrl: string;
language: string;
requestTimeoutMs: number;
};
type ServiceEvent =
| { type: 'start'; id: string; model: string; language: string }
| { type: 'delta'; id: string; text: string; fullText: string }
| { type: 'done'; id: string; text: string }
| { type: 'error'; id?: string; message: string };
export class SpeechTranscriber {
constructor(
private readonly options: SpeechTranscriberOptions,
private readonly logger: LoggerLike,
) {}
async transcribe(requestId: string, audioBase64: string, mimeType: string): Promise<string> {
const audio = this.normalizeAudioPayload(audioBase64, mimeType);
return await new Promise<string>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(this.options.serviceUrl);
const finish = (handler: () => void): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
socket.removeAllListeners();
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
socket.close();
}
handler();
};
const timeout = setTimeout(() => {
finish(() => {
reject(new Error(`The transcription service timed out after ${this.options.requestTimeoutMs}ms.`));
});
}, this.options.requestTimeoutMs);
socket.on('open', () => {
try {
socket.send(
JSON.stringify({
type: 'transcribe',
id: requestId,
language: this.options.language,
audio,
}),
);
} catch (error) {
finish(() => {
reject(error instanceof Error ? error : new Error('Could not send transcription request.'));
});
}
});
socket.on('message', (payload) => {
const event = this.parseEvent(payload);
if (!event) {
return;
}
if (event.id && event.id !== requestId) {
this.logger.warn({ requestId, event }, 'Ignored transcription event for another request');
return;
}
if (event.type === 'start') {
this.logger.info(
{ requestId, model: event.model, language: event.language },
'Speech transcription started',
);
return;
}
if (event.type === 'delta') {
return;
}
if (event.type === 'done') {
finish(() => {
resolve(event.text.trim());
});
return;
}
finish(() => {
reject(new Error(event.message));
});
});
socket.on('error', (error) => {
finish(() => {
reject(error instanceof Error ? error : new Error('The transcription service connection failed.'));
});
});
socket.on('close', (code, reasonBuffer) => {
if (settled) {
return;
}
const reason = reasonBuffer.toString().trim();
const detail = reason
? `The transcription service closed the connection unexpectedly (code=${code}, reason=${reason}).`
: `The transcription service closed the connection unexpectedly (code=${code}).`;
finish(() => {
reject(new Error(detail));
});
});
});
}
private normalizeAudioPayload(audioBase64: string, mimeType: string): string {
const trimmedAudio = audioBase64.trim();
if (trimmedAudio.startsWith('data:')) {
return trimmedAudio;
}
const normalizedMimeType = mimeType.trim() || 'audio/webm';
return `data:${normalizedMimeType};base64,${trimmedAudio}`;
}
private parseEvent(payload: RawData): ServiceEvent | null {
const message = this.rawDataToString(payload).trim();
if (!message) {
return null;
}
try {
return JSON.parse(message) as ServiceEvent;
} catch {
this.logger.warn({ transcriptionPayload: message }, 'Ignored non-JSON transcription event');
return null;
}
}
private rawDataToString(payload: RawData): string {
if (typeof payload === 'string') {
return payload;
}
if (payload instanceof ArrayBuffer) {
return Buffer.from(payload).toString('utf8');
}
if (Array.isArray(payload)) {
return Buffer.concat(payload).toString('utf8');
}
return payload.toString('utf8');
}
}