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:
1 parent
bedb6c98e7
commit
058370830f
71 files changed
+1627
-100
No files matched your search
@@ -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)}
|
||||
/>
|
||||
Reference in new issue
Block a user