add magic button

This commit is contained in:
ordinarthur 2026-04-16 10:57:26 +02:00
parent 32f3105bef
commit b71d5c8f47
17 changed files with 1128 additions and 85 deletions

View File

@ -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)"
]
}
}

View File

@ -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) {

View File

@ -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");

View 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);
}
}

View 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 {}

View 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)`);
}
}
}

View 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();
}
}

View 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 };
});
}
}

View File

@ -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>;
}

View 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;
}

View 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;
}
}

View File

@ -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 lexé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">
Loutil 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>
);
}

View 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);

View File

@ -22,6 +22,13 @@
"when": 1744934400000,
"tag": "0002_todos",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1745020800000,
"tag": "0003_ai_actions",
"breakpoints": true
}
]
}

View 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;

View File

@ -1,3 +1,4 @@
export { appSchema } from "./_schema";
export * from "./jobs";
export * from "./todos";
export * from "./ai_actions";

View File

@ -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>;