diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6bc3dd7..1a74e06 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e147f44..2fab59d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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) { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d1082f8..c67544c 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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"); diff --git a/apps/api/src/modules/ai/ai.controller.ts b/apps/api/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..119d5b1 --- /dev/null +++ b/apps/api/src/modules/ai/ai.controller.ts @@ -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 { + 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 { + 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 { + return this.ai.confirm(body.action_ids); + } +} diff --git a/apps/api/src/modules/ai/ai.module.ts b/apps/api/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..44e8a3b --- /dev/null +++ b/apps/api/src/modules/ai/ai.module.ts @@ -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 {} diff --git a/apps/api/src/modules/ai/ai.service.ts b/apps/api/src/modules/ai/ai.service.ts new file mode 100644 index 0000000..99fd716 --- /dev/null +++ b/apps/api/src/modules/ai/ai.service.ts @@ -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 { + const proposals = await this.mistral.proposeActions(text); + const actions = await this.persistProposals(proposals, { inputText: text }); + return { actions }; + } + + async voice(audio: Buffer): Promise { + 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 { + 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 { + 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 { + 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)`); + } + } +} diff --git a/apps/api/src/modules/ai/groq.client.ts b/apps/api/src/modules/ai/groq.client.ts new file mode 100644 index 0000000..e15c6b5 --- /dev/null +++ b/apps/api/src/modules/ai/groq.client.ts @@ -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 { + 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(); + } +} diff --git a/apps/api/src/modules/ai/mistral.client.ts b/apps/api/src/modules/ai/mistral.client.ts new file mode 100644 index 0000000..967fb62 --- /dev/null +++ b/apps/api/src/modules/ai/mistral.client.ts @@ -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> { + 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 }; + }); + } +} diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 6a802a7..d9e7177 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -47,3 +47,27 @@ export async function api( if (res.status === 204) return undefined as T; return res.json() as Promise; } + +/** + * 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( + path: string, + body: Blob | ArrayBuffer, + contentType: string, +): Promise { + const finalHeaders: Record = { "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; +} diff --git a/apps/pwa/src/components/ai/MagicButton.tsx b/apps/pwa/src/components/ai/MagicButton.tsx new file mode 100644 index 0000000..edda170 --- /dev/null +++ b/apps/pwa/src/components/ai/MagicButton.tsx @@ -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("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(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("/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 ( + <> +
+
+ + [ VOICE ] + + + + {isActive ? "REC" : isBusy ? "…" : "IDLE"} + +
+ + + +
+ + {hint} + + {error && ( + + {error} + + )} +
+
+ + {phase === "reviewing" && result && ( + + )} + + ); +} + +/** + * 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; +} diff --git a/apps/pwa/src/components/ai/VoiceConfirmModal.tsx b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx new file mode 100644 index 0000000..d8b4c18 --- /dev/null +++ b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx @@ -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>(initialIds); + + const confirm = useMutation({ + mutationFn: (body: AiConfirmRequest) => + api("/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 ( +
!confirm.isPending && onClose()} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + > +
+ + +
+ +
+ +

+ {response.transcript || Aucun audio reconnu.} +

+
+ +
+ + {hasActions ? ( +
    + {response.actions.map((a) => ( + toggle(a.id)} + /> + ))} +
+ ) : ( +

+ Mistral n'a pas trouvé d'action à proposer. Reformule ou ferme. +

+ )} +
+ +
+ {confirm.isError && ( + + Échec — réessaie + + )} +
+ + +
+
+
+
+ ); +} + +function ActionRow({ + action, + selected, + onToggle, +}: { + action: ProposedActionWithId; + selected: boolean; + onToggle: () => void; +}) { + return ( +
  • + +
  • + ); +} + +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; + } +} diff --git a/apps/pwa/src/routes/index.tsx b/apps/pwa/src/routes/index.tsx index e407b78..ec86437 100644 --- a/apps/pwa/src/routes/index.tsx +++ b/apps/pwa/src/routes/index.tsx @@ -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("/todos"), }); + const active = (todos.data ?? []).filter( + (t) => t.status !== "done" && t.status !== "archived", + ); + return ( -
    -
    - - - Un assistant qui n'agit jamais sans ton clic. +
    +
    + + + Parle. Il capture. +

    + Le bouton enregistre ta voix, Whisper transcrit, Mistral propose des actions — + rien n'est écrit tant que tu n'as pas cliqué "Confirmer". +

    - +
    + +
    - -
    - -
    - - {health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"} - - {health.data && ( - - v{health.data.version} · uptime {health.data.uptime}s - +
    +
    + + + Tout voir → + +
    + + {todos.isLoading ? ( + + ) : todos.isError ? ( + + ) : active.length === 0 ? ( + + ) : ( +
      + {active.slice(0, 5).map((t) => ( +
    • + + {t.title} + {t.ai_enriched && ( + + AI + + )} +
    • + ))} + {active.length > 5 && ( +
    • + + {active.length - 5} autre{active.length - 5 !== 1 ? "s" : ""} +
    • )} -
    -
    -
    - -
    - VITE · REACT - NESTJS - POSTGRES · DRIZZLE - K3S -
    -
    -
    - -
    -
    - -
    -
    - - - - - - - - -
    + + )}
    - - -
    - -

    - Les verticales métier arrivent les unes après les autres: jobs pour la veille, - puis todos pour piloter l’exécution au quotidien. -

    - - Ouvrir les jobs - -
    -
    - -

    - L’outil reste mono-utilisateur, simple, et chaque action IA continue de passer par une - confirmation explicite avant écriture. -

    - - Ouvrir les todos - -
    -
    +
    + ); +} + +function EmptyRow({ text }: { text: string }) { + return ( +
    +

    {text}

    ); } diff --git a/packages/db/migrations/0003_ai_actions.sql b/packages/db/migrations/0003_ai_actions.sql new file mode 100644 index 0000000..d257e57 --- /dev/null +++ b/packages/db/migrations/0003_ai_actions.sql @@ -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); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 2e3d537..f83f5b9 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1744934400000, "tag": "0002_todos", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1745020800000, + "tag": "0003_ai_actions", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/ai_actions.ts b/packages/db/src/schema/ai_actions.ts new file mode 100644 index 0000000..66a7fa6 --- /dev/null +++ b/packages/db/src/schema/ai_actions.ts @@ -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; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 6584521..001bee2 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,3 +1,4 @@ export { appSchema } from "./_schema"; export * from "./jobs"; export * from "./todos"; +export * from "./ai_actions"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 706a18a..0d83429 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -259,3 +259,50 @@ export const ProposedAction = z.discriminatedUnion("fn", [ }), ]); export type ProposedAction = z.infer; + +/** + * 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; + +// 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; + +export const AiCommandResponse = z.object({ + actions: z.array(ProposedActionWithId), +}); +export type AiCommandResponse = z.infer; + +// 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; + +// 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; + +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; + +export const AiConfirmResponse = z.object({ + results: z.array(AiActionResult), +}); +export type AiConfirmResponse = z.infer;