add magic button
This commit is contained in:
parent
32f3105bef
commit
b71d5c8f47
@ -6,7 +6,12 @@
|
|||||||
"Bash(pnpm --filter @ordinarthur-os/api add tsconfig-paths)",
|
"Bash(pnpm --filter @ordinarthur-os/api add tsconfig-paths)",
|
||||||
"Read(//private/tmp/test_ts/pkg/**)",
|
"Read(//private/tmp/test_ts/pkg/**)",
|
||||||
"Bash(node -e \"const m = require\\('./a.ts'\\); console.log\\('CJS require result:', m\\);\")",
|
"Bash(node -e \"const m = require\\('./a.ts'\\); console.log\\('CJS require result:', m\\);\")",
|
||||||
"Bash(pnpm --filter @ordinarthur-os/db migrate)"
|
"Bash(pnpm --filter @ordinarthur-os/db migrate)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/shared build)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/shared typecheck)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/api typecheck)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/pwa typecheck)",
|
||||||
|
"Bash(pnpm -r typecheck)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,10 @@ import { AuthModule } from "./modules/auth/auth.module";
|
|||||||
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
||||||
import { JobsModule } from "./modules/jobs/jobs.module";
|
import { JobsModule } from "./modules/jobs/jobs.module";
|
||||||
import { TodosModule } from "./modules/todos/todos.module";
|
import { TodosModule } from "./modules/todos/todos.module";
|
||||||
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule],
|
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule, AiModule],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import "reflect-metadata";
|
|||||||
import "tsconfig-paths/register";
|
import "tsconfig-paths/register";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { raw } from "express";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { loadConfig } from "./config/env";
|
import { loadConfig } from "./config/env";
|
||||||
|
|
||||||
@ -10,6 +11,12 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ["log", "warn", "error"],
|
logger: ["log", "warn", "error"],
|
||||||
});
|
});
|
||||||
|
// `/ai/voice` reçoit le blob audio brut — on court-circuite le json parser
|
||||||
|
// par défaut pour exposer `req.body` en tant que `Buffer`.
|
||||||
|
app.use(
|
||||||
|
"/ai/voice",
|
||||||
|
raw({ type: () => true, limit: "25mb" }),
|
||||||
|
);
|
||||||
app.enableCors({ origin: true, credentials: false });
|
app.enableCors({ origin: true, credentials: false });
|
||||||
await app.listen(config.PORT);
|
await app.listen(config.PORT);
|
||||||
Logger.log(`ordinarthur-os api ready on :${config.PORT}`, "Bootstrap");
|
Logger.log(`ordinarthur-os api ready on :${config.PORT}`, "Bootstrap");
|
||||||
|
|||||||
51
apps/api/src/modules/ai/ai.controller.ts
Normal file
51
apps/api/src/modules/ai/ai.controller.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import {
|
||||||
|
AiCommandRequest,
|
||||||
|
AiCommandResponse,
|
||||||
|
AiConfirmRequest,
|
||||||
|
AiConfirmResponse,
|
||||||
|
AiVoiceResponse,
|
||||||
|
} from "@ordinarthur-os/shared";
|
||||||
|
import { ZodPipe } from "@/lib/zod-pipe";
|
||||||
|
import { AiService } from "./ai.service";
|
||||||
|
|
||||||
|
@Controller("ai")
|
||||||
|
export class AiController {
|
||||||
|
constructor(private readonly ai: AiService) {}
|
||||||
|
|
||||||
|
@Post("command")
|
||||||
|
command(
|
||||||
|
@Body(new ZodPipe(AiCommandRequest)) body: AiCommandRequest,
|
||||||
|
): Promise<AiCommandResponse> {
|
||||||
|
return this.ai.command(body.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /ai/voice
|
||||||
|
* Body brut : le blob audio (WebM/Opus recommandé) — la PWA poste avec
|
||||||
|
* `Content-Type: audio/webm`. Un `express.raw()` est monté pour ce path
|
||||||
|
* dans `main.ts`.
|
||||||
|
*/
|
||||||
|
@Post("voice")
|
||||||
|
async voice(@Req() req: Request): Promise<AiVoiceResponse> {
|
||||||
|
const body = req.body;
|
||||||
|
if (!Buffer.isBuffer(body) || body.length === 0) {
|
||||||
|
throw new BadRequestException("Audio body manquant (envoie le blob brut)");
|
||||||
|
}
|
||||||
|
return this.ai.voice(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("command/confirm")
|
||||||
|
confirm(
|
||||||
|
@Body(new ZodPipe(AiConfirmRequest)) body: AiConfirmRequest,
|
||||||
|
): Promise<AiConfirmResponse> {
|
||||||
|
return this.ai.confirm(body.action_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/modules/ai/ai.module.ts
Normal file
11
apps/api/src/modules/ai/ai.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AiController } from "./ai.controller";
|
||||||
|
import { AiService } from "./ai.service";
|
||||||
|
import { MistralClient } from "./mistral.client";
|
||||||
|
import { GroqClient } from "./groq.client";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AiController],
|
||||||
|
providers: [AiService, MistralClient, GroqClient],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
186
apps/api/src/modules/ai/ai.service.ts
Normal file
186
apps/api/src/modules/ai/ai.service.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { schema } from "@ordinarthur-os/db";
|
||||||
|
import {
|
||||||
|
AiActionResult,
|
||||||
|
AiCommandResponse,
|
||||||
|
AiConfirmResponse,
|
||||||
|
AiVoiceResponse,
|
||||||
|
ProposedAction,
|
||||||
|
ProposedActionWithId,
|
||||||
|
TodoCreateDto,
|
||||||
|
} from "@ordinarthur-os/shared";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { InjectDb, type Db } from "@/db/db.module";
|
||||||
|
import { MistralClient } from "./mistral.client";
|
||||||
|
import { GroqClient } from "./groq.client";
|
||||||
|
|
||||||
|
const { aiActions, todos } = schema;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiService {
|
||||||
|
private readonly logger = new Logger(AiService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDb() private readonly db: Db,
|
||||||
|
private readonly mistral: MistralClient,
|
||||||
|
private readonly groq: GroqClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Endpoints
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async command(text: string): Promise<AiCommandResponse> {
|
||||||
|
const proposals = await this.mistral.proposeActions(text);
|
||||||
|
const actions = await this.persistProposals(proposals, { inputText: text });
|
||||||
|
return { actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async voice(audio: Buffer): Promise<AiVoiceResponse> {
|
||||||
|
const transcript = await this.groq.transcribe(audio);
|
||||||
|
if (!transcript) {
|
||||||
|
return { transcript: "", actions: [] };
|
||||||
|
}
|
||||||
|
const proposals = await this.mistral.proposeActions(transcript);
|
||||||
|
const actions = await this.persistProposals(proposals, { transcript });
|
||||||
|
return { transcript, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(actionIds: string[]): Promise<AiConfirmResponse> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(aiActions)
|
||||||
|
.where(inArray(aiActions.id, actionIds));
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new NotFoundException("Aucune action proposée trouvée");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: AiActionResult[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.status !== "proposed") {
|
||||||
|
// Idempotent : on ne re-exécute pas une action déjà traitée.
|
||||||
|
results.push({
|
||||||
|
id: row.id,
|
||||||
|
status: row.status === "confirmed" ? "confirmed" : "failed",
|
||||||
|
error: row.status !== "confirmed" ? `Déjà en status ${row.status}` : undefined,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.executeAction({
|
||||||
|
fn: row.functionName as ProposedAction["fn"],
|
||||||
|
args: row.functionArgs,
|
||||||
|
} as ProposedAction);
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.update(aiActions)
|
||||||
|
.set({ status: "confirmed", result: result as object, confirmedAt: new Date() })
|
||||||
|
.where(eq(aiActions.id, row.id));
|
||||||
|
|
||||||
|
results.push({ id: row.id, status: "confirmed", result });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(`Exécution action ${row.id} échouée: ${message}`);
|
||||||
|
await this.db
|
||||||
|
.update(aiActions)
|
||||||
|
.set({ status: "failed", error: message, confirmedAt: new Date() })
|
||||||
|
.where(eq(aiActions.id, row.id));
|
||||||
|
results.push({ id: row.id, status: "failed", error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Internals
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async persistProposals(
|
||||||
|
proposals: Array<{ name: string; args: unknown }>,
|
||||||
|
source: { inputText?: string; transcript?: string },
|
||||||
|
): Promise<ProposedActionWithId[]> {
|
||||||
|
const persisted: ProposedActionWithId[] = [];
|
||||||
|
|
||||||
|
for (const { name, args } of proposals) {
|
||||||
|
// Valide côté serveur avec zod — si Mistral a hallucinne, on log + skip.
|
||||||
|
const parsed = ProposedAction.safeParse({ fn: name, args });
|
||||||
|
if (!parsed.success) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Action ignorée (validation zod): fn=${name}, raison=${JSON.stringify(parsed.error.flatten())}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await this.db
|
||||||
|
.insert(aiActions)
|
||||||
|
.values({
|
||||||
|
inputText: source.inputText ?? null,
|
||||||
|
transcript: source.transcript ?? null,
|
||||||
|
functionName: parsed.data.fn,
|
||||||
|
functionArgs: parsed.data.args as object,
|
||||||
|
status: "proposed",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
persisted.push({ id: row.id, action: parsed.data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exécute une action confirmée. Chaque branche doit être idempotente-safe
|
||||||
|
* (on ne réexécute pas une action déjà confirmée, cf. `confirm`).
|
||||||
|
*
|
||||||
|
* Phase 5 : seul `create_todo` est câblé — les autres tables (projets,
|
||||||
|
* agenda, daily_checkins) arrivent en phases suivantes. On lève une erreur
|
||||||
|
* explicite pour les fonctions non encore implémentées.
|
||||||
|
*/
|
||||||
|
private async executeAction(action: ProposedAction): Promise<unknown> {
|
||||||
|
switch (action.fn) {
|
||||||
|
case "create_todo": {
|
||||||
|
const dto: TodoCreateDto = {
|
||||||
|
title: action.args.title,
|
||||||
|
description: action.args.description ?? null,
|
||||||
|
status: "inbox",
|
||||||
|
priority: action.args.priority ?? null,
|
||||||
|
due_at: action.args.due_at ?? null,
|
||||||
|
tags: action.args.tags ?? [],
|
||||||
|
project_id: action.args.project_id ?? null,
|
||||||
|
checklist: [],
|
||||||
|
verification_steps: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [row] = await this.db
|
||||||
|
.insert(todos)
|
||||||
|
.values({
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
status: dto.status ?? "inbox",
|
||||||
|
priority: dto.priority ?? null,
|
||||||
|
dueAt: dto.due_at ? new Date(dto.due_at) : null,
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
projectId: dto.project_id ?? null,
|
||||||
|
checklist: dto.checklist ?? [],
|
||||||
|
verificationSteps: dto.verification_steps ?? [],
|
||||||
|
aiEnriched: true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) throw new Error("Insertion todo: aucune ligne renvoyée");
|
||||||
|
return { todo_id: row.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add_project_idea":
|
||||||
|
case "add_project_step":
|
||||||
|
case "create_calendar_event":
|
||||||
|
case "toggle_daily_checkin":
|
||||||
|
throw new Error(`Fonction "${action.fn}" pas encore câblée (phase ultérieure)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/api/src/modules/ai/groq.client.ts
Normal file
50
apps/api/src/modules/ai/groq.client.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Inject, Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { APP_CONFIG } from "@/config/config.module";
|
||||||
|
import type { AppConfig } from "@/config/env";
|
||||||
|
|
||||||
|
interface GroqTranscriptionResponse {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GroqClient {
|
||||||
|
private readonly logger = new Logger(GroqClient.name);
|
||||||
|
|
||||||
|
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie le blob audio à Groq Whisper, langue forcée à `fr`.
|
||||||
|
* Accepte WebM/Opus (MediaRecorder par défaut) ou tout autre format supporté.
|
||||||
|
*/
|
||||||
|
async transcribe(audio: Buffer, filename = "audio.webm"): Promise<string> {
|
||||||
|
if (!this.config.GROQ_API_KEY) {
|
||||||
|
throw new ServiceUnavailableException("GROQ_API_KEY non configurée");
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
// Node 20+ expose un Blob global conforme. On copie dans un ArrayBuffer
|
||||||
|
// dédié : lib.dom type `Blob` refuse `ArrayBufferLike` (Buffer sous-jacent).
|
||||||
|
const copy = new ArrayBuffer(audio.byteLength);
|
||||||
|
new Uint8Array(copy).set(audio);
|
||||||
|
form.set("file", new Blob([copy], { type: "audio/webm" }), filename);
|
||||||
|
form.set("model", this.config.GROQ_STT_MODEL);
|
||||||
|
form.set("language", "fr");
|
||||||
|
form.set("response_format", "json");
|
||||||
|
form.set("temperature", "0");
|
||||||
|
|
||||||
|
const res = await fetch("https://api.groq.com/openai/v1/audio/transcriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${this.config.GROQ_API_KEY}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
this.logger.error(`Groq ${res.status}: ${body}`);
|
||||||
|
throw new ServiceUnavailableException("Groq API error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as GroqTranscriptionResponse;
|
||||||
|
return data.text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
184
apps/api/src/modules/ai/mistral.client.ts
Normal file
184
apps/api/src/modules/ai/mistral.client.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { APP_CONFIG } from "@/config/config.module";
|
||||||
|
import type { AppConfig } from "@/config/env";
|
||||||
|
import { Inject } from "@nestjs/common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonctions exposées à Mistral. Doivent matcher exactement le schéma
|
||||||
|
* `ProposedAction` de `@ordinarthur-os/shared` — la validation zod se fait
|
||||||
|
* côté AiService.
|
||||||
|
*/
|
||||||
|
export const MISTRAL_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "create_todo",
|
||||||
|
description:
|
||||||
|
"Crée une tâche dans l'inbox d'Arthur. Utilise cette fonction dès qu'Arthur parle d'un truc à faire, relancer, envoyer, préparer, etc.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string", description: "Titre court de la tâche (impératif)" },
|
||||||
|
description: { type: "string", description: "Détail en markdown si pertinent" },
|
||||||
|
due_at: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
description: "Échéance ISO 8601 (UTC). À déduire d'expressions comme 'demain', 'vendredi'.",
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 3,
|
||||||
|
description: "0 = bas, 3 = urgent. Défaut 1.",
|
||||||
|
},
|
||||||
|
project_id: { type: "string", format: "uuid" },
|
||||||
|
tags: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["title"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "add_project_idea",
|
||||||
|
description: "Ajoute une idée à un projet existant.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
project_id: { type: "string", format: "uuid" },
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["project_id", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "add_project_step",
|
||||||
|
description: "Ajoute une étape (carte kanban) à un projet.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
project_id: { type: "string", format: "uuid" },
|
||||||
|
title: { type: "string" },
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["backlog", "todo", "doing", "review", "done"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["project_id", "title"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "create_calendar_event",
|
||||||
|
description: "Crée un événement dans l'agenda d'Arthur.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string" },
|
||||||
|
starts_at: { type: "string", format: "date-time" },
|
||||||
|
ends_at: { type: "string", format: "date-time" },
|
||||||
|
location: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["title", "starts_at", "ends_at"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "toggle_daily_checkin",
|
||||||
|
description:
|
||||||
|
"Marque le check-in quotidien d'Arthur (médocs pris, état du jour). À utiliser sur 'j'ai pris mes médocs', 'check in'.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
note: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MistralToolCall {
|
||||||
|
id: string;
|
||||||
|
type: "function";
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistralResponse {
|
||||||
|
choices: Array<{
|
||||||
|
message: {
|
||||||
|
role: string;
|
||||||
|
content: string | null;
|
||||||
|
tool_calls?: MistralToolCall[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os.
|
||||||
|
|
||||||
|
- Réponds UNIQUEMENT en français.
|
||||||
|
- Ton rôle est d'interpréter la demande et d'appeler 0, 1 ou plusieurs fonctions pour capturer son intention.
|
||||||
|
- Tu ne poses pas de questions : si une info manque, tu proposes un titre court et laisses les champs optionnels vides.
|
||||||
|
- Si la demande ne matche aucune fonction (ex : question / discussion), n'appelle aucun outil.
|
||||||
|
- N'exécute rien toi-même : tu ne fais que proposer. L'utilisateur confirmera côté UI.
|
||||||
|
- Dates : convertis les expressions relatives ('demain', 'vendredi soir', 'dans 2h') en ISO 8601 UTC en te basant sur l'heure courante fournie.`;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MistralClient {
|
||||||
|
private readonly logger = new Logger(MistralClient.name);
|
||||||
|
|
||||||
|
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
||||||
|
|
||||||
|
async proposeActions(userText: string): Promise<Array<{ name: string; args: unknown }>> {
|
||||||
|
if (!this.config.MISTRAL_API_KEY) {
|
||||||
|
throw new ServiceUnavailableException("MISTRAL_API_KEY non configurée");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const res = await fetch("https://api.mistral.ai/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.config.MISTRAL_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.config.MISTRAL_MODEL,
|
||||||
|
temperature: 0.2,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: `${SYSTEM_PROMPT}\n\nHeure courante : ${now}` },
|
||||||
|
{ role: "user", content: userText },
|
||||||
|
],
|
||||||
|
tools: MISTRAL_TOOLS,
|
||||||
|
tool_choice: "auto",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
this.logger.error(`Mistral ${res.status}: ${body}`);
|
||||||
|
throw new ServiceUnavailableException("Mistral API error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as MistralResponse;
|
||||||
|
const message = data.choices[0]?.message;
|
||||||
|
const toolCalls = message?.tool_calls ?? [];
|
||||||
|
|
||||||
|
return toolCalls.map((call) => {
|
||||||
|
let parsed: unknown = {};
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(call.function.arguments);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Mistral a renvoyé des args non-JSON: ${call.function.arguments}`);
|
||||||
|
}
|
||||||
|
return { name: call.function.name, args: parsed };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,3 +47,27 @@ export async function api<T = unknown>(
|
|||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload binaire (ex : blob audio). Omet le Content-Type JSON par défaut pour
|
||||||
|
* laisser le navigateur poser celui du blob.
|
||||||
|
*/
|
||||||
|
export async function apiBinary<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
body: Blob | ArrayBuffer,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const finalHeaders: Record<string, string> = { "Content-Type": contentType };
|
||||||
|
const token = getToken();
|
||||||
|
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: finalHeaders,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new ApiError(res.status, text || res.statusText);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|||||||
206
apps/pwa/src/components/ai/MagicButton.tsx
Normal file
206
apps/pwa/src/components/ai/MagicButton.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { AiVoiceResponse } from "@ordinarthur-os/shared";
|
||||||
|
import { apiBinary } from "@/api/client";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { VoiceConfirmModal } from "./VoiceConfirmModal";
|
||||||
|
|
||||||
|
type Phase = "idle" | "recording" | "uploading" | "reviewing" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le magic button est l'entrée principale d'ordinarthur-os :
|
||||||
|
* 1. Clic (ou hold) → MediaRecorder capture l'audio
|
||||||
|
* 2. Relâche → envoie le blob à POST /ai/voice
|
||||||
|
* 3. Modale affiche transcript + actions proposées → confirme côté UI
|
||||||
|
*/
|
||||||
|
export function MagicButton() {
|
||||||
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<AiVoiceResponse | null>(null);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopStream();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
const mime = pickMimeType();
|
||||||
|
const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
recorder.onstop = () => {
|
||||||
|
void uploadRecording(recorder.mimeType);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start();
|
||||||
|
mediaRecorderRef.current = recorder;
|
||||||
|
setPhase("recording");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(`Accès au micro refusé : ${msg}`);
|
||||||
|
setPhase("error");
|
||||||
|
stopStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
const recorder = mediaRecorderRef.current;
|
||||||
|
if (!recorder || recorder.state === "inactive") return;
|
||||||
|
recorder.stop();
|
||||||
|
stopStream();
|
||||||
|
setPhase("uploading");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadRecording(mime: string) {
|
||||||
|
try {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: mime });
|
||||||
|
if (blob.size === 0) {
|
||||||
|
setPhase("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const contentType = mime || "audio/webm";
|
||||||
|
const response = await apiBinary<AiVoiceResponse>("/ai/voice", blob, contentType);
|
||||||
|
setResult(response);
|
||||||
|
setPhase("reviewing");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(`Upload échoué : ${msg}`);
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (phase === "idle" || phase === "error") {
|
||||||
|
void startRecording();
|
||||||
|
} else if (phase === "recording") {
|
||||||
|
void stopRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalClose() {
|
||||||
|
setResult(null);
|
||||||
|
setPhase("idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = {
|
||||||
|
idle: "Enregistrer",
|
||||||
|
recording: "Stop",
|
||||||
|
uploading: "Transcription…",
|
||||||
|
reviewing: "…",
|
||||||
|
error: "Réessayer",
|
||||||
|
}[phase];
|
||||||
|
|
||||||
|
const isActive = phase === "recording";
|
||||||
|
const isBusy = phase === "uploading";
|
||||||
|
|
||||||
|
const hint =
|
||||||
|
phase === "recording"
|
||||||
|
? "parle · clique pour stop"
|
||||||
|
: phase === "uploading"
|
||||||
|
? "whisper + mistral…"
|
||||||
|
: phase === "error"
|
||||||
|
? "réessaie"
|
||||||
|
: "clique pour parler";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full max-w-xl border-2 border-ink">
|
||||||
|
<div className="flex items-center justify-between border-b border-ink px-4 py-2">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-label text-muted">
|
||||||
|
[ VOICE ]
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 font-mono text-[10px] uppercase tracking-label",
|
||||||
|
isActive ? "text-accent" : "text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5",
|
||||||
|
isActive ? "bg-accent animate-pulse" : "bg-muted",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{isActive ? "REC" : isBusy ? "…" : "IDLE"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-label="Magic button voice"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
className={cn(
|
||||||
|
"relative block w-full px-6 py-10 text-left",
|
||||||
|
"font-sans font-light tracking-tightest",
|
||||||
|
"text-[clamp(2rem,6vw,3.5rem)] leading-none",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-bg"
|
||||||
|
: "bg-bg text-ink hover:bg-ink hover:text-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
className="absolute bottom-3 right-4 w-2 h-2 bg-bg animate-pulse"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-ink px-4 py-2">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-label text-muted">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
{error && (
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-label text-accent truncate max-w-[60%]">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === "reviewing" && result && (
|
||||||
|
<VoiceConfirmModal response={result} onClose={handleModalClose} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaRecorder formats supportés diffèrent Safari/Chrome/Firefox.
|
||||||
|
* On essaye WebM/Opus d'abord, puis fallbacks.
|
||||||
|
*/
|
||||||
|
function pickMimeType(): string | null {
|
||||||
|
const candidates = [
|
||||||
|
"audio/webm;codecs=opus",
|
||||||
|
"audio/webm",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/ogg;codecs=opus",
|
||||||
|
];
|
||||||
|
for (const t of candidates) {
|
||||||
|
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
233
apps/pwa/src/components/ai/VoiceConfirmModal.tsx
Normal file
233
apps/pwa/src/components/ai/VoiceConfirmModal.tsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
AiConfirmRequest,
|
||||||
|
AiConfirmResponse,
|
||||||
|
AiVoiceResponse,
|
||||||
|
ProposedAction,
|
||||||
|
ProposedActionWithId,
|
||||||
|
} from "@ordinarthur-os/shared";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { Label } from "@/design";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
response: AiVoiceResponse;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modale de confirmation : l'IA a proposé N actions, l'utilisateur les
|
||||||
|
* confirme ou annule en bloc (ou décoche certaines). Tant que le bouton
|
||||||
|
* "Confirmer" n'a pas été pressé, RIEN n'est écrit en base.
|
||||||
|
*/
|
||||||
|
export function VoiceConfirmModal({ response, onClose }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const initialIds = useMemo(
|
||||||
|
() => new Set(response.actions.map((a) => a.id)),
|
||||||
|
[response.actions],
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(initialIds);
|
||||||
|
|
||||||
|
const confirm = useMutation({
|
||||||
|
mutationFn: (body: AiConfirmRequest) =>
|
||||||
|
api<AiConfirmResponse>("/ai/command/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["todos"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape" && !confirm.isPending) onClose();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose, confirm.isPending]);
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
const ids = Array.from(selected);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirm.mutate({ action_ids: ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActions = response.actions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
|
||||||
|
onClick={() => !confirm.isPending && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl bg-bg border-2 border-ink"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<header className="border-b border-ink px-4 py-3 flex items-center justify-between">
|
||||||
|
<Label prefix="[ VOICE ]">CONFIRMATION</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={confirm.isPending}
|
||||||
|
aria-label="Fermer"
|
||||||
|
className="font-mono text-sm text-muted hover:text-ink disabled:opacity-30"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="px-4 py-4 border-b border-ink space-y-2">
|
||||||
|
<Label>TRANSCRIPT</Label>
|
||||||
|
<p className="font-sans text-sm leading-6 text-ink">
|
||||||
|
{response.transcript || <span className="text-muted">Aucun audio reconnu.</span>}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="px-4 py-4 space-y-3">
|
||||||
|
<Label>{hasActions ? "ACTIONS PROPOSÉES" : "AUCUNE ACTION"}</Label>
|
||||||
|
{hasActions ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{response.actions.map((a) => (
|
||||||
|
<ActionRow
|
||||||
|
key={a.id}
|
||||||
|
action={a}
|
||||||
|
selected={selected.has(a.id)}
|
||||||
|
onToggle={() => toggle(a.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="font-sans text-sm text-muted">
|
||||||
|
Mistral n'a pas trouvé d'action à proposer. Reformule ou ferme.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="border-t border-ink px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
{confirm.isError && (
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-label text-accent">
|
||||||
|
Échec — réessaie
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={confirm.isPending}
|
||||||
|
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink hover:bg-ink hover:text-bg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!hasActions || selected.size === 0 || confirm.isPending}
|
||||||
|
className="border border-ink bg-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-bg hover:bg-accent hover:border-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{confirm.isPending ? "Exécution…" : `Confirmer (${selected.size})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionRow({
|
||||||
|
action,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
action: ProposedActionWithId;
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="border border-ink">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full flex items-start gap-3 px-3 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 flex-shrink-0 w-4 h-4 border border-ink",
|
||||||
|
selected ? "bg-ink" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-label text-muted">
|
||||||
|
{functionLabel(action.action.fn)}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans text-sm text-ink">{summarize(action.action)}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionLabel(fn: ProposedAction["fn"]): string {
|
||||||
|
switch (fn) {
|
||||||
|
case "create_todo":
|
||||||
|
return "CRÉER UNE TÂCHE";
|
||||||
|
case "add_project_idea":
|
||||||
|
return "IDÉE DE PROJET";
|
||||||
|
case "add_project_step":
|
||||||
|
return "ÉTAPE DE PROJET";
|
||||||
|
case "create_calendar_event":
|
||||||
|
return "ÉVÉNEMENT AGENDA";
|
||||||
|
case "toggle_daily_checkin":
|
||||||
|
return "CHECK-IN DU JOUR";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(action: ProposedAction): string {
|
||||||
|
switch (action.fn) {
|
||||||
|
case "create_todo": {
|
||||||
|
const parts = [action.args.title];
|
||||||
|
if (action.args.due_at) parts.push(`· échéance ${formatDate(action.args.due_at)}`);
|
||||||
|
if (typeof action.args.priority === "number") parts.push(`· p${action.args.priority}`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
case "add_project_idea":
|
||||||
|
return action.args.content;
|
||||||
|
case "add_project_step":
|
||||||
|
return `${action.args.title}${action.args.status ? ` · ${action.args.status}` : ""}`;
|
||||||
|
case "create_calendar_event":
|
||||||
|
return `${action.args.title} · ${formatDate(action.args.starts_at)} → ${formatDate(action.args.ends_at)}`;
|
||||||
|
case "toggle_daily_checkin":
|
||||||
|
return action.args.note ?? "Check-in du jour";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,101 +1,85 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Todo } from "@ordinarthur-os/shared";
|
||||||
import { api } from "@/api/client";
|
import { api } from "@/api/client";
|
||||||
import { BigHeading, DataChip, GridFrame, Label, MetaRow, SectionHeader } from "@/design";
|
import { BigHeading, Label } from "@/design";
|
||||||
|
import { MagicButton } from "@/components/ai/MagicButton";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({ component: Dashboard });
|
export const Route = createFileRoute("/")({ component: Dashboard });
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const health = useQuery({
|
const todos = useQuery({
|
||||||
queryKey: ["health"],
|
queryKey: ["todos", false],
|
||||||
queryFn: () => api<{ ok: true; version: string; uptime: number }>("/health", { auth: false }),
|
queryFn: () => api<Todo[]>("/todos"),
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const active = (todos.data ?? []).filter(
|
||||||
|
(t) => t.status !== "done" && t.status !== "archived",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-16">
|
||||||
<section>
|
<section className="space-y-6">
|
||||||
<Label prefix="[ 00 ]">PHASE 0 → 1</Label>
|
<Label prefix="[ 00 ]">ORDINARTHUR-OS</Label>
|
||||||
<BigHeading className="mt-4">
|
<BigHeading>
|
||||||
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
Parle. <em>Il capture.</em>
|
||||||
</BigHeading>
|
</BigHeading>
|
||||||
|
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
||||||
|
Le bouton enregistre ta voix, Whisper transcrit, Mistral propose des actions —
|
||||||
|
rien n'est écrit tant que tu n'as pas cliqué "Confirmer".
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<SectionHeader
|
<section className="flex justify-center">
|
||||||
number="01"
|
<MagicButton />
|
||||||
label="STATUS"
|
</section>
|
||||||
title="Backend handshake"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GridFrame cols={2}>
|
<section className="space-y-4">
|
||||||
<div className="p-6 space-y-3">
|
<div className="flex items-center justify-between border-b border-ink pb-3">
|
||||||
<Label>API HEALTH</Label>
|
<Label prefix="[ 01 ]">EN COURS</Label>
|
||||||
<div className="flex items-center gap-3">
|
<Link
|
||||||
<DataChip dotColor={health.isSuccess ? "accent" : "ink"}>
|
to="/todos"
|
||||||
{health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"}
|
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink"
|
||||||
</DataChip>
|
>
|
||||||
{health.data && (
|
Tout voir →
|
||||||
<span className="font-mono text-[11px] text-muted">
|
</Link>
|
||||||
v{health.data.version} · uptime {health.data.uptime}s
|
</div>
|
||||||
|
|
||||||
|
{todos.isLoading ? (
|
||||||
|
<EmptyRow text="Chargement…" />
|
||||||
|
) : todos.isError ? (
|
||||||
|
<EmptyRow text="Impossible de charger les todos. Vérifie le bearer token." />
|
||||||
|
) : active.length === 0 ? (
|
||||||
|
<EmptyRow text="Aucune tâche en cours. Dicte-en une." />
|
||||||
|
) : (
|
||||||
|
<ul className="border border-ink divide-y divide-ink">
|
||||||
|
{active.slice(0, 5).map((t) => (
|
||||||
|
<li key={t.id} className="px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent flex-shrink-0" />
|
||||||
|
<span className="font-sans text-sm text-ink truncate">{t.title}</span>
|
||||||
|
{t.ai_enriched && (
|
||||||
|
<span className="ml-auto font-mono text-[10px] uppercase tracking-label text-muted">
|
||||||
|
AI
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
</div>
|
))}
|
||||||
<div className="p-6 space-y-3">
|
{active.length > 5 && (
|
||||||
<Label>STACK</Label>
|
<li className="px-4 py-2 font-mono text-[10px] uppercase tracking-label text-muted">
|
||||||
<div className="flex flex-wrap gap-2">
|
+ {active.length - 5} autre{active.length - 5 !== 1 ? "s" : ""}
|
||||||
<DataChip>VITE · REACT</DataChip>
|
</li>
|
||||||
<DataChip>NESTJS</DataChip>
|
)}
|
||||||
<DataChip>POSTGRES · DRIZZLE</DataChip>
|
</ul>
|
||||||
<DataChip>K3S</DataChip>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GridFrame>
|
|
||||||
|
|
||||||
<section className="border border-ink">
|
|
||||||
<div className="px-4 py-3 border-b border-ink">
|
|
||||||
<Label prefix="[ 02 ]">ROADMAP</Label>
|
|
||||||
</div>
|
|
||||||
<div className="px-4">
|
|
||||||
<MetaRow label="PHASE 0" value="Scaffold socle en place" />
|
|
||||||
<MetaRow label="PHASE 1" value="Jobs · première livraison utilisable" />
|
|
||||||
<MetaRow label="PHASE 2" value="Todos riches" />
|
|
||||||
<MetaRow label="PHASE 3" value="Projets + Kanban" />
|
|
||||||
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
|
|
||||||
<MetaRow label="PHASE 5" value="IA texte + voice magic button" />
|
|
||||||
<MetaRow label="PHASE 6" value="Telegram bot" />
|
|
||||||
<MetaRow label="PHASE 7" value="Health tab" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
<GridFrame cols={2}>
|
);
|
||||||
<div className="p-6 space-y-4">
|
}
|
||||||
<Label prefix="[ 03 ]">PRIORITÉ</Label>
|
|
||||||
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
function EmptyRow({ text }: { text: string }) {
|
||||||
Les verticales métier arrivent les unes après les autres: jobs pour la veille,
|
return (
|
||||||
puis todos pour piloter l’exécution au quotidien.
|
<div className="border border-ink px-4 py-6">
|
||||||
</p>
|
<p className="font-sans text-sm text-muted">{text}</p>
|
||||||
<a
|
|
||||||
href="/jobs"
|
|
||||||
className="inline-flex items-center border border-ink px-3 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg"
|
|
||||||
>
|
|
||||||
Ouvrir les jobs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<Label prefix="[ 04 ]">MODE</Label>
|
|
||||||
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
|
||||||
L’outil reste mono-utilisateur, simple, et chaque action IA continue de passer par une
|
|
||||||
confirmation explicite avant écriture.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/todos"
|
|
||||||
className="inline-flex items-center border border-ink px-3 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg"
|
|
||||||
>
|
|
||||||
Ouvrir les todos
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</GridFrame>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
packages/db/migrations/0003_ai_actions.sql
Normal file
20
packages/db/migrations/0003_ai_actions.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-- 0003_ai_actions.sql — Phase 5
|
||||||
|
-- Audit log des intentions IA (texte/voix) + du résultat d'exécution.
|
||||||
|
set search_path to ordinarthur_os, public;
|
||||||
|
|
||||||
|
create table if not exists ordinarthur_os.ai_actions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
input_text text, -- ce que l'utilisateur a tapé (null si voice)
|
||||||
|
transcript text, -- transcription Whisper (null si text)
|
||||||
|
function_name text, -- nom de la fonction proposée par Mistral
|
||||||
|
function_args jsonb, -- arguments de la fonction
|
||||||
|
result jsonb, -- row(s) créé(s) / payload de retour
|
||||||
|
status text not null default 'proposed'
|
||||||
|
check (status in ('proposed', 'confirmed', 'cancelled', 'failed')),
|
||||||
|
error text, -- message d'erreur si status='failed'
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
confirmed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists ai_actions_status_idx on ordinarthur_os.ai_actions(status);
|
||||||
|
create index if not exists ai_actions_created_at_idx on ordinarthur_os.ai_actions(created_at desc);
|
||||||
@ -22,6 +22,13 @@
|
|||||||
"when": 1744934400000,
|
"when": 1744934400000,
|
||||||
"tag": "0002_todos",
|
"tag": "0002_todos",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745020800000,
|
||||||
|
"tag": "0003_ai_actions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
26
packages/db/src/schema/ai_actions.ts
Normal file
26
packages/db/src/schema/ai_actions.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { index, jsonb, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { appSchema } from "./_schema";
|
||||||
|
|
||||||
|
export const aiActions = appSchema.table(
|
||||||
|
"ai_actions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
inputText: text("input_text"),
|
||||||
|
transcript: text("transcript"),
|
||||||
|
functionName: text("function_name"),
|
||||||
|
functionArgs: jsonb("function_args"),
|
||||||
|
result: jsonb("result"),
|
||||||
|
status: text("status").notNull().default("proposed"),
|
||||||
|
error: text("error"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
statusIdx: index("ai_actions_status_idx").on(t.status),
|
||||||
|
createdAtIdx: index("ai_actions_created_at_idx").on(t.createdAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AiActionRow = typeof aiActions.$inferSelect;
|
||||||
|
export type AiActionInsert = typeof aiActions.$inferInsert;
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export { appSchema } from "./_schema";
|
export { appSchema } from "./_schema";
|
||||||
export * from "./jobs";
|
export * from "./jobs";
|
||||||
export * from "./todos";
|
export * from "./todos";
|
||||||
|
export * from "./ai_actions";
|
||||||
|
|||||||
@ -259,3 +259,50 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
export type ProposedAction = z.infer<typeof ProposedAction>;
|
export type ProposedAction = z.infer<typeof ProposedAction>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chaque action proposée est persistée dans `ai_actions` en status='proposed'.
|
||||||
|
* L'API renvoie un id par action pour que la PWA puisse les confirmer/annuler.
|
||||||
|
*/
|
||||||
|
export const ProposedActionWithId = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
action: ProposedAction,
|
||||||
|
});
|
||||||
|
export type ProposedActionWithId = z.infer<typeof ProposedActionWithId>;
|
||||||
|
|
||||||
|
// POST /ai/command — texte brut (ex: Cmd-K)
|
||||||
|
export const AiCommandRequest = z.object({
|
||||||
|
text: z.string().min(1).max(4000),
|
||||||
|
});
|
||||||
|
export type AiCommandRequest = z.infer<typeof AiCommandRequest>;
|
||||||
|
|
||||||
|
export const AiCommandResponse = z.object({
|
||||||
|
actions: z.array(ProposedActionWithId),
|
||||||
|
});
|
||||||
|
export type AiCommandResponse = z.infer<typeof AiCommandResponse>;
|
||||||
|
|
||||||
|
// POST /ai/voice — réponse multipart (transcript + actions)
|
||||||
|
export const AiVoiceResponse = z.object({
|
||||||
|
transcript: z.string(),
|
||||||
|
actions: z.array(ProposedActionWithId),
|
||||||
|
});
|
||||||
|
export type AiVoiceResponse = z.infer<typeof AiVoiceResponse>;
|
||||||
|
|
||||||
|
// POST /ai/command/confirm — la PWA confirme/annule les actions proposées
|
||||||
|
export const AiConfirmRequest = z.object({
|
||||||
|
action_ids: z.array(z.string().uuid()).min(1),
|
||||||
|
});
|
||||||
|
export type AiConfirmRequest = z.infer<typeof AiConfirmRequest>;
|
||||||
|
|
||||||
|
export const AiActionResult = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
status: z.enum(["confirmed", "failed"]),
|
||||||
|
result: z.unknown().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type AiActionResult = z.infer<typeof AiActionResult>;
|
||||||
|
|
||||||
|
export const AiConfirmResponse = z.object({
|
||||||
|
results: z.array(AiActionResult),
|
||||||
|
});
|
||||||
|
export type AiConfirmResponse = z.infer<typeof AiConfirmResponse>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user