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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
"@deepgram/sdk": "^5.0.0",
|
"@deepgram/sdk": "^5.0.0",
|
||||||
|
"@mastra/core": "^1.17.0",
|
||||||
"@nestjs/common": "^11.1.17",
|
"@nestjs/common": "^11.1.17",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.1.17",
|
"@nestjs/core": "^11.1.17",
|
||||||
@ -46,7 +47,8 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"typeorm": "^0.3.28"
|
"typeorm": "^0.3.28",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.16",
|
"@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 { AnthropicAdapter } from './adapters/outbound/llm/anthropic.adapter';
|
||||||
import { OpenAIAdapter } from './adapters/outbound/llm/openai.adapter';
|
import { OpenAIAdapter } from './adapters/outbound/llm/openai.adapter';
|
||||||
import { ElevenLabsAdapter } from './adapters/outbound/tts/elevenlabs.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 { CONVERSATION_PORT } from './core/ports/inbound/conversation.port';
|
||||||
import { STT_PORT } from './core/ports/outbound/stt.port';
|
import { STT_PORT } from './core/ports/outbound/stt.port';
|
||||||
import { LLM_PORT } from './core/ports/outbound/llm.port';
|
import { LLM_PORT } from './core/ports/outbound/llm.port';
|
||||||
import { TTS_PORT } from './core/ports/outbound/tts.port';
|
import { TTS_PORT } from './core/ports/outbound/tts.port';
|
||||||
import { DEVICE_GATEWAY_PORT } from './core/ports/outbound/device-gateway.port';
|
import { DEVICE_GATEWAY_PORT } from './core/ports/outbound/device-gateway.port';
|
||||||
|
import { CACHE_PORT } from './core/ports/outbound/cache.port';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -83,6 +85,10 @@ import { DEVICE_GATEWAY_PORT } from './core/ports/outbound/device-gateway.port';
|
|||||||
provide: TTS_PORT,
|
provide: TTS_PORT,
|
||||||
useClass: ElevenLabsAdapter,
|
useClass: ElevenLabsAdapter,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CACHE_PORT,
|
||||||
|
useClass: RedisAdapter,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: DEVICE_GATEWAY_PORT,
|
provide: DEVICE_GATEWAY_PORT,
|
||||||
useExisting: RobotGateway,
|
useExisting: RobotGateway,
|
||||||
|
|||||||
@ -1,38 +1,79 @@
|
|||||||
import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common';
|
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 { IConversationPort } from '../ports/inbound/conversation.port';
|
||||||
import { ISTTPort, ISTTStream, STT_PORT, TranscriptionResult } from '../ports/outbound/stt.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 { 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 { IDeviceGatewayPort, DEVICE_GATEWAY_PORT } from '../ports/outbound/device-gateway.port';
|
||||||
|
import { datetimeTool, mathTool } from '../tools';
|
||||||
|
|
||||||
interface ActiveSession {
|
interface ActiveSession {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
transcription: string;
|
finalTranscription: string;
|
||||||
messages: LLMMessage[];
|
interimTranscription: string;
|
||||||
sttStream: ISTTStream | null;
|
sttStream: ISTTStream | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Tu es Ti-Pote, un petit robot de bureau animatronique, chaleureux et serviable.
|
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 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.
|
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()
|
@Injectable()
|
||||||
export class ConversationService implements IConversationPort {
|
export class ConversationService implements IConversationPort {
|
||||||
private readonly logger = new Logger(ConversationService.name);
|
private readonly logger = new Logger(ConversationService.name);
|
||||||
private readonly activeSessions = new Map<string, ActiveSession>();
|
private readonly activeSessions = new Map<string, ActiveSession>();
|
||||||
|
private readonly agent: Agent;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(STT_PORT) private readonly sttPort: ISTTPort,
|
@Inject(STT_PORT) private readonly sttPort: ISTTPort,
|
||||||
@Inject(LLM_PORT) private readonly llmPort: ILLMPort,
|
|
||||||
@Inject(TTS_PORT) private readonly ttsPort: ITTSPort,
|
@Inject(TTS_PORT) private readonly ttsPort: ITTSPort,
|
||||||
|
@Inject(CACHE_PORT) private readonly cache: ICachePort,
|
||||||
@Inject(forwardRef(() => DEVICE_GATEWAY_PORT)) private readonly deviceGateway: IDeviceGatewayPort,
|
@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> {
|
async startListening(deviceId: string): Promise<void> {
|
||||||
this.logger.log(`Start listening for device ${deviceId}`);
|
this.logger.log(`Start listening for device ${deviceId}`);
|
||||||
|
|
||||||
// Close previous STT stream if any
|
|
||||||
const existing = this.activeSessions.get(deviceId);
|
const existing = this.activeSessions.get(deviceId);
|
||||||
if (existing?.sttStream) {
|
if (existing?.sttStream) {
|
||||||
await existing.sttStream.close();
|
await existing.sttStream.close();
|
||||||
@ -40,8 +81,8 @@ export class ConversationService implements IConversationPort {
|
|||||||
|
|
||||||
const session: ActiveSession = {
|
const session: ActiveSession = {
|
||||||
deviceId,
|
deviceId,
|
||||||
transcription: '',
|
finalTranscription: '',
|
||||||
messages: existing?.messages ?? [],
|
interimTranscription: '',
|
||||||
sttStream: null,
|
sttStream: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -53,7 +94,10 @@ export class ConversationService implements IConversationPort {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.isFinal) {
|
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;
|
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}"`);
|
this.logger.log(`Final transcription for ${deviceId}: "${finalText}"`);
|
||||||
|
|
||||||
if (!finalText) {
|
if (!finalText) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.messages.push({ role: 'user', content: finalText });
|
const messages = await this.loadMessages(deviceId);
|
||||||
session.transcription = '';
|
messages.push({ role: 'user', content: finalText });
|
||||||
|
session.finalTranscription = '';
|
||||||
|
session.interimTranscription = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.deviceGateway.sendStatus(deviceId, 'thinking');
|
this.deviceGateway.sendStatus(deviceId, 'thinking');
|
||||||
|
|
||||||
const llmMessages: LLMMessage[] = [
|
const coreMessages = messages.map((m) => ({
|
||||||
{ role: 'system', content: SYSTEM_PROMPT },
|
role: m.role as 'user' | 'assistant',
|
||||||
...session.messages,
|
content: m.content,
|
||||||
];
|
}));
|
||||||
|
|
||||||
const llmResponse = await this.llmPort.chat(llmMessages);
|
const response = await this.agent.generate(coreMessages as any, { maxSteps: 5 });
|
||||||
const responseText = llmResponse.content ?? '';
|
|
||||||
this.logger.log(`LLM response for ${deviceId}: "${responseText}"`);
|
|
||||||
|
|
||||||
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) {
|
if (responseText) {
|
||||||
this.deviceGateway.sendStatus(deviceId, 'speaking');
|
this.deviceGateway.sendStatus(deviceId, 'speaking');
|
||||||
@ -119,6 +174,7 @@ export class ConversationService implements IConversationPort {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error processing conversation for ${deviceId}:`, error);
|
this.logger.error(`Error processing conversation for ${deviceId}:`, error);
|
||||||
|
await this.saveMessages(deviceId, messages);
|
||||||
this.deviceGateway.sendStatus(deviceId, 'idle');
|
this.deviceGateway.sendStatus(deviceId, 'idle');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,8 +194,8 @@ export class ConversationService implements IConversationPort {
|
|||||||
header.writeUInt32LE(dataSize + headerSize - 8, 4);
|
header.writeUInt32LE(dataSize + headerSize - 8, 4);
|
||||||
header.write('WAVE', 8);
|
header.write('WAVE', 8);
|
||||||
header.write('fmt ', 12);
|
header.write('fmt ', 12);
|
||||||
header.writeUInt32LE(16, 16); // subchunk1 size
|
header.writeUInt32LE(16, 16);
|
||||||
header.writeUInt16LE(1, 20); // PCM format
|
header.writeUInt16LE(1, 20);
|
||||||
header.writeUInt16LE(numChannels, 22);
|
header.writeUInt16LE(numChannels, 22);
|
||||||
header.writeUInt32LE(sampleRate, 24);
|
header.writeUInt32LE(sampleRate, 24);
|
||||||
header.writeUInt32LE(byteRate, 28);
|
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>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { background: #1a1a2e; }
|
body { background: #1a1a2e; }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.3); } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -4,24 +4,28 @@ import { useMicrophone } from './hooks/useMicrophone';
|
|||||||
|
|
||||||
const STATE_COLORS: Record<RobotState, string> = {
|
const STATE_COLORS: Record<RobotState, string> = {
|
||||||
disconnected: '#666',
|
disconnected: '#666',
|
||||||
idle: '#888',
|
idle: '#4caf50',
|
||||||
listening: '#2196f3',
|
listening: '#2196f3',
|
||||||
thinking: '#ff9800',
|
thinking: '#ff9800',
|
||||||
speaking: '#4caf50',
|
speaking: '#ab47bc',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATE_LABELS: Record<RobotState, string> = {
|
const STATE_LABELS: Record<RobotState, string> = {
|
||||||
disconnected: 'Déconnecté',
|
disconnected: 'Déconnecté',
|
||||||
idle: 'Veille',
|
idle: 'Prêt',
|
||||||
listening: 'Écoute...',
|
listening: 'Écoute...',
|
||||||
thinking: 'Réflexion...',
|
thinking: 'Réflexion...',
|
||||||
speaking: 'Parle...',
|
speaking: 'Parle...',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LS_KEY_TOKEN = 'tipote_device_token';
|
||||||
|
const LS_KEY_URL = 'tipote_server_url';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [serverUrl, setServerUrl] = useState('http://localhost:3000');
|
const [serverUrl, setServerUrl] = useState(() => localStorage.getItem(LS_KEY_URL) || 'http://localhost:3000');
|
||||||
const [deviceToken, setDeviceToken] = useState('');
|
const [deviceToken, setDeviceToken] = useState(() => localStorage.getItem(LS_KEY_TOKEN) || '');
|
||||||
const [conversationActive, setConversationActive] = useState(false);
|
const [conversationActive, setConversationActive] = useState(false);
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
const { state, connected, logs, connect, disconnect, emit, clearLogs } = useSocket();
|
const { state, connected, logs, connect, disconnect, emit, clearLogs } = useSocket();
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
const prevStateRef = useRef<RobotState>('disconnected');
|
const prevStateRef = useRef<RobotState>('disconnected');
|
||||||
@ -64,6 +68,17 @@ function App() {
|
|||||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [logs]);
|
}, [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 = () => {
|
const handleConnect = () => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
disconnect();
|
disconnect();
|
||||||
@ -74,110 +89,147 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWakeWord = () => {
|
const handleConversation = () => {
|
||||||
setConversationActive(true);
|
if (conversationActive) {
|
||||||
emit('wake_word_detected');
|
setConversationActive(false);
|
||||||
if (!recording) startMic();
|
emit('user_interrupt');
|
||||||
};
|
if (recording) stopMic();
|
||||||
|
} else {
|
||||||
const handleInterrupt = () => {
|
setConversationActive(true);
|
||||||
setConversationActive(false);
|
emit('wake_word_detected');
|
||||||
emit('user_interrupt');
|
startMic();
|
||||||
if (recording) stopMic();
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<h1 style={styles.title}>Ti-Pote Simulator</h1>
|
{/* Header */}
|
||||||
|
<div style={styles.header}>
|
||||||
{/* Connection panel */}
|
<h1 style={styles.title}>Ti-Pote Simulator</h1>
|
||||||
<div style={styles.panel}>
|
<div
|
||||||
<h2 style={styles.panelTitle}>Connexion</h2>
|
style={{
|
||||||
<div style={styles.field}>
|
...styles.statusBadge,
|
||||||
<label style={styles.label}>Server URL</label>
|
background: STATE_COLORS[state],
|
||||||
<input
|
boxShadow: state !== 'disconnected' ? `0 0 10px ${STATE_COLORS[state]}60` : 'none',
|
||||||
style={styles.input}
|
}}
|
||||||
value={serverUrl}
|
>
|
||||||
onChange={(e) => setServerUrl(e.target.value)}
|
{STATE_LABELS[state]}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Connection */}
|
||||||
<div style={styles.panel}>
|
{!connected ? (
|
||||||
<h2 style={styles.panelTitle}>Contrôles</h2>
|
<div style={styles.panel}>
|
||||||
<div style={styles.btnRow}>
|
<h2 style={styles.panelTitle}>Connexion</h2>
|
||||||
<button style={{ ...styles.btn, background: '#2196f3' }} onClick={handleWakeWord} disabled={!connected}>
|
<div style={styles.field}>
|
||||||
Wake Word
|
<label style={styles.label}>Server URL</label>
|
||||||
</button>
|
<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
|
<button
|
||||||
style={{ ...styles.btn, background: recording ? '#f44336' : '#ff9800' }}
|
style={{ ...styles.btn, background: '#4caf50', width: '100%', padding: '12px 20px', fontSize: 16 }}
|
||||||
onClick={recording ? stopMic : startMic}
|
onClick={handleConnect}
|
||||||
disabled={!connected}
|
disabled={!deviceToken.trim()}
|
||||||
>
|
>
|
||||||
{recording ? 'Stop Micro' : 'Start Micro'}
|
Connecter
|
||||||
</button>
|
|
||||||
<button style={{ ...styles.btn, background: '#9c27b0' }} onClick={handleInterrupt} disabled={!connected}>
|
|
||||||
Interrupt
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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.panel}>
|
||||||
<div style={styles.logHeader}>
|
<div style={styles.logHeader}>
|
||||||
<h2 style={styles.panelTitle}>Logs</h2>
|
<button
|
||||||
<button style={{ ...styles.btn, background: '#666', padding: '4px 12px', fontSize: 12 }} onClick={clearLogs}>
|
style={{ ...styles.btn, background: 'transparent', color: '#888', padding: '4px 0', fontSize: 12 }}
|
||||||
Clear
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
|
>
|
||||||
|
{showLogs ? '▼' : '▶'} Logs ({logs.length})
|
||||||
</button>
|
</button>
|
||||||
|
{showLogs && (
|
||||||
|
<button
|
||||||
|
style={{ ...styles.btn, background: '#333', padding: '4px 12px', fontSize: 11 }}
|
||||||
|
onClick={clearLogs}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.logContainer}>
|
{showLogs && (
|
||||||
{logs.map((log, i) => (
|
<div style={styles.logContainer}>
|
||||||
<div key={i} style={styles.logEntry}>
|
{logs.map((log, i) => (
|
||||||
<span style={styles.logTime}>{log.timestamp.toLocaleTimeString()}</span>
|
<div key={i} style={styles.logEntry}>
|
||||||
<span
|
<span style={styles.logTime}>{log.timestamp.toLocaleTimeString()}</span>
|
||||||
style={{
|
<span
|
||||||
...styles.logDirection,
|
style={{
|
||||||
color: log.direction === 'in' ? '#4caf50' : log.direction === 'out' ? '#2196f3' : '#ff9800',
|
...styles.logDirection,
|
||||||
}}
|
color: log.direction === 'in' ? '#4caf50' : log.direction === 'out' ? '#2196f3' : '#ff9800',
|
||||||
>
|
}}
|
||||||
{log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'}
|
>
|
||||||
</span>
|
{log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'}
|
||||||
<span style={styles.logEvent}>{log.event}</span>
|
</span>
|
||||||
{log.data && <span style={styles.logData}>{log.data}</span>}
|
<span style={styles.logEvent}>{log.event}</span>
|
||||||
</div>
|
{log.data && <span style={styles.logData}>{log.data}</span>}
|
||||||
))}
|
</div>
|
||||||
<div ref={logsEndRef} />
|
))}
|
||||||
</div>
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -185,36 +237,50 @@ function App() {
|
|||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
container: {
|
container: {
|
||||||
maxWidth: 640,
|
maxWidth: 480,
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
padding: 24,
|
padding: 20,
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
color: '#e0e0e0',
|
color: '#e0e0e0',
|
||||||
background: '#1a1a2e',
|
background: '#1a1a2e',
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
},
|
},
|
||||||
title: {
|
header: {
|
||||||
fontSize: 24,
|
display: 'flex',
|
||||||
fontWeight: 700,
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 20,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
transition: 'all 0.3s',
|
||||||
},
|
},
|
||||||
panel: {
|
panel: {
|
||||||
background: '#16213e',
|
background: '#16213e',
|
||||||
borderRadius: 8,
|
borderRadius: 12,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
marginBottom: 16,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
panelTitle: {
|
panelTitle: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: 'uppercase' as const,
|
textTransform: 'uppercase' as const,
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
color: '#888',
|
color: '#666',
|
||||||
margin: '0 0 12px 0',
|
margin: '0 0 12px 0',
|
||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
marginBottom: 10,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@ -224,41 +290,61 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '8px 12px',
|
padding: '10px 12px',
|
||||||
border: '1px solid #333',
|
border: '1px solid #333',
|
||||||
borderRadius: 4,
|
borderRadius: 8,
|
||||||
background: '#0f3460',
|
background: '#0f3460',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
boxSizing: 'border-box' as const,
|
boxSizing: 'border-box' as const,
|
||||||
|
outline: 'none',
|
||||||
},
|
},
|
||||||
btn: {
|
btn: {
|
||||||
padding: '8px 20px',
|
padding: '8px 20px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: 4,
|
borderRadius: 8,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
btnRow: {
|
listeningIndicator: {
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
statusRow: {
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 10,
|
||||||
|
marginTop: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: '#1a237e',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#90caf9',
|
||||||
},
|
},
|
||||||
statusDot: {
|
pulse: {
|
||||||
width: 16,
|
display: 'inline-block',
|
||||||
height: 16,
|
width: 10,
|
||||||
|
height: 10,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
transition: 'all 0.3s',
|
background: '#2196f3',
|
||||||
|
animation: 'pulse 1.5s infinite',
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
statusLabel: {
|
thinkingIndicator: {
|
||||||
fontSize: 18,
|
marginTop: 12,
|
||||||
fontWeight: 600,
|
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: {
|
logHeader: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -266,24 +352,25 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
logContainer: {
|
logContainer: {
|
||||||
height: 250,
|
maxHeight: 200,
|
||||||
overflowY: 'auto' as const,
|
overflowY: 'auto' as const,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
background: '#0a0a1a',
|
background: '#0a0a1a',
|
||||||
borderRadius: 4,
|
borderRadius: 6,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
logEntry: {
|
logEntry: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
padding: '2px 0',
|
padding: '2px 0',
|
||||||
borderBottom: '1px solid #1a1a2e',
|
borderBottom: '1px solid #1a1a2e',
|
||||||
},
|
},
|
||||||
logTime: { color: '#666', flexShrink: 0 },
|
logTime: { color: '#555', flexShrink: 0 },
|
||||||
logDirection: { flexShrink: 0 },
|
logDirection: { flexShrink: 0 },
|
||||||
logEvent: { color: '#fff', fontWeight: 600, flexShrink: 0 },
|
logEvent: { color: '#ccc', fontWeight: 600, flexShrink: 0 },
|
||||||
logData: { color: '#aaa', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const },
|
logData: { color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
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