Tt call recording app (#18281)

Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Paul Rastoin
2026-03-04 15:11:57 +01:00
committed by GitHub
parent c97d872b9f
commit 845a1934d3
103 changed files with 31158 additions and 60 deletions
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty/*
!.twenty/output/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
# Remove once prod ready
.twenty
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,9 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
@@ -0,0 +1,51 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -0,0 +1,31 @@
{
"name": "call-recording",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"twenty-sdk": "0.6.3-alpha"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,9 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: '4daa5147-7e70-4e43-b091-c27e1e8a32e3',
displayName: 'Call recording',
description: 'Allows to record calls',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,54 @@
import styled from '@emotion/styled';
import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
const StyledAudioWrapper = styled.div`
background: linear-gradient(135deg, #f8f9fb 0%, #eef0f4 100%);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
`;
const StyledAudio = styled.audio`
width: 100%;
height: 36px;
border-radius: 8px;
outline: none;
&::-webkit-media-controls-panel {
background: transparent;
}
`;
type AudioPlayerProps = {
src: string;
extension: string;
onTimeUpdate?: (currentTimeSeconds: number) => void;
};
export const AudioPlayer = ({
src,
extension,
onTimeUpdate,
}: AudioPlayerProps) => {
return (
<StyledAudioWrapper>
<StyledAudio
controls
onTimeUpdate={(event: unknown) => {
const currentTime = (event as CustomEvent<SerializedEventData>)
.detail.currentTime;
if (typeof currentTime === 'number') {
onTimeUpdate?.(currentTime);
}
}}
>
<source src={src} type={`audio/${extension}`} />
</StyledAudio>
</StyledAudioWrapper>
);
};
@@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import {
SKELETON_BASE_COLOR,
SKELETON_HIGHLIGHT_COLOR,
StyledSummarySkeletonContainer,
StyledViewerSkeletonContainer,
} from 'src/constants/skeleton-constants';
const StyledMediaSkeletonCard = styled.div`
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
`;
const StyledTranscriptSkeletonCard = styled.div`
background: #ffffff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
`;
export const CallRecordingViewerSkeleton = () => {
return (
<SkeletonTheme
baseColor={SKELETON_BASE_COLOR}
highlightColor={SKELETON_HIGHLIGHT_COLOR}
borderRadius={4}
>
<StyledViewerSkeletonContainer>
<StyledMediaSkeletonCard>
<Skeleton height={54} width="100%" borderRadius={0} />
</StyledMediaSkeletonCard>
<StyledTranscriptSkeletonCard>
<StyledSummarySkeletonContainer>
<Skeleton height={14} count={4} style={{ marginBottom: 6 }} />
<Skeleton height={14} width="60%" />
</StyledSummarySkeletonContainer>
</StyledTranscriptSkeletonCard>
</StyledViewerSkeletonContainer>
</SkeletonTheme>
);
};
@@ -0,0 +1,40 @@
import { AudioPlayer } from 'src/components/AudioPlayer';
import { VideoPlayer } from 'src/components/VideoPlayer';
import { isAudioExtension } from 'src/utils/is-audio-extension';
import { isVideoExtension } from 'src/utils/is-video-extension';
type MediaPlayerProps = {
url: string;
extension: string;
onTimeUpdate?: (currentTimeSeconds: number) => void;
};
export const MediaPlayer = ({
url,
extension,
onTimeUpdate,
}: MediaPlayerProps) => {
const normalizedExtension = extension.toLowerCase().replace(/^\./, '');
if (isAudioExtension(normalizedExtension)) {
return (
<AudioPlayer
src={url}
extension={normalizedExtension}
onTimeUpdate={onTimeUpdate}
/>
);
}
if (isVideoExtension(normalizedExtension)) {
return (
<VideoPlayer
src={url}
extension={normalizedExtension}
onTimeUpdate={onTimeUpdate}
/>
);
}
throw new Error('Unsupported file extension');
};
@@ -0,0 +1,107 @@
import styled from '@emotion/styled';
import Markdown from 'react-markdown';
import { isDefined } from 'twenty-shared/utils';
type SummaryViewerProps = {
markdown: string | null | undefined;
};
const StyledSummaryCard = styled.div`
background: #ffffff;
border-radius: 12px;
overflow: hidden;
`;
const StyledSummaryContent = styled.div`
line-height: 1.7;
padding: 20px 24px;
font-size: 13.5px;
color: #333;
max-height: 500px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.12);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1.2em;
margin-bottom: 0.5em;
color: #1a1a1a;
font-weight: 600;
&:first-child {
margin-top: 0;
}
}
p {
margin: 0.6em 0;
}
strong {
color: #1a1a1a;
font-weight: 600;
}
ul,
ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
code {
background-color: rgba(0, 0, 0, 0.04);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.88em;
font-family: 'SF Mono', 'Fira Code', monospace;
}
pre {
background-color: #f6f8fa;
padding: 14px 16px;
border-radius: 8px;
overflow-x: auto;
border: 1px solid rgba(0, 0, 0, 0.06);
}
blockquote {
border-left: 3px solid #d0d7de;
margin: 0.6em 0;
padding-left: 1em;
color: #57606a;
}
`;
export const SummaryViewer = ({ markdown }: SummaryViewerProps) => {
if (!isDefined(markdown) || markdown.trim().length === 0) {
return;
}
return (
<StyledSummaryCard>
<StyledSummaryContent>
<Markdown>{markdown}</Markdown>
</StyledSummaryContent>
</StyledSummaryCard>
);
};
@@ -0,0 +1,36 @@
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import {
SKELETON_BASE_COLOR,
SKELETON_HIGHLIGHT_COLOR,
StyledSummarySkeletonContainer,
} from 'src/constants/skeleton-constants';
const StyledSkeletonCard = styled.div`
background: #ffffff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
`;
export const SummaryViewerSkeleton = () => {
return (
<SkeletonTheme
baseColor={SKELETON_BASE_COLOR}
highlightColor={SKELETON_HIGHLIGHT_COLOR}
borderRadius={4}
>
<StyledSkeletonCard>
<StyledSummarySkeletonContainer>
<Skeleton height={16} width="40%" />
<Skeleton height={14} count={3} style={{ marginBottom: 6 }} />
<Skeleton height={16} width="55%" />
<Skeleton height={14} count={2} style={{ marginBottom: 6 }} />
<Skeleton height={14} width="75%" />
</StyledSummarySkeletonContainer>
</StyledSkeletonCard>
</SkeletonTheme>
);
};
@@ -0,0 +1,136 @@
import styled from '@emotion/styled';
import {
type TranscriptEntry,
type TranscriptWord,
} from 'src/hooks/useTranscript';
import { isDefined } from 'twenty-shared/utils';
type TranscriptViewerProps = {
entries: TranscriptEntry[];
currentTimeSeconds: number;
};
const StyledTranscriptCard = styled.div`
background: #ffffff;
border-radius: 8px;
overflow: hidden;
`;
const StyledTranscriptContent = styled.div`
max-height: 500px;
overflow-y: auto;
`;
const StyledEntry = styled.div<{ isActive: boolean }>`
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s ease;
background-color: ${({ isActive }) =>
isActive ? '#f1f1f1' : 'transparent'};
& + & {
margin-top: 2px;
}
`;
const StyledSpeaker = styled.span`
font-weight: 600;
color: #333333;
font-size: 0.92rem;
`;
const StyledTextContent = styled.div`
line-height: 1.5;
`;
const StyledWord = styled.span<{ isSpoken: boolean }>`
font-size: 0.92rem;
line-height: 1.5;
transition: color 0.15s ease;
color: ${({ isSpoken }) => (isSpoken ? '#333333' : '#b3b3b3')};
`;
const getEntryTimeRange = (entry: TranscriptEntry) => {
const firstWord = entry.words[0];
const lastWord = entry.words[entry.words.length - 1];
const start = firstWord?.start_timestamp?.relative;
const end = lastWord?.end_timestamp?.relative;
return { start, end };
};
const findActiveEntryIndex = (
entries: TranscriptEntry[],
currentTimeSeconds: number,
): number => {
for (let index = entries.length - 1; index >= 0; index--) {
const { start, end } = getEntryTimeRange(entries[index]);
if (!isDefined(start) || !isDefined(end)) {
continue;
}
if (currentTimeSeconds >= start && currentTimeSeconds <= end) {
return index;
}
}
return -1;
};
const isWordSpoken = (
word: TranscriptWord,
currentTimeSeconds: number,
): boolean => {
const start = word.start_timestamp?.relative;
if (!isDefined(start)) {
return false;
}
return currentTimeSeconds >= start;
};
export const TranscriptViewer = ({
entries,
currentTimeSeconds,
}: TranscriptViewerProps) => {
if (entries.length === 0) {
return;
}
const activeEntryIndex = findActiveEntryIndex(entries, currentTimeSeconds);
return (
<StyledTranscriptCard>
<StyledTranscriptContent>
{entries.map((entry, index) => {
const speaker = entry.participant?.name ?? 'Unknown';
const isActive = index === activeEntryIndex;
return (
<StyledEntry key={index} isActive={isActive}>
<StyledSpeaker>{speaker}</StyledSpeaker>
<StyledTextContent>
{entry.words.map((word, wordIndex) => (
<StyledWord
key={wordIndex}
isSpoken={isWordSpoken(word, currentTimeSeconds)}
>
{wordIndex > 0 ? ' ' : ''}
{word.text}
</StyledWord>
))}
</StyledTextContent>
</StyledEntry>
);
})}
</StyledTranscriptContent>
</StyledTranscriptCard>
);
};
@@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
const StyledVideoWrapper = styled.div`
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
background: #000;
`;
const StyledVideo = styled.video`
width: 100%;
display: block;
`;
type VideoPlayerProps = {
src: string;
extension: string;
onTimeUpdate?: (currentTimeSeconds: number) => void;
};
export const VideoPlayer = ({
src,
extension,
onTimeUpdate,
}: VideoPlayerProps) => {
return (
<StyledVideoWrapper>
<StyledVideo
controls
onTimeUpdate={(event: unknown) => {
const currentTime = (event as CustomEvent<SerializedEventData>)
.detail.currentTime;
if (typeof currentTime === 'number') {
onTimeUpdate?.(currentTime);
}
}}
>
<source src={src} type={`video/${extension}`} />
</StyledVideo>
</StyledVideoWrapper>
);
};
@@ -0,0 +1,8 @@
export const AUDIO_EXTENSIONS = [
'mp3',
'wav',
'ogg',
'aac',
'flac',
'webm',
] as const;
@@ -0,0 +1,2 @@
export const CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'43aae2d4-396a-4c5e-9f45-0162a2904825';
@@ -0,0 +1,2 @@
export const CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'9f3bbb39-042d-4216-b8fc-bedfc3487208';
@@ -0,0 +1,5 @@
export const SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'0894353c-86d2-484c-84f2-802cf5c4d22b';
export const SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
'e0e5b7a1-d472-4897-88d0-ce5f1bf6a5ea';
@@ -0,0 +1,24 @@
import styled from '@emotion/styled';
export const SKELETON_BASE_COLOR = '#f0f1f3';
export const SKELETON_HIGHLIGHT_COLOR = '#f8f9fb';
export const StyledSummarySkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 24px;
width: 100%;
box-sizing: border-box;
`;
export const StyledViewerSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
max-width: 960px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
`;
@@ -0,0 +1,5 @@
export const SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'958c796e-baf0-472c-838f-8d0a7f572774';
export const SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
'30e73886-18e7-476f-9c55-b19c59397a81';
@@ -0,0 +1,8 @@
export const VIDEO_EXTENSIONS = [
'mp4',
'webm',
'ogv',
'avi',
'mov',
'mkv',
] as const;
@@ -0,0 +1,574 @@
export type MockCallRecording = {
name: string;
createdAt: string;
endedAt: string;
status: 'ENDED';
transcript: { blocknote: null; markdown: string };
summary: { blocknote: null; markdown: string };
};
export const MOCK_CALL_RECORDINGS: MockCallRecording[] = [
{
name: 'Call Sarah Chen / Mike Johnson',
createdAt: '2025-01-08T10:00:00.000Z',
endedAt: '2025-01-08T10:32:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Mike Johnson:** Hi Sarah, thanks for taking the time today. I wanted to walk you through how we handle pipeline management and see if there might be a fit for your team.',
'**Sarah Chen:** Of course, Mike. We\'ve been looking at several CRM options. Right now we\'re using spreadsheets and it\'s becoming unmanageable with the team growing.',
'**Mike Johnson:** That\'s a common pain point. How many reps are on your team currently?',
'**Sarah Chen:** We have twelve sales reps and three managers. The biggest issue is visibility. Managers can\'t see deal progress in real time, and reps are spending too much time on data entry.',
'**Mike Johnson:** I hear that a lot. One thing that sets us apart is our approach to reducing manual entry. We automatically capture emails, meeting notes, and even call data. Would that address your main concern?',
'**Sarah Chen:** That would be huge. Our reps probably spend an hour a day just logging activities. What about reporting? We need weekly pipeline reviews with accurate forecasting.',
'**Mike Johnson:** Absolutely. I can set up a demo environment for your team. We have built-in forecasting that uses historical win rates and deal velocity. Should we schedule a more in-depth demo with your managers next week?',
'**Sarah Chen:** Yes, let\'s do that. Tuesday or Wednesday afternoon would work best for us.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Initial discovery call with Sarah Chen (VP Sales at prospect company) to assess CRM needs and pipeline management requirements.',
'',
'## Key Discussion Points',
'- Current setup: spreadsheets, 12 reps + 3 managers',
'- Main pain points: lack of real-time visibility, excessive manual data entry (~1 hour/day per rep)',
'- Interest in automated activity capture (emails, meetings, calls)',
'- Need for weekly pipeline reporting and accurate forecasting',
'',
'## Action Items',
'- [ ] Schedule in-depth demo with Sarah\'s managers for Tuesday or Wednesday afternoon',
'- [ ] Prepare demo environment with pipeline forecasting features',
'- [ ] Send follow-up email with product overview deck',
].join('\n'),
},
},
{
name: 'Call David Park / Emily Rivera',
createdAt: '2025-01-22T14:30:00.000Z',
endedAt: '2025-01-22T15:05:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Emily Rivera:** David, good to connect again. I wanted to follow up on the demo we did last week. How did the team feel about it?',
'**David Park:** The feedback was really positive overall. The interface is clean and the automation features impressed everyone. A couple of questions came up though.',
'**Emily Rivera:** Great to hear! What questions did the team have?',
'**David Park:** First, our legal team wants to know about data residency. We need our data hosted in the EU. Second, we\'re wondering about the integration with Salesforce — we still have some legacy data there.',
'**Emily Rivera:** Both great questions. We offer EU data residency with our Business plan. For Salesforce, we have a native migration tool that can import your historical data including contacts, deals, and activities. It typically takes about a day for a dataset your size.',
'**David Park:** That\'s reassuring. And what about pricing for our team size? We\'d be looking at about forty users.',
'**Emily Rivera:** For forty users on the Business plan with EU residency, I can put together a custom proposal. We also offer annual billing discounts. Let me send that over by Friday.',
'**David Park:** Perfect. If the pricing works, I think we\'re ready to move forward. We\'d want to start onboarding in March.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Follow-up call with David Park post-demo to address team feedback and move toward closing.',
'',
'## Key Discussion Points',
'- Demo feedback was positive across the team',
'- EU data residency requirement — available on Business plan',
'- Salesforce migration needed for legacy data (contacts, deals, activities)',
'- Team size: ~40 users',
'- Target onboarding start: March',
'',
'## Action Items',
'- [ ] Send custom pricing proposal for 40 users on Business plan by Friday',
'- [ ] Include EU data residency details and Salesforce migration timeline',
'- [ ] Prepare onboarding plan for March start',
].join('\n'),
},
},
{
name: 'Call Lisa Wong / James Martinez',
createdAt: '2025-02-05T09:00:00.000Z',
endedAt: '2025-02-05T09:28:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**James Martinez:** Lisa, thanks for joining. This is your first quarterly review since onboarding. How has the first month been going?',
'**Lisa Wong:** It\'s been great honestly. The team adopted it much faster than I expected. We\'re already seeing about a thirty percent reduction in time spent on data entry.',
'**James Martinez:** That\'s fantastic. Are there any areas where you feel we could improve the experience?',
'**Lisa Wong:** One thing that came up is custom fields. We have some industry-specific data points we track — like compliance status and license numbers — and we\'d like to add those as fields on our contact records.',
'**James Martinez:** You can absolutely do that. I\'ll send you a guide on creating custom fields. You can add text, dropdown, date, or number fields to any object. Any other requests?',
'**Lisa Wong:** Our marketing team is asking about the API. They want to push lead data from our website forms directly into the CRM.',
'**James Martinez:** Our REST API and GraphQL API both support that. I\'ll connect you with our developer relations team for a quick walkthrough. They can have your marketing team set up in about an hour.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'First quarterly review with Lisa Wong, one month post-onboarding. Team adoption is strong with measurable productivity gains.',
'',
'## Key Discussion Points',
'- 30% reduction in data entry time reported',
'- Need for custom fields: compliance status, license numbers',
'- Marketing team interest in API integration for website lead forms',
'',
'## Action Items',
'- [ ] Send custom fields documentation to Lisa',
'- [ ] Connect Lisa\'s marketing team with developer relations for API walkthrough',
'- [ ] Schedule next quarterly review for May',
].join('\n'),
},
},
{
name: 'Call Robert Taylor / Anna Schmidt',
createdAt: '2025-02-19T16:00:00.000Z',
endedAt: '2025-02-19T16:45:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Anna Schmidt:** Robert, I appreciate you making time. I wanted to understand your current sales process before we put together a proposal.',
'**Robert Taylor:** Sure. We\'re a B2B SaaS company, about two hundred employees. Our sales cycle is typically three to six months. We have SDRs doing outbound, AEs closing, and a small customer success team.',
'**Anna Schmidt:** What tools are you using today across those teams?',
'**Robert Taylor:** It\'s a mess honestly. SDRs use one tool for outreach, AEs use a different CRM, and customer success has their own platform. Nothing talks to each other.',
'**Anna Schmidt:** That fragmentation is really common. How does it impact your day-to-day?',
'**Robert Taylor:** The biggest issue is handoffs. When an SDR qualifies a lead and passes it to an AE, context gets lost. Same thing when a deal closes and moves to customer success. We lose notes, meeting history, everything.',
'**Anna Schmidt:** That\'s exactly what we solve. A single platform for the entire customer lifecycle — from first touch through renewal. All the context travels with the record. Let me show you a quick overview of how that handoff looks in practice.',
'**Robert Taylor:** That would be great. And can you also show me how your workflow automation works? We want to automate some of our internal notifications and task assignments.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Discovery call with Robert Taylor (B2B SaaS, ~200 employees) to map current sales process and identify pain points.',
'',
'## Key Discussion Points',
'- Sales cycle: 3-6 months with SDR → AE → CS handoffs',
'- Tool fragmentation: separate tools for outreach, CRM, and customer success',
'- Critical pain: context loss during handoffs (notes, meeting history)',
'- Interest in workflow automation for notifications and task assignments',
'',
'## Action Items',
'- [ ] Prepare demo focused on lifecycle handoff workflows',
'- [ ] Include workflow automation examples for internal notifications',
'- [ ] Schedule demo for next week',
].join('\n'),
},
},
{
name: 'Call Priya Patel / Tom Wilson',
createdAt: '2025-03-12T11:00:00.000Z',
endedAt: '2025-03-12T11:38:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Tom Wilson:** Hi Priya, I wanted to touch base about the pricing proposal we sent over. Have you had a chance to review it with your CFO?',
'**Priya Patel:** Yes, we went through it yesterday. The per-seat pricing is within our budget, but we\'re concerned about the implementation cost. Fifty thousand for onboarding feels steep.',
'**Tom Wilson:** I understand. That fee covers dedicated onboarding support, data migration from your three existing systems, custom training sessions, and sixty days of post-launch support. What would make it work for your budget?',
'**Priya Patel:** If we could break the implementation into two phases, that would help. Phase one would be the core sales team migration, and phase two would be marketing and customer success. That way we spread the cost over two quarters.',
'**Tom Wilson:** We can absolutely structure it that way. In fact, phased rollouts often lead to better adoption. We could do phase one for thirty thousand and phase two for twenty-five, with phase two starting three months later.',
'**Priya Patel:** That works much better. I think we can get sign-off on that. Can you send a revised proposal with those terms?',
'**Tom Wilson:** I\'ll have it in your inbox by end of day. If everything looks good, we could target April first for kickoff.',
'**Priya Patel:** Let\'s plan on that. I\'ll schedule an internal alignment meeting for Friday to finalize.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Pricing negotiation call with Priya Patel. Agreed on a phased implementation to fit budget constraints.',
'',
'## Key Discussion Points',
'- Per-seat pricing approved, implementation cost ($50K) was a concern',
'- Agreed on phased approach: Phase 1 ($30K, core sales) + Phase 2 ($25K, marketing & CS)',
'- Phase 2 starts 3 months after Phase 1',
'- Target kickoff: April 1st',
'',
'## Action Items',
'- [ ] Send revised proposal with phased implementation terms by end of day',
'- [ ] Prepare Phase 1 kickoff plan targeting April 1st',
'- [ ] Priya to schedule internal alignment meeting for Friday',
].join('\n'),
},
},
{
name: 'Call Marcus Lee / Sophie Dubois',
createdAt: '2025-04-03T13:00:00.000Z',
endedAt: '2025-04-03T13:22:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Sophie Dubois:** Marcus, welcome to your onboarding kickoff. I\'m your dedicated customer success manager and I\'ll be guiding you through the setup process over the next four weeks.',
'**Marcus Lee:** Great, the team is excited to get started. We have twenty users ready to go on day one.',
'**Sophie Dubois:** Perfect. Let me walk you through the onboarding timeline. Week one is data migration and system configuration. Week two is user training. Week three is a guided pilot where your team uses it in parallel with your old system. Week four is full cutover and go-live.',
'**Marcus Lee:** That sounds structured. For the data migration, we have about fifty thousand contacts and ten thousand deals in our current system. Is that going to be an issue?',
'**Sophie Dubois:** Not at all. That\'s well within our standard migration capacity. I\'ll need your team to export the data in CSV format and I\'ll handle the mapping and import. We typically complete migrations of that size in two to three days.',
'**Marcus Lee:** And what about our custom deal stages? We have a pretty specific pipeline with eight stages.',
'**Sophie Dubois:** We\'ll configure that during week one. You can have as many stages as you need, and we can set up automation rules for each transition. I\'ll send you a configuration worksheet to fill out before our next session.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Onboarding kickoff with Marcus Lee. Covered the 4-week timeline and initial setup requirements.',
'',
'## Key Discussion Points',
'- 20 users ready for day one',
'- Data migration: ~50K contacts, ~10K deals (CSV export needed)',
'- Custom pipeline: 8 deal stages with automation rules',
'- 4-week onboarding plan: migration → training → pilot → go-live',
'',
'## Action Items',
'- [ ] Send configuration worksheet for custom pipeline stages',
'- [ ] Marcus to prepare CSV exports of contacts and deals',
'- [ ] Schedule week 1 check-in for data migration review',
].join('\n'),
},
},
{
name: 'Call Jennifer Brooks / Carlos Mendez',
createdAt: '2025-05-14T15:30:00.000Z',
endedAt: '2025-05-14T16:02:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Carlos Mendez:** Jennifer, thanks for the demo request. I see you\'re interested in our analytics and reporting capabilities. Can you tell me what you\'re looking for specifically?',
'**Jennifer Brooks:** We need better visibility into rep performance. Right now I\'m pulling data from three different sources to build my weekly report. It takes me half a day every Monday.',
'**Carlos Mendez:** That\'s painful. What metrics do you track in those reports?',
'**Jennifer Brooks:** Calls made, emails sent, meetings booked, pipeline generated, deals closed, and average deal size. I also need to compare rep-to-rep and track trends over time.',
'**Carlos Mendez:** All of those are available out of the box in our analytics dashboard. Let me share my screen and show you. Here you can see a real-time dashboard with all those metrics. You can filter by rep, team, date range, and even deal stage.',
'**Jennifer Brooks:** Oh wow, that\'s exactly what I need. Can I schedule these reports to be sent automatically?',
'**Carlos Mendez:** Yes, you can set up scheduled email reports — daily, weekly, or monthly. You can also create custom dashboards and share them with your team or leadership. Each person only sees data they have access to.',
'**Jennifer Brooks:** This would save me so much time. What does pricing look like for a team of twenty-five?',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Demo call with Jennifer Brooks focused on analytics and reporting capabilities. Strong interest in automated reporting.',
'',
'## Key Discussion Points',
'- Current report building takes half a day weekly across 3 data sources',
'- Key metrics: calls, emails, meetings, pipeline, deals closed, avg deal size',
'- Rep-to-rep comparison and trend tracking required',
'- Demonstrated real-time dashboard, scheduled reports, and custom dashboards',
'',
'## Action Items',
'- [ ] Send pricing proposal for 25-user team',
'- [ ] Share sample analytics dashboard templates',
'- [ ] Schedule follow-up to review proposal with Jennifer\'s leadership',
].join('\n'),
},
},
{
name: 'Call Alex Nguyen / Rachel Foster',
createdAt: '2025-06-20T10:00:00.000Z',
endedAt: '2025-06-20T10:41:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Rachel Foster:** Alex, this is your six-month review. Let\'s go over the numbers. How are things going since you fully migrated in January?',
'**Alex Nguyen:** Really well. Our sales cycle has shortened by about twenty percent. Reps are closing deals faster because they have all the context in one place.',
'**Rachel Foster:** That\'s a significant improvement. What about adoption? Are all teams using the platform regularly?',
'**Alex Nguyen:** The sales team is fully on board, probably ninety-five percent daily usage. Marketing is at about eighty percent. The one area where we\'re struggling is getting our field sales team to use the mobile app consistently.',
'**Rachel Foster:** Mobile adoption is often the trickiest. We recently launched offline mode which helps a lot for field reps with spotty connectivity. I can set up a quick training session specifically for your field team.',
'**Alex Nguyen:** That would be great. Also, we\'re looking at expanding to our APAC team next quarter. That would add about thirty more users. What does that look like from a licensing perspective?',
'**Rachel Foster:** I\'ll work with your account executive to prepare an expansion quote. We can usually offer volume discounts when scaling beyond fifty users. I\'ll have that ready for your budget planning meeting.',
'**Alex Nguyen:** Perfect timing. Our planning cycle starts in August so if we can have numbers by mid-July that would be ideal.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Six-month review with Alex Nguyen. Strong results with 20% shorter sales cycles. Planning APAC expansion.',
'',
'## Key Discussion Points',
'- Sales cycle reduced by 20% since migration',
'- Adoption: 95% daily (sales), 80% (marketing), field team needs mobile training',
'- New offline mode could help field sales adoption',
'- APAC expansion planned: +30 users next quarter',
'',
'## Action Items',
'- [ ] Schedule mobile app training for field sales team',
'- [ ] Prepare APAC expansion quote with volume discounts by mid-July',
'- [ ] Connect with account executive on expansion pricing',
].join('\n'),
},
},
{
name: 'Call Kevin O\'Brien / Maria Santos',
createdAt: '2025-07-09T14:00:00.000Z',
endedAt: '2025-07-09T14:35:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Maria Santos:** Kevin, I understand your team is evaluating us alongside two other CRM vendors. I\'d love to understand what criteria are most important to you.',
'**Kevin O\'Brien:** Right, we\'re in the final stages of evaluation. The three biggest factors for us are ease of use, integration ecosystem, and total cost of ownership over three years.',
'**Maria Santos:** Makes sense. On ease of use, our average onboarding time is two weeks and we consistently score highest in user satisfaction surveys. What integrations are most critical for you?',
'**Kevin O\'Brien:** We need Slack, Google Workspace, Zoom, and our billing system which runs on Stripe. We also use Notion for internal documentation.',
'**Maria Santos:** All of those have native integrations except Notion, which we support through our Zapier and Make connectors. For Stripe, our integration syncs billing data bidirectionally so your sales team can see payment status directly on the deal record.',
'**Kevin O\'Brien:** The Stripe integration is a big differentiator actually. The other vendors we\'re looking at don\'t offer that natively. What about the three-year cost comparison?',
'**Maria Santos:** I\'ll put together a total cost of ownership analysis that includes licensing, implementation, training, and ongoing support. We\'re typically fifteen to twenty percent lower than our main competitors when you factor in everything.',
'**Kevin O\'Brien:** Send that over and I\'ll present it to our executive team next Thursday. We\'re planning to make a decision by end of month.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Competitive evaluation call with Kevin O\'Brien. In final stages, comparing against two other vendors.',
'',
'## Key Discussion Points',
'- Evaluation criteria: ease of use, integrations, 3-year TCO',
'- Required integrations: Slack, Google Workspace, Zoom, Stripe (native), Notion (via Zapier/Make)',
'- Stripe bidirectional sync is a key differentiator vs competitors',
'- Decision timeline: end of month, executive presentation next Thursday',
'',
'## Action Items',
'- [ ] Prepare 3-year TCO analysis vs competitors',
'- [ ] Send integration ecosystem overview document',
'- [ ] Follow up before Thursday executive presentation',
].join('\n'),
},
},
{
name: 'Call Diana Hughes / Ryan Cooper',
createdAt: '2025-08-18T09:30:00.000Z',
endedAt: '2025-08-18T10:05:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Ryan Cooper:** Diana, I noticed some of your team\'s usage metrics dropped last month. Everything okay?',
'**Diana Hughes:** Honestly, we\'ve been struggling with a few things. The email sync stopped working for about half our team two weeks ago and we haven\'t been able to figure out why.',
'**Ryan Cooper:** I\'m sorry to hear that. Let me look into this right now. Can you tell me which email provider you\'re using?',
'**Diana Hughes:** We\'re on Microsoft 365. It was working fine and then suddenly half the team\'s emails stopped syncing. The other half is still fine.',
'**Ryan Cooper:** I think I see the issue. Microsoft recently changed their OAuth token refresh policy. The affected users likely need to re-authenticate. I\'ll send you a step-by-step guide. It should take each user about two minutes.',
'**Diana Hughes:** Okay that\'s a relief, I was worried it was something bigger. The other thing is we need better support response times. We submitted a ticket about this five days ago and didn\'t hear back until yesterday.',
'**Ryan Cooper:** That\'s not acceptable and I apologize. I\'m escalating this with our support team. For your account, I\'m also going to set up a dedicated Slack channel so you can reach me or someone on my team directly for urgent issues.',
'**Diana Hughes:** That would make a huge difference. We need to be able to get help quickly when something breaks.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Issue resolution call with Diana Hughes regarding email sync failures and support response times.',
'',
'## Key Discussion Points',
'- Email sync broken for ~50% of team (Microsoft 365, OAuth token refresh issue)',
'- Support ticket response took 5 days — unacceptable',
'- Setting up dedicated Slack channel for urgent issues',
'',
'## Risks',
'- Customer satisfaction at risk due to slow support response',
'- Usage metrics declining — need to restore confidence quickly',
'',
'## Action Items',
'- [ ] Send OAuth re-authentication guide to Diana immediately',
'- [ ] Escalate support response time issue internally',
'- [ ] Set up dedicated Slack channel for Diana\'s team',
'- [ ] Follow up in 48 hours to confirm email sync is restored',
].join('\n'),
},
},
{
name: 'Call Nathan Kim / Laura Chen / Steve Morris',
createdAt: '2025-09-25T16:00:00.000Z',
endedAt: '2025-09-25T16:42:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Steve Morris:** Alright team, let\'s do our monthly pipeline review. Nathan, kick us off with the numbers.',
'**Nathan Kim:** Sure. Total pipeline is at two point four million, up twelve percent from last month. We have eight deals in the negotiation stage totaling about nine hundred K. Three of those should close this month.',
'**Laura Chen:** Which three are you most confident about?',
'**Nathan Kim:** Meridian Corp at three hundred and twenty K — contract is out for signature. TechFlow at two hundred and ten K — verbal agreement, just working through procurement. And BrightPath at one hundred and fifty K — they want to start before their fiscal year end on October fifteenth.',
'**Steve Morris:** Good. What about the other five? Any at risk?',
'**Nathan Kim:** Two are solid but won\'t close until November. The other three I\'m worried about. DataSync keeps pushing their timeline back and I think they might be evaluating a competitor.',
'**Laura Chen:** Let me jump on a call with the DataSync champion this week. I have a relationship there from a previous company. Maybe I can help move things forward.',
'**Steve Morris:** Great idea. Nathan, can you also do a deep dive on our Q4 pipeline generation? We need to make sure we\'re building enough top-of-funnel to hit our annual target.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Internal monthly pipeline review. Pipeline at $2.4M (+12% MoM), 8 deals in negotiation stage.',
'',
'## Key Discussion Points',
'- 3 deals expected to close this month: Meridian ($320K), TechFlow ($210K), BrightPath ($150K)',
'- 2 deals solid for November close',
'- 3 deals at risk, especially DataSync (may be evaluating competitor)',
'- Need to build Q4 top-of-funnel pipeline',
'',
'## Action Items',
'- [ ] Laura to contact DataSync champion this week',
'- [ ] Nathan to prepare Q4 pipeline generation analysis',
'- [ ] Follow up on Meridian contract signature',
'- [ ] Track BrightPath against Oct 15 fiscal year deadline',
].join('\n'),
},
},
{
name: 'Call Amanda Price / Ben Watson',
createdAt: '2025-10-30T11:00:00.000Z',
endedAt: '2025-10-30T11:25:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Ben Watson:** Amanda, thanks for your interest. I understand you\'re a startup looking for your first CRM. Tell me about your team.',
'**Amanda Price:** We\'re a Series A startup, fifteen people total. Five of us are in go-to-market roles. We\'ve been tracking everything in Airtable and Google Sheets but we need something purpose-built as we scale.',
'**Ben Watson:** What\'s your growth plan? Understanding your trajectory helps me recommend the right package.',
'**Amanda Price:** We plan to triple the sales team by end of next year. So we need something that can grow with us without breaking the bank in the early days.',
'**Ben Watson:** Our startup program is designed exactly for that. You\'d get our full platform at a seventy percent discount for the first year, then fifty percent off the second year, with a gradual step-up to standard pricing.',
'**Amanda Price:** That\'s compelling. What\'s the catch? Do we lose any features on the startup plan?',
'**Ben Watson:** No catch — full feature parity. The only difference is the per-seat price. You also get priority onboarding support included. We want to grow with you.',
'**Amanda Price:** I love that approach. Can you send me the startup program details? I\'ll review it with my co-founder this week.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Discovery call with Amanda Price, Series A startup looking for first CRM. Good fit for startup program.',
'',
'## Key Discussion Points',
'- Series A, 15 people, 5 in GTM roles',
'- Currently using Airtable + Google Sheets',
'- Plan to 3x sales team by end of next year',
'- Startup program: 70% off Y1, 50% off Y2, full features, priority onboarding',
'',
'## Action Items',
'- [ ] Send startup program details and application form',
'- [ ] Amanda to review with co-founder this week',
'- [ ] Schedule follow-up call for next week',
].join('\n'),
},
},
{
name: 'Call Chris Yamamoto / Olivia Barrett',
createdAt: '2025-12-03T10:30:00.000Z',
endedAt: '2025-12-03T11:08:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Olivia Barrett:** Chris, you\'re coming up on your annual renewal. I wanted to check in and see how things are going before we discuss the renewal terms.',
'**Chris Yamamoto:** Overall we\'re happy. The platform has become essential for our team. There are a few enhancements I\'d love to see though.',
'**Olivia Barrett:** I\'d love to hear them. What\'s on your wish list?',
'**Chris Yamamoto:** First, we really need better territory management. We operate in eight regions and right now we\'re managing territories manually with filters. Second, we\'d like more advanced workflow branching — if-then-else logic in automations.',
'**Olivia Barrett:** Good news on both fronts. Territory management is in our Q1 roadmap — we\'re targeting a February launch. Advanced workflow logic is already in beta. I can get your team access to the beta next week if you\'re interested.',
'**Chris Yamamoto:** Definitely, sign us up for the beta. For the renewal, we want to add ten more seats. What does that look like pricing-wise?',
'**Olivia Barrett:** Adding ten seats at your current rate would bring you to sixty users. Given your expansion and commitment, I can offer a five percent loyalty discount on the full renewal. I\'ll draft the renewal proposal and have it ready by next week.',
'**Chris Yamamoto:** That sounds fair. Let\'s aim to have the renewal signed before the holiday break.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Annual renewal discussion with Chris Yamamoto. Expanding from 50 to 60 seats with loyalty discount.',
'',
'## Key Discussion Points',
'- Customer is satisfied, platform is essential to operations',
'- Feature requests: territory management (in Q1 roadmap), advanced workflow logic (beta available)',
'- Expansion: +10 seats (50 → 60 users)',
'- 5% loyalty discount offered on full renewal',
'- Target: sign renewal before holiday break',
'',
'## Action Items',
'- [ ] Enroll Chris\'s team in workflow logic beta next week',
'- [ ] Draft renewal proposal for 60 seats with 5% loyalty discount',
'- [ ] Share Q1 territory management roadmap details',
'- [ ] Get renewal signed before December holidays',
].join('\n'),
},
},
{
name: 'Call Samantha Reed / Derek Chang',
createdAt: '2026-01-15T14:00:00.000Z',
endedAt: '2026-01-15T14:33:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Derek Chang:** Samantha, your team has been on the platform for about two months now. I wanted to do a health check and see how the implementation is going.',
'**Samantha Reed:** The core sales team is doing well. We\'re tracking about ninety percent of our deals in the system now. But I have some concerns about data quality.',
'**Derek Chang:** What kind of data quality issues are you seeing?',
'**Samantha Reed:** Duplicate contacts mostly. When we imported our data we ended up with a lot of duplicates. And our reps sometimes create new contacts instead of linking to existing ones.',
'**Derek Chang:** That\'s a common post-migration issue. We have a built-in deduplication tool that can merge duplicates. I can run an audit on your database this week and present the results. For preventing new duplicates, we can enable duplicate detection rules that alert reps when they\'re about to create a potential duplicate.',
'**Samantha Reed:** Yes, please run that audit. The other thing is we\'re not using the email automation features yet. Can we schedule a training session specifically on email sequences?',
'**Derek Chang:** Absolutely. I\'ll set up a one-hour training session for your team next week. We\'ll cover sequence creation, personalization tokens, A/B testing, and analytics.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Two-month health check with Samantha Reed. Good deal tracking (90%) but data quality concerns with duplicates.',
'',
'## Key Discussion Points',
'- 90% of deals tracked in system — good adoption',
'- Duplicate contact issue from data migration and manual creation',
'- Built-in deduplication tool and detection rules available',
'- Email automation features not yet adopted — training needed',
'',
'## Action Items',
'- [ ] Run duplicate contact audit this week and present results',
'- [ ] Enable duplicate detection rules for new contact creation',
'- [ ] Schedule 1-hour email sequence training for next week',
].join('\n'),
},
},
{
name: 'Call Michelle Torres / Greg Anderson',
createdAt: '2026-02-10T09:00:00.000Z',
endedAt: '2026-02-10T09:40:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Greg Anderson:** Michelle, I\'m reaching out because we noticed your contract is expiring in thirty days and we haven\'t heard from you about renewal. Is everything alright?',
'**Michelle Torres:** To be honest Greg, we\'ve been having some internal discussions about whether to continue. Our new VP of Sales wants to consolidate vendors and he\'s pushing for an all-in-one suite from one of the larger providers.',
'**Greg Anderson:** I appreciate the transparency. Can you help me understand what the all-in-one suite offers that we don\'t currently provide?',
'**Michelle Torres:** They bundle marketing automation, CRM, and customer service into one platform. The appeal is a single vendor relationship and unified data.',
'**Greg Anderson:** I understand the appeal of consolidation. A few things to consider though. You\'d be replacing a tool your team has adopted and loves with something new. Migration costs and ramp-up time are real. And our open API means we integrate deeply with best-of-breed tools for each function. Would it be possible to get fifteen minutes with your VP of Sales? I\'d like to address his concerns directly.',
'**Michelle Torres:** I think that\'s fair. Let me check his calendar. If you can make a compelling case for the best-of-breed approach, I think we have a shot at keeping this.',
'**Greg Anderson:** I\'ll prepare a comparison analysis showing total cost, migration risk, and feature-by-feature breakdown. I want to make sure your VP has all the data he needs to make the right decision for the team.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'At-risk renewal call with Michelle Torres. New VP of Sales pushing for vendor consolidation with all-in-one suite.',
'',
'## Key Discussion Points',
'- Contract expires in 30 days, internal debate about renewal',
'- New VP wants all-in-one suite (marketing + CRM + service from single vendor)',
'- Counter-argument: team adoption, migration costs, best-of-breed approach via API',
'- Requested meeting with VP of Sales to present case',
'',
'## Risks',
'- **High churn risk** — decision-maker change driving vendor review',
'- Timeline is tight (30 days to contract expiry)',
'',
'## Action Items',
'- [ ] Prepare best-of-breed vs all-in-one comparison analysis',
'- [ ] Schedule meeting with Michelle\'s VP of Sales ASAP',
'- [ ] Include total cost, migration risk, and feature comparison',
'- [ ] Escalate internally as at-risk account',
].join('\n'),
},
},
];
@@ -0,0 +1,23 @@
import {
PEOPLE_ON_CALL_RECORDING_ID,
} from 'src/fields/people-on-call-recording.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export const CALL_RECORDING_ON_PERSON_ID =
'c62ae064-88aa-48a7-84b3-c9940e3a5db9';
export default defineField({
universalIdentifier: CALL_RECORDING_ON_PERSON_ID,
objectUniversalIdentifier: '20202020-e674-48e5-a542-72570eee7213',
type: FieldType.RELATION,
name: 'callRecordings',
label: 'Call Recordings',
relationTargetObjectMetadataUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PEOPLE_ON_CALL_RECORDING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,21 @@
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export const CALL_RECORDING_ON_WORKSPACE_MEMBER_ID =
'ae57f5bc-b9f1-4867-887a-834c14737bae';
export default defineField({
universalIdentifier: CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
objectUniversalIdentifier: '20202020-3319-4234-a34c-82d5c0e881a6',
type: FieldType.RELATION,
name: 'callRecordings',
label: 'Call Recordings',
relationTargetObjectMetadataUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { CALL_RECORDING_ON_PERSON_ID } from 'src/fields/call-recording-on-person.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
export const PEOPLE_ON_CALL_RECORDING_ID =
'0066e1d2-59f6-4ca7-8073-ca9bd964bfe0';
export default defineField({
universalIdentifier: PEOPLE_ON_CALL_RECORDING_ID,
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
CALL_RECORDING_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'personId',
},
icon: 'IconUser',
});
@@ -0,0 +1,23 @@
import { CALL_RECORDING_ON_WORKSPACE_MEMBER_ID } from 'src/fields/call-recording-on-workspace-member.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
export const WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID =
'5550e26a-4354-434b-b32e-3f7b04585113';
export default defineField({
universalIdentifier: WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'workspaceMember',
label: 'Workspace Member',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'workspaceMemberId',
},
icon: 'IconUser',
});
@@ -0,0 +1,28 @@
import { SummaryViewer } from 'src/components/SummaryViewer';
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
import { CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summary-viewer-front-component-universal-identifier';
import { useCallRecording } from 'src/hooks/useCallRecording';
import { defineFrontComponent } from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
const CallRecordingSummaryViewer = () => {
const { callRecording, loading, error } = useCallRecording();
if (loading) {
return <SummaryViewerSkeleton />;
}
if (isDefined(error)) {
throw error;
}
return <SummaryViewer markdown={callRecording?.summary?.markdown} />;
};
export default defineFrontComponent({
universalIdentifier:
CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Call Recording Summary Viewer',
description: 'Displays the AI-generated summary of a call recording',
component: CallRecordingSummaryViewer,
});
@@ -0,0 +1,78 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { CallRecordingViewerSkeleton } from 'src/components/CallRecordingViewerSkeleton';
import { MediaPlayer } from 'src/components/MediaPlayer';
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
import { TranscriptViewer } from 'src/components/TranscriptViewer';
import { CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-viewer-front-component-universal-identifier';
import { useCallRecording } from 'src/hooks/useCallRecording';
import { useTranscript } from 'src/hooks/useTranscript';
import { defineFrontComponent } from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
max-width: 960px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
`;
export const CallRecordingViewer = () => {
const [currentTimeSeconds, setCurrentTimeSeconds] = useState(0);
const { callRecording, loading, error } = useCallRecording();
const transcriptFileUrl = callRecording?.transcriptFile[0]?.url;
const { entries: transcriptEntries, loading: transcriptLoading } =
useTranscript(transcriptFileUrl);
if (loading) {
return <CallRecordingViewerSkeleton />;
}
if (isDefined(error)) {
throw error;
}
const recordingFile = callRecording?.recordingFile[0];
const recordingFileUrl = recordingFile?.url;
const recordingFileExtension = recordingFile?.extension;
const hasRecording =
isDefined(recordingFileUrl) && isDefined(recordingFileExtension);
return (
<StyledContainer>
{hasRecording && (
<MediaPlayer
url={recordingFileUrl}
extension={recordingFileExtension}
onTimeUpdate={setCurrentTimeSeconds}
/>
)}
{transcriptLoading ? (
<SummaryViewerSkeleton />
) : (
<TranscriptViewer
entries={transcriptEntries}
currentTimeSeconds={currentTimeSeconds}
/>
)}
</StyledContainer>
);
};
export default defineFrontComponent({
universalIdentifier:
CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Call Recording Viewer',
description: 'A viewer for call recordings',
component: CallRecordingViewer,
});
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import {
SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/seed-call-recordings-universal-identifiers';
import { MOCK_CALL_RECORDINGS } from 'src/data/mock-call-recordings';
import { defineFrontComponent } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
type SeedStatus = 'seeding' | 'done' | 'error';
const fetchPeopleIds = async (
client: InstanceType<typeof CoreApiClient>,
): Promise<string[]> => {
const result: any = await client.query({
people: {
__args: { first: 50 },
edges: { node: { id: true } },
},
} as any);
return (
result?.people?.edges?.map(
(edge: { node: { id: string } }) => edge.node.id,
) ?? []
);
};
const fetchWorkspaceMemberIds = async (
client: InstanceType<typeof CoreApiClient>,
): Promise<string[]> => {
const result: any = await client.query({
workspaceMembers: {
__args: { first: 50 },
edges: { node: { id: true } },
},
} as any);
return (
result?.workspaceMembers?.edges?.map(
(edge: { node: { id: string } }) => edge.node.id,
) ?? []
);
};
const pickRandom = <T,>(items: T[]): T | undefined =>
items.length > 0 ? items[Math.floor(Math.random() * items.length)] : undefined;
const SeedCallRecordings = () => {
const [status, setStatus] = useState<SeedStatus>('seeding');
const [count, setCount] = useState(0);
useEffect(() => {
const seed = async () => {
try {
const client = new CoreApiClient();
const [personIds, workspaceMemberIds] = await Promise.all([
fetchPeopleIds(client),
fetchWorkspaceMemberIds(client),
]);
const recordsToCreate = MOCK_CALL_RECORDINGS.map((recording) => ({
...recording,
personId: pickRandom(personIds),
workspaceMemberId: pickRandom(workspaceMemberIds),
}));
await client.mutation({
createCallRecordings: {
__args: { data: recordsToCreate as any },
id: true,
},
} as any);
setCount(recordsToCreate.length);
setStatus('done');
} catch {
setStatus('error');
}
};
seed();
}, []);
if (status === 'seeding') {
return <div>Seeding call recordings...</div>;
}
if (status === 'error') {
return <div>Failed to seed call recordings.</div>;
}
return <div>Seeded {count} call recordings.</div>;
};
export default defineFrontComponent({
universalIdentifier:
SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Seed Call Recordings',
description: 'Seeds the workspace with mock call recordings for testing',
isHeadless: true,
component: SeedCallRecordings,
command: {
universalIdentifier: SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Seed call recordings',
icon: 'IconDatabase',
isPinned: false,
availabilityType: 'GLOBAL',
},
});
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { SummaryViewer } from 'src/components/SummaryViewer';
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
import {
SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/summarize-person-recordings-universal-identifiers';
import { defineFrontComponent, useRecordId } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { isDefined } from 'twenty-shared/utils';
const SUMMARIZATION_SYSTEM_PROMPT = [
'You are a helpful assistant that summarizes call transcripts.',
'Provide a concise summary with:',
'1) A brief overview of the calls',
'2) Key themes across all calls',
'3) Action items (if any)',
'4) Risks or opportunities identified',
'Use markdown formatting.',
].join(' ');
type Recording = {
id: string;
name: string | null;
createdAt: string;
summary: { markdown: string | null } | null;
};
const summarizeAllRecordings = async (
recordings: Recording[],
): Promise<string | undefined> => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
return undefined;
}
const summariesText = recordings
.map(
(recording, index) =>
`### ${index + 1}. ${recording.name ?? 'Untitled'} (${recording.createdAt})\n${recording.summary?.markdown ?? 'No summary available'}`,
)
.join('\n\n---\n\n');
const userPrompt = [
`Here are the summaries of ${recordings.length} call recording(s) linked to this person:`,
'',
summariesText,
'',
'Generate a detailed summary of these calls.',
'Highlight key themes, action items, and any risks or opportunities.',
].join('\n');
const url = `${apiBaseUrl}/rest/ai/generate-text`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
userPrompt,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`AI summarization request failed with status ${response.status}: ${errorBody}`,
);
}
const data = (await response.json()) as { text?: string };
return data.text ?? undefined;
};
const SummarizePersonRecordings = () => {
const personRecordId = useRecordId();
const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!isDefined(personRecordId)) {
setError(new Error('No person record selected'));
setLoading(false);
return;
}
const fetchAndSummarize = async () => {
try {
setLoading(true);
setError(null);
const client = new CoreApiClient();
const result: Record<string, unknown> = await client.query({
callRecordings: {
__args: {
filter: { personId: { eq: personRecordId } },
},
edges: {
node: {
id: true,
name: true,
summary: { markdown: true },
createdAt: true,
},
},
},
});
const recordings: Recording[] =
(
result?.callRecordings as {
edges?: { node: Recording }[];
}
)?.edges?.map((edge) => edge.node) ?? [];
if (recordings.length === 0) {
setError(new Error('No call recordings linked to this person'));
setLoading(false);
return;
}
const generatedSummary = await summarizeAllRecordings(recordings);
setSummary(generatedSummary ?? null);
} catch (fetchError) {
setError(
fetchError instanceof Error
? fetchError
: new Error('Failed to summarize recordings'),
);
}
setLoading(false);
};
fetchAndSummarize();
return () => {
setSummary(null);
setLoading(false);
setError(null);
};
}, [personRecordId]);
if (loading) {
return <SummaryViewerSkeleton />;
}
if (isDefined(error)) {
throw error;
}
return <SummaryViewer markdown={summary} />;
};
export default defineFrontComponent({
universalIdentifier:
SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Summarize Person Call Recordings',
description:
'Generates and displays a summary of recent call recordings for a person',
component: SummarizePersonRecordings,
command: {
universalIdentifier:
SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Summarize call recordings',
icon: 'IconSparkles',
isPinned: false,
availabilityType: 'SINGLE_RECORD',
availabilityObjectUniversalIdentifier:
'20202020-e674-48e5-a542-72570eee7213',
},
});
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useRecordId } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { isDefined } from 'twenty-shared/utils';
type CallRecording = {
id: string;
name: string;
createdAt: string;
endedAt: string | null;
recordingFile: Array<{ fileId: string; label: string; url: string | null; extension: string | null }>;
transcriptFile: Array<{ fileId: string; label: string; url: string | null; extension: string | null }>;
transcript: { markdown: string | null } | null;
summary: { markdown: string | null } | null;
};
export const useCallRecording = () => {
const recordId = useRecordId();
const [callRecording, setCallRecording] = useState<CallRecording | null>(
null,
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!isDefined(recordId)) {
setError(new Error('Record ID is not defined'));
setLoading(false);
return;
}
const fetchRecord = async () => {
try {
setLoading(true);
setError(null);
const client = new CoreApiClient();
const { callRecording } = await client.query({
callRecording: {
__args: {
filter: { id: { eq: recordId } },
},
id: true,
name: true,
createdAt: true,
endedAt: true,
recordingFile: {
fileId: true,
label: true,
url: true,
extension: true,
},
transcriptFile: {
fileId: true,
label: true,
url: true,
extension: true,
},
transcript: {
markdown: true,
},
summary: {
markdown: true,
},
},
});
setCallRecording({
id: callRecording?.id ?? '',
name: callRecording?.name ?? '',
createdAt: callRecording?.createdAt ?? '',
endedAt: callRecording?.endedAt ?? null,
recordingFile: callRecording?.recordingFile?.map((file) => ({
fileId: file.fileId,
label: file.label,
url: file.url ?? null,
extension: file.extension ?? null,
})) ?? [],
transcriptFile: callRecording?.transcriptFile?.map((file) => ({
fileId: file.fileId,
label: file.label,
url: file.url ?? null,
extension: file.extension ?? null,
})) ?? [],
transcript: callRecording?.transcript
? { markdown: callRecording.transcript.markdown ?? null }
: null,
summary: callRecording?.summary
? { markdown: callRecording.summary.markdown ?? null }
: null,
});
} catch (fetchError) {
if (fetchError instanceof Error) {
setError(fetchError);
} else {
setError(new Error('Failed to fetch call recording'));
}
}
setLoading(false);
};
fetchRecord();
return () => {
setCallRecording(null);
setLoading(false);
setError(null);
};
}, [recordId]);
return { callRecording, loading, error };
};
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
export type TranscriptTimestamp = {
relative: number;
absolute: string;
};
export type TranscriptWord = {
text: string;
start_timestamp?: TranscriptTimestamp;
end_timestamp?: TranscriptTimestamp;
};
export type TranscriptEntry = {
participant: {
name: string | null;
};
words: TranscriptWord[];
};
export const useTranscript = (
transcriptFileUrl: string | null | undefined,
) => {
const [entries, setEntries] = useState<TranscriptEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!isDefined(transcriptFileUrl)) {
return;
}
const fetchTranscript = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(transcriptFileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch transcript: ${response.statusText}`);
}
const data = await response.json();
setEntries(data);
} catch (fetchError) {
if (fetchError instanceof Error) {
setError(fetchError);
} else {
setError(new Error('Failed to fetch transcript'));
}
}
setLoading(false);
};
fetchTranscript();
return () => {
setEntries([]);
setLoading(false);
setError(null);
};
}, [transcriptFileUrl]);
return { entries, loading, error };
};
@@ -0,0 +1,306 @@
import {
RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/objects/call-recording';
import {
matchParticipants,
type Participant,
} from 'src/utils/match-participants';
import { summarizeTranscript } from 'src/utils/summarize-transcript';
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
import { z } from 'zod';
interface LocalTranscriptWord {
text: string;
start_timestamp?: { relative: number; absolute: string };
end_timestamp?: { relative: number; absolute: string };
}
interface LocalTranscriptEntry {
participant: { name: string };
words: LocalTranscriptWord[];
}
interface EndRecordingBody {
callRecordingId: string;
audioUrl: string;
transcriptUrl?: string;
participants?: Participant[];
localTranscript?: LocalTranscriptEntry[];
}
type UploadedFileRef = { fileId: string; label: string };
const timestampSchema = z.object({
relative: z.number(),
absolute: z.string(),
});
const transcriptEntrySchema = z.object({
participant: z.object({
name: z.string().nullable(),
}),
words: z.array(
z.object({
text: z.string(),
start_timestamp: timestampSchema.optional(),
end_timestamp: timestampSchema.optional(),
}),
),
});
const transcriptSchema = z.array(transcriptEntrySchema);
const transcriptToMarkdown = (
entries: z.infer<typeof transcriptSchema>,
): string =>
entries
.map((entry) => {
const speaker = entry.participant?.name ?? 'Unknown';
const text = entry.words.map((word) => word.text).join(' ');
return `**${speaker}:** ${text}`;
})
.join('\n\n');
const localTranscriptToMarkdown = (
entries: LocalTranscriptEntry[],
): string =>
entries
.map((entry) => {
const speaker = entry.participant?.name ?? 'Unknown';
const text = entry.words.map((word) => word.text).join(' ');
return `**${speaker}:** ${text}`;
})
.join('\n\n');
const downloadFile = async (
url: string,
): Promise<{ buffer: Buffer; contentType: string; fileName: string }> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download file from ${url}: ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? 'audio/mpeg';
const urlPath = new URL(url).pathname;
const fileName = urlPath.split('/').pop() ?? 'recording.mp4';
const arrayBuffer = await response.arrayBuffer();
return {
buffer: Buffer.from(arrayBuffer),
contentType,
fileName,
};
};
const processTranscript = async (
metadataClient: InstanceType<typeof MetadataApiClient>,
transcriptUrl: string | undefined,
localTranscript?: LocalTranscriptEntry[],
): Promise<
| {
transcriptFile?: UploadedFileRef[];
transcript?: { blocknote: null; markdown: string };
}
| undefined
> => {
// The local transcript already has correct speakers (from isActiveSpeaker
// tracking) and word-level timestamps (from the SDK events). Use it
// directly instead of the Recall file which misattributes speakers.
if (localTranscript?.length) {
const transcriptBuffer = Buffer.from(
JSON.stringify(localTranscript),
'utf-8',
);
const uploadedTranscript = await metadataClient.uploadFile(
transcriptBuffer,
'transcript.json',
'application/json',
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
return {
transcriptFile: [
{ fileId: uploadedTranscript.id, label: 'transcript.json' },
],
transcript: {
blocknote: null,
markdown: localTranscriptToMarkdown(localTranscript),
},
};
}
// Fallback: use the Recall-provided transcript file when no local data
if (!transcriptUrl) {
return undefined;
}
const { buffer, fileName } = await downloadFile(transcriptUrl);
const uploadedTranscript = await metadataClient.uploadFile(
buffer,
fileName,
'application/json',
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
const parsedEntries = transcriptSchema.parse(
JSON.parse(buffer.toString('utf-8')),
);
return {
transcriptFile: [{ fileId: uploadedTranscript.id, label: fileName }],
transcript: { blocknote: null, markdown: transcriptToMarkdown(parsedEntries) },
};
};
const handler = async (event: any) => {
const body = event.body as EndRecordingBody | null;
if (!body?.callRecordingId) {
throw new Error('Missing callRecordingId in request body');
}
if (!body?.audioUrl) {
throw new Error('Missing audioUrl in request body');
}
const client = new CoreApiClient();
const metadataClient = new MetadataApiClient();
const { callRecording } = await client.query({
callRecording: {
__args: {
filter: { id: { eq: body.callRecordingId } },
},
id: true,
name: true,
status: true,
},
});
if (!callRecording) {
throw new Error(`Call recording not found: ${body.callRecordingId}`);
}
if (callRecording.status === 'ENDED') {
throw new Error(`Call recording already ended: ${body.callRecordingId}`);
}
const { buffer, contentType, fileName } = await downloadFile(body.audioUrl);
const uploadedRecording = await metadataClient.uploadFile(
buffer,
fileName,
contentType,
RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
const transcriptData = await processTranscript(
metadataClient,
body.transcriptUrl,
body.localTranscript,
);
const callName = body.participants?.length
? `Call ${body.participants
.map((participant) => participant.name)
.join(' / ')}`
: undefined;
const updateData: Record<string, unknown> = {
status: 'ENDED',
endedAt: new Date().toISOString(),
recordingFile: [{ fileId: uploadedRecording.id, label: fileName }],
...transcriptData,
...(callName ? { name: callName } : {}),
};
delete updateData.createdAt;
await client.mutation({
updateCallRecording: {
__args: {
id: callRecording.id,
data: updateData,
},
id: true,
endedAt: true,
status: true,
},
});
// TODO: remove `as any` after running `yarn twenty app:dev` to regenerate the typed client
const updateSummary = async (markdown: string) => {
await client.mutation({
updateCallRecording: {
__args: {
id: callRecording.id,
data: {
summary: { blocknote: null, markdown },
} as any,
},
id: true,
},
});
};
if (transcriptData?.transcript?.markdown) {
console.log(
'[end-recording] Transcript available, attempting summarization...',
);
await updateSummary('*Generating summary...*');
try {
const summaryMarkdown = await summarizeTranscript(
transcriptData.transcript.markdown,
);
console.log(
'[end-recording] Summarization result:',
summaryMarkdown ? `${summaryMarkdown.length} chars` : 'undefined',
);
if (summaryMarkdown) {
await updateSummary(summaryMarkdown);
console.log('[end-recording] Summary saved to record');
} else {
await updateSummary('*Failed to generate summary: NO_RESPONSE*');
}
} catch (error) {
const errorCode =
error instanceof Error ? error.message : 'UNKNOWN_ERROR';
console.error('[end-recording] AI summarization failed:', error);
await updateSummary(`*Failed to generate summary: ${errorCode}*`);
}
} else {
console.log(
'[end-recording] No transcript markdown, skipping summarization',
);
}
if (body.participants?.length) {
await matchParticipants(callRecording.id, body.participants);
}
};
export default defineLogicFunction({
universalIdentifier: '471353f6-5933-417b-8062-9ad0fc44cd7f',
name: 'end-recording',
description: 'Endpoint to end a call recording',
timeoutSeconds: 60,
handler,
httpRouteTriggerSettings: {
path: '/end-recording',
httpMethod: 'POST',
isAuthRequired: false,
},
});
@@ -0,0 +1,10 @@
import { CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/call-recording-view';
import { defineNavigationMenuItem } from 'twenty-sdk';
export default defineNavigationMenuItem({
universalIdentifier: '5248a62d-7d2e-43a7-ba45-6e8f61876a71',
name: 'Call recordings',
icon: 'IconPhone',
position: 0,
viewUniversalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,124 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
'af251b70-85c6-49bd-bf4a-2631f34c8f1a';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'272dca6f-b3aa-49f5-b7ed-39780052f1fe';
export const CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'c581a044-f646-464b-aa4b-56b8ea9bf05a';
export const ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'56185e64-6591-41c1-a3e0-af8de20a5471';
export const RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER =
'e78d41fd-a493-4d06-b036-0dd7b7617dbe';
export const TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER =
'b2a3c8e1-7f94-4d5b-a6e2-9c1d0f3e8b47';
export const TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER =
'a1d4e7c3-5b28-4f96-8e3a-0c7d9f2b6a15';
export const SUMMARY_FIELD_UNIVERSAL_IDENTIFIER =
'55eb083f-0b68-4f5c-bcd7-c853ad77ba11';
export const STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'24c92ad0-4559-4bf9-a9fa-09168914a142';
export default defineObject({
universalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'callRecording',
namePlural: 'callRecordings',
labelSingular: 'Call recording',
labelPlural: 'Call recordings',
description: 'A recorded call',
icon: 'IconPhone',
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the call recording',
icon: 'IconAbc',
},
{
universalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the call recording was created',
icon: 'IconCalendar',
},
{
universalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'endedAt',
label: 'Ended at',
description: 'When the call ended',
icon: 'IconCalendar',
},
{
universalIdentifier: RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'recordingFile',
label: 'Recording file',
description: 'The recording file of the call recording',
icon: 'IconFile',
universalSettings: { maxNumberOfValues: 1 },
},
{
universalIdentifier: TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'transcriptFile',
label: 'Transcript file',
description: 'The transcript file of the call recording',
icon: 'IconFileText',
universalSettings: { maxNumberOfValues: 1 },
},
{
universalIdentifier: TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT_V2,
name: 'transcript',
label: 'Transcript',
description: 'Human-readable transcript of the call',
icon: 'IconMessage',
},
{
universalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
description: 'Status of the call recording',
icon: 'IconStatusChange',
defaultValue: "'ONGOING'",
options: [
{
id: '8b275a4d-98ba-4718-912d-b1d97e713f5d',
value: 'ONGOING',
label: 'Ongoing',
position: 0,
color: 'blue',
},
{
id: 'a515ac77-44f8-4744-9c50-0a29352a800d',
value: 'ENDED',
label: 'Ended',
position: 1,
color: 'green',
},
],
},
{
universalIdentifier: SUMMARY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT_V2,
name: 'summary',
label: 'Summary',
description: 'AI-generated summary of the call',
icon: 'IconSparkles',
},
],
});
@@ -0,0 +1,105 @@
import { PEOPLE_ON_CALL_RECORDING_ID } from 'src/fields/people-on-call-recording.field';
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
import {
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
NAME_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/objects/call-recording';
import { AggregateOperations, definePageLayout, ObjectRecordGroupByDateGranularity, PageLayoutTabLayoutMode } from 'twenty-sdk';
export const CALL_RECORDING_DASHBOARD_LAYOUT_UNIVERSAL_IDENTIFIER =
'17ff2924-00c9-4105-ac4e-64c28cba781f';
export default definePageLayout({
universalIdentifier: CALL_RECORDING_DASHBOARD_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Call Recording Insights',
type: 'DASHBOARD',
tabs: [
{
universalIdentifier: 'aa3398e8-b7e8-402f-9681-4cdc44fdd6d8',
title: 'Overview',
position: 0,
icon: 'IconChartBar',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '1a04a308-5318-4f75-9b3a-4ee414750508',
title: 'Total Calls',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Total Calls',
},
},
{
universalIdentifier: '26c8190f-ff63-45f8-9cd9-d8bfd1380cca',
title: 'Calls per Person',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, column: 3, rowSpan: 6, columnSpan: 4 },
configuration: {
configurationType: 'PIE_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataUniversalIdentifier:
PEOPLE_ON_CALL_RECORDING_ID,
groupBySubFieldName: 'name.firstName',
showCenterMetric: true,
displayLegend: true,
displayDataLabel: false,
color: 'blue',
},
},
{
universalIdentifier: 'd3741561-e9ca-4dd2-8510-4f49d25911e5',
title: 'Calls per Workspace Member',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, column: 7, rowSpan: 6, columnSpan: 5 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
primaryAxisGroupBySubFieldName: 'name.firstName',
displayDataLabel: true,
displayLegend: false,
color: 'turquoise',
layout: 'VERTICAL',
},
},
{
universalIdentifier: '7916f127-8f60-4226-a0b8-45632cf3cfe7',
title: 'Calls Over Time',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, column: 0, rowSpan: 6, columnSpan: 12 },
configuration: {
configurationType: 'LINE_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
displayDataLabel: false,
displayLegend: false,
color: 'purple',
},
},
],
},
],
});
@@ -0,0 +1,119 @@
import { CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summary-viewer-front-component-universal-identifier';
import { CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-viewer-front-component-universal-identifier';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: 'b7e3a1d4-5c92-4f68-9a0b-3e8d7c6f1a25',
name: 'Call Recording Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: 'e6b2d8f4-7a13-4c59-b2e1-9d4f0c8a3b67',
title: 'Summary',
position: 50,
icon: 'IconSparkles',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'e5c93fce-76b4-41e9-9c5d-9b17e034366c',
title: 'Summary',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier: 'c4f8e2a6-3d71-4b95-8e0c-1a9f6d5b7c34',
title: 'Transcript',
position: 100,
icon: 'IconVideo',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'd5a9f3b7-4e82-4c06-9f1d-2b0a7e6c8d45',
title: 'Media Player',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier: 'f7c1b5d9-6a04-4e28-b13f-4d2c9a8e0f67',
title: 'Timeline',
position: 200,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '23c87a9c-25e3-4e83-84d9-02fb1a6fde76',
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
{
universalIdentifier: '498e47e5-bed1-4492-a08d-b12b7ff40ed9',
title: 'Tasks',
position: 300,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'ae93482f-384f-42a8-9c06-6bc14b10da6a',
title: 'Tasks',
type: 'TASKS',
configuration: {
configurationType: 'TASKS',
},
},
],
},
{
universalIdentifier: 'c111ccf0-b95b-4333-b2bc-a7a9da40f913',
title: 'Notes',
position: 400,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'e2b6a0c4-1f59-4d73-a684-9c7b4f3d5e12',
title: 'Notes',
type: 'NOTES',
configuration: {
configurationType: 'NOTES',
},
},
],
},
{
universalIdentifier: 'f3c7b1d5-2a60-4e84-b795-0d8c5a4e6f23',
title: 'Files',
position: 500,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'a17bf74a-a7ff-48a0-8628-3fd905539c8d',
title: 'Files',
type: 'FILES',
configuration: {
configurationType: 'FILES',
},
},
],
},
],
});
@@ -0,0 +1,16 @@
import { defineRole } from 'twenty-sdk';
import { PermissionFlagType } from 'twenty-shared/constants';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'f9cfb3ce-cb1e-4f55-af85-be45f6059054';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Call recording default function role',
description: 'Call recording default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
permissionFlags: [PermissionFlagType.UPLOAD_FILE, PermissionFlagType.AI],
});
@@ -0,0 +1,51 @@
import { defineSkill } from 'twenty-sdk';
export const CALL_TRANSCRIPT_SUMMARIZATION_SKILL_UNIVERSAL_IDENTIFIER =
'11fb51a7-4d5a-4168-91d7-9fbe5eb7d609';
export default defineSkill({
universalIdentifier: CALL_TRANSCRIPT_SUMMARIZATION_SKILL_UNIVERSAL_IDENTIFIER,
name: 'call-transcript-summarization',
label: 'Call Transcript Summarization',
description:
'Instructions for summarizing and analyzing call recording transcripts',
content: `# Call Transcript Summarization
## When to Use
Use this skill when a user asks you to summarize, analyze, or extract insights from a call recording.
## How to Access the Data
1. Use \`find_one_callRecording\` to fetch the call recording by its ID.
2. Read the \`transcript\` field (RICH_TEXT_V2, markdown format) which contains the full conversation.
3. The transcript uses the format: **Speaker Name:** spoken text
## What to Produce
Generate a structured summary with these sections:
### Overview
A 2-3 sentence high-level description of what the call was about, who participated, and the general outcome.
### Key Discussion Points
Bullet points covering the main topics discussed, organized chronologically or by theme.
### Decisions Made
Any explicit decisions or agreements reached during the call.
### Action Items
Concrete next steps mentioned during the call, including who is responsible (if stated).
### Sentiment & Tone
A brief note on the overall tone of the conversation (collaborative, tense, exploratory, etc.).
## Output Format
- Use markdown formatting.
- Keep the summary concise — aim for roughly 20% of the transcript length.
- If the transcript is very short (under 200 words), provide a brief 2-3 sentence summary instead of the full structure.
## Saving the Summary
After generating the summary, use \`update_callRecording\` to save it in the \`summary\` field with the format:
\`\`\`json
{ "summary": { "blocknote": null, "markdown": "<your summary>" } }
\`\`\`
`,
});
@@ -0,0 +1,4 @@
import { AUDIO_EXTENSIONS } from 'src/constants/audio-extensions';
export const isAudioExtension = (extension: string): boolean =>
AUDIO_EXTENSIONS.includes(extension.toLowerCase() as (typeof AUDIO_EXTENSIONS)[number]);
@@ -0,0 +1,4 @@
import { VIDEO_EXTENSIONS } from 'src/constants/video-extensions';
export const isVideoExtension = (extension: string): boolean =>
VIDEO_EXTENSIONS.includes(extension.toLowerCase() as (typeof VIDEO_EXTENSIONS)[number]);
@@ -0,0 +1,148 @@
import { CoreApiClient } from 'twenty-sdk/generated';
export interface Participant {
id: string;
name: string;
isHost: boolean;
platform: string;
}
const parseName = (
fullName: string,
): { firstName: string; lastName: string } => {
const parts = fullName.trim().split(/\s+/);
const firstName = parts[0] ?? '';
const lastName = parts.slice(1).join(' ');
return { firstName, lastName };
};
const findWorkspaceMember = async (
client: InstanceType<typeof CoreApiClient>,
firstName: string,
lastName: string,
): Promise<string | null> => {
const result: any = await client.query({
workspaceMembers: {
__args: {
filter: {
name: {
firstName: { ilike: `%${firstName}%` },
lastName: { ilike: `%${lastName}%` },
},
},
},
edges: {
node: {
id: true,
name: { firstName: true, lastName: true },
},
},
},
} as any);
const firstMatch = result.workspaceMembers?.edges?.[0]?.node;
return firstMatch?.id ?? null;
};
const findPerson = async (
client: InstanceType<typeof CoreApiClient>,
firstName: string,
lastName: string,
): Promise<string | null> => {
const result: any = await client.query({
people: {
__args: {
filter: {
name: {
firstName: { ilike: `%${firstName}%` },
lastName: { ilike: `%${lastName}%` },
},
},
},
edges: {
node: {
id: true,
name: { firstName: true, lastName: true },
},
},
},
} as any);
const firstMatch = result.people?.edges?.[0]?.node;
return firstMatch?.id ?? null;
};
export const matchParticipants = async (
callRecordingId: string,
participants: Participant[],
): Promise<void> => {
if (!participants.length) {
console.log('No participants to match, skipping');
return;
}
const client = new CoreApiClient();
for (const participant of participants) {
const { firstName, lastName } = parseName(participant.name);
if (!firstName) {
console.log(
`Skipping participant with empty name: ${participant.id}`,
);
continue;
}
const workspaceMemberId = await findWorkspaceMember(
client,
firstName,
lastName,
);
if (workspaceMemberId) {
console.log(
`Matched "${participant.name}" to workspace member ${workspaceMemberId}`,
);
await client.mutation({
updateCallRecording: {
__args: {
id: callRecordingId,
data: { workspaceMemberId },
},
id: true,
},
} as any);
continue;
}
const personId = await findPerson(client, firstName, lastName);
if (personId) {
console.log(
`Matched "${participant.name}" to person ${personId}`,
);
await client.mutation({
updateCallRecording: {
__args: {
id: callRecordingId,
data: { personId },
},
id: true,
},
} as any);
continue;
}
console.log(
`No match found for participant "${participant.name}"`,
);
}
};
@@ -0,0 +1,73 @@
const SUMMARIZATION_SYSTEM_PROMPT = [
'You are a helpful assistant that summarizes call transcripts.',
'Provide a concise summary with:',
'1) A brief overview of the call',
'2) Key discussion points',
'3) Action items (if any)',
'Use markdown formatting.',
].join(' ');
export const summarizeTranscript = async (
transcriptMarkdown: string,
): Promise<string | undefined> => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
console.log(
'[summarizeTranscript] Starting summarization',
JSON.stringify({
hasApiBaseUrl: !!apiBaseUrl,
hasToken: !!token,
transcriptLength: transcriptMarkdown.length,
}),
);
if (!apiBaseUrl || !token) {
console.log(
'[summarizeTranscript] Skipping: missing apiBaseUrl or token',
);
return undefined;
}
const url = `${apiBaseUrl}/rest/ai/generate-text`;
console.log('[summarizeTranscript] Calling', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
userPrompt: `Summarize this call transcript:\n\n${transcriptMarkdown}`,
}),
});
console.log(
'[summarizeTranscript] Response status:',
response.status,
response.statusText,
);
if (!response.ok) {
const errorBody = await response.text();
console.error('[summarizeTranscript] Error response body:', errorBody);
throw new Error(
`AI summarization request failed with status ${response.status}: ${errorBody}`,
);
}
const data = (await response.json()) as { text?: string };
console.log(
'[summarizeTranscript] Result text length:',
data.text?.length ?? 0,
);
return data.text ?? undefined;
};
@@ -0,0 +1,87 @@
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
import { PEOPLE_ON_CALL_RECORDING_ID } from 'src/fields/people-on-call-recording.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER, CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER, RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER, STATUS_FIELD_UNIVERSAL_IDENTIFIER, SUMMARY_FIELD_UNIVERSAL_IDENTIFIER, TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER, TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineView } from 'twenty-sdk';
export const CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER =
'9c9c09bb-de9f-4248-89f2-e7d91f29c3ed';
export default defineView({
universalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Call recordings',
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconPhone',
position: 0,
fields: [
{
universalIdentifier: 'b5609679-8451-45ec-ad52-5a4e3720af45',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier: 'd791bea6-c49e-4d6f-8864-737ed00276f8',
fieldMetadataUniversalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier: 'db96d9cf-cbd8-407b-b748-7b12913f018b',
fieldMetadataUniversalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier: '17c4e68a-5b62-4509-b8ed-19f82dfb8e2f',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier: 'a7bce6c7-39ce-406a-bc6f-00b495951b4a',
fieldMetadataUniversalIdentifier: RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier: 'f3e2d1c0-a9b8-47c6-85d4-3e2f1a0b9c8d',
fieldMetadataUniversalIdentifier: TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier: 'e4c3b2a1-0d9e-48f7-a6b5-1c2d3e4f5a6b',
fieldMetadataUniversalIdentifier: TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
{
universalIdentifier: '56925631-0077-4b65-a25a-24afec4d8bff',
fieldMetadataUniversalIdentifier: WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
isVisible: true,
size: 12,
position: 7,
},
{
universalIdentifier: '1ba8538d-9c8e-47bf-b485-a8ba07b3d9a3',
fieldMetadataUniversalIdentifier: PEOPLE_ON_CALL_RECORDING_ID,
isVisible: true,
size: 12,
position: 8,
},
{
universalIdentifier: '58124ded-56ff-40a0-8858-eae1b5ae75d7',
fieldMetadataUniversalIdentifier: SUMMARY_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
],
});
@@ -0,0 +1,32 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
RECALLAI_API_URL=https://us-west-2.recall.ai
RECALLAI_API_KEY=recall_api_key
# Twenty CRM integration (optional)
# Generate an API key at <your-twenty-instance>/settings/api-webhooks
TWENTY_API_URL=http://localhost:3000
# Workspace subdomain, required for logic function routes when multiworkspace is enabled
TWENTY_WORKSPACE_SUBDOMAIN=
TWENTY_API_KEY=replace_me_with_your_twenty_api_key
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+92
View File
@@ -0,0 +1,92 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Webpack
.webpack/
# Vite
.vite/
# Electron-Forge
out/
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.print</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>
+7
View File
@@ -0,0 +1,7 @@
Copyright 2025 Recall.ai
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+52
View File
@@ -0,0 +1,52 @@
# Twenty Desktop
> **WARNING: This application is a Proof of Concept (POC) and must NOT be used in production.** It is intended for demonstration and experimentation purposes only. Security, stability, and performance have not been validated for production use.
This is a demo application that shows off what you can build with the [Recall.ai Desktop Recording SDK.](https://www.recall.ai/product/desktop-recording-sdk)
This repo is intended to be a mockup of the kind of experience you can build using the Desktop Recording SDK.
Need help? Reach out to our support team [support@recall.ai](mailto:support@recall.ai).
# Setup
- Copy the `env.example` file to a `.env` file:
- `cp .env.example .env`
- Replace `RECALLAI_API_URL` with the base URL for the [Recall region](https://docs.recall.ai/docs/regions#/) that you're using that matches your API key, example:
- `RECALLAI_API_URL=https://us-east-1.recall.ai`
- Modify `.env` to include your Recall.ai API key:
- `RECALLAI_API_KEY=<your key>`
Required: This project also uses live transcription with Assembly AI. You'll need to configure your own Assembly credentials on the Recall.ai dashboard. Follow our [AssemblyAI real-time transcription guide](https://docs.recall.ai/docs/dsdk-realtime-transcription#assemblyai-transcription-setup) to set this up.
If you want to enable the AI summary after a recording is finished, you can specify an OpenRouter API key.
```
OPENROUTER_KEY=<your key>
```
### Twenty CRM Integration (optional)
To automatically create `callRecording` records in Twenty when a meeting starts (and mark them as ended when the meeting closes), configure:
```
TWENTY_API_URL=http://localhost:3000
TWENTY_API_KEY=<your key>
```
The `call-recording` Twenty app must be installed in your workspace first (`packages/twenty-apps/internal/call-recording`). Generate an API key at `<your-twenty-instance>/settings/api-webhooks`.
To launch the Twenty Desktop application, start the server first, then the app:
```sh
npm ci
npm start
```
# Screenshots
![Screenshot 2025-06-16 at 10 10 57PM](https://github.com/user-attachments/assets/9df12246-b5be-466d-958e-e09ff0b4b3cb)
![Screenshot 2025-06-16 at 10 22 44PM](https://github.com/user-attachments/assets/685f13ab-7c02-4f29-a987-830d331c4d36)
![Screenshot 2025-06-16 at 10 14 38PM](https://github.com/user-attachments/assets/75817823-084c-46b0-bbe8-e0195a3f9051)
+89
View File
@@ -0,0 +1,89 @@
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
module.exports = {
packagerConfig: {
asar: {
unpackDir: "node_modules/@recallai"
},
osxSign: process.env.SKIP_SIGN ? false : {
continueOnError: false,
optionsForFile: (_) => {
return {
entitlements: './Entitlements.plist'
};
}
},
icon: './twenty',
extendInfo: {
NSUserNotificationAlertStyle: "alert",
}
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-dmg'
},
// {
// name: '@electron-forge/maker-squirrel',
// config: {},
// },
// {
// name: '@electron-forge/maker-zip',
// platforms: ['darwin'],
// },
// {
// name: '@electron-forge/maker-deb',
// config: {},
// },
// {
// name: '@electron-forge/maker-rpm',
// config: {},
// },
],
plugins: [
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {},
},
{
name: '@electron-forge/plugin-webpack',
config: {
port: 3042,
devContentSecurityPolicy: "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: filesystem: mediastream: file:;",
mainConfig: './webpack.main.config.js',
renderer: {
config: './webpack.renderer.config.js',
entryPoints: [
{
html: './src/index.html',
js: './src/renderer.js',
name: 'main_window',
preload: {
js: './src/preload.js',
},
},
],
},
},
},
{
name: "@timfish/forge-externals-plugin",
config: {
externals: ["@recallai/desktop-sdk"],
includeDeps: true
}
},
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
};
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
{
"name": "twenty-desktop",
"productName": "Twenty",
"version": "1.0.0",
"description": "Twenty meeting recorder",
"main": ".webpack/main",
"scripts": {
"start": "concurrently \"npm run start:server\" \"npm run start:electron\"",
"start:electron": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "echo \"No linting configured\"",
"start:server": "node ./src/server.js"
},
"keywords": [],
"author": {
"name": "Nick Faro",
"email": "yux50000@hotmail.com"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@electron-forge/cli": "^7.8.0",
"@electron-forge/maker-deb": "^7.8.0",
"@electron-forge/maker-dmg": "^7.8.1",
"@electron-forge/maker-rpm": "^7.8.0",
"@electron-forge/maker-squirrel": "^7.8.0",
"@electron-forge/maker-zip": "^7.8.0",
"@electron-forge/plugin-auto-unpack-natives": "^7.8.0",
"@electron-forge/plugin-fuses": "^7.8.0",
"@electron-forge/plugin-webpack": "^7.8.0",
"@electron/fuses": "^1.8.0",
"@vercel/webpack-asset-relocator-loader": "^1.7.3",
"css-loader": "^6.11.0",
"electron": "36.0.1",
"node-loader": "^2.1.0",
"style-loader": "^3.3.4"
},
"overrides": {
"@electron/packager": {
"@electron/osx-sign": "github:recallai/osx-sign"
}
},
"dependencies": {
"@anthropic-ai/sdk": "^0.40.1",
"@recallai/desktop-sdk": "^2.0.0",
"@timfish/forge-externals-plugin": "^0.2.1",
"axios": "^1.9.0",
"codemirror": "^6.0.1",
"concurrently": "^9.2.1",
"dotenv": "^16.5.0",
"easymde": "^2.20.0",
"electron-squirrel-startup": "^1.0.1",
"express": "^4.18.2",
"marked": "^5.0.0",
"openai": "^4.97.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"simplemde": "^1.11.2",
"zod": "^3.25.76"
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
echo "==> Packaging Twenty Desktop..."
SKIP_SIGN=1 npx electron-forge package
echo "==> Ad-hoc signing Twenty.app..."
codesign --force --deep --sign - out/Twenty-darwin-arm64/Twenty.app
echo "==> Starting local server..."
node src/server.js &
SERVER_PID=$!
trap "echo '==> Stopping server...'; kill $SERVER_PID 2>/dev/null" EXIT INT TERM
echo "==> Launching Twenty.app..."
./out/Twenty-darwin-arm64/Twenty.app/Contents/MacOS/Twenty
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
npm start
@@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="20" fill="#f0f0f0"/>
<path d="M20 20C22.7614 20 25 17.7614 25 15C25 12.2386 22.7614 10 20 10C17.2386 10 15 12.2386 15 15C15 17.7614 17.2386 20 20 20Z" fill="#a0a0a0"/>
<path d="M12 31C12 26.0294 15.5817 22 20 22C24.4183 22 28 26.0294 28 31" stroke="#a0a0a0" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,5 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="11.293" fill="#000000"/>
<path d="M19.25 35.746C19.25 30.5 23.508 26.246 28.75 26.246H47.039C47.309 26.246 47.555 26.406 47.668 26.652C47.781 26.902 47.73 27.191 47.547 27.395L43.539 31.75C42.84 32.508 41.859 32.945 40.828 32.945H28.801C27.227 32.945 25.949 34.223 25.949 35.797V42.98C25.949 43.906 25.199 44.652 24.273 44.652H20.93C20.004 44.652 19.258 43.906 19.258 42.98V35.746Z" fill="white"/>
<path d="M76.152 60.254C76.152 65.5 71.895 69.754 66.648 69.754H58.879C53.633 69.754 49.375 65.5 49.375 60.254V46.652C49.375 45.727 49.723 44.836 50.352 44.152L54.883 39.234C55.074 39.027 55.371 38.957 55.637 39.055C55.898 39.164 56.074 39.41 56.074 39.691V60.211C56.074 61.785 57.352 63.063 58.926 63.063H66.605C68.18 63.063 69.457 61.785 69.457 60.211V35.797C69.457 34.223 68.18 32.945 66.605 32.945H57.676C56.652 32.945 55.68 33.375 54.98 34.121L28.348 63.063H44.352C45.273 63.063 46.023 63.813 46.023 64.738V68.082C46.023 69.008 45.273 69.754 44.352 69.754H22.785C20.832 69.754 19.242 68.168 19.242 66.211V64.441C19.242 63.551 19.574 62.695 20.18 62.039L50.039 29.605C52.016 27.457 54.789 26.246 57.707 26.246H66.641C71.887 26.246 76.145 30.5 76.145 35.746V60.254Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Twenty - Meeting Notes</title>
</head>
<body>
<div class="app-container">
<header class="header" id="drag-region">
<div class="header-left">
<button class="btn back-btn" id="backButton" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 11H7.83L13.42 5.41L12 4L4 12L12 20L13.41 18.59L7.83 13H20V11Z" fill="currentColor"/>
</svg>
</button>
<div class="app-logo">
<svg width="28" height="28" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="11.293" fill="#000000"/>
<path d="M19.25 35.746C19.25 30.5 23.508 26.246 28.75 26.246H47.039C47.309 26.246 47.555 26.406 47.668 26.652C47.781 26.902 47.73 27.191 47.547 27.395L43.539 31.75C42.84 32.508 41.859 32.945 40.828 32.945H28.801C27.227 32.945 25.949 34.223 25.949 35.797V42.98C25.949 43.906 25.199 44.652 24.273 44.652H20.93C20.004 44.652 19.258 43.906 19.258 42.98V35.746Z" fill="white"/>
<path d="M76.152 60.254C76.152 65.5 71.895 69.754 66.648 69.754H58.879C53.633 69.754 49.375 65.5 49.375 60.254V46.652C49.375 45.727 49.723 44.836 50.352 44.152L54.883 39.234C55.074 39.027 55.371 38.957 55.637 39.055C55.898 39.164 56.074 39.41 56.074 39.691V60.211C56.074 61.785 57.352 63.063 58.926 63.063H66.605C68.18 63.063 69.457 61.785 69.457 60.211V35.797C69.457 34.223 68.18 32.945 66.605 32.945H57.676C56.652 32.945 55.68 33.375 54.98 34.121L28.348 63.063H44.352C45.273 63.063 46.023 63.813 46.023 64.738V68.082C46.023 69.008 45.273 69.754 44.352 69.754H22.785C20.832 69.754 19.242 68.168 19.242 66.211V64.441C19.242 63.551 19.574 62.695 20.18 62.039L50.039 29.605C52.016 27.457 54.789 26.246 57.707 26.246H66.641C71.887 26.246 76.145 30.5 76.145 35.746V60.254Z" fill="white"/>
</svg>
</div>
</div>
<div class="header-center">
<div class="search-container">
<span class="search-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z" fill="#666666"/>
</svg>
</span>
<input type="text" class="search-input" placeholder="Search notes">
</div>
</div>
<div class="header-right">
<button class="btn new-note-btn" id="newNoteBtn">Record In-person Meeting</button>
<button class="btn join-meeting-btn" id="joinMeetingBtn">Record Meeting</button>
<button id="toggleSidebar" class="btn toggle-sidebar-btn" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 18H21V16H3V18ZM3 13H21V11H3V13ZM3 6V8H21V6H3Z" fill="#666666"/>
</svg>
</button>
<div class="user-avatar">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="20" fill="#f0f0f0"/>
<path d="M20 20C22.7614 20 25 17.7614 25 15C25 12.2386 22.7614 10 20 10C17.2386 10 15 12.2386 15 15C15 17.7614 17.2386 20 20 20Z" fill="#a0a0a0"/>
<path d="M12 31C12 26.0294 15.5817 22 20 22C24.4183 22 28 26.0294 28 31" stroke="#a0a0a0" stroke-width="4"/>
</svg>
</div>
</div>
</header>
<!-- Home Screen View -->
<div id="homeView">
<main class="main-content">
<div class="content-container">
<!-- Only showing notes section, calendar hidden -->
<section class="meetings-section">
<h2 class="section-title">Notes</h2>
<div class="meetings-list" id="notes-list">
<!-- Meeting cards will be inserted here by JavaScript -->
</div>
</section>
</div>
</main>
</div>
<!-- Note Editor View -->
<div id="editorView" style="display: none;">
<div class="note-container">
<main class="editor-content">
<div class="note-header">
<div class="title-container">
<h1 id="noteTitle" contenteditable="true">Paint shopping plan</h1>
<div class="edit-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" fill="#a0a0a0"/>
</svg>
</div>
</div>
<div class="note-meta">
<span class="note-date">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 4H18V2H16V4H8V2H6V4H5C3.89 4 3.01 4.9 3.01 6L3 20C3 21.1 3.89 22 5 22H19C20.1 22 21 21.1 21 20V6C21 4.9 20.1 4 19 4ZM19 20H5V10H19V20ZM19 8H5V6H19V8ZM9 14H7V12H9V14ZM13 14H11V12H13V14ZM17 14H15V12H17V14ZM9 18H7V16H9V18ZM13 18H11V16H13V18ZM17 18H15V16H17V18Z" fill="#6947BD"/>
</svg>
<span id="noteDate">Apr 24</span>
</span>
<span class="note-author">
<svg width="14" height="14" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="20" fill="#f0f0f0"/>
<path d="M20 20C22.7614 20 25 17.7614 25 15C25 12.2386 22.7614 10 20 10C17.2386 10 15 12.2386 15 15C15 17.7614 17.2386 20 20 20Z" fill="#a0a0a0"/>
<path d="M12 31C12 26.0294 15.5817 22 20 22C24.4183 22 28 26.0294 28 31" stroke="#a0a0a0" stroke-width="4"/>
</svg>
Me
</span>
<a class="recall-link" id="recallLink" href="#" style="display: none;" title="View recording on Recall.ai">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" fill="currentColor"/>
</svg>
View Recording
</a>
</div>
</div>
<textarea id="simple-editor"># Meeting Title
• Paint shopping plan
# Meeting Date and Time
• April 24, 2025, 22:36:12
# Participants
• Nick Faro
# Description
• The meeting involved Nick Faro discussing plans to purchase a can of paint. The conversation was brief and included a humorous remark about going to Mars. There were no specific action items or further details discussed during the meeting.
Chat with meeting transcript: https://notes.granola.ai/d/393a95cf-4788-4a47-9deb-3952c665a56b</textarea>
</main>
<aside class="sidebar" id="sidebar" style="display:none;">
<div class="sidebar-content">
<section class="sidebar-section">
<h3>SHARE NOTES</h3>
<div class="share-options">
<button class="share-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.9 12C3.9 10.29 5.29 8.9 7 8.9H11V7H7C4.24 7 2 9.24 2 12C2 14.76 4.24 17 7 17H11V15.1H7C5.29 15.1 3.9 13.71 3.9 12ZM8 13H16V11H8V13ZM17 7H13V8.9H17C18.71 8.9 20.1 10.29 20.1 12C20.1 13.71 18.71 15.1 17 15.1H13V17H17C19.76 17 22 14.76 22 12C22 9.24 19.76 7 17 7Z" fill="#666666"/>
</svg>
Copy link
</button>
<button class="share-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3ZM19 19H5V5H19V19ZM7 10H9V17H7V10ZM11 7H13V17H11V7ZM15 13H17V17H15V13Z" fill="#666666"/>
</svg>
Copy text
</button>
</div>
<div class="share-export">
<button class="share-btn wide">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4C2.9 4 2.01 4.9 2.01 6L2 18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6C22 4.9 21.1 4 20 4ZM20 18H4V8L12 13L20 8V18ZM12 11L4 6H20L12 11Z" fill="#666666"/>
</svg>
Email
</button>
<button class="share-btn wide">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6ZM20 6L12 11L4 6H20ZM20 18H4V8L12 13L20 8V18Z" fill="#4285F4"/>
</svg>
Slack
</button>
</div>
</section>
<section class="sidebar-section">
<h3>ASK TWENTY</h3>
<div class="ai-options">
<button class="ai-btn">Generate meeting summary</button>
<button class="ai-btn">List action items</button>
<button class="ai-btn">Write follow-up email</button>
<button class="ai-btn">List Q&A</button>
</div>
</section>
</div>
</aside>
</div>
<div class="floating-controls">
<div class="control-buttons">
<button class="control-btn record-btn" id="recordButton">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="record-icon">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" fill="currentColor"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" fill="currentColor"/>
</svg>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="stop-icon" style="display: none;">
<path d="M6 6h12v12H6V6z" fill="currentColor"/>
</svg>
</button>
<button class="control-btn generate-btn">
<svg width="16" height="16" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 4px;">
<path d="M208,512a24.84,24.84,0,0,1-23.34-16l-39.84-103.6a16.06,16.06,0,0,0-9.19-9.19L32,343.34a25,25,0,0,1,0-46.68l103.6-39.84a16.06,16.06,0,0,0,9.19-9.19L184.66,144a25,25,0,0,1,46.68,0l39.84,103.6a16.06,16.06,0,0,0,9.19,9.19l103,39.63A25.49,25.49,0,0,1,400,320.52a24.82,24.82,0,0,1-16,22.82l-103.6,39.84a16.06,16.06,0,0,0-9.19,9.19L231.34,496A24.84,24.84,0,0,1,208,512Z" fill="currentColor"/>
<path d="M88,176a14.67,14.67,0,0,1-13.69-9.4L57.45,122.76a7.28,7.28,0,0,0-4.21-4.21L9.4,101.69a14.67,14.67,0,0,1,0-27.38L53.24,57.45a7.31,7.31,0,0,0,4.21-4.21L74.16,9.79A15,15,0,0,1,86.23.11,14.67,14.67,0,0,1,101.69,9.4l16.86,43.84a7.31,7.31,0,0,0,4.21,4.21L166.6,74.31a14.67,14.67,0,0,1,0,27.38l-43.84,16.86a7.28,7.28,0,0,0-4.21,4.21L101.69,166.6A14.67,14.67,0,0,1,88,176Z" fill="currentColor"/>
<path d="M400,256a16,16,0,0,1-14.93-10.26l-22.84-59.37a8,8,0,0,0-4.6-4.6l-59.37-22.84a16,16,0,0,1,0-29.86l59.37-22.84a8,8,0,0,0,4.6-4.6L384.9,42.68a16.45,16.45,0,0,1,13.17-10.57,16,16,0,0,1,16.86,10.15l22.84,59.37a8,8,0,0,0,4.6,4.6l59.37,22.84a16,16,0,0,1,0,29.86l-59.37,22.84a8,8,0,0,0-4.6,4.6l-22.84,59.37A16,16,0,0,1,400,256Z" fill="currentColor"/>
</svg>
Auto
</button>
</div>
</div>
<div class="chat-input-container">
<div class="chat-input">
<input type="text" id="chatInput" placeholder="Ask about the meeting..." style="display: none;" />
<button id="sendButton" style="display: none;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.01 21L23 12L2.01 3L2 10L17 12L2 14L2.01 21Z" fill="#0077FF"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Debug Panel -->
<div class="debug-panel hidden" id="debugPanel">
<div class="debug-panel-header" id="debug-drag-region">
<h3>Debugging Tools</h3>
<button class="debug-panel-close" id="closeDebugPanelBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="currentColor"/>
</svg>
</button>
</div>
<div class="debug-panel-content">
<!-- SDK Logger Section -->
<div class="debug-section" id="loggerSection">
<div class="debug-section-header">
<h4>RecallAI SDK Logger</h4>
<button class="debug-section-action" id="clearLoggerBtn">Clear</button>
</div>
<div class="debug-section-content" id="sdkLoggerContent">
<!-- Log entries will be added here dynamically -->
</div>
</div>
<!-- Transcript Section -->
<div class="debug-section" id="transcriptSection">
<div class="debug-section-header">
<h4>Transcript</h4>
</div>
<div class="debug-section-content" id="transcriptContent">
<div class="placeholder-content">
<p>Live transcript will appear here during recording</p>
</div>
</div>
</div>
<!-- Participants Section -->
<div class="debug-section" id="participantsSection">
<div class="debug-section-header">
<h4>Meeting Participants</h4>
</div>
<div class="debug-section-content" id="participantsContent">
<div class="placeholder-content">
<p>Detected participants will appear here</p>
</div>
</div>
</div>
<!-- Participant Video Debug Section -->
<div class="debug-section" id="videoSection">
<div class="debug-section-header">
<h4>Participant Video</h4>
</div>
<div class="debug-section-content" id="videoContent">
<div class="placeholder-content video-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" fill="#999"/>
</svg>
<p>Participant video will appear here</p>
</div>
</div>
</div>
<!-- Screenshare Video Debug Section -->
<div class="debug-section" id="screenshareSection">
<div class="debug-section-header">
<h4>Screenshare</h4>
</div>
<div class="debug-section-content" id="screenshareContent">
<div class="placeholder-content video-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" fill="#999"/>
</svg>
<p>Screenshare will appear here</p>
</div>
</div>
</div>
</div>
</div>
<button class="debug-panel-toggle panel-hidden" id="debugPanelToggle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z" fill="currentColor"/>
</svg>
</button>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Twenty - Note Editor</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
</head>
<body>
<div class="app-container">
<header class="header" id="drag-region">
<div class="header-left">
<button class="btn back-btn" id="backButton">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 11H7.83L13.42 5.41L12 4L4 12L12 20L13.41 18.59L7.83 13H20V11Z" fill="currentColor"/>
</svg>
</button>
<div class="app-logo">
<svg width="28" height="28" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="11.293" fill="#000000"/>
<path d="M19.25 35.746C19.25 30.5 23.508 26.246 28.75 26.246H47.039C47.309 26.246 47.555 26.406 47.668 26.652C47.781 26.902 47.73 27.191 47.547 27.395L43.539 31.75C42.84 32.508 41.859 32.945 40.828 32.945H28.801C27.227 32.945 25.949 34.223 25.949 35.797V42.98C25.949 43.906 25.199 44.652 24.273 44.652H20.93C20.004 44.652 19.258 43.906 19.258 42.98V35.746Z" fill="white"/>
<path d="M76.152 60.254C76.152 65.5 71.895 69.754 66.648 69.754H58.879C53.633 69.754 49.375 65.5 49.375 60.254V46.652C49.375 45.727 49.723 44.836 50.352 44.152L54.883 39.234C55.074 39.027 55.371 38.957 55.637 39.055C55.898 39.164 56.074 39.41 56.074 39.691V60.211C56.074 61.785 57.352 63.063 58.926 63.063H66.605C68.18 63.063 69.457 61.785 69.457 60.211V35.797C69.457 34.223 68.18 32.945 66.605 32.945H57.676C56.652 32.945 55.68 33.375 54.98 34.121L28.348 63.063H44.352C45.273 63.063 46.023 63.813 46.023 64.738V68.082C46.023 69.008 45.273 69.754 44.352 69.754H22.785C20.832 69.754 19.242 68.168 19.242 66.211V64.441C19.242 63.551 19.574 62.695 20.18 62.039L50.039 29.605C52.016 27.457 54.789 26.246 57.707 26.246H66.641C71.887 26.246 76.145 30.5 76.145 35.746V60.254Z" fill="white"/>
</svg>
</div>
</div>
<div class="header-center">
<div class="search-container">
<span class="search-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z" fill="#666666"/>
</svg>
</span>
<input type="text" class="search-input" placeholder="Search">
</div>
</div>
<div class="header-right">
<button id="toggleSidebar" class="btn toggle-sidebar-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 18H21V16H3V18ZM3 13H21V11H3V13ZM3 6V8H21V6H3Z" fill="#666666"/>
</svg>
</button>
<div class="user-avatar">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="20" fill="#f0f0f0"/>
<path d="M20 20C22.7614 20 25 17.7614 25 15C25 12.2386 22.7614 10 20 10C17.2386 10 15 12.2386 15 15C15 17.7614 17.2386 20 20 20Z" fill="#a0a0a0"/>
<path d="M12 31C12 26.0294 15.5817 22 20 22C24.4183 22 28 26.0294 28 31" stroke="#a0a0a0" stroke-width="4"/>
</svg>
</div>
</div>
</header>
<div class="note-container">
<main class="editor-content">
<div class="note-header">
<h1 id="noteTitle">Paint shopping plan</h1>
<div class="note-meta">
<span class="note-date">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 4H18V2H16V4H8V2H6V4H5C3.89 4 3.01 4.9 3.01 6L3 20C3 21.1 3.89 22 5 22H19C20.1 22 21 21.1 21 20V6C21 4.9 20.1 4 19 4ZM19 20H5V10H19V20ZM19 8H5V6H19V8ZM9 14H7V12H9V14ZM13 14H11V12H13V14ZM17 14H15V12H17V14ZM9 18H7V16H9V18ZM13 18H11V16H13V18ZM17 18H15V16H17V18Z" fill="#6947BD"/>
</svg>
Apr 24
</span>
<span class="note-author">
<svg width="14" height="14" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="20" fill="#f0f0f0"/>
<path d="M20 20C22.7614 20 25 17.7614 25 15C25 12.2386 22.7614 10 20 10C17.2386 10 15 12.2386 15 15C15 17.7614 17.2386 20 20 20Z" fill="#a0a0a0"/>
<path d="M12 31C12 26.0294 15.5817 22 20 22C24.4183 22 28 26.0294 28 31" stroke="#a0a0a0" stroke-width="4"/>
</svg>
Me
</span>
</div>
</div>
<div id="editor">
# Meeting Title
* Paint shopping plan
# Meeting Date and Time
* April 24, 2025, 22:36:12
# Participants
* Nick Faro
# Description
* The meeting involved Nick Faro discussing plans to purchase a can of paint. The conversation was brief and included a humorous remark about going to Mars. There were no specific action items or further details discussed during the meeting.
Chat with meeting transcript: https://notes.granola.ai/d/393a95cf-4788-4a47-9deb-3952c665a56b
</div>
</main>
<aside class="sidebar" id="sidebar">
<div class="sidebar-content">
<section class="sidebar-section">
<h3>SHARE NOTES</h3>
<div class="share-options">
<button class="share-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.9 12C3.9 10.29 5.29 8.9 7 8.9H11V7H7C4.24 7 2 9.24 2 12C2 14.76 4.24 17 7 17H11V15.1H7C5.29 15.1 3.9 13.71 3.9 12ZM8 13H16V11H8V13ZM17 7H13V8.9H17C18.71 8.9 20.1 10.29 20.1 12C20.1 13.71 18.71 15.1 17 15.1H13V17H17C19.76 17 22 14.76 22 12C22 9.24 19.76 7 17 7Z" fill="#666666"/>
</svg>
Copy link
</button>
<button class="share-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3ZM19 19H5V5H19V19ZM7 10H9V17H7V10ZM11 7H13V17H11V7ZM15 13H17V17H15V13Z" fill="#666666"/>
</svg>
Copy text
</button>
</div>
<div class="share-export">
<button class="share-btn wide">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4C2.9 4 2.01 4.9 2.01 6L2 18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6C22 4.9 21.1 4 20 4ZM20 18H4V8L12 13L20 8V18ZM12 11L4 6H20L12 11Z" fill="#666666"/>
</svg>
Email
</button>
<button class="share-btn wide">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6ZM20 6L12 11L4 6H20ZM20 18H4V8L12 13L20 8V18Z" fill="#4285F4"/>
</svg>
Slack
</button>
</div>
</section>
<section class="sidebar-section">
<h3>ASK TWENTY</h3>
<div class="ai-options">
<button class="ai-btn">List action items</button>
<button class="ai-btn">Write follow-up email</button>
<button class="ai-btn">List Q&A</button>
</div>
</section>
</div>
</aside>
</div>
<div class="chat-input">
<input type="text" id="chatInput" placeholder="Ask about the meeting..." />
<button id="sendButton">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.01 21L23 12L2.01 3L2 10L17 12L2 14L2.01 21Z" fill="#0077FF"/>
</svg>
</button>
</div>
</div>
<script src="renderer.js"></script>
</body>
</html>
@@ -0,0 +1,79 @@
// Import styles
import './styles.css';
// Initialize the markdown editor when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Initialize SimpleMDE Markdown Editor
const editor = new SimpleMDE({
element: document.getElementById('editor'),
spellChecker: false,
autofocus: true,
status: false,
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', '|',
'preview', 'side-by-side', 'fullscreen',
],
placeholder: 'Write your notes here...',
initialValue: document.getElementById('editor').textContent.trim(),
});
// Handle sidebar toggle
const toggleSidebarBtn = document.getElementById('toggleSidebar');
const sidebar = document.getElementById('sidebar');
const editorContent = document.querySelector('.editor-content');
toggleSidebarBtn.addEventListener('click', () => {
sidebar.classList.toggle('hidden');
editorContent.classList.toggle('full-width');
});
// Handle back button
const backButton = document.getElementById('backButton');
backButton.addEventListener('click', () => {
window.electronAPI.navigate('home');
});
// Chat input handling
const chatInput = document.getElementById('chatInput');
const sendButton = document.getElementById('sendButton');
// When send button is clicked
sendButton.addEventListener('click', () => {
const message = chatInput.value.trim();
if (message) {
console.log('Sending message:', message);
// Here you would handle the AI chat functionality
// For now, just clear the input
chatInput.value = '';
}
});
// Send message on Enter key
chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendButton.click();
}
});
// Handle share buttons
const shareButtons = document.querySelectorAll('.share-btn');
shareButtons.forEach(button => {
button.addEventListener('click', () => {
const action = button.textContent.trim();
console.log(`Share action: ${action}`);
// Implement actual sharing functionality here
});
});
// Handle AI option buttons
const aiButtons = document.querySelectorAll('.ai-btn');
aiButtons.forEach(button => {
button.addEventListener('click', () => {
const action = button.textContent.trim();
console.log(`AI action: ${action}`);
// Implement actual AI functionality here
});
});
});
@@ -0,0 +1,356 @@
:root {
--primary-bg: #f9f9f9;
--card-bg: #fff;
--light-purple: #f3e9ff;
--light-green: #e8f5e9;
--border-color: #e0e0e0;
--text-primary: #000;
--text-secondary: #666;
--sidebar-width: 320px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--primary-bg);
margin: 0;
overflow-x: hidden;
color: var(--text-primary);
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header Styles */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
}
#drag-region {
-webkit-app-region: drag;
}
.header-left, .header-right {
display: flex;
align-items: center;
}
.header-left {
margin-left: 60px;
gap: 8px;
}
.app-logo {
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.header-center {
flex-grow: 1;
max-width: 400px;
margin: 0 20px;
}
.search-container {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 10px;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.search-input {
width: 100%;
padding: 8px 8px 8px 35px;
border-radius: 5px;
border: 1px solid var(--border-color);
background-color: #f2f2f2;
font-size: 14px;
-webkit-app-region: no-drag;
}
.btn {
padding: 8px 16px;
border-radius: 5px;
border: none;
cursor: pointer;
font-weight: 500;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn {
background-color: transparent;
color: var(--text-secondary);
padding: 8px;
}
.toggle-sidebar-btn {
background-color: transparent;
color: var(--text-secondary);
padding: 8px;
margin-right: 10px;
display: none;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
background-color: #ddd;
-webkit-app-region: no-drag;
}
/* Note Editor Layout */
.note-container {
display: flex;
flex: 1;
position: relative;
}
.editor-content {
flex: 1;
padding: 40px;
background-color: var(--card-bg);
max-width: calc(100% - var(--sidebar-width));
transition: max-width 0.3s ease;
}
.editor-content.full-width {
max-width: 100%;
}
.note-header {
margin-bottom: 30px;
}
.note-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 10px;
}
.note-meta {
display: flex;
gap: 15px;
color: var(--text-secondary);
font-size: 14px;
margin-top: 10px;
}
.note-date, .note-author {
display: flex;
align-items: center;
gap: 5px;
}
/* Editor */
.CodeMirror, .editor-preview {
font-size: 16px;
line-height: 1.6;
}
.CodeMirror {
border: none;
height: calc(100vh - 230px);
}
.editor-toolbar {
border: none;
opacity: 0.8;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background-color: var(--card-bg);
border-left: 1px solid var(--border-color);
padding: 20px;
overflow-y: auto;
transition: transform 0.3s ease;
}
.sidebar.hidden {
transform: translateX(var(--sidebar-width));
}
.sidebar-section {
margin-bottom: 30px;
}
.sidebar-section h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 15px;
font-weight: 500;
}
.share-options, .share-export {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.share-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 5px;
border: 1px solid var(--border-color);
background-color: var(--card-bg);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
flex: 1;
justify-content: center;
}
.share-btn.wide {
flex: 1;
}
.ai-options {
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-btn {
padding: 10px 16px;
border-radius: 5px;
border: 1px solid var(--border-color);
background-color: var(--card-bg);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
text-align: left;
}
/* Chat Input */
.chat-input {
display: flex;
margin: 20px auto;
width: 90%;
max-width: 800px;
position: relative;
bottom: 20px;
}
#chatInput {
flex: 1;
padding: 12px 20px;
border-radius: 20px;
border: 1px solid var(--border-color);
font-size: 14px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
#sendButton {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
}
/* SimpleMDE customizations */
.CodeMirror-fullscreen, .editor-preview-side {
z-index: 999;
}
.editor-toolbar.fullscreen {
z-index: 1000;
}
/* Custom markdown rendering */
.editor-preview h1, .editor-preview-side h1 {
font-size: 22px;
color: #333;
margin: 25px 0 15px 0;
}
.editor-preview h2, .editor-preview-side h2 {
font-size: 18px;
color: #444;
margin: 20px 0 10px 0;
}
.editor-preview ul, .editor-preview-side ul {
padding-left: 20px;
margin: 10px 0;
}
.editor-preview a, .editor-preview-side a {
color: #0077ff;
text-decoration: none;
}
.editor-preview a:hover, .editor-preview-side a:hover {
text-decoration: underline;
}
/* Make code look nice */
.editor-preview code, .editor-preview-side code {
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.editor-content {
padding: 20px;
}
.sidebar {
width: 100%;
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 200;
transform: translateX(100%);
}
.sidebar.hidden {
transform: translateX(0);
}
.editor-content {
max-width: 100%;
}
}
+40
View File
@@ -0,0 +1,40 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
const { contextBridge, ipcRenderer } = require('electron');
// Set up the SDK logger bridge between main and renderer
contextBridge.exposeInMainWorld('sdkLoggerBridge', {
// Receive logs from main process
onSdkLog: (callback) => ipcRenderer.on('sdk-log', (_, logEntry) => callback(logEntry)),
// Send logs from renderer to main process
sendSdkLog: (logEntry) => ipcRenderer.send('sdk-log', logEntry)
});
contextBridge.exposeInMainWorld('electronAPI', {
navigate: (page) => ipcRenderer.send('navigate', page),
saveMeetingsData: (data) => ipcRenderer.invoke('saveMeetingsData', data),
loadMeetingsData: () => ipcRenderer.invoke('loadMeetingsData'),
deleteMeeting: (meetingId) => ipcRenderer.invoke('deleteMeeting', meetingId),
generateMeetingSummary: (meetingId) => ipcRenderer.invoke('generateMeetingSummary', meetingId),
generateMeetingSummaryStreaming: (meetingId) => ipcRenderer.invoke('generateMeetingSummaryStreaming', meetingId),
startManualRecording: (meetingId) => ipcRenderer.invoke('startManualRecording', meetingId),
stopManualRecording: (recordingId) => ipcRenderer.invoke('stopManualRecording', recordingId),
debugGetHandlers: () => ipcRenderer.invoke('debugGetHandlers'),
checkForDetectedMeeting: () => ipcRenderer.invoke('checkForDetectedMeeting'),
joinDetectedMeeting: () => ipcRenderer.invoke('joinDetectedMeeting'),
onOpenMeetingNote: (callback) => ipcRenderer.on('open-meeting-note', (_, meetingId) => callback(meetingId)),
onRecordingCompleted: (callback) => ipcRenderer.on('recording-completed', (_, meetingId) => callback(meetingId)),
onTranscriptUpdated: (callback) => ipcRenderer.on('transcript-updated', (_, meetingId) => callback(meetingId)),
onSummaryGenerated: (callback) => ipcRenderer.on('summary-generated', (_, meetingId) => callback(meetingId)),
onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', (_, data) => callback(data)),
onRecordingStateChange: (callback) => ipcRenderer.on('recording-state-change', (_, data) => callback(data)),
onParticipantsUpdated: (callback) => ipcRenderer.on('participants-updated', (_, meetingId) => callback(meetingId)),
onVideoFrame: (callback) => ipcRenderer.on('video-frame', (_, data) => callback(data)),
onMeetingDetectionStatus: (callback) => ipcRenderer.on('meeting-detection-status', (_, data) => callback(data)),
onMeetingTitleUpdated: (callback) => ipcRenderer.on('meeting-title-updated', (_, data) => callback(data)),
getActiveRecordingId: (noteId) => ipcRenderer.invoke('getActiveRecordingId', noteId),
openExternal: (url) => ipcRenderer.invoke('openExternal', url),
getRecordingVideoUrl: (recordingId) => ipcRenderer.invoke('getRecordingVideoUrl', recordingId)
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
// SDK Logger for tracking RecallAI SDK operations
// This module is shared between main and renderer processes
// Create an event emitter for communication between processes
const { EventEmitter } = require('events');
const logger = new EventEmitter();
// Export the logger
module.exports = {
// Log an SDK API call
logApiCall: function(method, params = {}) {
const logEntry = {
type: 'api-call',
method,
params,
timestamp: new Date()
};
// Emit the log event
logger.emit('log', logEntry);
// Return a reference to the logger for chaining
return this;
},
// Log an SDK event
logEvent: function(eventType, data = {}) {
const logEntry = {
type: 'event',
eventType,
data,
timestamp: new Date()
};
// Emit the log event
logger.emit('log', logEntry);
// Return a reference to the logger for chaining
return this;
},
// Log an error
logError: function(errorType, message) {
const logEntry = {
type: 'error',
errorType,
message,
timestamp: new Date()
};
// Emit the log event
logger.emit('log', logEntry);
// Return a reference to the logger for chaining
return this;
},
// Log a generic message
log: function(message, level = 'info') {
const logEntry = {
type: level,
message,
timestamp: new Date()
};
// Emit the log event
logger.emit('log', logEntry);
// Return a reference to the logger for chaining
return this;
},
// Set up a listener for logs
onLog: function(callback) {
logger.on('log', callback);
return this;
},
// Remove a log listener
removeLogListener: function(callback) {
logger.off('log', callback);
return this;
}
};
+104
View File
@@ -0,0 +1,104 @@
const express = require('express');
const axios = require('axios');
const { z } = require('zod');
const app = express();
require('dotenv').config();
// API configuration for Recall.ai
const RECALLAI_API_URL = process.env.RECALLAI_API_URL || 'https://api.recall.ai';
const RECALLAI_API_KEY = process.env.RECALLAI_API_KEY;
app.get('/start-recording', async (req, res) => {
console.log('Creating upload token with configured Recall.ai API key');
if (!RECALLAI_API_KEY) {
console.error("RECALLAI_API_KEY is missing! Set it in .env file");
return res.json({ status: 'error', message: 'RECALLAI_API_KEY is missing' });
}
const url = `${RECALLAI_API_URL}/api/v1/sdk_upload/`;
try {
const response = await axios.post(url, {
recording_config: {
transcript: {
provider: {
assembly_ai_v3_streaming: {}
}
},
realtime_endpoints: [
{
type: "desktop_sdk_callback",
events: [
"participant_events.join",
"video_separate_png.data",
"transcript.data",
"transcript.provider_data"
]
},
],
}
}, {
headers: { 'Authorization': `Token ${RECALLAI_API_KEY}` },
timeout: 9000,
});
res.json({
status: 'success',
upload_token: response.data.upload_token,
upload_id: response.data.id,
recording_id: response.data.recording_id,
});
} catch (e) {
console.error("Error creating upload token:", JSON.stringify(e.errors || e.response?.data || e.message));
res.json({ status: 'error', message: e.message });
}
});
app.get('/recording/:recordingId', async (req, res) => {
const parseResult = z.string().uuid().safeParse(req.params.recordingId);
if (!RECALLAI_API_KEY) {
return res.json({ status: 'error', message: 'RECALLAI_API_KEY is missing' });
}
if (!parseResult.success) {
return res.status(400).json({ status: 'error', message: 'Invalid recording ID' });
}
const validatedRecordingId = parseResult.data;
try {
const response = await axios.get(
`${RECALLAI_API_URL}/api/v1/recording/${validatedRecordingId}/`,
{
headers: { 'Authorization': `Token ${RECALLAI_API_KEY}` },
timeout: 10000,
}
);
const data = response.data;
const videoUrl = data.media_shortcuts?.video_mixed?.data?.download_url || null;
const transcriptUrl = data.media_shortcuts?.transcript?.data?.download_url || null;
const statusCode = data.status?.code || 'unknown';
res.json({
status: 'success',
recording_status: statusCode,
video_url: videoUrl,
transcript_url: transcriptUrl,
});
} catch (e) {
console.error("Error fetching recording:", JSON.stringify(e.response?.data || e.message));
res.json({ status: 'error', message: e.response?.data || e.message });
}
});
if (require.main === module) {
app.listen(13373, () => {
console.log(`Server listening on http://localhost:13373`);
});
}
module.exports = app;
@@ -0,0 +1,119 @@
const axios = require('axios');
const LOG_PREFIX = '[Twenty]';
let configLogged = false;
function getApiUrl() {
return process.env.TWENTY_API_URL;
}
function getWorkspaceSubdomain() {
return process.env.TWENTY_WORKSPACE_SUBDOMAIN;
}
function getApiKey() {
return process.env.TWENTY_API_KEY;
}
function isConfigured() {
const configured = Boolean(getApiUrl() && getApiKey());
if (!configLogged) {
configLogged = true;
if (!configured) {
console.log(
`${LOG_PREFIX} Integration disabled — TWENTY_API_URL or TWENTY_API_KEY not set`,
);
} else {
console.log(
`${LOG_PREFIX} Integration enabled — ${getApiUrl()}`,
);
}
}
return configured;
}
function getClient() {
return axios.create({
baseURL: `${getApiUrl()}/rest`,
headers: {
Authorization: `Bearer ${getApiKey()}`,
'Content-Type': 'application/json',
},
timeout: 10000,
});
}
function logError(action, error) {
const status = error.response?.status;
const body = error.response?.data;
console.error(
`${LOG_PREFIX} ${action} failed —`,
status ? `HTTP ${status}` : error.code || error.message,
body ? JSON.stringify(body) : '',
);
}
async function createCallRecording(name) {
console.log(`${LOG_PREFIX} Creating callRecording: "${name}"`);
const client = getClient();
try {
const response = await client.post('/callRecordings', {
name,
createdAt: new Date().toISOString(),
});
const record = response.data?.data?.createCallRecording;
console.log(
`${LOG_PREFIX} callRecording created — id=${record?.id}`,
);
return record;
} catch (error) {
logError('createCallRecording', error);
throw error;
}
}
async function endCallRecording({ callRecordingId, audioUrl, transcriptUrl, participants, localTranscript }) {
console.log(
`${LOG_PREFIX} Ending callRecording ${callRecordingId} — audioUrl=${audioUrl} transcriptUrl=${transcriptUrl || 'none'} participants=${participants?.length || 0} localTranscriptEntries=${localTranscript?.length || 0}`,
);
try {
const apiUrl = new URL(getApiUrl());
const subdomain = getWorkspaceSubdomain();
const host = subdomain
? `${subdomain}.${apiUrl.hostname}:${apiUrl.port}`
: apiUrl.host;
const response = await axios.post(
`${getApiUrl()}/s/end-recording`,
{ callRecordingId, audioUrl, transcriptUrl, participants, localTranscript },
{
headers: {
'Content-Type': 'application/json',
Host: host,
},
timeout: 30000,
},
);
console.log(
`${LOG_PREFIX} callRecording ended — id=${callRecordingId}`,
);
return response.data;
} catch (error) {
logError('endCallRecording', error);
throw error;
}
}
module.exports = { isConfigured, createCallRecording, endCallRecording };
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,14 @@
module.exports = {
/**
* This is the main entry point for your application, it's the first file
* that runs in the main process.
*/
entry: './src/main.js',
// Put your normal webpack config below here
module: {
rules: require('./webpack.rules'),
},
externals: {
'@recallai/desktop-sdk': 'commonjs @recallai/desktop-sdk'
}
};
@@ -0,0 +1,17 @@
const rules = require('./webpack.rules');
rules.push({
test: /\.css$/,
use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
});
module.exports = {
// Put your normal webpack config below here
module: {
rules,
},
entry: {
renderer: './src/renderer.js',
'note-editor/renderer': './src/pages/note-editor/renderer.js',
},
};
@@ -0,0 +1,17 @@
module.exports = [
// Add support for native node modules
{
// We're specifying native_modules in the test because the asset relocator loader generates a
// "fake" .node file which is really a cjs file.
test: /native_modules[/\\].+\.node$/,
use: 'node-loader',
},
{
test: /\.(png|jpe?g|gif)$/i,
type: 'asset/inline',
},
{
test: /\.svg$/i,
type: 'asset/resource',
},
];
@@ -10,7 +10,6 @@ import { isDefined } from 'twenty-shared/utils';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
@@ -18,6 +17,7 @@ import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
type UseAIChatEditorProps = {
@@ -1,19 +1,22 @@
import { Action } from '@/action-menu/actions/components/Action';
import { HeadlessFrontComponentAction } from '@/action-menu/actions/display/components/HeadlessFrontComponentAction';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { HeadlessFrontComponentAction } from '@/action-menu/actions/display/components/HeadlessFrontComponentAction';
import { useOpenFrontComponentInCommandMenu } from '@/command-menu/hooks/useOpenFrontComponentInCommandMenu';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent, useIcons } from 'twenty-ui/display';
import { type HeadlessFrontComponentMountContext } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import {
type CommandMenuItemFieldsFragment,
@@ -36,8 +39,16 @@ type BuildActionFromItemParams = {
frontComponentId: string;
pageTitle: string;
pageIcon: IconComponent;
recordContext?: {
recordId: string;
objectNameSingular: string;
};
}) => void;
mountHeadlessFrontComponent: (frontComponentId: string) => void;
mountHeadlessFrontComponent: (
frontComponentId: string,
context?: HeadlessFrontComponentMountContext,
) => void;
mountContext?: HeadlessFrontComponentMountContext;
};
const buildActionFromItem = ({
@@ -48,6 +59,7 @@ const buildActionFromItem = ({
getIcon,
openFrontComponentInCommandMenu,
mountHeadlessFrontComponent,
mountContext,
}: BuildActionFromItemParams) => {
const displayLabel = item.label;
@@ -57,12 +69,18 @@ const buildActionFromItem = ({
const handleClick = () => {
if (isHeadless) {
mountHeadlessFrontComponent(item.frontComponentId);
mountHeadlessFrontComponent(item.frontComponentId, mountContext);
} else {
openFrontComponentInCommandMenu({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel,
pageIcon: Icon,
recordContext: isDefined(mountContext)
? {
recordId: mountContext.recordId,
objectNameSingular: mountContext.objectNameSingular,
}
: undefined,
});
}
};
@@ -108,6 +126,25 @@ export const useCommandMenuItemFrontComponentActions = () => {
contextStoreTargetedRecordsRuleComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const currentObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataItemId,
);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const mountContext: HeadlessFrontComponentMountContext | undefined =
selectedRecordIds.length === 1 && isDefined(currentObjectMetadataItem)
? {
recordId: selectedRecordIds[0],
objectNameSingular: currentObjectMetadataItem.nameSingular,
}
: undefined;
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
@@ -169,6 +206,7 @@ export const useCommandMenuItemFrontComponentActions = () => {
getIcon,
openFrontComponentInCommandMenu,
mountHeadlessFrontComponent,
mountContext,
}),
);
@@ -1,9 +1,10 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { viewableFrontComponentIdComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState';
import { viewableFrontComponentRecordContextComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentRecordContextComponentState';
import { useStore } from 'jotai';
import { CommandMenuPages } from 'twenty-shared/types';
import { type IconComponent } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useStore } from 'jotai';
export const useOpenFrontComponentInCommandMenu = () => {
const store = useStore();
@@ -14,11 +15,16 @@ export const useOpenFrontComponentInCommandMenu = () => {
pageTitle,
pageIcon,
resetNavigationStack = false,
recordContext,
}: {
frontComponentId: string;
pageTitle: string;
pageIcon: IconComponent;
resetNavigationStack?: boolean;
recordContext?: {
recordId: string;
objectNameSingular: string;
};
}) => {
const pageComponentInstanceId = v4();
@@ -29,6 +35,13 @@ export const useOpenFrontComponentInCommandMenu = () => {
frontComponentId,
);
store.set(
viewableFrontComponentRecordContextComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
recordContext ?? null,
);
navigateCommandMenu({
page: CommandMenuPages.ViewFrontComponent,
pageTitle,
@@ -1,8 +1,11 @@
import { Suspense, lazy } from 'react';
import { viewableFrontComponentIdComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState';
import { viewableFrontComponentRecordContextComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentRecordContextComponentState';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
@@ -15,13 +18,31 @@ export const CommandMenuFrontComponentPage = () => {
viewableFrontComponentIdComponentState,
);
const viewableFrontComponentRecordContext = useAtomComponentStateValue(
viewableFrontComponentRecordContextComponentState,
);
if (!isDefined(viewableFrontComponentId)) {
return null;
}
return (
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={viewableFrontComponentId} />
</Suspense>
<LayoutRenderingProvider
value={{
targetRecordIdentifier: isDefined(viewableFrontComponentRecordContext)
? {
id: viewableFrontComponentRecordContext.recordId,
targetObjectNameSingular:
viewableFrontComponentRecordContext.objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInRightDrawer: true,
}}
>
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={viewableFrontComponentId} />
</Suspense>
</LayoutRenderingProvider>
);
};
@@ -0,0 +1,14 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
type FrontComponentRecordContext = {
recordId: string;
objectNameSingular: string;
};
export const viewableFrontComponentRecordContextComponentState =
createAtomComponentState<FrontComponentRecordContext | null>({
key: 'command-menu/viewable-front-component-record-context',
defaultValue: null,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});
@@ -1,7 +1,10 @@
import { Suspense, lazy } from 'react';
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
import { mountedHeadlessFrontComponentMapsState } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
@@ -10,21 +13,39 @@ const FrontComponentRenderer = lazy(() =>
);
export const HeadlessFrontComponentMountRoot = () => {
const mountedHeadlessFrontComponentIds = useAtomStateValue(
mountedHeadlessFrontComponentIdsState,
const mountedHeadlessFrontComponentMaps = useAtomStateValue(
mountedHeadlessFrontComponentMapsState,
);
if (mountedHeadlessFrontComponentIds.size === 0) {
if (mountedHeadlessFrontComponentMaps.size === 0) {
return null;
}
return (
<>
{[...mountedHeadlessFrontComponentIds].map((frontComponentId) => (
<Suspense key={frontComponentId} fallback={null}>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</Suspense>
))}
{[...mountedHeadlessFrontComponentMaps.entries()].map(
([frontComponentId, mountContext]) => (
<Suspense key={frontComponentId} fallback={null}>
<LayoutRenderingProvider
value={{
targetRecordIdentifier:
isDefined(mountContext) &&
isDefined(mountContext.objectNameSingular)
? {
id: mountContext.recordId,
targetObjectNameSingular:
mountContext.objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInRightDrawer: false,
}}
>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</LayoutRenderingProvider>
</Suspense>
),
)}
</>
);
};
@@ -12,6 +12,7 @@ import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchS
import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh';
import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { assertUnreachable } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -101,9 +102,12 @@ export const useFrontComponentExecutionContext = ({
}
};
const { targetRecordIdentifier } = useLayoutRenderingContext();
const executionContext: FrontComponentExecutionContext = {
frontComponentId,
userId: currentUser?.id ?? null,
recordId: targetRecordIdentifier?.id ?? null,
};
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
@@ -1,15 +1,23 @@
import { useCallback } from 'react';
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
import {
type HeadlessFrontComponentMountContext,
mountedHeadlessFrontComponentMapsState,
} from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { useStore } from 'jotai';
export const useMountHeadlessFrontComponent = () => {
const store = useStore();
const mountHeadlessFrontComponent = useCallback(
(frontComponentId: string) => {
store.set(mountedHeadlessFrontComponentIdsState.atom, (previousIds) =>
new Set(previousIds).add(frontComponentId),
);
(
frontComponentId: string,
context?: HeadlessFrontComponentMountContext,
) => {
store.set(mountedHeadlessFrontComponentMapsState.atom, (previousMap) => {
const next = new Map(previousMap);
next.set(frontComponentId, context ?? undefined);
return next;
});
},
[store],
);
@@ -1,14 +1,14 @@
import { useCallback } from 'react';
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
import { mountedHeadlessFrontComponentMapsState } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { useStore } from 'jotai';
export const useUnmountHeadlessFrontComponent = () => {
const store = useStore();
const unmountHeadlessFrontComponent = useCallback(
(frontComponentId: string) => {
store.set(mountedHeadlessFrontComponentIdsState.atom, (previousIds) => {
const next = new Set(previousIds);
store.set(mountedHeadlessFrontComponentMapsState.atom, (previousMap) => {
const next = new Map(previousMap);
next.delete(frontComponentId);
return next;
});
@@ -1,4 +1,4 @@
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
import { mountedHeadlessFrontComponentMapsState } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
export const isHeadlessFrontComponentMountedFamilySelector =
@@ -7,8 +7,8 @@ export const isHeadlessFrontComponentMountedFamilySelector =
get:
(frontComponentId: string) =>
({ get }) => {
const mountedIds = get(mountedHeadlessFrontComponentIdsState);
const mountedMap = get(mountedHeadlessFrontComponentMapsState);
return mountedIds.has(frontComponentId);
return mountedMap.has(frontComponentId);
},
});
@@ -1,8 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const mountedHeadlessFrontComponentIdsState = createAtomState<
Set<string>
>({
key: 'mountedHeadlessFrontComponentIdsState',
defaultValue: new Set(),
});
@@ -0,0 +1,15 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export type HeadlessFrontComponentMountContext =
| {
recordId: string;
objectNameSingular: string;
}
| undefined;
export const mountedHeadlessFrontComponentMapsState = createAtomState<
Map<string, HeadlessFrontComponentMountContext>
>({
key: 'mountedHeadlessFrontComponentMapsState',
defaultValue: new Map(),
});
@@ -116,7 +116,7 @@ export class ClientService {
await this.injectClientWrapper(join(tempPath, 'core'), {
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: false,
includeUploadFile: true,
});
await this.injectClientWrapper(join(tempPath, 'metadata'), {
@@ -1,4 +1,5 @@
export type FrontComponentExecutionContext = {
frontComponentId: string;
userId: string | null;
recordId: string | null;
};
@@ -0,0 +1,10 @@
import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext';
import { useFrontComponentExecutionContext } from './useFrontComponentExecutionContext';
const selectRecordId = (
context: FrontComponentExecutionContext,
): string | null => context.recordId;
export const useRecordId = (): string | null => {
return useFrontComponentExecutionContext(selectRecordId);
};
@@ -6,6 +6,7 @@ export { openSidePanelPage } from './functions/openSidePanelPage';
export { unmountFrontComponent } from './functions/unmountFrontComponent';
export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext';
export { useFrontComponentId } from './hooks/useFrontComponentId';
export { useRecordId } from './hooks/useRecordId';
export { useUserId } from './hooks/useUserId';
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
export { getFrontComponentActionErrorDedupeKey } from './utils/getFrontComponentActionErrorDedupeKey';
@@ -1,4 +1,5 @@
export type FrontComponentExecutionContext = {
frontComponentId: string;
userId: string | null;
recordId: string | null;
};
+23 -20
View File
@@ -1,3 +1,9 @@
export {
AggregateOperations,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
} from 'twenty-shared/types';
export type { PageLayoutWidgetUniversalConfiguration } from 'twenty-shared/types';
export type { ApplicationConfig } from './application/application-config';
export { defineApplication } from './application/define-application';
export type {
@@ -29,12 +35,12 @@ export type {
FrontComponentType,
} from './front-component-config';
export { defineLogicFunction } from './logic-functions/define-logic-function';
export type {
InstallLogicFunctionPayload,
InstallLogicFunctionHandler,
} from './logic-functions/install-logic-function-payload-type';
export { definePreInstallLogicFunction } from './logic-functions/define-pre-install-logic-function';
export { definePostInstallLogicFunction } from './logic-functions/define-post-install-logic-function';
export { definePreInstallLogicFunction } from './logic-functions/define-pre-install-logic-function';
export type {
InstallLogicFunctionHandler,
InstallLogicFunctionPayload,
} from './logic-functions/install-logic-function-payload-type';
export type {
LogicFunctionConfig,
LogicFunctionHandler,
@@ -54,16 +60,12 @@ export type {
export type { RoutePayload } from './logic-functions/triggers/route-payload-type';
export { defineNavigationMenuItem } from './navigation-menu-items/define-navigation-menu-item';
export { defineObject } from './objects/define-object';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS as STANDARD_OBJECT } from './objects/standard-object-ids';
export {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS as STANDARD_OBJECT,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from './objects/standard-object-ids';
export { definePageLayout } from './page-layouts/define-page-layout';
export type { PageLayoutConfig } from './page-layouts/page-layout-config';
export {
AggregateOperations,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
} from 'twenty-shared/types';
export type { PageLayoutWidgetUniversalConfiguration } from 'twenty-shared/types';
export { defineRole } from './roles/define-role';
export { PermissionFlag } from './roles/permission-flag-type';
export { defineSkill } from './skills/define-skill';
@@ -71,23 +73,24 @@ export { defineView } from './views/define-view';
export type { ViewConfig } from './views/view-config';
// Action components for front components
export { Action } from './action';
export type { ActionProps } from './action';
export { ActionLink } from './action';
export type { ActionLinkProps } from './action';
export { ActionOpenSidePanelPage } from './action';
export type { ActionOpenSidePanelPageProps } from './action';
export { Action, ActionLink, ActionOpenSidePanelPage } from './action';
export type {
ActionLinkProps,
ActionOpenSidePanelPageProps,
ActionProps,
} from './action';
// Front Component API exports
export {
closeSidePanel,
enqueueSnackbar,
getFrontComponentActionErrorDedupeKey,
closeSidePanel,
navigate,
openSidePanelPage,
unmountFrontComponent,
useFrontComponentExecutionContext,
useFrontComponentId,
useRecordId,
useUserId,
} from './front-component-api';
export type { FrontComponentExecutionContext } from './front-component-api';
@@ -73,4 +73,26 @@ describe('fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget', () => {
url: 'https://example.com',
});
});
it('should use manifest gridPosition when provided', () => {
const result = fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({
pageLayoutWidgetManifest: {
universalIdentifier: 'widget-uuid-3',
title: 'Positioned Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 6, rowSpan: 4, columnSpan: 6 },
configuration: { configurationType: 'VIEW' },
},
pageLayoutTabUniversalIdentifier,
applicationUniversalIdentifier,
now,
});
expect(result.gridPosition).toEqual({
row: 2,
column: 6,
rowSpan: 4,
columnSpan: 6,
});
});
});
@@ -23,7 +23,12 @@ export const fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget = ({
objectMetadataUniversalIdentifier:
pageLayoutWidgetManifest.objectUniversalIdentifier ?? null,
conditionalDisplay: pageLayoutWidgetManifest.conditionalDisplay ?? null,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
gridPosition: pageLayoutWidgetManifest.gridPosition ?? {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
position: null,
universalConfiguration:
pageLayoutWidgetManifest.configuration as UniversalFlatPageLayoutWidget['universalConfiguration'],
@@ -8,8 +8,8 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { AiGenerateTextController } from './controllers/ai-generate-text.controller';
@Module({
imports: [TokenModule, WorkspaceCacheStorageModule, PermissionsModule],
controllers: [AiGenerateTextController],
})
export class AiGenerateTextModule {}
@@ -0,0 +1,67 @@
import { Body, Controller, Post, UseFilters, UseGuards } from '@nestjs/common';
import { generateText } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentRestApiExceptionFilter } from 'src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter';
import { GenerateTextInput } from 'src/engine/metadata-modules/ai/ai-generate-text/dtos/generate-text-input.dto';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Controller('rest/ai')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(AgentRestApiExceptionFilter, RestApiExceptionFilter)
export class AiGenerateTextController {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
) {}
@Post('generate-text')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
async handleGenerateText(
@Body() body: GenerateTextInput,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
throw new AgentException(
'No AI models are available. Please configure at least one AI provider API key.',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
const resolvedModelId = body.modelId ?? workspace.fastModel;
this.aiModelRegistryService.validateModelAvailability(
resolvedModelId,
workspace,
);
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent({
modelId: resolvedModelId,
});
const result = await generateText({
model: registeredModel.model,
system: body.systemPrompt,
prompt: body.userPrompt,
});
return {
text: result.text,
usage: {
inputTokens: result.usage?.inputTokens ?? 0,
outputTokens: result.usage?.outputTokens ?? 0,
},
};
}
}

Some files were not shown because too many files have changed in this diff Show More