Skip to content

Commit d58f6cd

Browse files
Add STT/TTS settings
Expanded chat settings with STT/TTS providers and related fields, added speech tab in ChatSettingsPage, introduced consultant check-in link, migrated check-in flow to consultant_profiles and added save_consultant_profile, and updated admin UI to generate check-in links. Also wired initial defaults for STT/TTS providers. X-Lovable-Edit-ID: edt-551b4558-de14-4028-85f0-f609a9e1c3ac Co-authored-by: magnusfroste <38864257+magnusfroste@users.noreply.github.com>
2 parents 717bdc1 + c4fe68e commit d58f6cd

File tree

4 files changed

+255
-23
lines changed

4 files changed

+255
-23
lines changed

src/hooks/useSiteSettings.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ const defaultMaintenanceSettings: MaintenanceSettings = {
171171

172172
// Chat settings
173173
export type ChatAiProvider = 'openai' | 'gemini' | 'local' | 'n8n';
174+
export type ChatSttProvider = 'browser' | 'openai' | 'gemini' | 'local';
175+
export type ChatTtsProvider = 'none' | 'openai' | 'gemini' | 'local';
174176
export type ChatWidgetStyle = 'floating' | 'pill' | 'expanded';
175177
export type ChatWidgetSize = 'sm' | 'md' | 'lg';
176178

@@ -263,6 +265,16 @@ export interface ChatSettings {
263265
// FlowPilot integration
264266
showEscalationsInCopilot: boolean;
265267
showPublicChatsInCopilot: boolean;
268+
269+
// Speech — STT & TTS
270+
sttProvider: ChatSttProvider;
271+
sttLocalEndpoint: string; // OpenAI-compatible Whisper endpoint
272+
sttLocalModel: string;
273+
ttsProvider: ChatTtsProvider;
274+
ttsLocalEndpoint: string; // OpenAI-compatible TTS endpoint
275+
ttsLocalModel: string;
276+
ttsVoice: string; // Voice ID (e.g. 'alloy', 'shimmer')
277+
ttsAutoPlay: boolean; // Auto-play TTS in check-in mode
266278
}
267279

268280
export const defaultChatSettings: ChatSettings = {
@@ -317,6 +329,14 @@ export const defaultChatSettings: ChatSettings = {
317329
showChatIcons: true,
318330
showEscalationsInCopilot: false,
319331
showPublicChatsInCopilot: false,
332+
sttProvider: 'browser',
333+
sttLocalEndpoint: '',
334+
sttLocalModel: 'whisper-1',
335+
ttsProvider: 'none',
336+
ttsLocalEndpoint: '',
337+
ttsLocalModel: 'tts-1',
338+
ttsVoice: 'alloy',
339+
ttsAutoPlay: false,
320340
};
321341

322342
// Generic hook for fetching settings

src/pages/admin/ChatSettingsPage.tsx

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,11 @@ export default function ChatSettingsPage() {
228228

229229
{formData.enabled && (
230230
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
231-
<TabsList className="grid grid-cols-8 w-full">
231+
<TabsList className="grid grid-cols-9 w-full">
232232
<TabsTrigger value="general">General</TabsTrigger>
233233
<TabsTrigger value="provider">Provider</TabsTrigger>
234234
<TabsTrigger value="knowledge">Knowledge</TabsTrigger>
235+
<TabsTrigger value="speech">Speech</TabsTrigger>
235236
<TabsTrigger value="advanced">Advanced</TabsTrigger>
236237
<TabsTrigger value="display">Display</TabsTrigger>
237238
<TabsTrigger value="analytics">Analytics</TabsTrigger>
@@ -877,6 +878,158 @@ export default function ChatSettingsPage() {
877878
</Card>
878879
</TabsContent>
879880

881+
{/* Speech — STT & TTS */}
882+
<TabsContent value="speech">
883+
<div className="space-y-6">
884+
<Card>
885+
<CardHeader>
886+
<CardTitle className="flex items-center gap-2">
887+
<Headphones className="h-5 w-5" />
888+
Speech-to-Text (STT)
889+
</CardTitle>
890+
<CardDescription>
891+
Configure voice input for the chat interface. Used in check-in mode and regular chat.
892+
</CardDescription>
893+
</CardHeader>
894+
<CardContent className="space-y-4">
895+
<div className="space-y-2">
896+
<Label>STT Provider</Label>
897+
<Select
898+
value={formData.sttProvider}
899+
onValueChange={(v) => setFormData({ ...formData, sttProvider: v as any })}
900+
>
901+
<SelectTrigger><SelectValue /></SelectTrigger>
902+
<SelectContent>
903+
<SelectItem value="browser">Browser (Web Speech API)</SelectItem>
904+
<SelectItem value="openai">OpenAI Whisper</SelectItem>
905+
<SelectItem value="gemini">Google Gemini</SelectItem>
906+
<SelectItem value="local">Private / Local Whisper</SelectItem>
907+
</SelectContent>
908+
</Select>
909+
<p className="text-xs text-muted-foreground">
910+
{formData.sttProvider === 'browser' && 'Uses the browser\'s built-in speech recognition. Free, but quality varies by browser.'}
911+
{formData.sttProvider === 'openai' && 'Uses OpenAI Whisper API. High quality, 50+ languages. Requires OPENAI_API_KEY.'}
912+
{formData.sttProvider === 'gemini' && 'Uses Google Gemini for transcription. Requires GEMINI_API_KEY.'}
913+
{formData.sttProvider === 'local' && 'Point to your own OpenAI-compatible Whisper endpoint for full data sovereignty.'}
914+
</p>
915+
</div>
916+
{formData.sttProvider === 'local' && (
917+
<>
918+
<div className="space-y-2">
919+
<Label>Endpoint URL</Label>
920+
<Input
921+
value={formData.sttLocalEndpoint}
922+
onChange={(e) => setFormData({ ...formData, sttLocalEndpoint: e.target.value })}
923+
placeholder="https://your-whisper.local/v1"
924+
/>
925+
</div>
926+
<div className="space-y-2">
927+
<Label>Model</Label>
928+
<Input
929+
value={formData.sttLocalModel}
930+
onChange={(e) => setFormData({ ...formData, sttLocalModel: e.target.value })}
931+
placeholder="whisper-1"
932+
/>
933+
</div>
934+
</>
935+
)}
936+
</CardContent>
937+
</Card>
938+
939+
<Card>
940+
<CardHeader>
941+
<CardTitle className="flex items-center gap-2">
942+
<Headphones className="h-5 w-5" />
943+
Text-to-Speech (TTS)
944+
</CardTitle>
945+
<CardDescription>
946+
Enable voice output so FlowPilot reads responses aloud. Useful in consultant check-in mode.
947+
</CardDescription>
948+
</CardHeader>
949+
<CardContent className="space-y-4">
950+
<div className="space-y-2">
951+
<Label>TTS Provider</Label>
952+
<Select
953+
value={formData.ttsProvider}
954+
onValueChange={(v) => setFormData({ ...formData, ttsProvider: v as any })}
955+
>
956+
<SelectTrigger><SelectValue /></SelectTrigger>
957+
<SelectContent>
958+
<SelectItem value="none">Disabled</SelectItem>
959+
<SelectItem value="openai">OpenAI TTS</SelectItem>
960+
<SelectItem value="gemini">Google Gemini</SelectItem>
961+
<SelectItem value="local">Private / Local TTS</SelectItem>
962+
</SelectContent>
963+
</Select>
964+
</div>
965+
{formData.ttsProvider !== 'none' && (
966+
<>
967+
{formData.ttsProvider === 'openai' && (
968+
<div className="space-y-2">
969+
<Label>Voice</Label>
970+
<Select
971+
value={formData.ttsVoice}
972+
onValueChange={(v) => setFormData({ ...formData, ttsVoice: v })}
973+
>
974+
<SelectTrigger><SelectValue /></SelectTrigger>
975+
<SelectContent>
976+
<SelectItem value="alloy">Alloy (neutral)</SelectItem>
977+
<SelectItem value="echo">Echo (male)</SelectItem>
978+
<SelectItem value="fable">Fable (storytelling)</SelectItem>
979+
<SelectItem value="onyx">Onyx (deep)</SelectItem>
980+
<SelectItem value="nova">Nova (female)</SelectItem>
981+
<SelectItem value="shimmer">Shimmer (warm)</SelectItem>
982+
</SelectContent>
983+
</Select>
984+
</div>
985+
)}
986+
{formData.ttsProvider === 'local' && (
987+
<>
988+
<div className="space-y-2">
989+
<Label>Endpoint URL</Label>
990+
<Input
991+
value={formData.ttsLocalEndpoint}
992+
onChange={(e) => setFormData({ ...formData, ttsLocalEndpoint: e.target.value })}
993+
placeholder="https://your-tts.local/v1"
994+
/>
995+
</div>
996+
<div className="space-y-2">
997+
<Label>Model</Label>
998+
<Input
999+
value={formData.ttsLocalModel}
1000+
onChange={(e) => setFormData({ ...formData, ttsLocalModel: e.target.value })}
1001+
placeholder="tts-1"
1002+
/>
1003+
</div>
1004+
<div className="space-y-2">
1005+
<Label>Voice ID</Label>
1006+
<Input
1007+
value={formData.ttsVoice}
1008+
onChange={(e) => setFormData({ ...formData, ttsVoice: e.target.value })}
1009+
placeholder="alloy"
1010+
/>
1011+
</div>
1012+
</>
1013+
)}
1014+
<div className="flex items-center justify-between pt-2">
1015+
<div>
1016+
<Label>Auto-play in check-in mode</Label>
1017+
<p className="text-xs text-muted-foreground">
1018+
Automatically read responses aloud during consultant check-in
1019+
</p>
1020+
</div>
1021+
<Switch
1022+
checked={formData.ttsAutoPlay}
1023+
onCheckedChange={(v) => setFormData({ ...formData, ttsAutoPlay: v })}
1024+
/>
1025+
</div>
1026+
</>
1027+
)}
1028+
</CardContent>
1029+
</Card>
1030+
</div>
1031+
</TabsContent>
1032+
8801033
{/* Advanced / Tool Calling settings */}
8811034
<TabsContent value="advanced">
8821035
<div className="space-y-6">

src/pages/admin/ConsultantProfilesPage.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import {
5151
FileUser,
5252
Upload,
5353
Loader2,
54+
Link as LinkIcon,
55+
Check,
5456
} from "lucide-react";
5557
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
5658
import { supabase } from "@/integrations/supabase/client";
@@ -210,6 +212,36 @@ function useConsultantProfiles() {
210212
});
211213
}
212214

215+
/** Small button that copies the check-in link for a consultant */
216+
function CopyCheckinLinkButton({ profileId, profileName }: { profileId: string; profileName: string }) {
217+
const [copied, setCopied] = useState(false);
218+
const { toast } = useToast();
219+
220+
const handleCopy = async () => {
221+
const url = `${window.location.origin}/chat?mode=checkin&id=${profileId}`;
222+
try {
223+
await navigator.clipboard.writeText(url);
224+
setCopied(true);
225+
toast({ title: "Check-in link copied", description: `Send this link to ${profileName} to update their profile via chat.` });
226+
setTimeout(() => setCopied(false), 2000);
227+
} catch {
228+
toast({ title: "Copy failed", variant: "destructive" });
229+
}
230+
};
231+
232+
return (
233+
<Button
234+
variant="ghost"
235+
size="icon"
236+
className="h-8 w-8"
237+
onClick={handleCopy}
238+
title={`Copy check-in link for ${profileName}`}
239+
>
240+
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <LinkIcon className="h-3.5 w-3.5" />}
241+
</Button>
242+
);
243+
}
244+
213245
export default function ConsultantProfilesPage() {
214246
const { data: profiles = [], isLoading } = useConsultantProfiles();
215247
const queryClient = useQueryClient();
@@ -537,6 +569,7 @@ export default function ConsultantProfilesPage() {
537569
</TableCell>
538570
<TableCell>
539571
<div className="flex items-center gap-1">
572+
<CopyCheckinLinkButton profileId={profile.id} profileName={profile.name} />
540573
<Button
541574
variant="ghost"
542575
size="icon"

supabase/functions/chat-completion/index.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -124,17 +124,31 @@ const AVAILABLE_TOOLS = {
124124
}
125125
}
126126
},
127-
save_kb_article: {
127+
save_consultant_profile: {
128128
type: "function",
129129
function: {
130-
name: "save_kb_article",
131-
description: "Save the updated consultant profile to the knowledge base. Call this when you have gathered enough information (at least 3 exchanges) about the consultant's latest project, skills, and availability.",
130+
name: "save_consultant_profile",
131+
description: "Save the updated consultant profile after gathering information about their latest project, skills, and availability. Call this when you have enough information (at least 3 exchanges).",
132132
parameters: {
133133
type: "object",
134134
properties: {
135135
summary: {
136136
type: "string",
137-
description: "A comprehensive plain-text summary of the consultant's profile including: name, role/title, skills and tech stack, latest project description, what went well, challenges, and current availability."
137+
description: "A comprehensive updated bio/summary of the consultant including: latest project, skills, what went well, challenges, and current availability."
138+
},
139+
skills: {
140+
type: "array",
141+
items: { type: "string" },
142+
description: "Updated list of all skills and technologies the consultant mentioned."
143+
},
144+
availability: {
145+
type: "string",
146+
enum: ["available", "busy", "on_leave"],
147+
description: "Current availability status."
148+
},
149+
title: {
150+
type: "string",
151+
description: "Updated professional title if mentioned."
138152
}
139153
},
140154
required: ["summary"]
@@ -499,21 +513,31 @@ async function executeToolCall(
499513
}
500514
}
501515

502-
case 'save_kb_article': {
516+
case 'save_consultant_profile': {
503517
if (!checkinId || !args.summary) return 'Missing checkin context or summary.';
504518
try {
505519
const supabaseClient = createClient(supabaseUrl, supabaseKey);
520+
const updateData: Record<string, unknown> = {
521+
summary: args.summary,
522+
updated_at: new Date().toISOString(),
523+
};
524+
if (args.skills && Array.isArray(args.skills) && args.skills.length > 0) {
525+
updateData.skills = args.skills;
526+
}
527+
if (args.availability) {
528+
updateData.availability = args.availability;
529+
}
530+
if (args.title) {
531+
updateData.title = args.title;
532+
}
506533
const { error } = await supabaseClient
507-
.from('kb_articles')
508-
.update({
509-
answer_text: args.summary,
510-
updated_at: new Date().toISOString(),
511-
})
534+
.from('consultant_profiles')
535+
.update(updateData)
512536
.eq('id', checkinId);
513537
if (error) throw error;
514-
return 'Profile updated successfully in the knowledge base.';
538+
return 'Consultant profile updated successfully.';
515539
} catch (err) {
516-
console.error('save_kb_article error:', err);
540+
console.error('save_consultant_profile error:', err);
517541
return `Failed to save profile: ${err instanceof Error ? err.message : 'Unknown error'}`;
518542
}
519543
}
@@ -648,18 +672,20 @@ serve(async (req) => {
648672
// Check-in mode: fetch consultant profile and build interview system prompt
649673
const isCheckinMode = mode === 'checkin' && !!checkinId;
650674
if (isCheckinMode) {
651-
const { data: kbArticle } = await supabase
652-
.from('kb_articles')
653-
.select('title, question, answer_text')
675+
const { data: consultant } = await supabase
676+
.from('consultant_profiles')
677+
.select('name, title, skills, summary, availability, experience_years, bio')
654678
.eq('id', checkinId)
655679
.maybeSingle();
656680

657-
const consultantName = kbArticle?.title || 'consultant';
658-
const existingProfile = kbArticle?.answer_text || 'No existing profile information.';
681+
const consultantName = consultant?.name || 'consultant';
682+
const existingProfile = consultant
683+
? `Name: ${consultant.name}\nTitle: ${consultant.title || 'N/A'}\nSkills: ${(consultant.skills || []).join(', ')}\nExperience: ${consultant.experience_years || 0} years\nAvailability: ${consultant.availability || 'unknown'}\nSummary: ${consultant.summary || 'No summary yet.'}\nBio: ${consultant.bio || 'N/A'}`
684+
: 'No existing profile found.';
659685

660686
systemPrompt = `You are FlowPilot, conducting a friendly professional check-in interview with ${consultantName}.
661687
662-
Your goal is to update their knowledge base profile by asking conversational questions. Keep it natural and brief — this is a quick check-in, not a formal interview.
688+
Your goal is to update their consultant profile by asking conversational questions. Keep it natural and brief — this is a quick check-in, not a formal interview.
663689
664690
Current profile:
665691
${existingProfile}
@@ -668,9 +694,9 @@ Ask about (one at a time, conversationally):
668694
1. Their most recent project or assignment (what, where, duration, tech stack)
669695
2. What went particularly well
670696
3. Any interesting challenges
671-
4. Current availability
697+
4. Current availability and preferred next role
672698
673-
After 3–5 exchanges when you have enough information, call the save_kb_article tool with a comprehensive updated profile summary. Then confirm to the consultant that their profile has been updated.
699+
After 3–5 exchanges when you have enough information, call the save_consultant_profile tool with the updated summary, skills array, availability, and title. Then confirm to the consultant that their profile has been updated.
674700
675701
IMPORTANT: Always respond in the same language the consultant writes in.`;
676702
}
@@ -691,9 +717,9 @@ IMPORTANT: Always respond in the same language the consultant writes in.`;
691717
firecrawlIntegrationEnabled: aiIntegrations?.firecrawl?.enabled,
692718
});
693719

694-
// Always add save_kb_article in check-in mode (requires tool calling support)
720+
// Always add save_consultant_profile in check-in mode (requires tool calling support)
695721
if (isCheckinMode && toolCallingSupported) {
696-
tools.push(AVAILABLE_TOOLS.save_kb_article);
722+
tools.push(AVAILABLE_TOOLS.save_consultant_profile);
697723
}
698724

699725
if (settings?.toolCallingEnabled && toolCallingSupported) {

0 commit comments

Comments
 (0)