Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export const SELF_PARTIAL_STALE_MS = 15_000;
// cost for no effect - and it grew for the whole interview.
//
// Deliberately larger than the backend's window, so a change there does not silently starve the
// prompt. This does NOT bound retained history: the end-of-interview summary and .docx export
// read the full transcript from app state.
// prompt. This does NOT bound retained history: the end-of-interview summary and the .docx/.md
// export read the full transcript from app state.
export const TRANSCRIPT_UPLOAD_LIMIT = 60;

// Suggestion constants
Expand Down
5 changes: 3 additions & 2 deletions src/main/ipc/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { dialog, ipcMain } from 'electron';
import fs from 'fs/promises';

import { toolsService } from '../services/tools.service.js';
import { ExportFormat } from '../types/export.js';

export function registerToolsHandlers(): void {
ipcMain.handle('tools:export-transcript', async () => {
return toolsService.exportTranscript();
ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => {
return toolsService.exportTranscript(format);
});
ipcMain.handle('tools:clear-all', async () => {
await toolsService.clearAll();
Expand Down
3 changes: 2 additions & 1 deletion src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ const electronApi = {
},

tools: {
exportTranscript: () => ipcRenderer.invoke('tools:export-transcript'),
exportTranscript: (format: 'docx' | 'md') =>
ipcRenderer.invoke('tools:export-transcript', format),
clearAll: () => ipcRenderer.invoke('tools:clear-all'),
setPlaceholderData: () => ipcRenderer.invoke('tools:set-placeholder-data'),
saveImage: (opts: { filename: string; data: number[] }) =>
Expand Down
79 changes: 22 additions & 57 deletions src/main/services/tools.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import fs from 'fs/promises';

import { LLMApi } from '../api/llm.js';
import { configStore } from '../store/config.store.js';
import { Speaker } from '../types/app-state.js';
import { ExportFormat } from '../types/export.js';
import { GenerateSummarizeRequest } from '../types/llm.js';
import { buildExportMarkdown, generateExportFilename } from '../utils/export-markdown.js';
import { appStateService } from './app-state.service.js';
import { actionSuggestionService } from './suggestion-action.service.js';
import { liveSuggestionService } from './suggestion-live.service.js';
Expand All @@ -14,22 +15,7 @@ import { transcriptService } from './transcript.service.js';
class ToolsService {
private llmApi: LLMApi = new LLMApi();

private generateFilename(): string {
const d = new Date();

const pad = (n: number) => String(n).padStart(2, '0');

const yyyy = d.getFullYear();
const mm = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const min = pad(d.getMinutes());
const ss = pad(d.getSeconds());

return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.docx`;
}

async exportTranscript(): Promise<string | null> {
async exportTranscript(format: ExportFormat = 'docx'): Promise<string | null> {
// Prepare request data
const username = appStateService.getState().interviewConfig.fullName;
const transcripts = appStateService.getState().transcripts;
Expand All @@ -45,43 +31,30 @@ class ToolsService {
throw new Error(response.error.message);
}

const summaryPartRaw = response.data ?? '';
const fullMarkdown = buildExportMarkdown({
username,
summary: response.data ?? '',
transcripts,
suggestions,
});

// Add Date/Time to summary (insert after first line)
let summaryPart = summaryPartRaw;
if (summaryPart) {
const lines = summaryPart.split('\n');
if (lines.length > 0) {
const datetimeNow = new Date().toLocaleString();
lines.splice(1, 0, `\n##### Date/Time: ${datetimeNow}`);
summaryPart = lines.join('\n');
}
}
const isMarkdown = format === 'md';

// Build Transcripts section
const transcriptLines: string[] = [];
for (const t of transcripts) {
const timeStr = new Date(t.timestamp).toLocaleString();
const speakerName = t.speaker === Speaker.Self ? username : 'Interviewer';
transcriptLines.push(`#### ***${timeStr} | ${speakerName}***\n${t.text}\n`);
}
const transcriptsPart = `# **Transcripts**\n\n${transcriptLines.join('\n')}`;
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save Transcript',
defaultPath: generateExportFilename(format),
filters: isMarkdown
? [{ name: 'Markdown', extensions: ['md'] }]
: [{ name: 'Word Document', extensions: ['docx'] }],
});

// Build Suggestions section
const suggestionLines: string[] = [];
for (const s of suggestions) {
const timeStr = new Date(s.timestamp).toLocaleString();
suggestionLines.push(
`#### ***${timeStr} | Interviewer***\n${s.last_question}\n\n#### ***Suggestion***\n${s.answer}\n`
);
}
const suggestionsPart = `# **Suggestions**\n\n${suggestionLines.join('\n')}`;
if (canceled || !filePath) return null;

// Combine all parts into final Markdown content
const fullMarkdown =
`${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim();
if (isMarkdown) {
await fs.writeFile(filePath, fullMarkdown, 'utf8');
return filePath;
}

// Convert Markdown to DOCX
const docxBlob = await convertMarkdownToDocx(fullMarkdown, {
documentType: 'document',
style: {
Expand All @@ -90,14 +63,6 @@ class ToolsService {
},
});

const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save Transcript',
defaultPath: this.generateFilename(),
filters: [{ name: 'Word Document', extensions: ['docx'] }],
});

if (canceled || !filePath) return null;

await fs.writeFile(filePath, Buffer.from(await docxBlob.arrayBuffer()));
return filePath;
}
Expand Down
1 change: 1 addition & 0 deletions src/main/types/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ExportFormat = 'docx' | 'md';
72 changes: 72 additions & 0 deletions src/main/utils/export-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { LiveSuggestion, Speaker, Transcript } from '../types/app-state.js';
import { ExportFormat } from '../types/export.js';

interface ExportMarkdownInput {
username: string;
summary: string;
transcripts: Transcript[];
suggestions: LiveSuggestion[];
}

/**
* Builds the report every export format is rendered from. Kept free of any `electron` import so
* it stays loadable outside an Electron process - see test/tools-export.test.mjs.
*/
export function buildExportMarkdown({
username,
summary,
transcripts,
suggestions,
}: ExportMarkdownInput): string {
// Add Date/Time to summary (insert after first line)
let summaryPart = summary;
if (summaryPart) {
const lines = summaryPart.split('\n');
if (lines.length > 0) {
const datetimeNow = new Date().toLocaleString();
lines.splice(1, 0, `\n##### Date/Time: ${datetimeNow}`);
summaryPart = lines.join('\n');
}
}

// Build Transcripts section
const transcriptLines: string[] = [];
for (const t of transcripts) {
const timeStr = new Date(t.timestamp).toLocaleString();
const speakerName = t.speaker === Speaker.Self ? username : 'Interviewer';
transcriptLines.push(`#### ***${timeStr} | ${speakerName}***\n${t.text}\n`);
}
const transcriptsPart = `# **Transcripts**\n\n${transcriptLines.join('\n')}`;

// Build Suggestions section
const suggestionLines: string[] = [];
for (const s of suggestions) {
const timeStr = new Date(s.timestamp).toLocaleString();
suggestionLines.push(
`#### ***${timeStr} | Interviewer***\n${s.last_question}\n\n#### ***Suggestion***\n${s.answer}\n`
);
}
const suggestionsPart = `# **Suggestions**\n\n${suggestionLines.join('\n')}`;

return `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim();
}

export function generateExportFilename(format: ExportFormat): string {
const d = new Date();

const pad = (n: number) => String(n).padStart(2, '0');

const yyyy = d.getFullYear();
const mm = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const min = pad(d.getMinutes());
const ss = pad(d.getSeconds());

// Anything that is not an explicit `md` falls back to docx: `format` arrives over IPC from the
// renderer, where the type annotation is erased, so an unexpected value must not reach the
// extension.
const ext = format === 'md' ? 'md' : 'docx';

return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.${ext}`;
}
71 changes: 52 additions & 19 deletions src/renderer/components/custom/control-panel/tools-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
CaptionsOff,
CircleCheck,
FileIcon,
FileText,
FolderOpenIcon,
Hash,
Loader,
Save,
Trash2,
Expand All @@ -13,13 +15,20 @@ import { useState } from 'react';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppState } from '@/hooks/use-app-state';
import useTools from '@/hooks/use-tools';
import { useTranscriptPanel } from '@/hooks/use-transcript-panel';
import { Hotkey, HOTKEYS } from '@/lib/hotkeys';
import { getElectron } from '@/lib/utils';
import { RunningState } from '@/types/app-state';
import type { ExportFormat } from '@/types/export';

interface ToolsGroupProps {
getDisabled: (state: RunningState, disableOnRunning?: boolean) => boolean;
Expand All @@ -46,9 +55,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) {
}
};

const onExportTranscript = async () => {
const onExportTranscript = async (format: ExportFormat) => {
try {
const filePath = await exportTranscript();
const filePath = await exportTranscript(format);
if (!filePath) return;
const electron = getElectron();
const toastId = `export-${Date.now()}`;
Expand All @@ -63,7 +72,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) {
}}
>
<CircleCheck className="h-4 w-4 shrink-0" />
<span className="flex-1 text-sm font-medium">Interview exported</span>
<span className="flex-1 text-sm font-medium">
Interview exported as {format === 'md' ? 'Markdown' : 'Word'}
</span>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -161,22 +172,44 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) {
<p>Clear</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
onClick={onExportTranscript}
size="sm"
className="h-8 w-8 text-xs rounded-xl cursor-pointer"
disabled={getDisabled(runningState) || exporting}
>
{exporting ? <Loader className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Export Interview</p>
</TooltipContent>
</Tooltip>
{/* Non-modal for the same reason as the titlebar menu: a modal menu locks body pointer
events, and picking a format unmounts the menu before it releases the lock. */}
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 w-8 text-xs rounded-xl cursor-pointer"
disabled={getDisabled(runningState) || exporting}
>
{exporting ? (
<Loader className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Export Interview</p>
</TooltipContent>
</Tooltip>
{/* Opens upward, and not just for looks: the menu is portalled into the overflow-hidden
<main> from main-frame, and the control panel is the bottom-most thing in it, so a
downward menu would open past that edge and get clipped rather than merely flipped. */}
<DropdownMenuContent align="end" side="top">
<DropdownMenuItem onClick={() => void onExportTranscript('docx')}>
<FileText className="mr-2 h-4 w-4" />
Word Document (.docx)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void onExportTranscript('md')}>
<Hash className="mr-2 h-4 w-4" />
Markdown (.md)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
5 changes: 3 additions & 2 deletions src/renderer/hooks/use-tools.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { useState } from 'react';

import { getElectron } from '@/lib/utils';
import type { ExportFormat } from '@/types/export';

export default function useTools() {
const [exporting, setExporting] = useState(false);

const exportTranscript = async (): Promise<string | null> => {
const exportTranscript = async (format: ExportFormat): Promise<string | null> => {
setExporting(true);
try {
const electron = getElectron();
if (!electron) {
throw new Error('Electron API not available');
}
return await electron.tools.exportTranscript();
return await electron.tools.exportTranscript(format);
} catch (error) {
console.error('Failed to export transcript:', error);
throw error;
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/types/electron-api.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AppState } from './app-state';
import type { Config } from './config';
import type { ExportFormat } from './export';
import type { LLMConfig, LLMConfigValidationResult, LLMModelInfo } from './llm';
import type {
AvailableCurrency,
Expand Down Expand Up @@ -131,7 +132,7 @@ declare global {

// Tools management
tools: {
exportTranscript: () => Promise<string | null>;
exportTranscript: (format: ExportFormat) => Promise<string | null>;
clearAll: () => Promise<void>;
setPlaceholderData: () => Promise<void>;
saveImage: (opts: {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/types/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ExportFormat = 'docx' | 'md';
1 change: 1 addition & 0 deletions test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ for (const module of [
'./account.test.mjs',
'./stealth-surface.test.mjs',
'./stealth-toggle.test.mjs',
'./tools-export.test.mjs',
]) {
const { run } = await import(module);
failures.push(...(await run(userDataDir)));
Expand Down
Loading