feat: add detailed business plan document outlining production costs, monetization, and financial projections.
This commit is contained in:
parent
787a5805b7
commit
92605d728b
@ -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",
|
||||
|
||||
56
apps/backend/src/adapters/outbound/cache/redis.adapter.ts
vendored
Normal file
56
apps/backend/src/adapters/outbound/cache/redis.adapter.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
30
apps/backend/src/core/tools/datetime.tool.ts
Normal file
30
apps/backend/src/core/tools/datetime.tool.ts
Normal 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() };
|
||||
},
|
||||
});
|
||||
2
apps/backend/src/core/tools/index.ts
Normal file
2
apps/backend/src/core/tools/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { datetimeTool } from './datetime.tool';
|
||||
export { mathTool } from './math.tool';
|
||||
37
apps/backend/src/core/tools/math.tool.ts
Normal file
37
apps/backend/src/core/tools/math.tool.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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
224
docs/business-plan.md
Normal 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
949
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user