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)",
|
||||
"Read(//private/tmp/test_ts/pkg/**)",
|
||||
"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 { JobsModule } from "./modules/jobs/jobs.module";
|
||||
import { TodosModule } from "./modules/todos/todos.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule],
|
||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule, AiModule],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@ -2,6 +2,7 @@ import "reflect-metadata";
|
||||
import "tsconfig-paths/register";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { raw } from "express";
|
||||
import { AppModule } from "./app.module";
|
||||
import { loadConfig } from "./config/env";
|
||||
|
||||
@ -10,6 +11,12 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
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 });
|
||||
await app.listen(config.PORT);
|
||||
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;
|
||||
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 type { Todo } from "@ordinarthur-os/shared";
|
||||
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 });
|
||||
|
||||
function Dashboard() {
|
||||
const health = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => api<{ ok: true; version: string; uptime: number }>("/health", { auth: false }),
|
||||
refetchInterval: 30_000,
|
||||
const todos = useQuery({
|
||||
queryKey: ["todos", false],
|
||||
queryFn: () => api<Todo[]>("/todos"),
|
||||
});
|
||||
|
||||
const active = (todos.data ?? []).filter(
|
||||
(t) => t.status !== "done" && t.status !== "archived",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<section>
|
||||
<Label prefix="[ 00 ]">PHASE 0 → 1</Label>
|
||||
<BigHeading className="mt-4">
|
||||
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
||||
<div className="space-y-16">
|
||||
<section className="space-y-6">
|
||||
<Label prefix="[ 00 ]">ORDINARTHUR-OS</Label>
|
||||
<BigHeading>
|
||||
Parle. <em>Il capture.</em>
|
||||
</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>
|
||||
|
||||
<SectionHeader
|
||||
number="01"
|
||||
label="STATUS"
|
||||
title="Backend handshake"
|
||||
/>
|
||||
<section className="flex justify-center">
|
||||
<MagicButton />
|
||||
</section>
|
||||
|
||||
<GridFrame cols={2}>
|
||||
<div className="p-6 space-y-3">
|
||||
<Label>API HEALTH</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<DataChip dotColor={health.isSuccess ? "accent" : "ink"}>
|
||||
{health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"}
|
||||
</DataChip>
|
||||
{health.data && (
|
||||
<span className="font-mono text-[11px] text-muted">
|
||||
v{health.data.version} · uptime {health.data.uptime}s
|
||||
</span>
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-ink pb-3">
|
||||
<Label prefix="[ 01 ]">EN COURS</Label>
|
||||
<Link
|
||||
to="/todos"
|
||||
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink"
|
||||
>
|
||||
Tout voir →
|
||||
</Link>
|
||||
</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>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{active.length > 5 && (
|
||||
<li className="px-4 py-2 font-mono text-[10px] uppercase tracking-label text-muted">
|
||||
+ {active.length - 5} autre{active.length - 5 !== 1 ? "s" : ""}
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
<Label>STACK</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DataChip>VITE · REACT</DataChip>
|
||||
<DataChip>NESTJS</DataChip>
|
||||
<DataChip>POSTGRES · DRIZZLE</DataChip>
|
||||
<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>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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">
|
||||
Les verticales métier arrivent les unes après les autres: jobs pour la veille,
|
||||
puis todos pour piloter l’exécution au quotidien.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="border border-ink px-4 py-6">
|
||||
<p className="font-sans text-sm text-muted">{text}</p>
|
||||
</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,
|
||||
"tag": "0002_todos",
|
||||
"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 * from "./jobs";
|
||||
export * from "./todos";
|
||||
export * from "./ai_actions";
|
||||
|
||||
@ -259,3 +259,50 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
||||
}),
|
||||
]);
|
||||
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