tâches planifiées : prompts cron persistés, outil scheduler, conversations liées aux tâches, rattrapage des exécutions manquées au redémarrage (go-cron MissedRunOnce)

This commit is contained in:
fabien committed 2026-10-06 22:15:59 +02:00
1 parent bedb6c98e7
commit 058370830f
71 files changed
+1627 -100

No files matched your search

+2 -2
View File
@@ -4,8 +4,8 @@ import type { Chat, Chunk, ChatRequest, ChatWithMessages } from "$lib/types/api"
export const chatApi = {
createChat: (space: string, data: Partial<ChatRequest>) => api.post<Chat>(`/chat/${space}`, data),
createChatMessage: (space: string, id: string, data: Partial<ChatRequest>) => api.postStream<Chunk>(`/chat/${space}/${id}`, data),
allChats: (space: string, pageNum: number, query?: string) =>
api.get<Chat[]>(`/chat/${space}/all/${pageNum}${query ? `?q=${encodeURIComponent(query)}` : ''}`),
allChats: (space: string, pageNum: number, query?: string, taskId?: string) =>
api.get<Chat[]>(`/chat/${space}/all/${pageNum}${query || taskId ? `?${[query && `q=${encodeURIComponent(query)}`, taskId && `task=${taskId}`].filter(Boolean).join('&')}` : ''}`),
getChat: (space: string, id: string) => api.get<ChatWithMessages>(`/chat/${space}/${id}`),
subscribe: (space: string, id: string) => api.get<ChatWithMessages>(`/chat/${space}/${id}/subscribe`),
deleteChat: (space: string, id: string) => api.delete(`/chat/${space}/${id}`),
@@ -0,0 +1,9 @@
import { api } from '../client';
import type { Task } from "$lib/types/api"
export const taskApi = {
listTasks: (space: string) => api.get<Task[]>(`/task/${space}`),
updateTask: (space: string, id: string, data: Partial<Task>) =>
api.put<Task>(`/task/${space}/${id}`, data),
deleteTask: (space: string, id: string) => api.delete(`/task/${space}/${id}`),
};
+15
View File
@@ -92,6 +92,21 @@ export type Chat = {
_version: string
}
export interface Task {
id: string
teamId: string
modelId: string
label: string
prompt: string
cron: string
repeat: boolean
status: string
nextDatetime: string | null
_date_created: string
_date_updated: string
_version: string
}
export interface Message {
id: string
role: string
@@ -97,6 +97,16 @@
</script>
<div class="h-full flex flex-col gap-2">
{#if data.chatWithMessage.chat.taskId}
<div class="container">
<a
class="text-xs text-sky-700 dark:text-sky-300"
href={`/${data.spaceId}/tasks?task=${data.chatWithMessage.chat.taskId}`}
>
<i class="icon-tasks"></i> Tâche planifiée
</a>
</div>
{/if}
<div id="history" class="flex-1 container overflow-auto" bind:this={historyDiv}>
{#each messages as message (message.id)}
<div class={["message", "rounded", message.role]}>
@@ -4,12 +4,26 @@
import { putMessage } from "$lib/state/toaster.svelte";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import type { PageProps } from './$types';
import type { Chat } from "$lib/types/api";
import type { Chat, Task } from "$lib/types/api";
let { data }: PageProps = $props();
const PAGE_SIZE = 20;
const formatDate = (value: string | null | undefined) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
let search = $state("");
let taskFilter = $state("");
// svelte-ignore state_referenced_locally -- la liste est gérée localement
// (filtre, pagination, suppression) et ne doit pas suivre les rechargements de data
let conversations = $state<Chat[]>(data.conversations);
@@ -18,23 +32,32 @@
let conversationToDelete = $state<Chat | null>(null);
const hasMore = $derived(conversations.length === (page + 1) * PAGE_SIZE);
const tasks = $derived<Task[]>(data.tasks ?? []);
const refresh = async () => {
page = 0;
const taskId = taskFilter || undefined;
const result = await chatApi.allChats(data.space, 0, search, taskId);
conversations = result ?? [];
};
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const handleSearch = () => {
if (searchTimer !== undefined) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
page = 0;
const result = await chatApi.allChats(data.space, 0, search);
conversations = result ?? [];
}, 300);
searchTimer = setTimeout(refresh, 300);
};
const handleTaskFilter = () => {
refresh();
};
const loadMore = async () => {
if (loading) return;
loading = true;
try {
const result = await chatApi.allChats(data.space, page + 1, search);
const taskId = taskFilter || undefined;
const result = await chatApi.allChats(data.space, page + 1, search, taskId);
const next = result ?? [];
conversations = [...conversations, ...next];
page += 1;
@@ -51,9 +74,7 @@
await chatApi.deleteChat(data.space, conversationToDelete.id);
putMessage("success", "Conversation supprimée");
await invalidateAll();
const result = await chatApi.allChats(data.space, 0, search);
conversations = result ?? [];
page = 0;
await refresh();
} catch {
putMessage("failure", "Erreur lors de la suppression de la conversation");
} finally {
@@ -63,14 +84,26 @@
</script>
<div class="container">
<div class="formelement my-2">
<input
class="textinput"
type="search"
placeholder="Rechercher une conversation..."
bind:value={search}
oninput={handleSearch}
/>
<div class="my-2 flex gap-2">
<div class="formelement flex-1">
<input
class="textinput"
type="search"
placeholder="Rechercher une conversation..."
bind:value={search}
oninput={handleSearch}
/>
</div>
{#if tasks.length > 0}
<div class="formelement w-60">
<select class="textinput" bind:value={taskFilter} onchange={handleTaskFilter}>
<option value="">Toutes les tâches</option>
{#each tasks as task (task.id)}
<option value={task.id}>{task.label}</option>
{/each}
</select>
</div>
{/if}
</div>
{#each conversations as conversation (conversation.id)}
@@ -87,7 +120,18 @@
<i class="icon-trash-empty"></i>
</button>
</div>
<span class="text-xs">{conversation._date_created}</span>
<div class="mt-1 text-xs text-stone-500">
<span title="Créée le">Créée le {formatDate(conversation._date_created)}</span>
<span class="mx-1">·</span>
<span title="Dernière interaction">Dernière interaction : {formatDate(conversation._date_updated)}</span>
{#if conversation.taskId}
<span class="mx-1">·</span>
<a
class="text-sky-700 dark:text-sky-300"
href={`/${data.space}/tasks?task=${conversation.taskId}`}
>Tâche planifiée</a>
{/if}
</div>
</div>
{/each}
@@ -1,10 +1,12 @@
import { chatApi } from "$lib/api/endpoints/chat"
import { taskApi } from "$lib/api/endpoints/task"
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return {
title: 'Historique',
conversations: await chatApi.allChats(params.space, 0),
tasks: await taskApi.listTasks(params.space),
space: params.space,
};
}
@@ -1,3 +1,214 @@
<div>
Tâches
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { taskApi } from "$lib/api/endpoints/task";
import { chatApi } from "$lib/api/endpoints/chat";
import { putMessage } from "$lib/state/toaster.svelte";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import type { PageProps } from './$types';
import type { Chat, Task } from "$lib/types/api";
let { data }: PageProps = $props();
// svelte-ignore state_referenced_locally -- la liste est gérée localement
const tasks = $derived<Task[]>(data.tasks ?? []);
let editing = $state<Task | null>(null);
let taskToDelete = $state<Task | null>(null);
let saving = $state(false);
// conversations liées à une tâche (chargées à la demande) ;
// ?task=<id> dans l'URL ouvre directement la section
let expandedTask = $state<string | null>(page.url.searchParams.get("task"));
let taskConversations = $state<Record<string, Chat[]>>({});
let loadingConversations = $state(false);
const loadConversations = async (taskId: string) => {
loadingConversations = true;
try {
const result = await chatApi.allChats(data.space, 0, undefined, taskId);
taskConversations = { ...taskConversations, [taskId]: result ?? [] };
} catch {
putMessage("failure", "Erreur lors du chargement des conversations");
} finally {
loadingConversations = false;
}
};
const toggleConversations = async (task: Task) => {
if (expandedTask === task.id) {
expandedTask = null;
return;
}
expandedTask = task.id;
if (!taskConversations[task.id]) {
await loadConversations(task.id);
}
};
// ouverture initiale depuis ?task=<id>
let initialExpandDone = false;
$effect(() => {
if (!initialExpandDone && expandedTask) {
initialExpandDone = true;
loadConversations(expandedTask);
}
initialExpandDone = true;
});
const formatDate = (value: string | null) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const startEdit = (task: Task) => {
editing = { ...task };
};
const handleSave = async (e: Event) => {
e.preventDefault();
if (!editing) return;
if (!editing.label.trim() || !editing.prompt.trim() || !editing.cron.trim()) {
putMessage("failure", "Titre, prompt et planification sont obligatoires");
return;
}
saving = true;
try {
await taskApi.updateTask(data.space, editing.id, {
label: editing.label,
prompt: editing.prompt,
cron: editing.cron,
repeat: editing.repeat,
});
putMessage("success", "Tâche mise à jour");
editing = null;
await invalidateAll();
} catch {
putMessage("failure", "Erreur lors de la mise à jour de la tâche");
} finally {
saving = false;
}
};
const handleDelete = async () => {
if (!taskToDelete) return;
try {
await taskApi.deleteTask(data.space, taskToDelete.id);
putMessage("success", "Tâche supprimée");
await invalidateAll();
} catch {
putMessage("failure", "Erreur lors de la suppression de la tâche");
} finally {
taskToDelete = null;
}
};
</script>
<div class="container">
{#if tasks.length === 0}
<p class="my-4 text-center text-sm text-stone-500">
Aucune tâche planifiée. Demandez-en une dans le chat
(« résume-moi l'actualité tous les jours à 8h »).
</p>
{/if}
{#each tasks as task (task.id)}
{#if editing && editing.id === task.id}
<div class="dark:bg-stone-800 rounded p-3 my-2">
<form onsubmit={handleSave}>
<div class="formgroup my-2">
<div class="formelement">
<label for={`label-${task.id}`}>Titre</label>
<input id={`label-${task.id}`} class="textinput" type="text" bind:value={editing.label} />
</div>
<div class="formelement">
<label for={`cron-${task.id}`}>Planification (cron)</label>
<input id={`cron-${task.id}`} class="textinput" type="text" bind:value={editing.cron} />
</div>
<div class="formelement">
<label for={`repeat-${task.id}`}>Répétition</label>
<input id={`repeat-${task.id}`} type="checkbox" bind:checked={editing.repeat} />
</div>
</div>
<div class="formelement">
<label for={`prompt-${task.id}`}>Prompt</label>
<textarea id={`prompt-${task.id}`} class="textinput w-full" rows="4" bind:value={editing.prompt}></textarea>
</div>
<div class="mt-2 flex gap-2">
<button class="bt small" type="submit" disabled={saving}>
{saving ? "Enregistrement..." : "Enregistrer"}
</button>
<button class="bt small" type="button" onclick={() => (editing = null)}>Annuler</button>
</div>
</form>
</div>
{:else}
<div class="dark:bg-stone-800 rounded p-2 my-2" class:ring-2={expandedTask === task.id}
class:ring-sky-500={expandedTask === task.id}>
<div class="flex flex-row items-center">
<div class="flex-1">
<div class="font-bold text-base">{task.label}</div>
<div class="text-xs text-stone-500">
{task.cron}
{task.repeat ? "· répétée" : "· ponctuelle"}
{task.status === "done" ? "· terminée" : ""}
</div>
</div>
<button
class="bt small"
title="Conversations liées"
onclick={() => toggleConversations(task)}
>
<i class="icon-chat"></i>
</button>
<button class="bt small" title="Modifier la tâche" onclick={() => startEdit(task)}>
<i class="icon-edit"></i>
</button>
<button class="bt small" title="Supprimer la tâche" onclick={() => (taskToDelete = task)}>
<i class="icon-trash-empty"></i>
</button>
</div>
<p class="mt-1 text-sm text-stone-600 dark:text-stone-400">{task.prompt}</p>
<span class="text-xs">Prochaine exécution : {formatDate(task.nextDatetime)}</span>
{#if expandedTask === task.id}
<div class="mt-2 border-t border-stone-600 pt-2">
<div class="text-xs font-medium uppercase text-stone-500">Conversations liées</div>
{#if loadingConversations && !taskConversations[task.id]}
<p class="text-sm text-stone-500">Chargement...</p>
{:else if (taskConversations[task.id] ?? []).length === 0}
<p class="text-sm text-stone-500">Aucune conversation pour cette tâche.</p>
{:else}
<ul class="mt-1">
{#each taskConversations[task.id] as conversation (conversation.id)}
<li class="my-1 text-sm">
<a class="text-sky-700 dark:text-sky-300" href={`/${data.space}/${conversation.id}`}>
{conversation.title}
</a>
<span class="ml-2 text-xs text-stone-500">{formatDate(conversation._date_created)}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
{/each}
</div>
<ConfirmDialog
open={taskToDelete !== null}
title="Supprimer la tâche"
message={`Voulez-vous vraiment supprimer « ${taskToDelete?.label} » ? Cette action est irréversible.`}
confirmLabel="Supprimer"
onConfirm={handleDelete}
onCancel={() => (taskToDelete = null)}
/>
@@ -1,3 +1,10 @@
export function load() {
return { title: 'Tâches' };
import { taskApi } from "$lib/api/endpoints/task"
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return {
title: 'Tâches',
tasks: await taskApi.listTasks(params.space),
space: params.space,
};
}