orchai/src/components/projects/ProjectLiveAgent.tsx

377 lines
13 KiB
TypeScript
Raw Normal View History

import { listen } from "@tauri-apps/api/event";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
createLiveSession,
listAgents,
listLiveMessages,
listProjectModules,
listLiveSessions,
sendLiveMessage,
} from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { Agent, LiveMessage, LiveSession } from "../../lib/types";
interface LiveEventPayload {
project_id: string;
session_id: string;
message: LiveMessage;
}
interface LiveStreamChunkPayload {
project_id: string;
session_id: string;
chunk: string;
}
interface LiveStreamStatusPayload {
project_id: string;
session_id: string;
error?: string | null;
}
export default function ProjectLiveAgent() {
const { projectId } = useParams<{ projectId: string }>();
const [agents, setAgents] = useState<Agent[]>([]);
const [sessions, setSessions] = useState<LiveSession[]>([]);
const [messages, setMessages] = useState<LiveMessage[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string>("");
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [sessionTitle, setSessionTitle] = useState("");
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
const [moduleEnabled, setModuleEnabled] = useState(true);
const [streamingAgentResponse, setStreamingAgentResponse] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const usableAgents = useMemo(
() => agents.filter((agent) => agent.role === "analyst" || agent.role === "developer"),
[agents]
);
async function refreshSessions(defaultSessionId?: string) {
if (!projectId) return;
const result = await listLiveSessions(projectId);
setSessions(result);
const targetSessionId = defaultSessionId ?? selectedSessionId;
const firstSessionId = result[0]?.id;
if (targetSessionId && result.some((session) => session.id === targetSessionId)) {
setSelectedSessionId(targetSessionId);
const sessionMessages = await listLiveMessages(targetSessionId);
setMessages(sessionMessages);
return;
}
if (firstSessionId) {
setSelectedSessionId(firstSessionId);
const sessionMessages = await listLiveMessages(firstSessionId);
setMessages(sessionMessages);
return;
}
setSelectedSessionId("");
setMessages([]);
}
useEffect(() => {
async function load() {
if (!projectId) return;
setError(null);
try {
const [availableAgents, modules] = await Promise.all([
listAgents(),
listProjectModules(projectId),
]);
setAgents(availableAgents);
setModuleEnabled(
modules.find((mod) => mod.module_key === "ai_live_chat")?.enabled ?? true
);
const firstAgentId = availableAgents[0]?.id ?? "";
setSelectedAgentId((current) => current || firstAgentId);
await refreshSessions();
} catch (err: unknown) {
setError(getErrorMessage(err));
}
}
void load();
}, [projectId]);
useEffect(() => {
if (!projectId) return;
let stop: (() => void) | null = null;
void (async () => {
const [unlistenMessage, unlistenStarted, unlistenChunk, unlistenFinished, unlistenError] =
await Promise.all([
listen<LiveEventPayload>("live-agent-message", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setMessages((prev) => {
const existingIndex = prev.findIndex((msg) => msg.id === payload.message.id);
if (existingIndex === -1) {
return [...prev, payload.message];
}
const next = [...prev];
next[existingIndex] = payload.message;
return next;
});
if (payload.message.sender === "agent") {
setStreamingAgentResponse(null);
}
}),
listen<LiveStreamStatusPayload>("live-agent-stream-started", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse("");
}),
listen<LiveStreamChunkPayload>("live-agent-stream-chunk", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse((prev) => `${prev ?? ""}${payload.chunk}`);
}),
listen<LiveStreamStatusPayload>("live-agent-stream-finished", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse(null);
}),
listen<LiveStreamStatusPayload>("live-agent-stream-error", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse(null);
setMessages((prev) =>
prev.filter((msg) => !(msg.sender === "agent" && msg.content.trim() === ""))
);
if (payload.error) {
setError(payload.error);
}
}),
]);
stop = () => {
unlistenMessage();
unlistenStarted();
unlistenChunk();
unlistenFinished();
unlistenError();
};
})();
return () => {
if (stop) stop();
};
}, [projectId, selectedSessionId]);
async function handleCreateSession(event: FormEvent) {
event.preventDefault();
if (!projectId || !selectedAgentId) return;
if (!moduleEnabled) {
setError("Le module Live chat agent est désactivé pour ce projet.");
return;
}
setCreatingSession(true);
setError(null);
try {
const created = await createLiveSession(projectId, selectedAgentId, sessionTitle);
setSessionTitle("");
await refreshSessions(created.id);
} catch (err: unknown) {
setError(getErrorMessage(err));
} finally {
setCreatingSession(false);
}
}
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
if (!selectedSessionId || !draft.trim()) return;
if (!moduleEnabled) {
setError("Le module Live chat agent est désactivé pour ce projet.");
return;
}
const content = draft.trim();
setDraft("");
setSending(true);
setError(null);
try {
await sendLiveMessage(selectedSessionId, content);
} catch (err: unknown) {
setDraft(content);
setStreamingAgentResponse(null);
setError(getErrorMessage(err));
} finally {
setSending(false);
}
}
async function handleSessionChange(sessionId: string) {
setSelectedSessionId(sessionId);
setStreamingAgentResponse(null);
if (!sessionId) {
setMessages([]);
return;
}
try {
const result = await listLiveMessages(sessionId);
setMessages(result);
} catch (err: unknown) {
setError(getErrorMessage(err));
}
}
return (
<div className="p-8 space-y-6">
<h2 className="text-xl font-bold">Live agent</h2>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
{error}
</div>
)}
{!moduleEnabled && (
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700">
Le module est désactivé. La lecture reste possible, mais la création de session et l'envoi de message sont bloqués.
</div>
)}
<form onSubmit={handleCreateSession} className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold text-gray-800">Nouvelle session</h3>
<div className="grid gap-3 md:grid-cols-3">
<select
value={selectedAgentId}
onChange={(e) => setSelectedAgentId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
{usableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.tool})
</option>
))}
</select>
<input
type="text"
value={sessionTitle}
onChange={(e) => setSessionTitle(e.target.value)}
placeholder="Titre de session (optionnel)"
className="rounded border border-gray-300 px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={creatingSession || !selectedAgentId || !moduleEnabled}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{creatingSession ? "Création..." : "Créer la session"}
</button>
</div>
</form>
<div className="grid gap-4 md:grid-cols-[260px,1fr]">
<div className="rounded-lg border border-gray-200 bg-white p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Sessions
</div>
<div className="space-y-2">
{sessions.map((session) => (
<button
type="button"
key={session.id}
onClick={() => void handleSessionChange(session.id)}
className={`w-full rounded px-3 py-2 text-left text-sm ${
selectedSessionId === session.id
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
<div className="truncate">{session.title}</div>
<div className="mt-1 text-[11px] opacity-70">
{new Date(session.updated_at).toLocaleString()}
</div>
</button>
))}
{sessions.length === 0 && (
<div className="text-xs text-gray-400">Aucune session.</div>
)}
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-3 text-sm font-semibold text-gray-800">Discussion</div>
<div className="max-h-[360px] space-y-2 overflow-y-auto rounded border border-gray-100 bg-gray-50 p-3">
{messages
.filter((msg) => !(msg.sender === "agent" && msg.content.trim() === ""))
.map((msg) => (
<div
key={msg.id}
className={`rounded px-3 py-2 text-sm ${
msg.sender === "agent"
? "bg-blue-100 text-blue-900"
: msg.sender === "system"
? "bg-amber-100 text-amber-900"
: "bg-gray-200 text-gray-800"
}`}
>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide opacity-70">
{msg.sender}
</div>
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
))}
{streamingAgentResponse !== null && (
<div className="rounded bg-blue-100 px-3 py-2 text-sm text-blue-900">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide opacity-70">
agent
</div>
<div className="whitespace-pre-wrap">
{streamingAgentResponse || "En train de repondre..."}
</div>
</div>
)}
{messages.length === 0 && streamingAgentResponse === null && (
<div className="text-sm text-gray-400">Pas encore de message.</div>
)}
</div>
<form onSubmit={handleSendMessage} className="mt-3 flex gap-2">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Ton message..."
disabled={!selectedSessionId || sending}
className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={!selectedSessionId || sending || !draft.trim() || !moduleEnabled}
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black disabled:opacity-50"
>
{sending ? "Envoi..." : "Envoyer"}
</button>
</form>
</div>
</div>
</div>
);
}