Quellcode des ICON CRM (6)
This commit is contained in:
+415
@@ -0,0 +1,415 @@
|
||||
import type {
|
||||
AppUser,
|
||||
Company,
|
||||
Connection,
|
||||
Contact,
|
||||
DirectoryUser,
|
||||
Employment,
|
||||
HistoryEntry,
|
||||
Interview,
|
||||
Lead,
|
||||
LeadTodo,
|
||||
PerincoContact,
|
||||
Project,
|
||||
Tender,
|
||||
} from '../types';
|
||||
|
||||
const TOKEN_KEY = 'icon_crm_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wird bei 401 gefeuert, damit die Anwendung zurueck auf den Login springt. */
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api${path}`, { ...options, headers });
|
||||
} catch {
|
||||
throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json().catch(() => null) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) ||
|
||||
`Anfrage fehlgeschlagen (HTTP ${response.status}).`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
const get = <T>(path: string) => apiRequest<T>(path);
|
||||
const post = <T>(path: string, body?: unknown) =>
|
||||
apiRequest<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const patch = <T>(path: string, body: unknown) =>
|
||||
apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
|
||||
const del = <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' });
|
||||
|
||||
/** Haengt nur gesetzte Werte als Abfrageparameter an. */
|
||||
function query(params: Record<string, unknown>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const text = search.toString();
|
||||
return text ? `?${text}` : '';
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (identifier: string, password: string) =>
|
||||
post<{ token: string; user: AppUser }>('/auth/login', { identifier, password }),
|
||||
me: () => get<{ user: AppUser }>('/auth/me'),
|
||||
updateMe: (patchBody: Partial<Pick<AppUser, 'color' | 'avatar' | 'theme' | 'theme_mode'>>) =>
|
||||
patch<{ user: AppUser }>('/auth/me', patchBody),
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
post<{ ok: true }>('/auth/change-password', { current_password, new_password }),
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
directory: () => get<{ users: DirectoryUser[] }>('/users/directory'),
|
||||
list: () => get<{ users: AppUser[] }>('/users'),
|
||||
create: (body: Record<string, unknown>) => post<{ user: AppUser }>('/users', body),
|
||||
update: (id: number, body: Record<string, unknown>) => patch<{ user: AppUser }>(`/users/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/users/${id}`),
|
||||
};
|
||||
|
||||
export interface CompanyDetail {
|
||||
company: Company;
|
||||
contacts: Array<Pick<Contact, 'id' | 'first_name' | 'last_name' | 'position' | 'email' | 'phone' | 'avatar' | 'kind' | 'status'>>;
|
||||
former: Array<{
|
||||
employment_id: number;
|
||||
id: number;
|
||||
first_name: string | null;
|
||||
last_name: string;
|
||||
avatar: string | null;
|
||||
position: string | null;
|
||||
started_on: string | null;
|
||||
ended_on: string | null;
|
||||
now_company: string | null;
|
||||
now_company_id: number | null;
|
||||
}>;
|
||||
interviews: Interview[];
|
||||
history: HistoryEntry[];
|
||||
mentioned: HistoryEntry[];
|
||||
}
|
||||
|
||||
export const companiesApi = {
|
||||
list: (params: Record<string, unknown> = {}) =>
|
||||
get<{ companies: Company[] }>(`/companies${query(params)}`),
|
||||
options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'),
|
||||
detail: (id: number) => get<CompanyDetail>(`/companies/${id}`),
|
||||
create: (body: Record<string, unknown>) => post<{ company: Company }>('/companies', body),
|
||||
update: (id: number, body: Record<string, unknown>) =>
|
||||
patch<{ company: Company }>(`/companies/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/companies/${id}`),
|
||||
addInterview: (id: number, body: Record<string, unknown>) =>
|
||||
post<{ id: number }>(`/companies/${id}/interviews`, body),
|
||||
};
|
||||
|
||||
export interface ContactDetail {
|
||||
contact: Contact;
|
||||
employments: Employment[];
|
||||
interviews: Interview[];
|
||||
history: HistoryEntry[];
|
||||
mentioned: HistoryEntry[];
|
||||
}
|
||||
|
||||
export const contactsApi = {
|
||||
list: (params: Record<string, unknown> = {}) =>
|
||||
get<{ contacts: Contact[] }>(`/contacts${query(params)}`),
|
||||
detail: (id: number) => get<ContactDetail>(`/contacts/${id}`),
|
||||
create: (body: Record<string, unknown>) => post<{ contact: Contact }>('/contacts', body),
|
||||
update: (id: number, body: Record<string, unknown>) =>
|
||||
patch<{ contact: Contact }>(`/contacts/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`),
|
||||
addInterview: (id: number, body: Record<string, unknown>) =>
|
||||
post<{ id: number }>(`/contacts/${id}/interviews`, body),
|
||||
/*
|
||||
* Werdegang von Hand pflegen. Die Stationen entstehen zwar beim Setzen eines
|
||||
* Arbeitgebers und beim CV-Import von selbst - aber kein Lebenslauf wird
|
||||
* vollständig erkannt, und ein falscher Firmenname ließ sich sonst nicht mehr
|
||||
* geradeziehen.
|
||||
*
|
||||
* `company` (Name) oder `company_id` - ein unbekannter Name legt das
|
||||
* Unternehmen an, wie im CV-Import auch.
|
||||
*/
|
||||
addEmployment: (id: number, body: Record<string, unknown>) =>
|
||||
post<{ id: number }>(`/contacts/${id}/employments`, body),
|
||||
updateEmployment: (employmentId: number, body: Record<string, unknown>) =>
|
||||
patch<{ ok: true }>(`/contacts/employments/${employmentId}`, body),
|
||||
removeEmployment: (employmentId: number) =>
|
||||
del<{ ok: true }>(`/contacts/employments/${employmentId}`),
|
||||
lastEmployers: () =>
|
||||
get<{
|
||||
entries: Array<{
|
||||
contact_id: number;
|
||||
first_name: string | null;
|
||||
last_name: string;
|
||||
avatar: string | null;
|
||||
company_id: number;
|
||||
company: string;
|
||||
industry: string | null;
|
||||
position: string | null;
|
||||
ended_on: string | null;
|
||||
}>;
|
||||
}>('/contacts/last-employers'),
|
||||
linkedInPhoto: (url: string) =>
|
||||
post<{ avatar: string; bytes: number }>('/contacts/linkedin-photo', { url }),
|
||||
};
|
||||
|
||||
export const tendersApi = {
|
||||
list: (params: Record<string, unknown> = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`),
|
||||
detail: (id: number) =>
|
||||
get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`),
|
||||
create: (body: Record<string, unknown>) => post<{ tender: Tender }>('/tenders', body),
|
||||
update: (id: number, body: Record<string, unknown>) => patch<{ tender: Tender }>(`/tenders/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`),
|
||||
};
|
||||
|
||||
export const projectsApi = {
|
||||
list: (params: Record<string, unknown> = {}) =>
|
||||
get<{ projects: Project[] }>(`/projects${query(params)}`),
|
||||
create: (body: Record<string, unknown>) => post<{ project: Project }>('/projects', body),
|
||||
update: (id: number, body: Record<string, unknown>) =>
|
||||
patch<{ project: Project }>(`/projects/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/projects/${id}`),
|
||||
};
|
||||
|
||||
export const leadsApi = {
|
||||
list: (archived = false) => get<{ leads: Lead[] }>(`/leads${query({ archived: archived ? 1 : 0 })}`),
|
||||
detail: (id: number) =>
|
||||
get<{ lead: Lead; todos: LeadTodo[]; history: HistoryEntry[] }>(`/leads/${id}`),
|
||||
create: (body: Record<string, unknown>) => post<{ lead: Lead }>('/leads', body),
|
||||
update: (id: number, body: Record<string, unknown>) => patch<{ lead: Lead }>(`/leads/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/leads/${id}`),
|
||||
reminders: () =>
|
||||
get<{ due: Lead[]; stale: Lead[]; stale_days: number }>('/leads/reminders'),
|
||||
acknowledge: (id: number, snooze_days?: number) =>
|
||||
post<{ ok: true }>(`/leads/${id}/acknowledge`, { snooze_days }),
|
||||
addTodo: (id: number, text: string, due_on?: string | null) =>
|
||||
post<{ id: number }>(`/leads/${id}/todos`, { text, due_on }),
|
||||
updateTodo: (todoId: number, body: Record<string, unknown>) =>
|
||||
patch<{ ok: true }>(`/leads/todos/${todoId}`, body),
|
||||
removeTodo: (todoId: number) => del<{ ok: true }>(`/leads/todos/${todoId}`),
|
||||
};
|
||||
|
||||
/** Vorschlag der KI zu einem Lebenslauf. */
|
||||
export interface CvSuggestion {
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
position: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
linkedin: string | null;
|
||||
company: string | null;
|
||||
tags: string[];
|
||||
bio: string | null;
|
||||
rate_onsite: number | null;
|
||||
rate_remote: number | null;
|
||||
employments: Array<{ company: string; position: string | null; from: string | null; to: string | null }>;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
export interface CvImport {
|
||||
id: number;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
mime: string | null;
|
||||
size_bytes: number;
|
||||
status: 'eingang' | 'screening' | 'auswahl' | 'verworfen';
|
||||
extracted: CvSuggestion | null;
|
||||
/**
|
||||
* Traegt der Lebenslauf ein Bewerbungsfoto? Das Bild selbst steht nicht in
|
||||
* der Liste - es wird bei Bedarf einzeln geholt (`cvsApi.photo`) und erst im
|
||||
* Browser verkleinert (siehe lib/imageCompress.ts).
|
||||
*/
|
||||
has_photo: boolean;
|
||||
error: string | null;
|
||||
contact_id: number | null;
|
||||
contact_name: string | null;
|
||||
uploaded_by_name: string | null;
|
||||
created_at: string;
|
||||
processed_at: string | null;
|
||||
}
|
||||
|
||||
export const cvsApi = {
|
||||
list: () =>
|
||||
get<{ imports: CvImport[]; ai: { configured: boolean; model: string } }>('/cvs'),
|
||||
/** Mehrere Dateien auf einmal - der Browser setzt die multipart-Grenze selbst. */
|
||||
upload: (files: File[]) => {
|
||||
const body = new FormData();
|
||||
for (const file of files) body.append('files', file);
|
||||
return apiRequest<{ ids: number[]; count: number }>('/cvs', { method: 'POST', body });
|
||||
},
|
||||
analyze: (id: number) =>
|
||||
post<{ extracted: CvSuggestion; photo: string | null }>(`/cvs/${id}/analyze`),
|
||||
/** Das gefundene Foto, unveraendert - nur fuer das Formular. */
|
||||
photo: (id: number) => get<{ photo: string | null }>(`/cvs/${id}/photo`),
|
||||
accept: (id: number, overrides: Record<string, unknown> = {}) =>
|
||||
post<{ contact_id: number }>(`/cvs/${id}/accept`, overrides),
|
||||
reject: (id: number) => post<{ ok: true }>(`/cvs/${id}/reject`),
|
||||
remove: (id: number) => del<{ ok: true }>(`/cvs/${id}`),
|
||||
};
|
||||
|
||||
export type AiTask = 'write' | 'improve' | 'shorten' | 'bullets' | 'profile';
|
||||
|
||||
/**
|
||||
* Zustand der KI-Einrichtung. Der Schlüssel steht hier nur verkürzt
|
||||
* ("sk-proj-…a1b2") - vollständig verlässt er den Server nie.
|
||||
*/
|
||||
export interface AiConfig {
|
||||
configured: boolean;
|
||||
model: string;
|
||||
defaultModel: string;
|
||||
/** 'db' = in den Einstellungen gepflegt, 'env' = aus server/.env, 'none' = keiner */
|
||||
source: 'db' | 'env' | 'none';
|
||||
masked: string | null;
|
||||
envAvailable: boolean;
|
||||
}
|
||||
|
||||
export const aiApi = {
|
||||
status: () => get<{ configured: boolean; model: string; tasks: AiTask[] }>('/ai/status'),
|
||||
text: (task: AiTask, current: string, prompt?: string) =>
|
||||
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
|
||||
config: () => get<AiConfig>('/ai/config'),
|
||||
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
|
||||
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
|
||||
};
|
||||
|
||||
export const perincoApi = {
|
||||
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
|
||||
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
|
||||
};
|
||||
|
||||
export const historyApi = {
|
||||
list: (params: Record<string, unknown> = {}) =>
|
||||
get<{ entries: HistoryEntry[] }>(`/history${query(params)}`),
|
||||
add: (entity_type: string, entity_id: number | null, text: string) =>
|
||||
post<{ entry: HistoryEntry }>('/history', { entity_type, entity_id, text }),
|
||||
remove: (id: number) => del<{ ok: true }>(`/history/${id}`),
|
||||
};
|
||||
|
||||
export interface SearchGroup {
|
||||
type: 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'history';
|
||||
label: string;
|
||||
items: Array<Record<string, unknown> & { id: number; title?: string }>;
|
||||
}
|
||||
|
||||
export interface MentionCandidate {
|
||||
kind: 'user' | 'contact' | 'company';
|
||||
id: number;
|
||||
label: string;
|
||||
hint: string | null;
|
||||
avatar: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface AiAnswer {
|
||||
answer: string;
|
||||
refs: Array<{ type: 'contact' | 'company' | 'tender' | 'project' | 'lead'; id: number; label: string }>;
|
||||
model: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
search: (q: string) => get<{ query: string; groups: SearchGroup[]; total: number }>(`/search${query({ q })}`),
|
||||
mentions: (q: string) => get<{ candidates: MentionCandidate[] }>(`/search/mentions${query({ q })}`),
|
||||
ask: (question: string) => post<AiAnswer>('/search/ai', { question }),
|
||||
};
|
||||
|
||||
export interface OptionValue {
|
||||
id: number;
|
||||
field: string;
|
||||
value: string;
|
||||
position: number;
|
||||
/** Wie oft der Wert im Bestand verwendet wird. */
|
||||
used: number;
|
||||
}
|
||||
|
||||
export interface OptionField {
|
||||
field: string;
|
||||
label: string;
|
||||
values: OptionValue[];
|
||||
}
|
||||
|
||||
export const optionsApi = {
|
||||
list: () => get<{ fields: OptionField[] }>('/options'),
|
||||
create: (field: string, value: string) => post<{ id: number }>('/options', { field, value }),
|
||||
rename: (id: number, value: string) => patch<{ ok: true; renamed: number }>(`/options/${id}`, { value }),
|
||||
remove: (id: number) => del<{ ok: true }>(`/options/${id}`),
|
||||
};
|
||||
|
||||
export interface NetworkData {
|
||||
companies: Array<Company & { id: number }>;
|
||||
contacts: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
position: string | null;
|
||||
avatar: string | null;
|
||||
kind: string;
|
||||
status: string;
|
||||
company_id: number | null;
|
||||
}>;
|
||||
edges: Array<Record<string, unknown> & { type: 'employment' | 'former' | 'contact' | 'group' }>;
|
||||
groups: Array<{ name: string; count: number }>;
|
||||
}
|
||||
|
||||
export const networkApi = {
|
||||
load: (params: Record<string, unknown> = {}) => get<NetworkData>(`/network${query(params)}`),
|
||||
};
|
||||
|
||||
export const connectionsApi = {
|
||||
list: () => get<{ connections: Connection[] }>('/connections'),
|
||||
create: (contact_a: number, contact_b: number, kind?: string) =>
|
||||
post<Connection>('/connections', { contact_a, contact_b, kind }),
|
||||
update: (id: number, kind: string) => patch<{ ok: true }>(`/connections/${id}`, { kind }),
|
||||
remove: (id: number) => del<{ ok: true }>(`/connections/${id}`),
|
||||
};
|
||||
|
||||
export interface DashboardData {
|
||||
counts: Record<string, number>;
|
||||
contacts_by_status: Array<{ status: string; count: number }>;
|
||||
companies_by_industry: Array<{ industry: string; count: number }>;
|
||||
tenders_by_stage: Array<{ stage: string; count: number }>;
|
||||
recent: HistoryEntry[];
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
load: () => get<DashboardData>('/dashboard'),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Whisper erwartet Mono-PCM mit 16 kHz als Float32. Der MediaRecorder liefert
|
||||
* aber WebM/Opus in der Aufnahmerate der Hardware - hier wird dekodiert und
|
||||
* heruntergerechnet.
|
||||
*/
|
||||
export const WHISPER_SAMPLE_RATE = 16000;
|
||||
|
||||
export interface DecodedAudio {
|
||||
audio: Float32Array;
|
||||
/** Laenge der Aufnahme in Sekunden. */
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export async function toWhisperAudio(blob: Blob): Promise<DecodedAudio> {
|
||||
const bytes = await blob.arrayBuffer();
|
||||
|
||||
const AudioContextClass =
|
||||
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) throw new Error('Dieser Browser kann kein Audio dekodieren.');
|
||||
|
||||
const context = new AudioContextClass();
|
||||
let decoded: AudioBuffer;
|
||||
try {
|
||||
decoded = await context.decodeAudioData(bytes);
|
||||
} finally {
|
||||
// Nicht geschlossene Kontexte zaehlen gegen das Browserlimit.
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
|
||||
if (decoded.duration === 0) throw new Error('Die Aufnahme ist leer.');
|
||||
|
||||
// Resampling ueber einen OfflineAudioContext - das erledigt gleich das
|
||||
// Zusammenfassen mehrerer Kanaele auf Mono.
|
||||
const frames = Math.max(1, Math.ceil(decoded.duration * WHISPER_SAMPLE_RATE));
|
||||
const offline = new OfflineAudioContext(1, frames, WHISPER_SAMPLE_RATE);
|
||||
const source = offline.createBufferSource();
|
||||
source.buffer = decoded;
|
||||
source.connect(offline.destination);
|
||||
source.start();
|
||||
const resampled = await offline.startRendering();
|
||||
|
||||
return { audio: resampled.getChannelData(0), seconds: decoded.duration };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { AppUser } from '../types';
|
||||
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AppUser | null;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
/** Aktualisiert den lokalen Benutzer, z. B. nach Bearbeiten des eigenen Kontos. */
|
||||
updateUser: (user: AppUser) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AppUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
// Ein 401 aus irgendeinem Aufruf wirft sauber zurueck auf den Login, statt
|
||||
// eine leere Ansicht stehen zu lassen.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
// Beim Start pruefen, ob das gespeicherte Token noch gilt.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const restore = async () => {
|
||||
if (!getToken()) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { user: current } = await authApi.me();
|
||||
if (!cancelled) setUser(current);
|
||||
} catch {
|
||||
clearToken();
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void restore();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (identifier: string, password: string) => {
|
||||
const { token, user: loggedIn } = await authApi.login(identifier, password);
|
||||
setToken(token);
|
||||
setUser(loggedIn);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAdmin: user?.role === 'admin',
|
||||
login,
|
||||
logout,
|
||||
updateUser: setUser,
|
||||
}),
|
||||
[user, isLoading, login, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useWhisper } from './whisper';
|
||||
|
||||
/**
|
||||
* Aufnahme fuer das Diktat. Die Erkennung selbst macht der Whisper-Worker im
|
||||
* Browser (siehe whisper.tsx) - hier geht es nur um Mikrofon, Dauer und das
|
||||
* Weiterreichen der Aufnahme.
|
||||
*/
|
||||
|
||||
/** Erstes Format, das der Browser aufnehmen kann. */
|
||||
function pickMimeType(): string {
|
||||
const candidates = ['audio/webm', 'audio/ogg', 'audio/mp4'];
|
||||
for (const type of candidates) {
|
||||
if (MediaRecorder.isTypeSupported?.(type)) return type;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'transcribing';
|
||||
|
||||
interface UseRecorderResult {
|
||||
state: RecorderState;
|
||||
/** Laufende Aufnahmedauer in Sekunden. */
|
||||
seconds: number;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function useRecorder(
|
||||
onText: (text: string) => void,
|
||||
onError: (message: string) => void
|
||||
): UseRecorderResult {
|
||||
const { transcribe, status } = useWhisper();
|
||||
const [state, setState] = useState<RecorderState>('idle');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const discardRef = useRef(false);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// Mikrofon freigeben, sonst bleibt die Aufnahmeanzeige des Browsers stehen.
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setSeconds(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (state !== 'idle') return;
|
||||
if (status !== 'ready') {
|
||||
onError('Das Spracherkennungsmodell ist noch nicht geladen.');
|
||||
return;
|
||||
}
|
||||
discardRef.current = false;
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err) {
|
||||
const name = (err as DOMException)?.name;
|
||||
onError(
|
||||
name === 'NotAllowedError'
|
||||
? 'Kein Zugriff aufs Mikrofon. Bitte im Browser erlauben.'
|
||||
: name === 'NotFoundError'
|
||||
? 'Es wurde kein Mikrofon gefunden.'
|
||||
: 'Die Aufnahme konnte nicht gestartet werden.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = pickMimeType();
|
||||
let recorder: MediaRecorder;
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
} catch {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
onError('Dieser Browser kann kein passendes Audioformat aufnehmen.');
|
||||
return;
|
||||
}
|
||||
|
||||
streamRef.current = stream;
|
||||
recorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
|
||||
cleanup();
|
||||
|
||||
if (discardRef.current) {
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
if (blob.size < 1024) {
|
||||
setState('idle');
|
||||
onError('Die Aufnahme war zu kurz.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState('transcribing');
|
||||
try {
|
||||
const { text } = await transcribe(blob);
|
||||
if (text) onText(text);
|
||||
else onError('Es war nichts Verständliches zu hören.');
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.');
|
||||
} finally {
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setState('recording');
|
||||
setSeconds(0);
|
||||
timerRef.current = window.setInterval(() => setSeconds((value) => value + 1), 1000);
|
||||
}, [state, status, cleanup, onError, onText, transcribe]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
discardRef.current = true;
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
return { state, seconds, start, stop, cancel };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Bilder im Browser verkleinern, bevor sie hochgeladen werden.
|
||||
*
|
||||
* Fotos und Logos liegen als Data-URL in der Datenbank. Ohne diesen Schritt
|
||||
* landet ein 3-MB-Foto aus dem Telefon unveraendert in einer Textspalte und
|
||||
* geht bei jedem Laden der Liste erneut ueber die Leitung.
|
||||
*
|
||||
* 400 px ist bewusst mehr als jede Ansicht braucht (die groesste zeigt 96 px):
|
||||
* die Featureliste nennt diese Groesse fuer den CV-Import, und ein spaeterer
|
||||
* Ausbau soll das Bild nicht erneut skalieren muessen.
|
||||
*/
|
||||
|
||||
const TARGET_SIZE = 400;
|
||||
const QUALITY = 0.82;
|
||||
|
||||
/** Grenze fuer die Quelldatei - komprimiert wird sie ohnehin. */
|
||||
export const MAX_SOURCE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Obergrenze fuer das Ergebnis.
|
||||
*
|
||||
* 400 px WebP liegt in der Praxis bei 20-60 kB, die Grenze greift also nie -
|
||||
* sie ist die Zusicherung, nicht die Regel. Ein Foto aus einem Lebenslauf kann
|
||||
* ein Scan in Druckaufloesung sein; ohne Grenze laege so etwas ungeprueft in
|
||||
* einer Textspalte.
|
||||
*/
|
||||
export const MAX_RESULT_BYTES = 512 * 1024;
|
||||
|
||||
/** Groesse einer Data-URL in Byte - Base64 traegt 3 Byte je 4 Zeichen. */
|
||||
function dataUrlBytes(dataUrl: string): number {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
||||
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
||||
return Math.floor((base64.length * 3) / 4) - padding;
|
||||
}
|
||||
|
||||
export async function compressImageFile(file: File): Promise<string> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Die Datei ist kein Bild.');
|
||||
}
|
||||
if (file.size > MAX_SOURCE_BYTES) {
|
||||
throw new Error('Das Bild ist zu groß (max. 25 MB).');
|
||||
}
|
||||
return compressImageSource(await readAsDataUrl(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkleinert eine Data- oder http-Adresse auf ein Quadrat von 400 px und
|
||||
* hoechstens {@link MAX_RESULT_BYTES}.
|
||||
*
|
||||
* Bleibt das Ergebnis ueber der Grenze - ein sehr unruhiges Bild kann das -,
|
||||
* wird die Kantenlaenge schrittweise verringert, bis es passt. Lieber ein
|
||||
* kleineres Foto als eine Zeile, die jede Liste ausbremst.
|
||||
*/
|
||||
export async function compressImageSource(source: string): Promise<string> {
|
||||
const image = await loadImage(source);
|
||||
|
||||
// Mittiger Ausschnitt: ein Portrait wird sonst verzerrt, und der Kopf sitzt
|
||||
// bei Profilfotos praktisch immer in der Mitte.
|
||||
const side = Math.min(image.naturalWidth, image.naturalHeight);
|
||||
const sx = (image.naturalWidth - side) / 2;
|
||||
const sy = (image.naturalHeight - side) / 2;
|
||||
|
||||
let size = Math.min(TARGET_SIZE, side);
|
||||
let last = '';
|
||||
|
||||
// Hoechstens fuenf Anlaeufe: 400 -> 320 -> 256 -> 204 -> 163 px.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Das Bild konnte nicht verarbeitet werden.');
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, sx, sy, side, side, 0, 0, size, size);
|
||||
|
||||
// WebP zuerst, dann JPEG, dann PNG: aeltere Browser liefern bei einem nicht
|
||||
// unterstuetzten Typ stillschweigend PNG zurueck - das faellt hier auf.
|
||||
let dataUrl = '';
|
||||
for (const type of ['image/webp', 'image/jpeg', 'image/png']) {
|
||||
const candidate = canvas.toDataURL(type, QUALITY);
|
||||
if (candidate.startsWith(`data:${type}`)) {
|
||||
dataUrl = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!dataUrl) throw new Error('Das Bild konnte nicht umgewandelt werden.');
|
||||
|
||||
last = dataUrl;
|
||||
if (dataUrlBytes(dataUrl) <= MAX_RESULT_BYTES) return dataUrl;
|
||||
size = Math.round(size * 0.8);
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('Die Datei konnte nicht gelesen werden.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(source: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useState, type DragEvent } from 'react';
|
||||
|
||||
/*
|
||||
* Ziehen und Fallenlassen auf den Kanban-Brettern (Leads, Ausschreibungen).
|
||||
*
|
||||
* Die Gesten stecken hier und nicht in den Ansichten, weil sie dreimal an
|
||||
* denselben Stellen schiefgehen:
|
||||
*
|
||||
* 1. **Ohne Nutzlast keine Geste.** Firefox startet eine Ziehgeste nur, wenn
|
||||
* `dragstart` etwas in `dataTransfer` legt. Chrome ist da nachsichtiger -
|
||||
* deshalb faellt es beim Entwickeln nicht auf und beim Benutzen sofort.
|
||||
* 2. **Der Zustand allein reicht nicht.** Die Kennung wird zusaetzlich in
|
||||
* `dataTransfer` mitgegeben. Sie ueberlebt damit auch einen Neuaufbau der
|
||||
* Liste waehrend der Geste.
|
||||
* 3. **Ohne `dropEffect` zeigt der Zeiger ein Verbotsschild**, obwohl das
|
||||
* Fallenlassen erlaubt ist.
|
||||
*
|
||||
* Bilder in den Kacheln (Logo, Foto) muessen zusaetzlich `draggable={false}`
|
||||
* tragen - sonst zieht der Browser das Bild statt der Kachel. Das erledigt die
|
||||
* Avatar-Komponente.
|
||||
*/
|
||||
|
||||
/** Eigenes Format neben `text/plain`: erkennt eigene Kacheln von fremdem Text. */
|
||||
const MIME = 'application/x-icon-card';
|
||||
|
||||
interface KanbanDrag<K extends string> {
|
||||
/** Kennung der Kachel am Zeiger - fuer die gedaempfte Darstellung. */
|
||||
draggedId: number | null;
|
||||
/** Spalte unter dem Zeiger - fuer die Hervorhebung des Ziels. */
|
||||
hoverColumn: K | null;
|
||||
/** Auf die Kachel legen. */
|
||||
cardProps: (id: number) => {
|
||||
draggable: true;
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
/** Auf die Spalte legen. */
|
||||
columnProps: (column: K) => {
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => void;
|
||||
onDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useKanbanDrag<K extends string>(
|
||||
onMove: (id: number, column: K) => void
|
||||
): KanbanDrag<K> {
|
||||
const [draggedId, setDraggedId] = useState<number | null>(null);
|
||||
const [hoverColumn, setHoverColumn] = useState<K | null>(null);
|
||||
|
||||
const cardProps = useCallback(
|
||||
(id: number) =>
|
||||
({
|
||||
draggable: true,
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => {
|
||||
event.dataTransfer.setData(MIME, String(id));
|
||||
event.dataTransfer.setData('text/plain', String(id));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
setDraggedId(id);
|
||||
},
|
||||
onDragEnd: () => {
|
||||
setDraggedId(null);
|
||||
setHoverColumn(null);
|
||||
},
|
||||
}) as const,
|
||||
[]
|
||||
);
|
||||
|
||||
const columnProps = useCallback(
|
||||
(column: K) => ({
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
setHoverColumn((current) => (current === column ? current : column));
|
||||
},
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => {
|
||||
// Der Zeiger wandert staendig ueber Kindelemente - nur das Verlassen
|
||||
// der Spalte selbst zaehlt.
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
setHoverColumn((current) => (current === column ? null : current));
|
||||
},
|
||||
onDrop: (event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
const carried = event.dataTransfer.getData(MIME) || event.dataTransfer.getData('text/plain');
|
||||
const id = draggedId ?? Number(carried);
|
||||
setDraggedId(null);
|
||||
setHoverColumn(null);
|
||||
if (Number.isInteger(id) && id > 0) onMove(id, column);
|
||||
},
|
||||
}),
|
||||
[draggedId, onMove]
|
||||
);
|
||||
|
||||
return { draggedId, hoverColumn, cardProps, columnProps };
|
||||
}
|
||||
|
||||
/** Rahmen der Spalte, solange eine Kachel darueber schwebt. */
|
||||
export function columnClass(isHover: boolean): string {
|
||||
return isHover ? 'border-brand-400 bg-brand-50/40' : 'border-ink-200 bg-canvas';
|
||||
}
|
||||
|
||||
/** Die Kachel am Zeiger wird gedaempft - sonst sieht man nicht, was man zieht. */
|
||||
export function cardClass(isDragged: boolean): string {
|
||||
return isDragged ? 'opacity-40' : '';
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { searchApi, type MentionCandidate } from './api';
|
||||
import { useNavigation } from './navigation';
|
||||
import { initials } from '../types';
|
||||
|
||||
/*
|
||||
* @-Erwaehnungen.
|
||||
*
|
||||
* Gespeichert wird ein Token: `@[Anzeigename](contact:12)`, ebenso fuer
|
||||
* `company` und `user`. Der Name steht mit im Token, damit ein Text auch dann
|
||||
* lesbar bleibt, wenn die Listen nicht geladen sind.
|
||||
*
|
||||
* Jede Erwaehnung ist anklickbar und oeffnet das zugehoerige Profil - und sie
|
||||
* landet ueber den Index in icon_mentions in der Historie des Genannten.
|
||||
*/
|
||||
|
||||
const MENTION_TOKEN = /@\[([^\]\n]+)\]\((contact|company|user):(\d+)\)/g;
|
||||
|
||||
export type MentionKind = 'contact' | 'company' | 'user';
|
||||
|
||||
type Segment =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'mention'; kind: MentionKind; id: number; label: string };
|
||||
|
||||
export function parseMentions(text: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
MENTION_TOKEN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = MENTION_TOKEN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
segments.push({
|
||||
type: 'mention',
|
||||
label: match[1],
|
||||
kind: match[2] as MentionKind,
|
||||
id: Number(match[3]),
|
||||
});
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) segments.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Text ohne Token-Syntax - fuer Vorschauen und Kurzfassungen. */
|
||||
export function mentionPlainText(text: string): string {
|
||||
return parseMentions(text)
|
||||
.map((segment) => (segment.type === 'text' ? segment.value : `@${segment.label}`))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Enthält der Text mindestens eine Erwähnung? */
|
||||
export function hasMentions(text: string | null | undefined): boolean {
|
||||
if (!text) return false;
|
||||
MENTION_TOKEN.lastIndex = 0;
|
||||
return MENTION_TOKEN.test(text);
|
||||
}
|
||||
|
||||
export function mentionToken(candidate: MentionCandidate): string {
|
||||
// Klammern im Namen wuerden das Token zerlegen.
|
||||
const label = candidate.label.replace(/[[\]()]/g, '').trim() || 'Unbenannt';
|
||||
return `@[${label}](${candidate.kind}:${candidate.id})`;
|
||||
}
|
||||
|
||||
/** Rendert einen Text mit klickbaren Erwaehnungen. */
|
||||
export const MentionText: React.FC<{ text: string | null | undefined; className?: string }> = ({
|
||||
text,
|
||||
className,
|
||||
}) => {
|
||||
const { open } = useNavigation();
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{parseMentions(text).map((segment, index) =>
|
||||
segment.type === 'text' ? (
|
||||
<React.Fragment key={index}>{segment.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (segment.kind === 'user') return;
|
||||
open(segment.kind, segment.id);
|
||||
}}
|
||||
className={
|
||||
segment.kind === 'user'
|
||||
? 'mx-0.5 rounded bg-ink-100 px-1 font-medium text-ink-700'
|
||||
: 'mx-0.5 rounded bg-brand-100 px-1 font-medium text-brand-700 hover:bg-brand-200'
|
||||
}
|
||||
title={segment.kind === 'company' ? 'Unternehmen öffnen' : 'Profil öffnen'}
|
||||
>
|
||||
@{segment.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface MentionFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Textfeld mit Vorschlagsliste: ein `@` oeffnet sie, die Auswahl setzt den
|
||||
* Token an der Schreibmarke ein.
|
||||
*/
|
||||
export const MentionField: React.FC<MentionFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
className = '',
|
||||
id,
|
||||
}) => {
|
||||
/*
|
||||
* Zwei Zustaende.
|
||||
*
|
||||
* lesen der Text, wie er gemeint ist - Erwaehnungen sind Knoepfe und
|
||||
* fuehren auf das Profil
|
||||
* bearbeiten der Rohtext mit dem Token `@[Name](contact:12)`, denn anders
|
||||
* laesst sich eine Erwaehnung nicht setzen oder loeschen
|
||||
*
|
||||
* Vorher stand beides untereinander: oben der Rohtext, darunter dieselbe
|
||||
* Aussage noch einmal als Vorschau. Das war doppelt und niemand hat die
|
||||
* Vorschau als das Klickbare erkannt.
|
||||
*
|
||||
* Ein leeres Feld startet gleich im Bearbeiten-Zustand - sonst muesste man
|
||||
* erst einen Knopf finden, um ueberhaupt etwas schreiben zu koennen.
|
||||
*/
|
||||
const [editing, setEditing] = useState(() => !value.trim());
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [candidates, setCandidates] = useState<MentionCandidate[]>([]);
|
||||
const [queryText, setQueryText] = useState<string | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
// Steht die Schreibmarke hinter einem @ samt angefangenem Wort, ist das die
|
||||
// Suche. Ein Leerzeichen im Wort beendet sie - sonst stuende die Liste bei
|
||||
// jedem Satz offen, der ein @ enthaelt.
|
||||
const detectQuery = (text: string, caret: number): string | null => {
|
||||
const upToCaret = text.slice(0, caret);
|
||||
const match = /@([^\s@[\]()]{0,40})$/.exec(upToCaret);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (queryText === null) {
|
||||
setCandidates([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const { candidates: found } = await searchApi.mentions(queryText);
|
||||
if (!cancelled) {
|
||||
setCandidates(found.slice(0, 8));
|
||||
setActive(0);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setCandidates([]);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [queryText]);
|
||||
|
||||
const insert = (candidate: MentionCandidate) => {
|
||||
const field = textareaRef.current;
|
||||
if (!field) return;
|
||||
const caret = field.selectionStart ?? value.length;
|
||||
const before = value.slice(0, caret).replace(/@([^\s@[\]()]{0,40})$/, '');
|
||||
const after = value.slice(caret);
|
||||
const token = `${mentionToken(candidate)} `;
|
||||
|
||||
onChange(`${before}${token}${after}`);
|
||||
setQueryText(null);
|
||||
|
||||
// Die Schreibmarke hinter den eingefuegten Token setzen - sonst springt
|
||||
// sie ans Ende und der Satz wird von hinten weitergeschrieben.
|
||||
requestAnimationFrame(() => {
|
||||
const position = before.length + token.length;
|
||||
field.focus();
|
||||
field.setSelectionRange(position, position);
|
||||
});
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (queryText === null || candidates.length === 0) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index + 1) % candidates.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
insert(candidates[active]);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setQueryText(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hint = useMemo(
|
||||
() => 'Mit @ lassen sich Kontakte, Unternehmen und Kollegen nennen.',
|
||||
[]
|
||||
);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-200 bg-surface">
|
||||
<div className="flex items-start gap-2 px-3 py-2">
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm text-ink-800">
|
||||
{value.trim() ? (
|
||||
<MentionText text={value} />
|
||||
) : (
|
||||
<span className="text-ink-400">{placeholder ?? 'Kein Text'}</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditing(true);
|
||||
// Nach dem Wechsel steht das Feld erst im naechsten Frame.
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-ink-500 hover:bg-ink-100 hover:text-ink-800"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
{hasMentions(value) && (
|
||||
<p className="border-t border-ink-100 px-3 py-1.5 text-xs text-ink-400">
|
||||
Erwähnungen sind anklickbar und öffnen das Profil.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
setQueryText(detectQuery(event.target.value, event.target.selectionStart ?? 0));
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => {
|
||||
// Verzoegert, damit ein Klick auf einen Vorschlag noch ankommt.
|
||||
setTimeout(() => setQueryText(null), 150);
|
||||
}}
|
||||
className={`w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm text-ink-900 placeholder:text-ink-400 ${className}`}
|
||||
/>
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 text-xs text-ink-400">{hint}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-50"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{queryText !== null && candidates.length > 0 && (
|
||||
<ul className="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-ink-200 bg-surface shadow-lift">
|
||||
{candidates.map((candidate, index) => (
|
||||
<li key={`${candidate.kind}-${candidate.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => insert(candidate)}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm ${
|
||||
index === active ? 'bg-brand-50' : ''
|
||||
}`}
|
||||
>
|
||||
{candidate.avatar ? (
|
||||
<img src={candidate.avatar} alt="" className="h-6 w-6 rounded-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: candidate.color || '#93939b' }}
|
||||
>
|
||||
{initials(candidate.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-ink-900">{candidate.label}</span>
|
||||
<span className="ml-auto text-xs text-ink-400">
|
||||
{candidate.kind === 'user'
|
||||
? 'Benutzer'
|
||||
: candidate.kind === 'company'
|
||||
? 'Unternehmen'
|
||||
: candidate.hint || 'Kontakt'}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { View } from '../types';
|
||||
|
||||
/*
|
||||
* Ansichtswechsel fuer tief liegende Komponenten.
|
||||
*
|
||||
* Eine angeklickte @-Erwaehnung oder ein Treffer der globalen Suche muss die
|
||||
* passende Karte oeffnen koennen, ohne dass jede Ebene dazwischen einen
|
||||
* Rueckruf durchreicht.
|
||||
*/
|
||||
|
||||
export type OpenTarget = 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'user';
|
||||
|
||||
/** Reiter der Einstellungen - auch von aussen ansteuerbar. */
|
||||
export type SettingsTab = 'konto' | 'benutzer' | 'stammdaten' | 'ki' | 'diktat';
|
||||
|
||||
interface NavigationValue {
|
||||
setView: (view: View) => void;
|
||||
/** Oeffnet die Karte eines Datensatzes - wechselt dabei die Ansicht. */
|
||||
open: (type: OpenTarget, id: number) => void;
|
||||
/**
|
||||
* Wechselt in die Einstellungen und oeffnet dort einen bestimmten Reiter.
|
||||
*
|
||||
* Gebraucht fuer das Diktat: der Knopf im Formular meldet, dass das
|
||||
* Sprachmodell fehlt, und muss direkt dorthin fuehren koennen, wo es geladen
|
||||
* wird - ein blosser Hinweis "steht in den Einstellungen" waere eine
|
||||
* Suchaufgabe.
|
||||
*/
|
||||
goToSettings: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
const NavigationContext = createContext<NavigationValue | null>(null);
|
||||
|
||||
export const NavigationProvider: React.FC<{ value: NavigationValue; children: React.ReactNode }> = ({
|
||||
value,
|
||||
children,
|
||||
}) => <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
|
||||
|
||||
export function useNavigation(): NavigationValue {
|
||||
const ctx = useContext(NavigationContext);
|
||||
if (!ctx) throw new Error('useNavigation muss innerhalb von <NavigationProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { optionsApi } from './api';
|
||||
import { useAuth } from './auth';
|
||||
import { DEFAULT_CONTACT_KINDS } from '../types';
|
||||
|
||||
/*
|
||||
* Die gepflegten Auswahllisten (Einstellungen → Stammdaten).
|
||||
*
|
||||
* Sie werden einmal nach der Anmeldung geladen und von jedem Formular
|
||||
* mitbenutzt - Branche und Kontaktart tauchen an einem halben Dutzend Stellen
|
||||
* auf, und jede davon einzeln laden zu lassen hieße, dieselbe Anfrage
|
||||
* mehrfach zu stellen.
|
||||
*
|
||||
* Solange nichts geladen ist, gelten die Vorgabewerte: ein leeres Auswahlfeld
|
||||
* wäre schlimmer als eine unvollständige Liste.
|
||||
*/
|
||||
|
||||
interface OptionsValue {
|
||||
industries: string[];
|
||||
contactKinds: string[];
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
const OptionsContext = createContext<OptionsValue | null>(null);
|
||||
|
||||
export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [industries, setIndustries] = useState<string[]>([]);
|
||||
const [contactKinds, setContactKinds] = useState<string[]>(DEFAULT_CONTACT_KINDS);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { fields } = await optionsApi.list();
|
||||
const values = (field: string) =>
|
||||
fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? [];
|
||||
|
||||
setIndustries(values('industry'));
|
||||
const kinds = values('contact_kind');
|
||||
setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS);
|
||||
} catch {
|
||||
/* Ohne Liste gelten die Vorgabewerte - kein Grund, die Anwendung zu stoppen. */
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const value = useMemo<OptionsValue>(
|
||||
() => ({ industries, contactKinds, reload }),
|
||||
[industries, contactKinds, reload]
|
||||
);
|
||||
|
||||
return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>;
|
||||
};
|
||||
|
||||
export function useOptions(): OptionsValue {
|
||||
const ctx = useContext(OptionsContext);
|
||||
if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Hilfen für die WYSIWYG-Felder.
|
||||
*
|
||||
* Der Bestand ist gemischt: manche Texte sind reiner Text mit Zeilenumbrüchen
|
||||
* (aus dem alten CRM), manche sind HTML (aus dem Ausschreibungstext kopiert),
|
||||
* und manche sind beides - ein paar Kopfzeilen als Text, darunter ein HTML-Block.
|
||||
*
|
||||
* Ohne Umwandlung zeigt ein WYSIWYG-Feld den reinen Text als einen einzigen
|
||||
* Absatz und - schlimmer - HTML-Quelltext als sichtbare spitze Klammern.
|
||||
*/
|
||||
|
||||
/** Enthält der Text bereits Auszeichnung? */
|
||||
function hasTags(text: string): boolean {
|
||||
return /<[a-z][\s\S]*>/i.test(text);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** Reinen Text in Absätze umwandeln; Leerzeilen trennen, einfache Umbrüche bleiben. */
|
||||
function paragraphs(text: string): string {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/*
|
||||
* Der Altbestand kommt aus einem anderen Editor (Quill) und bringt drei Dinge
|
||||
* mit, die hier stoeren:
|
||||
*
|
||||
* 1. Inline-Farben - `background-color: rgb(255,255,255); color: rgb(34,34,34)`
|
||||
* an fast jedem Textstueck. Im hellen Modus faellt das nicht auf, im
|
||||
* dunklen wird daraus schwarze Schrift auf weissen Kaesten.
|
||||
* 2. Geschuetzte Leerzeichen zwischen JEDEM Wort (bis zu 470 je Text). Sie
|
||||
* verbieten den Zeilenumbruch - der Text laeuft waagerecht aus dem Bild.
|
||||
* 3. Verschachtelte Listen `<li><ul>…` ohne eigenen Text im aeusseren
|
||||
* Punkt - daraus werden doppelte Aufzaehlungszeichen.
|
||||
*
|
||||
* Bereinigt wird bewusst zurueckhaltend: nur Farbe und Hintergrund fliegen aus
|
||||
* den Stilangaben, Fett und Kursiv bleiben. Dieselbe Aufraeumarbeit macht die
|
||||
* ICON Website beim Anzeigen ohnehin schon.
|
||||
*/
|
||||
function sanitize(html: string): string {
|
||||
if (typeof DOMParser === 'undefined') return html;
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
for (const element of Array.from(doc.body.querySelectorAll<HTMLElement>('[style]'))) {
|
||||
element.style.removeProperty('color');
|
||||
element.style.removeProperty('background-color');
|
||||
element.style.removeProperty('background');
|
||||
element.style.removeProperty('font-family');
|
||||
element.style.removeProperty('font-size');
|
||||
if (!element.getAttribute('style')?.trim()) element.removeAttribute('style');
|
||||
}
|
||||
|
||||
// Verschachtelte Liste, deren aeusserer Punkt nichts enthaelt: der Punkt
|
||||
// faellt weg, die innere Liste rueckt an seine Stelle.
|
||||
for (const nested of Array.from(doc.body.querySelectorAll('li > ul, li > ol'))) {
|
||||
const parent = nested.parentElement;
|
||||
if (!parent) continue;
|
||||
const hasOwnText = Array.from(parent.childNodes).some(
|
||||
(node) => node !== nested && (node.textContent ?? '').replace(/\u00a0/g, ' ').trim().length > 0
|
||||
);
|
||||
if (!hasOwnText) parent.replaceWith(...Array.from(nested.childNodes));
|
||||
}
|
||||
|
||||
// Geschuetzte Leerzeichen zu normalen machen - sonst bricht keine Zeile um.
|
||||
return doc.body.innerHTML.replace(/\u00a0/g, ' ').replace(/ /g, ' ');
|
||||
} catch {
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt einen gespeicherten Text in eine Form, die der Editor darstellen kann.
|
||||
*
|
||||
* Drei Fälle:
|
||||
* * reiner Text → Absätze
|
||||
* * reines HTML → unverändert
|
||||
* * Text, dann HTML → der Kopf wird zu Absätzen, der Rest bleibt
|
||||
*
|
||||
* Der dritte Fall ist kein Sonderfall, sondern der Normalfall bei den
|
||||
* Projekten: dort steht ein kurzer Kopf (KUNDE, KONTAKT, START) und darunter
|
||||
* der übernommene Ausschreibungstext.
|
||||
*/
|
||||
export function toEditorHtml(value: string | null | undefined): string {
|
||||
const text = String(value ?? '');
|
||||
if (!text.trim()) return '';
|
||||
if (!hasTags(text)) return paragraphs(text);
|
||||
|
||||
const firstTag = text.search(/<[a-z]/i);
|
||||
if (firstTag <= 0) return sanitize(text);
|
||||
|
||||
const head = text.slice(0, firstTag).trim();
|
||||
const rest = sanitize(text.slice(firstTag));
|
||||
return head ? `${paragraphs(head)}${rest}` : rest;
|
||||
}
|
||||
|
||||
/** Reiner Text aus HTML - für Vorschauen und um zu prüfen, ob etwas dasteht. */
|
||||
export function stripHtml(html: string): string {
|
||||
return String(html ?? '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { authApi } from './api';
|
||||
import { useAuth } from './auth';
|
||||
import { THEME_PALETTES, type ThemePalette } from '../types';
|
||||
|
||||
/*
|
||||
* Modus (hell/dunkel) und Design.
|
||||
*
|
||||
* Die Wahl liegt doppelt: im localStorage, damit das Inline-Skript in
|
||||
* index.html sie vor dem ersten Frame setzen kann, und am Benutzer in der
|
||||
* Datenbank, damit sie auf jedem Geraet gilt. Ohne Wahl folgt der Modus der
|
||||
* Systemeinstellung.
|
||||
*/
|
||||
|
||||
export type ThemeName = ThemePalette;
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
const MODE_KEY = 'icon_crm_theme_mode';
|
||||
const THEME_KEY = 'icon_crm_theme';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeName;
|
||||
mode: ThemeMode;
|
||||
/** true, solange der Benutzer nichts gewaehlt hat und das System entscheidet. */
|
||||
followsSystem: boolean;
|
||||
setTheme: (theme: ThemeName) => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
toggleMode: () => void;
|
||||
/** Zurueck auf "folgt dem System". */
|
||||
resetMode: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
}
|
||||
|
||||
function readStored<T extends string>(key: string): T | null {
|
||||
try {
|
||||
return (localStorage.getItem(key) as T | null) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function store(key: string, value: string | null) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* Privater Modus oder gesperrte Website-Daten - dann gilt nur die Sitzung. */
|
||||
}
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, updateUser } = useAuth();
|
||||
const [storedMode, setStoredMode] = useState<ThemeMode | null>(() => readStored<ThemeMode>(MODE_KEY));
|
||||
const [theme, setThemeState] = useState<ThemeName>(() => {
|
||||
// Ein Wert aus einer früheren Fassung (etwa ein entferntes Design) darf
|
||||
// nicht dazu führen, dass eine Klasse gesetzt wird, die es nicht gibt.
|
||||
const stored = readStored<ThemeName>(THEME_KEY);
|
||||
return stored && THEME_PALETTES.includes(stored) ? stored : 'icon';
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(systemPrefersDark);
|
||||
|
||||
// Die Wahl des angemeldeten Benutzers gewinnt gegen den lokalen Speicher:
|
||||
// sie gilt auf jedem Geraet.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (user.theme_mode && user.theme_mode !== storedMode) {
|
||||
setStoredMode(user.theme_mode);
|
||||
store(MODE_KEY, user.theme_mode);
|
||||
}
|
||||
/*
|
||||
* Auch der Wert vom Server wird geprüft. Ein Design, das aus der Liste
|
||||
* geflogen ist, steht weiter am Konto - und hat die Anwendung schon einmal
|
||||
* beim Start abstürzen lassen (14.09.2026, "duke").
|
||||
*/
|
||||
if (user.theme && THEME_PALETTES.includes(user.theme as ThemeName) && user.theme !== theme) {
|
||||
setThemeState(user.theme as ThemeName);
|
||||
store(THEME_KEY, user.theme);
|
||||
}
|
||||
// Absichtlich nur an user gebunden - sonst schriebe jede lokale Aenderung
|
||||
// sich selbst wieder zurueck.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, user?.theme, user?.theme_mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!media) return;
|
||||
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
|
||||
media.addEventListener('change', listener);
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, []);
|
||||
|
||||
const mode: ThemeMode = storedMode ?? (systemDark ? 'dark' : 'light');
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', mode === 'dark');
|
||||
/*
|
||||
* Jede theme-* Klasse abräumen, nicht nur die bekannten: das Inline-Skript
|
||||
* in index.html setzt sie aus dem localStorage, und dort kann ein längst
|
||||
* entfernter Name stehen. Der bliebe sonst für immer am <html> hängen.
|
||||
*/
|
||||
for (const name of [...root.classList]) {
|
||||
if (name.startsWith('theme-')) root.classList.remove(name);
|
||||
}
|
||||
if (theme !== 'icon') root.classList.add(`theme-${theme}`);
|
||||
}, [mode, theme]);
|
||||
|
||||
const persist = useCallback(
|
||||
async (body: { theme?: ThemeName; theme_mode?: ThemeMode | null }) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { user: updated } = await authApi.updateMe(body);
|
||||
updateUser(updated);
|
||||
} catch {
|
||||
/* Die Darstellung steht lokal schon richtig - ein Fehler beim Sichern
|
||||
darf den Wechsel nicht zurueckdrehen. */
|
||||
}
|
||||
},
|
||||
[user, updateUser]
|
||||
);
|
||||
|
||||
const setMode = useCallback(
|
||||
(next: ThemeMode) => {
|
||||
setStoredMode(next);
|
||||
store(MODE_KEY, next);
|
||||
void persist({ theme_mode: next });
|
||||
},
|
||||
[persist]
|
||||
);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
mode,
|
||||
followsSystem: storedMode === null,
|
||||
setTheme: (next: ThemeName) => {
|
||||
setThemeState(next);
|
||||
store(THEME_KEY, next);
|
||||
void persist({ theme: next });
|
||||
},
|
||||
setMode,
|
||||
toggleMode: () => setMode(mode === 'dark' ? 'light' : 'dark'),
|
||||
resetMode: () => {
|
||||
setStoredMode(null);
|
||||
store(MODE_KEY, null);
|
||||
void persist({ theme_mode: null });
|
||||
},
|
||||
}),
|
||||
[theme, mode, storedMode, setMode, persist]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { toWhisperAudio } from './audio';
|
||||
import type { WorkerRequest, WorkerResponse } from './whisper.worker';
|
||||
|
||||
/**
|
||||
* Diktat laeuft vollstaendig im Browser: Whisper als ONNX-Modell ueber
|
||||
* transformers.js, gerechnet auf der GPU (WebGPU) oder ersatzweise auf der CPU
|
||||
* (WASM). Der Server ist daran nicht beteiligt - Aufnahmen verlassen das Geraet
|
||||
* nicht.
|
||||
*
|
||||
* Es gibt bewusst nichts einzustellen:
|
||||
*
|
||||
* large-v3-turbo - beste Erkennung im Deutschen, auch bei Fachbegriffen
|
||||
* ("S4 HANA"). small verliert genau dort, tiny ist unbrauchbar.
|
||||
* q4 - andere Genauigkeiten scheitern: q8 liefert auf der
|
||||
* Grafikkarte Unsinn, fp16 blockiert den Browser-Tab, fp32
|
||||
* passt nicht in einen Speicherblock.
|
||||
* automatisch - Grafikkarte wenn vorhanden, sonst CPU
|
||||
*
|
||||
* Die Belege dazu stehen im README. Wer daran drehen will, aendert diese
|
||||
* Konstanten - und prueft die Kombination vorher mit einer echten Aufnahme.
|
||||
*/
|
||||
export const WHISPER_MODEL = 'onnx-community/whisper-large-v3-turbo';
|
||||
export const WHISPER_DTYPE = 'q4' as const;
|
||||
export const WHISPER_LANGUAGE = 'de';
|
||||
|
||||
/**
|
||||
* WebGPU steht noch nicht in den TypeScript-Standardtypen. Statt eines eigenen
|
||||
* Typpakets hier nur das, was wirklich gebraucht wird.
|
||||
*/
|
||||
interface GpuAdapterLike {
|
||||
info?: Record<string, string>;
|
||||
limits?: Record<string, number>;
|
||||
}
|
||||
interface GpuLike {
|
||||
requestAdapter(): Promise<GpuAdapterLike | null>;
|
||||
}
|
||||
|
||||
function getGpu(): GpuLike | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
return (navigator as unknown as { gpu?: GpuLike }).gpu ?? null;
|
||||
}
|
||||
|
||||
/** Cache, in dem transformers.js die Modelldateien ablegt. */
|
||||
const MODEL_CACHE = 'transformers-cache';
|
||||
|
||||
/** Woran man im Cache erkennt, dass die Modelldateien schon vorliegen. */
|
||||
const MODEL_FILE_PATTERN = /encoder_model[^/]*q4[^/]*\.onnx$/i;
|
||||
|
||||
/**
|
||||
* Bittet den Browser, den Speicher dauerhaft zu behandeln.
|
||||
*
|
||||
* Die Modelldateien liegen im Cache-Speicher der Seite (nicht in Cookies - dort
|
||||
* passen nur wenige Kilobyte). Der ueberlebt Sitzungen ohnehin, gilt ohne diese
|
||||
* Bitte aber als "best effort": bei Platznot auf dem Geraet darf der Browser ihn
|
||||
* verwerfen. Mit erteilter Erlaubnis raeumt er ihn nur noch auf ausdruecklichen
|
||||
* Wunsch des Benutzers weg.
|
||||
*
|
||||
* Chrome entscheidet das nach eigenen Kriterien (u. a. wie oft die Seite genutzt
|
||||
* wird) und kann ablehnen. Dann funktioniert alles genauso - nur ohne diese
|
||||
* zusaetzliche Zusicherung.
|
||||
*/
|
||||
async function requestPersistentStorage(): Promise<boolean> {
|
||||
try {
|
||||
if (!navigator.storage?.persist) return false;
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liegt das Modell schon im Browser? Wird direkt am Cache geprueft und nicht
|
||||
* selbst mitgeschrieben - so wird auch erkannt, was in einer anderen Sitzung
|
||||
* geladen wurde.
|
||||
*/
|
||||
async function detectCached(): Promise<boolean> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return false;
|
||||
const cache = await caches.open(MODEL_CACHE);
|
||||
const keys = await cache.keys();
|
||||
return keys.some(
|
||||
(request) => request.url.includes(`${WHISPER_MODEL}/`) && MODEL_FILE_PATTERN.test(request.url)
|
||||
);
|
||||
} catch {
|
||||
// Cache API gesperrt (privater Modus) - dann eben ohne Erkennung.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne Rueckmeldung so lange gewartet? Dann ist etwas haengen geblieben. Das
|
||||
* Anlegen der ONNX-Sitzung meldet keinen Fortschritt, darf also dauern - aber
|
||||
* nicht endlos.
|
||||
*/
|
||||
const STALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Grenze fuer eine einzelne Erkennung, abhaengig von der Aufnahmelaenge: auf
|
||||
* der CPU ist ein Vielfaches der Aufnahmedauer normal, endlos aber nie.
|
||||
*/
|
||||
function transcribeTimeoutMs(seconds: number): number {
|
||||
return Math.min(300_000, Math.max(45_000, Math.round(seconds * 15_000)));
|
||||
}
|
||||
|
||||
export type WhisperStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
/** Woran gerade gearbeitet wird, solange geladen wird. */
|
||||
export type WhisperPhase = 'download' | 'init' | null;
|
||||
|
||||
export interface WhisperCapabilities {
|
||||
webgpu: boolean;
|
||||
adapter: string | null;
|
||||
cores: number | null;
|
||||
mediaRecorder: boolean;
|
||||
secureContext: boolean;
|
||||
}
|
||||
|
||||
interface WhisperContextValue {
|
||||
status: WhisperStatus;
|
||||
/** Download oder Initialisierung - nur waehrend des Ladens gesetzt. */
|
||||
phase: WhisperPhase;
|
||||
/** 0-100, waehrend des Downloads. */
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error: string | null;
|
||||
/** Modell liegt bereits im Browser-Cache. */
|
||||
isCached: boolean;
|
||||
capabilities: WhisperCapabilities;
|
||||
/** Tatsaechlich genutzter Rechenweg - Grafikkarte wenn vorhanden. */
|
||||
resolvedDevice: 'webgpu' | 'wasm';
|
||||
load: () => Promise<void>;
|
||||
unload: () => void;
|
||||
clearCache: () => Promise<void>;
|
||||
/** Nimmt eine Aufnahme und liefert den erkannten Text. */
|
||||
transcribe: (blob: Blob) => Promise<{ text: string; ms: number; seconds: number }>;
|
||||
/** Browser kann grundsaetzlich aufnehmen und rechnen. */
|
||||
isSupported: boolean;
|
||||
}
|
||||
|
||||
const WhisperContext = createContext<WhisperContextValue | null>(null);
|
||||
|
||||
export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = useState<WhisperStatus>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressLabel, setProgressLabel] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCached, setIsCached] = useState(false);
|
||||
const [phase, setPhase] = useState<WhisperPhase>(null);
|
||||
const [capabilities, setCapabilities] = useState<WhisperCapabilities>({
|
||||
webgpu: getGpu() !== null,
|
||||
adapter: null,
|
||||
cores: typeof navigator !== 'undefined' ? navigator.hardwareConcurrency ?? null : null,
|
||||
mediaRecorder: typeof MediaRecorder !== 'undefined',
|
||||
secureContext: typeof window !== 'undefined' ? window.isSecureContext : false,
|
||||
});
|
||||
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const loadWaiterRef = useRef<{ resolve: () => void; reject: (err: Error) => void } | null>(null);
|
||||
/** Zeitpunkt der letzten Worker-Nachricht - Grundlage der Haenger-Erkennung. */
|
||||
const lastActivityRef = useRef(0);
|
||||
const stallTimerRef = useRef<number | null>(null);
|
||||
const jobsRef = useRef(
|
||||
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
|
||||
);
|
||||
const nextJobId = useRef(1);
|
||||
|
||||
// Details zur GPU nachladen - das geht nur asynchron.
|
||||
useEffect(() => {
|
||||
const gpu = getGpu();
|
||||
if (!gpu) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const adapter = await gpu.requestAdapter();
|
||||
if (cancelled) return;
|
||||
if (!adapter) {
|
||||
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfügbar' }));
|
||||
return;
|
||||
}
|
||||
const info = adapter.info ?? {};
|
||||
const described = [info.vendor, info.architecture, info.device, info.description]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
setCapabilities((prev) => ({ ...prev, adapter: described || 'Adapter vorhanden' }));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setCapabilities((prev) => ({
|
||||
...prev,
|
||||
webgpu: false,
|
||||
adapter: err instanceof Error ? err.message : 'Abfrage fehlgeschlagen',
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Grafikkarte wenn verfuegbar, sonst CPU - daran gibt es nichts zu waehlen.
|
||||
const resolvedDevice: 'webgpu' | 'wasm' = capabilities.webgpu ? 'webgpu' : 'wasm';
|
||||
|
||||
const isSupported = capabilities.mediaRecorder && capabilities.secureContext;
|
||||
|
||||
// Cache-Zustand am Browser erfragen, nicht selbst mitschreiben.
|
||||
const refreshCacheState = useCallback(async () => {
|
||||
setIsCached(await detectCached());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCacheState();
|
||||
}, [refreshCacheState, status]);
|
||||
|
||||
const stopStallWatch = useCallback(() => {
|
||||
if (stallTimerRef.current !== null) {
|
||||
window.clearInterval(stallTimerRef.current);
|
||||
stallTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureWorker = useCallback((): Worker => {
|
||||
if (workerRef.current) return workerRef.current;
|
||||
|
||||
const worker = new Worker(new URL('./whisper.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
/** Bricht das Laden mit einer verwertbaren Meldung ab. */
|
||||
const failLoad = (message: string) => {
|
||||
stopStallWatch();
|
||||
setStatus('error');
|
||||
setPhase(null);
|
||||
setError(message);
|
||||
// Nach einem Fehlschlag ist der Worker nicht mehr vertrauenswuerdig.
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
loadWaiterRef.current?.reject(new Error(message));
|
||||
loadWaiterRef.current = null;
|
||||
// Laufende Erkennungen warten sonst fuer immer auf einen toten Worker.
|
||||
for (const job of jobsRef.current.values()) job.reject(new Error(message));
|
||||
jobsRef.current.clear();
|
||||
};
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const message = event.data;
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
if (message.type === 'progress') {
|
||||
if (message.total > 0) {
|
||||
const done = message.loaded >= message.total;
|
||||
setProgress(Math.min(100, (message.loaded / message.total) * 100));
|
||||
setProgressLabel(
|
||||
`${Math.round(message.loaded / 1e6)} von ${Math.round(message.total / 1e6)} MB`
|
||||
);
|
||||
// Nach dem letzten Byte folgt das Anlegen der Rechensitzung - das
|
||||
// meldet keinen Fortschritt mehr und soll trotzdem sichtbar sein.
|
||||
setPhase(done ? 'init' : 'download');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'ready') {
|
||||
stopStallWatch();
|
||||
setStatus('ready');
|
||||
setPhase(null);
|
||||
setProgress(100);
|
||||
setProgressLabel('');
|
||||
loadWaiterRef.current?.resolve();
|
||||
loadWaiterRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
jobsRef.current.get(message.id)?.resolve({ text: message.text, ms: message.ms });
|
||||
jobsRef.current.delete(message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fehler
|
||||
if (message.id !== undefined) {
|
||||
jobsRef.current.get(message.id)?.reject(new Error(message.message));
|
||||
jobsRef.current.delete(message.id);
|
||||
} else {
|
||||
failLoad(message.message);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (event) => {
|
||||
failLoad(event.message || 'Der Whisper-Worker ist abgestürzt.');
|
||||
};
|
||||
|
||||
workerRef.current = worker;
|
||||
return worker;
|
||||
}, [stopStallWatch]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (status === 'loading') return;
|
||||
if (status === 'ready') return;
|
||||
|
||||
setStatus('loading');
|
||||
setPhase('download');
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressLabel(isCached ? 'aus dem Cache' : 'Download startet...');
|
||||
|
||||
// Vor dem Download um dauerhaften Speicher bitten, damit der Browser die
|
||||
// Dateien danach nicht bei Platznot wieder wegraeumt.
|
||||
void requestPersistentStorage();
|
||||
|
||||
const worker = ensureWorker();
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
// Haenger-Erkennung: bleibt der Worker stumm - etwa weil das Anlegen der
|
||||
// Sitzung am Speicher scheitert - wird das Laden mit Meldung abgebrochen,
|
||||
// statt endlos "Laedt..." zu zeigen.
|
||||
stopStallWatch();
|
||||
stallTimerRef.current = window.setInterval(() => {
|
||||
if (Date.now() - lastActivityRef.current < STALL_TIMEOUT_MS) return;
|
||||
stopStallWatch();
|
||||
const message =
|
||||
'Das Modell reagiert seit zwei Minuten nicht. Bitte die Seite neu laden und es erneut versuchen.';
|
||||
setStatus('error');
|
||||
setPhase(null);
|
||||
setError(message);
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
loadWaiterRef.current?.reject(new Error(message));
|
||||
loadWaiterRef.current = null;
|
||||
}, 5000);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
loadWaiterRef.current = { resolve, reject };
|
||||
const request: WorkerRequest = {
|
||||
type: 'load',
|
||||
model: WHISPER_MODEL,
|
||||
device: resolvedDevice,
|
||||
dtype: WHISPER_DTYPE,
|
||||
};
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}, [status, isCached, ensureWorker, resolvedDevice, stopStallWatch]);
|
||||
|
||||
const unload = useCallback(() => {
|
||||
stopStallWatch();
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
jobsRef.current.clear();
|
||||
setStatus('idle');
|
||||
setPhase(null);
|
||||
setProgress(0);
|
||||
setProgressLabel('');
|
||||
}, [stopStallWatch]);
|
||||
|
||||
const clearCache = useCallback(async () => {
|
||||
unload();
|
||||
try {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((name) => caches.delete(name)));
|
||||
} catch {
|
||||
// Cache API gesperrt - dann bleibt der Download eben liegen.
|
||||
}
|
||||
await refreshCacheState();
|
||||
}, [unload, refreshCacheState]);
|
||||
|
||||
const transcribe = useCallback(
|
||||
async (blob: Blob) => {
|
||||
if (status !== 'ready') throw new Error('Das Spracherkennungsmodell ist nicht geladen.');
|
||||
|
||||
const { audio, seconds } = await toWhisperAudio(blob);
|
||||
const worker = ensureWorker();
|
||||
const id = nextJobId.current++;
|
||||
|
||||
const outcome = await new Promise<{ text: string; ms: number }>((resolve, reject) => {
|
||||
// Ohne Frist bliebe die Oberflaeche bei "Erkenne..." stehen, wenn die
|
||||
// Rechenumgebung nicht antwortet.
|
||||
const timer = window.setTimeout(() => {
|
||||
jobsRef.current.delete(id);
|
||||
const message =
|
||||
'Die Erkennung hat nicht geantwortet. Bitte die Seite neu laden und es erneut versuchen.';
|
||||
// Der Worker steckt fest; ein neuer Versuch braucht einen frischen.
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
setStatus('idle');
|
||||
setError(message);
|
||||
reject(new Error(message));
|
||||
}, transcribeTimeoutMs(seconds));
|
||||
|
||||
jobsRef.current.set(id, {
|
||||
resolve: (value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (err) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
|
||||
const request: WorkerRequest = {
|
||||
type: 'transcribe',
|
||||
id,
|
||||
audio,
|
||||
language: WHISPER_LANGUAGE,
|
||||
};
|
||||
// Der Puffer wird uebergeben statt kopiert - bei langen Aufnahmen spart
|
||||
// das mehrere Megabyte.
|
||||
worker.postMessage(request, [audio.buffer]);
|
||||
});
|
||||
|
||||
return { ...outcome, seconds };
|
||||
},
|
||||
[status, ensureWorker]
|
||||
);
|
||||
|
||||
/*
|
||||
* Das Modell wird NICHT von selbst bereitgestellt, auch nicht aus dem Cache.
|
||||
*
|
||||
* Es hat sich anders gezeigt, als es gedacht war: das Anlegen der
|
||||
* ONNX-Sitzung uebersetzt hunderte Shader und legt dabei Zeiger und
|
||||
* Darstellung fuer eine Weile still. Wer das CRM oeffnet, um einen Lead zu
|
||||
* verschieben, haelt das - zu Recht - fuer einen Absturz der Anwendung.
|
||||
*
|
||||
* Deshalb: ein Klick, und zwar der des Benutzers. Liegt das Modell im Cache,
|
||||
* kostet "Diktat aktivieren" nur noch das Anlegen der Sitzung; der Download
|
||||
* faellt nur einmal je Browser an.
|
||||
*/
|
||||
useEffect(() => () => workerRef.current?.terminate(), []);
|
||||
|
||||
const value = useMemo<WhisperContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
resolvedDevice,
|
||||
load,
|
||||
unload,
|
||||
clearCache,
|
||||
transcribe,
|
||||
isSupported,
|
||||
}),
|
||||
[
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
resolvedDevice,
|
||||
load,
|
||||
unload,
|
||||
clearCache,
|
||||
transcribe,
|
||||
isSupported,
|
||||
]
|
||||
);
|
||||
|
||||
return <WhisperContext.Provider value={value}>{children}</WhisperContext.Provider>;
|
||||
};
|
||||
|
||||
export function useWhisper(): WhisperContextValue {
|
||||
const ctx = useContext(WhisperContext);
|
||||
if (!ctx) throw new Error('useWhisper muss innerhalb von <WhisperProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Whisper laeuft im Worker, nicht im Hauptthread: die Erkennung rechnet je nach
|
||||
* Modell mehrere Sekunden, und solange darf die Oberflaeche nicht einfrieren.
|
||||
*
|
||||
* Das Modell wird beim ersten Laden vom Hugging-Face-CDN geholt und danach im
|
||||
* Browser-Cache gehalten. Die Aufnahme selbst verlaesst das Geraet nie.
|
||||
*/
|
||||
import { pipeline, env, type AutomaticSpeechRecognitionPipeline } from '@huggingface/transformers';
|
||||
|
||||
// Ohne das versucht transformers.js zuerst einen lokalen Pfad und laeuft in
|
||||
// unnoetige 404er.
|
||||
env.allowLocalModels = false;
|
||||
env.allowRemoteModels = true;
|
||||
|
||||
export type WorkerRequest =
|
||||
| { type: 'load'; model: string; device: 'webgpu' | 'wasm'; dtype: string }
|
||||
| { type: 'transcribe'; id: number; audio: Float32Array; language: string };
|
||||
|
||||
export type WorkerResponse =
|
||||
| { type: 'progress'; loaded: number; total: number; file: string }
|
||||
| { type: 'ready'; ms: number; downloadedBytes: number }
|
||||
| { type: 'result'; id: number; text: string; ms: number }
|
||||
| { type: 'error'; id?: number; message: string };
|
||||
|
||||
/**
|
||||
* `pipeline()` ist ueber alle Aufgabenarten hinweg typisiert; TypeScript kann
|
||||
* die entstehende Union nicht mehr darstellen ("union type too complex").
|
||||
* Deshalb hier eine schmale Signatur nur fuer die Spracherkennung.
|
||||
*/
|
||||
type CreatePipeline = (
|
||||
task: 'automatic-speech-recognition',
|
||||
model: string,
|
||||
options: Record<string, unknown>
|
||||
) => Promise<AutomaticSpeechRecognitionPipeline>;
|
||||
|
||||
const createPipeline = pipeline as unknown as CreatePipeline;
|
||||
|
||||
let transcriber: AutomaticSpeechRecognitionPipeline | null = null;
|
||||
/** Groesse je Datei, damit der Fortschritt ueber alle Dateien stimmt. */
|
||||
const files = new Map<string, { loaded: number; total: number }>();
|
||||
|
||||
const post = (message: WorkerResponse) => self.postMessage(message);
|
||||
|
||||
async function load(model: string, device: 'webgpu' | 'wasm', dtype: string) {
|
||||
files.clear();
|
||||
const started = performance.now();
|
||||
|
||||
transcriber = await createPipeline('automatic-speech-recognition', model, {
|
||||
device,
|
||||
dtype,
|
||||
progress_callback: (event: {
|
||||
status: string;
|
||||
file?: string;
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
}) => {
|
||||
if (event.status !== 'progress' || !event.file) return;
|
||||
files.set(event.file, { loaded: event.loaded ?? 0, total: event.total ?? 0 });
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
for (const entry of files.values()) {
|
||||
loaded += entry.loaded;
|
||||
total += entry.total;
|
||||
}
|
||||
post({ type: 'progress', loaded, total, file: event.file });
|
||||
},
|
||||
});
|
||||
|
||||
let downloadedBytes = 0;
|
||||
for (const entry of files.values()) downloadedBytes += entry.total;
|
||||
|
||||
post({ type: 'ready', ms: Math.round(performance.now() - started), downloadedBytes });
|
||||
}
|
||||
|
||||
async function transcribe(id: number, audio: Float32Array, language: string) {
|
||||
if (!transcriber) throw new Error('Das Modell ist nicht geladen.');
|
||||
|
||||
const started = performance.now();
|
||||
const output = await transcriber(audio, {
|
||||
language,
|
||||
task: 'transcribe',
|
||||
// Laengere Aufnahmen werden in Fenster zerlegt; der Ueberlappungsbereich
|
||||
// verhindert abgeschnittene Woerter an den Grenzen.
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
});
|
||||
|
||||
const text = Array.isArray(output)
|
||||
? output.map((part) => part.text ?? '').join(' ')
|
||||
: (output.text ?? '');
|
||||
|
||||
post({ type: 'result', id, text: text.trim(), ms: Math.round(performance.now() - started) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim Anlegen der ONNX-Sitzung koennen Fehler ausserhalb der await-Kette
|
||||
* auftreten - etwa "Array buffer allocation failed", wenn das Modell zu gross
|
||||
* fuer einen zusammenhaengenden Speicherblock ist. Ohne diese Handler bleibt
|
||||
* das Lade-Promise fuer immer offen und die Oberflaeche haengt bei "Laedt...".
|
||||
*/
|
||||
function reportFatal(reason: unknown) {
|
||||
const raw = reason instanceof Error ? reason.message : String(reason);
|
||||
const isMemory = /allocation failed|out of memory|Array buffer/i.test(raw);
|
||||
post({
|
||||
type: 'error',
|
||||
message: isMemory
|
||||
? `Der Speicher reicht für dieses Modell in dieser Genauigkeit nicht (${raw}). Bitte eine kleinere Genauigkeit wie q4 wählen.`
|
||||
: raw,
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('error', (event) => {
|
||||
event.preventDefault();
|
||||
reportFatal(event.error ?? event.message);
|
||||
});
|
||||
|
||||
self.addEventListener('unhandledrejection', (event) => {
|
||||
event.preventDefault();
|
||||
reportFatal(event.reason);
|
||||
});
|
||||
|
||||
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
const request = event.data;
|
||||
try {
|
||||
if (request.type === 'load') {
|
||||
await load(request.model, request.device, request.dtype);
|
||||
} else {
|
||||
await transcribe(request.id, request.audio, request.language);
|
||||
}
|
||||
} catch (err) {
|
||||
post({
|
||||
type: 'error',
|
||||
id: request.type === 'transcribe' ? request.id : undefined,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Downloadgroesse des Sprachmodells bei Hugging Face erfragen, damit im Reiter
|
||||
* eine echte Zahl steht statt einer Schaetzung.
|
||||
*
|
||||
* Geladen werden zwei Dateien: der Encoder und der zusammengefuehrte Decoder,
|
||||
* jeweils in der Variante q4.
|
||||
*/
|
||||
const DTYPE_SUFFIX = '_q4';
|
||||
|
||||
export interface ModelSize {
|
||||
totalBytes: number;
|
||||
/** Groesste Einzeldatei - sie entscheidet, ob der Browser sie halten kann. */
|
||||
largestBytes: number;
|
||||
}
|
||||
|
||||
interface Sibling {
|
||||
rfilename: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, ModelSize>();
|
||||
|
||||
export async function fetchModelSize(model: string): Promise<ModelSize | null> {
|
||||
const cached = cache.get(model);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(`https://huggingface.co/api/models/${model}?blobs=true`);
|
||||
if (!response.ok) throw new Error(`Modellinformationen nicht abrufbar (HTTP ${response.status}).`);
|
||||
|
||||
const data = (await response.json()) as { siblings?: Sibling[] };
|
||||
const files = new Map<string, number>();
|
||||
for (const sibling of data.siblings ?? []) {
|
||||
if (sibling.rfilename.endsWith('.onnx')) {
|
||||
files.set(sibling.rfilename.replace(/^onnx\//, '').replace(/\.onnx$/, ''), sibling.size ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// transformers.js nimmt den zusammengefuehrten Decoder, wenn er vorliegt.
|
||||
const decoderName = files.has(`decoder_model_merged${DTYPE_SUFFIX}`)
|
||||
? `decoder_model_merged${DTYPE_SUFFIX}`
|
||||
: `decoder_model${DTYPE_SUFFIX}`;
|
||||
const encoder = files.get(`encoder_model${DTYPE_SUFFIX}`);
|
||||
const decoder = files.get(decoderName);
|
||||
if (encoder === undefined || decoder === undefined) return null;
|
||||
|
||||
const size: ModelSize = {
|
||||
totalBytes: encoder + decoder,
|
||||
largestBytes: Math.max(encoder, decoder),
|
||||
};
|
||||
cache.set(model, size);
|
||||
return size;
|
||||
}
|
||||
|
||||
/** "299 MB" bzw. "1,6 GB" */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes >= 1_000_000_000) return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`;
|
||||
return `${Math.round(bytes / 1e6)} MB`;
|
||||
}
|
||||
Reference in New Issue
Block a user