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
@@ -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}`),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user