Add push-to-talk voice dictation that transcribes recorded audio on the server via the workspace's OpenAI-compatible AI provider (Whisper / gpt-4o-transcribe / self-hosted whisper), then inserts the text. Backend: - New `stt_api_key_enc` column + migration; STT creds parity with chat/ embeddings (sttModel/sttBaseUrl/sttApiKey, write-only key, fallbacks to chat baseUrl/key). Both provider whitelists updated (service + repo). - AiService.getTranscriptionModel + AiTranscriptionService. - Gated POST /ai-chat/transcribe (dictation flag → 403, JWT + workspace scope + throttle, 25MB cap, MIME whitelist, never logs audio/key). - New `settings.ai.dictation` workspace flag (DTO + service + audit). Frontend: - Wire up the Voice/STT settings card (model/base URL/key) and the Voice-dictation toggle. - New `features/dictation`: useDictation (MediaRecorder state machine), MicButton, transcribe service; integrated into the chat composer and a new editor-toolbar dictation group, both gated by ai.dictation.
82 lines
3.0 KiB
TypeScript
82 lines
3.0 KiB
TypeScript
import { FC } from "react";
|
|
import { useAtomValue } from "jotai";
|
|
import type { Editor } from "@tiptap/react";
|
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
|
import { useToolbarState } from "./use-toolbar-state";
|
|
import { BlockTypeGroup } from "./groups/block-type-group";
|
|
import { InlineMarksGroup } from "./groups/inline-marks-group";
|
|
import { ColorGroup } from "./groups/color-group";
|
|
import { ListsGroup } from "./groups/lists-group";
|
|
import { AlignmentGroup } from "./groups/alignment-group";
|
|
import { MediaGroup } from "./groups/media-group";
|
|
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
|
import { MoreInsertsGroup } from "./groups/more-inserts-group";
|
|
import { HistoryGroup } from "./groups/history-group";
|
|
import { AskAiGroup } from "./groups/ask-ai-group";
|
|
import { DictationGroup } from "./groups/dictation-group";
|
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
|
import classes from "./fixed-toolbar.module.css";
|
|
|
|
type FixedToolbarProps = {
|
|
editor?: Editor | null;
|
|
templateMode?: boolean;
|
|
};
|
|
|
|
export const FixedToolbar: FC<FixedToolbarProps> = ({
|
|
editor: editorProp,
|
|
templateMode = false,
|
|
}) => {
|
|
const editorFromAtom = useAtomValue(pageEditorAtom);
|
|
const editor = editorProp ?? editorFromAtom;
|
|
const state = useToolbarState(editor);
|
|
const workspace = useAtomValue(workspaceAtom);
|
|
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
|
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
|
|
|
if (!editor || !state) return null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={classes.fixedToolbar}
|
|
data-fixed-toolbar="true"
|
|
role="toolbar"
|
|
aria-label="Editor toolbar"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
<div className={classes.inner}>
|
|
{/* {isGenerativeAiEnabled && (
|
|
<>
|
|
<AskAiGroup />
|
|
<div className={classes.divider} />
|
|
</>
|
|
)} */}
|
|
<BlockTypeGroup editor={editor} />
|
|
<div className={classes.divider} />
|
|
<InlineMarksGroup editor={editor} state={state} />
|
|
<div className={classes.divider} />
|
|
<ColorGroup editor={editor} />
|
|
<div className={classes.divider} />
|
|
<ListsGroup editor={editor} state={state} />
|
|
<div className={classes.divider} />
|
|
<AlignmentGroup editor={editor} />
|
|
<div className={classes.divider} />
|
|
<MediaGroup editor={editor} templateMode={templateMode} />
|
|
<div className={classes.divider} />
|
|
<QuickInsertsGroup editor={editor} />
|
|
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
|
|
<div className={classes.divider} />
|
|
<HistoryGroup editor={editor} state={state} />
|
|
{isDictationEnabled && (
|
|
<>
|
|
<div className={classes.divider} />
|
|
<DictationGroup editor={editor} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className={classes.spacer} aria-hidden />
|
|
</>
|
|
);
|
|
};
|