feat: add detailed business plan document outlining production costs, monetization, and financial projections.

This commit is contained in:
ordinarthur 2026-03-27 16:35:53 +01:00
parent 787a5805b7
commit 92605d728b
11 changed files with 1595 additions and 155 deletions

View File

@ -24,6 +24,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@deepgram/sdk": "^5.0.0",
"@mastra/core": "^1.17.0",
"@nestjs/common": "^11.1.17",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.17",
@ -46,7 +47,8 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"typeorm": "^0.3.28"
"typeorm": "^0.3.28",
"zod": "^4.3.6"
},
"devDependencies": {
"@nestjs/cli": "^11.0.16",

View File

@ -0,0 +1,56 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { ICachePort } from '../../../core/ports/outbound/cache.port';
@Injectable()
export class RedisAdapter implements ICachePort, OnModuleDestroy {
private readonly logger = new Logger(RedisAdapter.name);
private readonly redis: Redis;
constructor(private readonly configService: ConfigService) {
this.redis = new Redis({
host: this.configService.get<string>('redis.host', 'localhost'),
port: this.configService.get<number>('redis.port', 6379),
password: this.configService.get<string>('redis.password'),
});
this.redis.on('connect', () => this.logger.log('Connected to Redis'));
this.redis.on('error', (err) => this.logger.error('Redis error:', err));
}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (!value) return null;
return JSON.parse(value) as T;
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const serialized = JSON.stringify(value);
if (ttlSeconds) {
await this.redis.set(key, serialized, 'EX', ttlSeconds);
} else {
await this.redis.set(key, serialized);
}
}
async del(key: string): Promise<void> {
await this.redis.del(key);
}
async lpush(key: string, value: string): Promise<void> {
await this.redis.lpush(key, value);
}
async lrange(key: string, start: number, stop: number): Promise<string[]> {
return this.redis.lrange(key, start, stop);
}
async expire(key: string, ttlSeconds: number): Promise<void> {
await this.redis.expire(key, ttlSeconds);
}
async onModuleDestroy() {
await this.redis.quit();
}
}

View File

@ -24,11 +24,13 @@ import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
import { AnthropicAdapter } from './adapters/outbound/llm/anthropic.adapter';
import { OpenAIAdapter } from './adapters/outbound/llm/openai.adapter';
import { ElevenLabsAdapter } from './adapters/outbound/tts/elevenlabs.adapter';
import { RedisAdapter } from './adapters/outbound/cache/redis.adapter';
import { CONVERSATION_PORT } from './core/ports/inbound/conversation.port';
import { STT_PORT } from './core/ports/outbound/stt.port';
import { LLM_PORT } from './core/ports/outbound/llm.port';
import { TTS_PORT } from './core/ports/outbound/tts.port';
import { DEVICE_GATEWAY_PORT } from './core/ports/outbound/device-gateway.port';
import { CACHE_PORT } from './core/ports/outbound/cache.port';
@Module({
imports: [
@ -83,6 +85,10 @@ import { DEVICE_GATEWAY_PORT } from './core/ports/outbound/device-gateway.port';
provide: TTS_PORT,
useClass: ElevenLabsAdapter,
},
{
provide: CACHE_PORT,
useClass: RedisAdapter,
},
{
provide: DEVICE_GATEWAY_PORT,
useExisting: RobotGateway,

View File

@ -1,38 +1,79 @@
import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Agent } from '@mastra/core/agent';
import { IConversationPort } from '../ports/inbound/conversation.port';
import { ISTTPort, ISTTStream, STT_PORT, TranscriptionResult } from '../ports/outbound/stt.port';
import { ILLMPort, LLM_PORT, LLMMessage } from '../ports/outbound/llm.port';
import { ITTSPort, TTS_PORT } from '../ports/outbound/tts.port';
import { ICachePort, CACHE_PORT } from '../ports/outbound/cache.port';
import { IDeviceGatewayPort, DEVICE_GATEWAY_PORT } from '../ports/outbound/device-gateway.port';
import { datetimeTool, mathTool } from '../tools';
interface ActiveSession {
deviceId: string;
transcription: string;
messages: LLMMessage[];
finalTranscription: string;
interimTranscription: string;
sttStream: ISTTStream | null;
}
const SYSTEM_PROMPT = `Tu es Ti-Pote, un petit robot de bureau animatronique, chaleureux et serviable.
Tu parles en français. Tu es concis dans tes réponses car elles seront lues à voix haute.
Tu as une personnalité enjouée mais tu restes utile et pertinent.
Réponds en 1 à 3 phrases maximum.`;
Réponds en 1 à 3 phrases maximum.
Tu as accès à des outils : utilise-les quand c'est pertinent au lieu de deviner.`;
const SESSION_TTL = 60 * 30;
const MAX_MESSAGES = 50;
@Injectable()
export class ConversationService implements IConversationPort {
private readonly logger = new Logger(ConversationService.name);
private readonly activeSessions = new Map<string, ActiveSession>();
private readonly agent: Agent;
constructor(
@Inject(STT_PORT) private readonly sttPort: ISTTPort,
@Inject(LLM_PORT) private readonly llmPort: ILLMPort,
@Inject(TTS_PORT) private readonly ttsPort: ITTSPort,
@Inject(CACHE_PORT) private readonly cache: ICachePort,
@Inject(forwardRef(() => DEVICE_GATEWAY_PORT)) private readonly deviceGateway: IDeviceGatewayPort,
) {}
private readonly configService: ConfigService,
) {
const provider = this.configService.get<string>('LLM_PROVIDER', 'anthropic');
const model =
provider === 'openai'
? `openai/${this.configService.get<string>('OPENAI_MODEL', 'gpt-4o')}`
: `anthropic/${this.configService.get<string>('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514')}`;
this.agent = new Agent({
id: 'ti-pote',
name: 'Ti-Pote',
instructions: SYSTEM_PROMPT,
model: model as any,
tools: {
datetimeTool,
mathTool,
},
});
this.logger.log(`Agent created with model: ${model}`);
}
private sessionKey(deviceId: string): string {
return `conversation:${deviceId}`;
}
private async loadMessages(deviceId: string): Promise<Array<{ role: string; content: string }>> {
const messages = await this.cache.get<Array<{ role: string; content: string }>>(this.sessionKey(deviceId));
return messages ?? [];
}
private async saveMessages(deviceId: string, messages: Array<{ role: string; content: string }>): Promise<void> {
const trimmed = messages.slice(-MAX_MESSAGES);
await this.cache.set(this.sessionKey(deviceId), trimmed, SESSION_TTL);
}
async startListening(deviceId: string): Promise<void> {
this.logger.log(`Start listening for device ${deviceId}`);
// Close previous STT stream if any
const existing = this.activeSessions.get(deviceId);
if (existing?.sttStream) {
await existing.sttStream.close();
@ -40,8 +81,8 @@ export class ConversationService implements IConversationPort {
const session: ActiveSession = {
deviceId,
transcription: '',
messages: existing?.messages ?? [],
finalTranscription: '',
interimTranscription: '',
sttStream: null,
};
@ -53,7 +94,10 @@ export class ConversationService implements IConversationPort {
);
if (result.isFinal) {
session.transcription += result.text + ' ';
session.finalTranscription += result.text + ' ';
session.interimTranscription = '';
} else {
session.interimTranscription = result.text;
}
});
@ -82,29 +126,40 @@ export class ConversationService implements IConversationPort {
session.sttStream = null;
}
const finalText = session.transcription.trim() || null;
// Use final transcription, fall back to last interim result
const finalText = session.finalTranscription.trim() || session.interimTranscription.trim() || null;
this.logger.log(`Final transcription for ${deviceId}: "${finalText}"`);
if (!finalText) {
return null;
}
session.messages.push({ role: 'user', content: finalText });
session.transcription = '';
const messages = await this.loadMessages(deviceId);
messages.push({ role: 'user', content: finalText });
session.finalTranscription = '';
session.interimTranscription = '';
try {
this.deviceGateway.sendStatus(deviceId, 'thinking');
const llmMessages: LLMMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...session.messages,
];
const coreMessages = messages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
const llmResponse = await this.llmPort.chat(llmMessages);
const responseText = llmResponse.content ?? '';
this.logger.log(`LLM response for ${deviceId}: "${responseText}"`);
const response = await this.agent.generate(coreMessages as any, { maxSteps: 5 });
session.messages.push({ role: 'assistant', content: responseText });
const responseText = response.text ?? '';
this.logger.log(`Agent response for ${deviceId}: "${responseText}"`);
if (response.toolCalls && response.toolCalls.length > 0) {
this.logger.log(
`Tools called: ${response.toolCalls.map((tc: any) => tc.toolName).join(', ')}`,
);
}
messages.push({ role: 'assistant', content: responseText });
await this.saveMessages(deviceId, messages);
if (responseText) {
this.deviceGateway.sendStatus(deviceId, 'speaking');
@ -119,6 +174,7 @@ export class ConversationService implements IConversationPort {
}
} catch (error) {
this.logger.error(`Error processing conversation for ${deviceId}:`, error);
await this.saveMessages(deviceId, messages);
this.deviceGateway.sendStatus(deviceId, 'idle');
}
@ -138,8 +194,8 @@ export class ConversationService implements IConversationPort {
header.writeUInt32LE(dataSize + headerSize - 8, 4);
header.write('WAVE', 8);
header.write('fmt ', 12);
header.writeUInt32LE(16, 16); // subchunk1 size
header.writeUInt16LE(1, 20); // PCM format
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(numChannels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);

View File

@ -0,0 +1,30 @@
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
export const datetimeTool = createTool({
id: 'get-datetime',
description:
"Donne la date et l'heure actuelles. Utilise cet outil quand l'utilisateur demande la date, l'heure, le jour de la semaine, etc.",
inputSchema: z.object({
timezone: z
.string()
.optional()
.describe('Fuseau horaire (ex: "Europe/Paris"). Par défaut: Europe/Paris'),
}),
outputSchema: z.object({
date: z.string(),
time: z.string(),
dayOfWeek: z.string(),
timestamp: z.number(),
}),
execute: async ({ context }) => {
const tz = context?.timezone || 'Europe/Paris';
const now = new Date();
const date = now.toLocaleDateString('fr-FR', { timeZone: tz, day: 'numeric', month: 'long', year: 'numeric' });
const time = now.toLocaleTimeString('fr-FR', { timeZone: tz, hour: '2-digit', minute: '2-digit' });
const dayOfWeek = now.toLocaleDateString('fr-FR', { timeZone: tz, weekday: 'long' });
return { date, time, dayOfWeek, timestamp: now.getTime() };
},
});

View File

@ -0,0 +1,2 @@
export { datetimeTool } from './datetime.tool';
export { mathTool } from './math.tool';

View File

@ -0,0 +1,37 @@
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
export const mathTool = createTool({
id: 'calculate',
description:
'Effectue un calcul mathématique précis. Utilise cet outil pour toute opération arithmétique au lieu de calculer toi-même.',
inputSchema: z.object({
expression: z.string().describe('Expression mathématique à évaluer (ex: "347 * 28", "sqrt(144)", "2^10")'),
}),
outputSchema: z.object({
result: z.number(),
expression: z.string(),
}),
execute: async ({ context }) => {
const expr = context?.expression;
if (!expr) throw new Error('Expression manquante');
// Safe math evaluation — no eval()
const sanitized = expr.replace(/[^0-9+\-*/().,%^ sqrtpiePIE]/g, '');
const prepared = sanitized
.replace(/\^/g, '**')
.replace(/sqrt\(/gi, 'Math.sqrt(')
.replace(/pi/gi, 'Math.PI')
.replace(/e(?![a-z])/gi, 'Math.E');
const fn = new Function(`"use strict"; return (${prepared})`);
const result = fn();
if (typeof result !== 'number' || !isFinite(result)) {
throw new Error(`Expression invalide: "${expr}"`);
}
return { result, expression: expr };
},
});

View File

@ -7,6 +7,7 @@
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; }
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.3); } }
</style>
</head>
<body>

View File

@ -4,24 +4,28 @@ import { useMicrophone } from './hooks/useMicrophone';
const STATE_COLORS: Record<RobotState, string> = {
disconnected: '#666',
idle: '#888',
idle: '#4caf50',
listening: '#2196f3',
thinking: '#ff9800',
speaking: '#4caf50',
speaking: '#ab47bc',
};
const STATE_LABELS: Record<RobotState, string> = {
disconnected: 'Déconnecté',
idle: 'Veille',
idle: 'Prêt',
listening: 'Écoute...',
thinking: 'Réflexion...',
speaking: 'Parle...',
};
const LS_KEY_TOKEN = 'tipote_device_token';
const LS_KEY_URL = 'tipote_server_url';
function App() {
const [serverUrl, setServerUrl] = useState('http://localhost:3000');
const [deviceToken, setDeviceToken] = useState('');
const [serverUrl, setServerUrl] = useState(() => localStorage.getItem(LS_KEY_URL) || 'http://localhost:3000');
const [deviceToken, setDeviceToken] = useState(() => localStorage.getItem(LS_KEY_TOKEN) || '');
const [conversationActive, setConversationActive] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const { state, connected, logs, connect, disconnect, emit, clearLogs } = useSocket();
const logsEndRef = useRef<HTMLDivElement>(null);
const prevStateRef = useRef<RobotState>('disconnected');
@ -64,6 +68,17 @@ function App() {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// Persist settings to localStorage
const handleServerUrlChange = (value: string) => {
setServerUrl(value);
localStorage.setItem(LS_KEY_URL, value);
};
const handleTokenChange = (value: string) => {
setDeviceToken(value);
localStorage.setItem(LS_KEY_TOKEN, value);
};
const handleConnect = () => {
if (connected) {
disconnect();
@ -74,110 +89,147 @@ function App() {
}
};
const handleWakeWord = () => {
setConversationActive(true);
emit('wake_word_detected');
if (!recording) startMic();
};
const handleInterrupt = () => {
setConversationActive(false);
emit('user_interrupt');
if (recording) stopMic();
const handleConversation = () => {
if (conversationActive) {
setConversationActive(false);
emit('user_interrupt');
if (recording) stopMic();
} else {
setConversationActive(true);
emit('wake_word_detected');
startMic();
}
};
return (
<div style={styles.container}>
<h1 style={styles.title}>Ti-Pote Simulator</h1>
{/* Connection panel */}
<div style={styles.panel}>
<h2 style={styles.panelTitle}>Connexion</h2>
<div style={styles.field}>
<label style={styles.label}>Server URL</label>
<input
style={styles.input}
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
disabled={connected}
/>
</div>
<div style={styles.field}>
<label style={styles.label}>Device Token</label>
<input
style={styles.input}
value={deviceToken}
onChange={(e) => setDeviceToken(e.target.value)}
disabled={connected}
placeholder="JWT du device"
/>
</div>
<button style={{ ...styles.btn, background: connected ? '#f44336' : '#4caf50' }} onClick={handleConnect}>
{connected ? 'Déconnecter' : 'Connecter'}
</button>
</div>
{/* Status */}
<div style={styles.panel}>
<h2 style={styles.panelTitle}>État du robot</h2>
<div style={styles.statusRow}>
<div
style={{
...styles.statusDot,
background: STATE_COLORS[state],
boxShadow: state !== 'disconnected' ? `0 0 12px ${STATE_COLORS[state]}` : 'none',
}}
/>
<span style={styles.statusLabel}>{STATE_LABELS[state]}</span>
{/* Header */}
<div style={styles.header}>
<h1 style={styles.title}>Ti-Pote Simulator</h1>
<div
style={{
...styles.statusBadge,
background: STATE_COLORS[state],
boxShadow: state !== 'disconnected' ? `0 0 10px ${STATE_COLORS[state]}60` : 'none',
}}
>
{STATE_LABELS[state]}
</div>
</div>
{/* Controls */}
<div style={styles.panel}>
<h2 style={styles.panelTitle}>Contrôles</h2>
<div style={styles.btnRow}>
<button style={{ ...styles.btn, background: '#2196f3' }} onClick={handleWakeWord} disabled={!connected}>
Wake Word
</button>
{/* Connection */}
{!connected ? (
<div style={styles.panel}>
<h2 style={styles.panelTitle}>Connexion</h2>
<div style={styles.field}>
<label style={styles.label}>Server URL</label>
<input
style={styles.input}
value={serverUrl}
onChange={(e) => handleServerUrlChange(e.target.value)}
placeholder="http://localhost:3000"
/>
</div>
<div style={styles.field}>
<label style={styles.label}>Device Token</label>
<input
style={styles.input}
value={deviceToken}
onChange={(e) => handleTokenChange(e.target.value)}
placeholder="Coller le JWT du device ici"
/>
</div>
<button
style={{ ...styles.btn, background: recording ? '#f44336' : '#ff9800' }}
onClick={recording ? stopMic : startMic}
disabled={!connected}
style={{ ...styles.btn, background: '#4caf50', width: '100%', padding: '12px 20px', fontSize: 16 }}
onClick={handleConnect}
disabled={!deviceToken.trim()}
>
{recording ? 'Stop Micro' : 'Start Micro'}
</button>
<button style={{ ...styles.btn, background: '#9c27b0' }} onClick={handleInterrupt} disabled={!connected}>
Interrupt
Connecter
</button>
</div>
</div>
) : (
<>
{/* Main action */}
<div style={styles.panel}>
<button
style={{
...styles.btn,
width: '100%',
padding: '20px',
fontSize: 18,
background: conversationActive ? '#f44336' : '#2196f3',
borderRadius: 12,
transition: 'all 0.2s',
}}
onClick={handleConversation}
>
{conversationActive ? 'Arrêter la conversation' : 'Parler à Ti-Pote'}
</button>
{/* Logs */}
{conversationActive && recording && (
<div style={styles.listeningIndicator}>
<span style={styles.pulse} />
Micro actif parlez, Ti-Pote écoute
</div>
)}
{conversationActive && state === 'thinking' && (
<div style={styles.thinkingIndicator}>Ti-Pote réfléchit...</div>
)}
{conversationActive && state === 'speaking' && (
<div style={styles.speakingIndicator}>Ti-Pote parle...</div>
)}
</div>
{/* Disconnect */}
<button
style={{ ...styles.btn, background: 'transparent', color: '#888', width: '100%', marginBottom: 16 }}
onClick={handleConnect}
>
Déconnecter
</button>
</>
)}
{/* Logs (collapsible) */}
<div style={styles.panel}>
<div style={styles.logHeader}>
<h2 style={styles.panelTitle}>Logs</h2>
<button style={{ ...styles.btn, background: '#666', padding: '4px 12px', fontSize: 12 }} onClick={clearLogs}>
Clear
<button
style={{ ...styles.btn, background: 'transparent', color: '#888', padding: '4px 0', fontSize: 12 }}
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? '▼' : '▶'} Logs ({logs.length})
</button>
{showLogs && (
<button
style={{ ...styles.btn, background: '#333', padding: '4px 12px', fontSize: 11 }}
onClick={clearLogs}
>
Clear
</button>
)}
</div>
<div style={styles.logContainer}>
{logs.map((log, i) => (
<div key={i} style={styles.logEntry}>
<span style={styles.logTime}>{log.timestamp.toLocaleTimeString()}</span>
<span
style={{
...styles.logDirection,
color: log.direction === 'in' ? '#4caf50' : log.direction === 'out' ? '#2196f3' : '#ff9800',
}}
>
{log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'}
</span>
<span style={styles.logEvent}>{log.event}</span>
{log.data && <span style={styles.logData}>{log.data}</span>}
</div>
))}
<div ref={logsEndRef} />
</div>
{showLogs && (
<div style={styles.logContainer}>
{logs.map((log, i) => (
<div key={i} style={styles.logEntry}>
<span style={styles.logTime}>{log.timestamp.toLocaleTimeString()}</span>
<span
style={{
...styles.logDirection,
color: log.direction === 'in' ? '#4caf50' : log.direction === 'out' ? '#2196f3' : '#ff9800',
}}
>
{log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'}
</span>
<span style={styles.logEvent}>{log.event}</span>
{log.data && <span style={styles.logData}>{log.data}</span>}
</div>
))}
<div ref={logsEndRef} />
</div>
)}
</div>
</div>
);
@ -185,36 +237,50 @@ function App() {
const styles: Record<string, React.CSSProperties> = {
container: {
maxWidth: 640,
maxWidth: 480,
margin: '0 auto',
padding: 24,
padding: 20,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: '#e0e0e0',
background: '#1a1a2e',
minHeight: '100vh',
},
title: {
fontSize: 24,
fontWeight: 700,
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 20,
fontWeight: 700,
color: '#fff',
margin: 0,
},
statusBadge: {
padding: '6px 14px',
borderRadius: 20,
fontSize: 12,
fontWeight: 600,
color: '#fff',
transition: 'all 0.3s',
},
panel: {
background: '#16213e',
borderRadius: 8,
borderRadius: 12,
padding: 16,
marginBottom: 16,
marginBottom: 12,
},
panelTitle: {
fontSize: 14,
fontSize: 13,
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: 1,
color: '#888',
color: '#666',
margin: '0 0 12px 0',
},
field: {
marginBottom: 10,
marginBottom: 12,
},
label: {
display: 'block',
@ -224,41 +290,61 @@ const styles: Record<string, React.CSSProperties> = {
},
input: {
width: '100%',
padding: '8px 12px',
padding: '10px 12px',
border: '1px solid #333',
borderRadius: 4,
borderRadius: 8,
background: '#0f3460',
color: '#fff',
fontSize: 14,
boxSizing: 'border-box' as const,
outline: 'none',
},
btn: {
padding: '8px 20px',
border: 'none',
borderRadius: 4,
borderRadius: 8,
color: '#fff',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
},
btnRow: {
display: 'flex',
gap: 8,
},
statusRow: {
listeningIndicator: {
display: 'flex',
alignItems: 'center',
gap: 12,
gap: 10,
marginTop: 12,
padding: '10px 14px',
background: '#1a237e',
borderRadius: 8,
fontSize: 13,
color: '#90caf9',
},
statusDot: {
width: 16,
height: 16,
pulse: {
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
transition: 'all 0.3s',
background: '#2196f3',
animation: 'pulse 1.5s infinite',
flexShrink: 0,
},
statusLabel: {
fontSize: 18,
fontWeight: 600,
thinkingIndicator: {
marginTop: 12,
padding: '10px 14px',
background: '#4a2800',
borderRadius: 8,
fontSize: 13,
color: '#ffb74d',
textAlign: 'center' as const,
},
speakingIndicator: {
marginTop: 12,
padding: '10px 14px',
background: '#2a1040',
borderRadius: 8,
fontSize: 13,
color: '#ce93d8',
textAlign: 'center' as const,
},
logHeader: {
display: 'flex',
@ -266,24 +352,25 @@ const styles: Record<string, React.CSSProperties> = {
alignItems: 'center',
},
logContainer: {
height: 250,
maxHeight: 200,
overflowY: 'auto' as const,
fontFamily: 'monospace',
fontSize: 12,
fontSize: 11,
background: '#0a0a1a',
borderRadius: 4,
borderRadius: 6,
padding: 8,
marginTop: 8,
},
logEntry: {
display: 'flex',
gap: 8,
gap: 6,
padding: '2px 0',
borderBottom: '1px solid #1a1a2e',
},
logTime: { color: '#666', flexShrink: 0 },
logTime: { color: '#555', flexShrink: 0 },
logDirection: { flexShrink: 0 },
logEvent: { color: '#fff', fontWeight: 600, flexShrink: 0 },
logData: { color: '#aaa', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const },
logEvent: { color: '#ccc', fontWeight: 600, flexShrink: 0 },
logData: { color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const },
};
export default App;

224
docs/business-plan.md Normal file
View File

@ -0,0 +1,224 @@
# Ti-Pote — Business Plan & Analyse de Rentabilité
## 1. Coûts de production hardware (par unité)
Les coûts ci-dessous sont basés sur le BOM détaillé dans `hardware.md`, avec une estimation des coûts réels en petite série (10-50 unités) incluant l'assemblage.
### 1.1 Configurations proposées
| Config | Composants clés | Coût composants | Assemblage & divers | **Coût total unitaire** |
|--------|----------------|-----------------|---------------------|------------------------|
| **Starter** (voix seule) | Pi Zero 2W, ESP32-S3, micro, speaker, ampli, boîtier 3D | 60€ | ~15€ | **~75€** |
| **Standard** (voix + caméra + écran + LEDs + servos) | Starter + caméra, OLED, NeoPixel, 2× servos | 112€ | ~25€ | **~137€** |
| **Mobile** (tout + mobilité) | Standard + moteurs, driver, batterie, capteurs | 171€ | ~35€ | **~206€** |
> **Note :** Les coûts d'assemblage incluent : soudure, câblage, impression 3D (filament + temps machine), carte PCB custom, tests QA. En petite série (fait main), c'est le poste le plus variable.
### 1.2 Projection en volume
| Volume de production | Réduction estimée sur composants | Coût unitaire Standard |
|---------------------|----------------------------------|----------------------|
| 1-10 unités | Prix catalogue (0%) | ~137€ |
| 10-50 unités | -10% (achats groupés) | ~125€ |
| 50-200 unités | -20% (fournisseurs directs) | ~115€ |
| 200+ unités | -30% (MOQ, PCB en série) | ~100€ |
---
## 2. Coûts opérationnels cloud (mensuels)
C'est le poste récurrent. Il augmente avec le nombre d'utilisateurs mais pas de façon linéaire grâce au mutualisation de l'infra.
### 2.1 Infrastructure fixe
| Service | Spec | Coût/mois |
|---------|------|-----------|
| VPS (Hetzner CPX31) | 4 vCPU, 8 Go RAM, 80 Go SSD | 15€ |
| Domaine + DNS | tipote.dev | ~1€ |
| Backups (S3-compatible) | pg_dump quotidien | ~2€ |
| **Total infra fixe** | | **~18€/mois** |
> Ce VPS supporte confortablement 10-30 utilisateurs simultanés. Au-delà, upgrade vers un CPX41 (~30€/mois) pour 50+ utilisateurs.
### 2.2 Coûts API par utilisateur (usage "raisonnable")
**Hypothèse d'usage raisonnable** = ~20-30 min d'interaction/jour, ~25 requêtes vocales/jour.
| Service | Provider | Tarif unitaire | Usage/user/mois | **Coût/user/mois** |
|---------|----------|---------------|-----------------|-------------------|
| **LLM** | Claude Haiku 3.5 | ~0.80$/M input, ~4$/M output | ~750 requêtes, ~2M tokens in, ~500K out | **~3.60€** |
| **LLM** | Claude Sonnet 4 | ~3$/M input, ~15$/M output | idem | **~13.50€** |
| **STT** | Deepgram Nova-2 | 0.0043$/min | ~450 min (15 min/jour) | **~1.95€** |
| **TTS** | OpenAI TTS | 15$/M chars | ~300K chars | **~4.50€** |
| **TTS** | ElevenLabs (Scale) | ~0.18$/1K chars | ~300K chars | **~5.40€** |
| **Embeddings** | OpenAI ada-002 | 0.10$/M tokens | ~100K tokens | **~0.01€** |
### 2.3 Scénarios de coût mensuel total (10 utilisateurs)
| Scénario | LLM | STT | TTS | Infra | **Total/mois** | **Par user** |
|----------|-----|-----|-----|-------|---------------|-------------|
| **Économique** (Haiku + Deepgram + OpenAI TTS) | 36€ | 19€ | 45€ | 18€ | **~118€** | **~11.80€** |
| **Équilibré** (Sonnet + Deepgram + OpenAI TTS) | 135€ | 19€ | 45€ | 18€ | **~217€** | **~21.70€** |
| **Premium** (Sonnet + Deepgram + ElevenLabs) | 135€ | 19€ | 54€ | 18€ | **~226€** | **~22.60€** |
| **Optimisé** (Haiku + cache agressif Redis) | 22€ | 19€ | 30€ | 18€ | **~89€** | **~8.90€** |
---
## 3. Modèles de monétisation
### 3.1 Option A — Vente du robot + abonnement cloud (recommandé)
Le modèle le plus viable : marge sur le hardware + revenu récurrent sur le service.
**Prix de vente du robot :**
| Config | Coût prod. | Prix de vente | Marge brute | Marge % |
|--------|-----------|---------------|-------------|---------|
| Starter | 75€ | 149€ | 74€ | 50% |
| Standard | 137€ | 249€ | 112€ | 45% |
| Mobile | 206€ | 399€ | 193€ | 48% |
**Abonnement mensuel :**
| Plan | Fonctionnalités | Prix/mois | Coût réel/user | **Marge/user/mois** | Scénario coût |
|------|----------------|-----------|---------------|-------------------|--------------|
| **Gratuit** | 5 interactions/jour, Haiku only | 0€ | ~1€ | -1€ (acquisition) | Minimal |
| **Essentiel** | 50 interactions/jour, Haiku, mémoire basique | 9.99€ | ~7.10€ | **+2.89€** | Optimisé (Haiku + cache) |
| **Pro** | Illimité, Sonnet, mémoire complète, caméra, intégrations | 19.99€ | ~14€ | **+5.99€** | Équilibré (Sonnet + cache partiel) |
| **Famille** | Jusqu'à 4 profils, tout inclus (Sonnet) | 29.99€ | ~22€ | **+7.99€** | Premium × ~1.6 profils actifs |
### 3.2 Option B — Vente du robot seul + self-hosted
Pour les utilisateurs tech qui veulent héberger eux-mêmes le backend.
- Robot vendu avec marge (149-399€)
- Code backend open-source ou licence one-time (~49€)
- Pas de revenu récurrent, mais aucun coût serveur
- Communauté = marketing gratuit
### 3.3 Option C — Kit DIY + abonnement
Vendre les composants en kit à monter soi-même (moins cher, plus fun pour les makers).
| Kit | Contenu | Prix | Coût | Marge |
|-----|---------|------|------|-------|
| Kit Starter | PCB + composants + fichiers STL + instructions | 89€ | ~45€ | 44€ |
| Kit Standard | Idem + caméra + écran + servos | 179€ | ~95€ | 84€ |
+ Abonnement cloud idem Option A.
---
## 4. Projections financières
### 4.1 Scénario réaliste — 10 utilisateurs (Mois 1-6)
**Hypothèses :** Config Standard, plan Essentiel, vente directe.
| | Revenus | Coûts | **Résultat** |
|---|---------|-------|-------------|
| **Vente initiale (×10)** | 2 490€ | 1 370€ (prod.) | +1 120€ |
| **Abonnements/mois (×10)** | 99.90€ | ~89€ (cloud) | +10.90€/mois |
| **Sur 6 mois** | 2 490€ + 599€ = 3 089€ | 1 370€ + 534€ = 1 904€ | **+1 185€** |
> Avec 10 utilisateurs sur plan Essentiel, le projet est légèrement rentable mais la marge est très faible côté abonnement. Le hardware porte l'essentiel du profit.
### 4.2 Scénario croissance — 50 utilisateurs (Mois 6-12)
**Hypothèses :** Mix de plans, coûts réduits en volume.
| | Mensuel |
|---|---------|
| Abonnements (20 Essentiel + 25 Pro + 5 Famille) | 200€ + 500€ + 150€ = **850€/mois** |
| Coûts cloud (50 users, VPS upgradé) | ~530€/mois |
| **Marge cloud/mois** | **+320€/mois** |
| Vente hardware (5 nouveaux/mois) | +560€ marge/mois |
| **Marge totale/mois** | **~880€/mois** |
### 4.3 Seuil de rentabilité
En comptant les coûts de développement comme du temps investi (pas de salaire), le seuil de rentabilité pour couvrir les frais courants (infra fixe + APIs variables) est atteint à :
**Calcul :** Chaque abonné Essentiel rapporte 9.99€ et coûte ~7.10€ en APIs variables (LLM + STT + TTS, scénario optimisé). La marge de contribution par user est donc ~2.89€/mois. Il faut couvrir les coûts fixes d'infra (~18€/mois).
| Scénario | Marge/user | Seuil pour couvrir l'infra fixe | Seuil total (fixe + variable) |
|----------|-----------|-------------------------------|------------------------------|
| Que des plans Essentiels (9.99€) | ~2.89€ | 18€ ÷ 2.89€ = **~7 abonnés** | Rentable dès ~7 abonnés |
| Mix Essentiel/Pro (marge moy. ~4.50€) | ~4.50€ | 18€ ÷ 4.50€ = **~4 abonnés** | Rentable dès ~4-5 abonnés |
| Self-hosted (0€ récurrent) | N/A | **Immédiat** (pas de frais cloud) | Immédiat |
> **Note :** Ces seuils supposent un scénario optimisé (cache Redis, routing LLM intelligent). Sans optimisation, le coût variable par user monte à ~11.80€ et le plan Essentiel à 9.99€ serait **déficitaire**. L'optimisation des coûts API est donc un prérequis, pas un bonus.
---
## 5. Leviers d'optimisation des coûts
### 5.1 Réduire le coût LLM (le poste n°1)
- **Routing intelligent** : utiliser Haiku pour les requêtes simples (timer, météo, heure) et Sonnet uniquement pour les conversations complexes. Économie estimée : -40%.
- **Cache sémantique Redis** : les mêmes questions reviennent souvent ("quelle heure est-il", "quel temps fait-il"). Cacher les réponses par similarité d'embedding. Économie : -15-25%.
- **Réduction du contexte** : résumer l'historique au lieu d'envoyer tous les messages. Moins de tokens = moins cher.
- **Modèles locaux (futur)** : si le VPS le permet, un petit modèle local (Mistral 7B, Phi-3) pour les requêtes triviales.
### 5.2 Réduire le coût TTS
- **TTS open-source** : Piper TTS (self-hosted, gratuit, qualité correcte) pour les réponses courtes et factuelles. ElevenLabs réservé aux conversations longues.
- **Cache audio** : les réponses types ("Minuteur de 10 minutes activé", "Il fait 22 degrés") peuvent être pré-générées.
### 5.3 Réduire le coût hardware
- **PCB en série** : passer de breakout boards à un PCB custom intégrant ESP32 + ampli + connecteurs. Réduit le coût de ~15€/unité et le temps d'assemblage.
- **Impression 3D en batch** : optimiser les placements sur le plateau pour imprimer 4-6 boîtiers d'un coup.
- **Approvisionnement direct** : acheter les composants sur LCSC/JLCPCB plutôt qu'Amazon/Adafruit.
---
## 6. Stratégie de lancement recommandée
### Phase 1 — Validation (0-3 mois, 0€ de revenu)
- Produire 5 prototypes (coût : ~700€)
- Les distribuer à des beta-testeurs (amis, makers, communauté)
- Valider l'usage réel, la latence, la fiabilité
- Itérer sur le hardware et le software
- **Budget nécessaire : ~1 000€** (prototypes + infra 3 mois)
### Phase 2 — Early Adopters (3-6 mois)
- Lancer une pré-vente (Kickstarter ou site direct) avec 50-100 unités
- Proposer le plan Essentiel à 9.99€/mois
- Objectif : 30-50 ventes
- **Revenus attendus : 7 500€ - 12 500€ (hardware) + 300-500€/mois (abo)**
### Phase 3 — Croissance (6-12 mois)
- Ajouter le plan Pro et Famille
- Ouvrir l'Option B (self-hosted) pour la communauté open-source
- Viser 100-200 utilisateurs actifs
- Explorer les partenariats (écoles, espaces de coworking, personnes âgées)
---
## 7. Risques et points d'attention
| Risque | Impact | Mitigation |
|--------|--------|------------|
| Hausse des prix API (LLM, TTS) | Marge cloud réduite ou négative | Routing intelligent, cache, modèles locaux en backup |
| SAV et retours hardware | Coût de remplacement + temps | Tests QA rigoureux, garantie limitée, pièces modulaires |
| Concurrence (Alexa, Google Home) | Difficulté à justifier le prix | Positionnement : privacy, personnalisable, open-source, mignon |
| Scalabilité VPS | Dégradation au-delà de 30 users | Prévoir migration vers cluster Docker / VPS plus gros |
| Réglementation (CE, données perso) | Blocage légal pour la vente | Anticiper la conformité CE et RGPD dès la Phase 1 |
---
## 8. Résumé
| Métrique | Valeur |
|----------|--------|
| Coût de production (Standard) | ~137€ |
| Prix de vente recommandé | 249€ |
| Marge brute hardware | ~45% |
| Coût cloud/user/mois (optimisé) | ~9€ |
| Abonnement recommandé (Essentiel) | 9.99€/mois |
| Seuil de rentabilité cloud | ~7-9 abonnés |
| Budget lancement (Phase 1) | ~1 000€ |
| Revenu potentiel à 50 users (mensuel) | ~880€/mois |
**Conclusion :** Ti-Pote peut être rentable à partir de ~10 utilisateurs payants si on combine marge hardware + abonnement cloud. Le modèle économique tient à condition de maîtriser les coûts API (le poste principal) via du routing intelligent et du caching. La vraie valeur est dans le revenu récurrent des abonnements — le hardware sert de porte d'entrée.

949
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff