Files
legal-ai/web-ui/src/components/training/chat-panel.tsx
Chaim bb0cd7c6a2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
Six-phase upgrade of /training from a read-only dashboard into a full
Style Studio for managing Daphna's style corpus.

- Upload Sheet on /training: file → proofread preview → commit (no more
  CLI-only `upload-training` skill).
- Rich corpus metadata: GET /api/training/corpus returns summary, outcome,
  key_principles, page_count, parties (regex), legal_citation, lessons_count.
  PATCH endpoint for chair edits. CorpusDetailDrawer with 4 tabs (details
  /content/lessons/patterns) replaces the bare table row.
- LLM metadata enrichment: style_metadata_extractor + MCP tools
  (style_corpus_enrich, style_corpus_pending_enrichment) fill summary
  /outcome/key_principles via claude_session (free, host-side).
- Per-decision lessons: new decision_lessons table + 4 REST endpoints +
  LessonsTab in drawer; hermes-curator now auto-posts findings as
  decision_lessons(source=curator).
- Curator Portrait tab: prompt rendered with link to Gitea, recent
  curator findings, style_analyzer training prompts, propose-change
  form that writes proposals to data/curator-proposals/ for manual
  chair review (no auto-mutation of the agent file).
- Style chat tab: SSE-streamed conversations with the style agent.
  New host-side pm2 service (legal-chat-service, port 8770) wraps
  claude CLI with stream-json + --resume continuation; FastAPI proxies
  via host.docker.internal. Zero API cost — uses chaim's claude.ai
  subscription. chat_conversations + chat_messages persist history.

Architecture: keeps the existing rule that claude_session only runs
on the host (not the container). The new legal-chat-service is the
canonical bridge between the container and the local CLI for the chat
feature; everything else (upload, metadata, lessons) stays within the
container's existing capabilities.

Audit script (scripts/audit_training_corpus.py) included for verifying
which corpus rows still need enrichment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:06:22 +00:00

435 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/*
* Style-agent chat panel — the new "שיחה" tab on /training.
*
* Layout: two columns.
* - Sidebar: list of conversations + "+ שיחה חדשה" button
* - Main: thread of messages + composer with SSE streaming
*
* Each message is persisted to the legal-ai DB; the LLM call goes
* out via FastAPI → host's legal-chat-service → claude CLI. There
* is no API cost — the claude CLI uses Daphna's claude.ai
* subscription via the host's auth.
*
* Health gate: if /api/training/chat/health reports the host service
* is unreachable, the composer is replaced by a setup notice telling
* the chair to start the pm2 service.
*/
import { useEffect, useRef, useState } from "react";
import {
Send, Plus, Trash2, Loader2, MessageSquare, Sparkles, AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
chatKeys,
useChatConversation,
useChatConversations,
useChatHealth,
useCorpus,
useCreateChat,
useDeleteChat,
type ChatMessage,
} from "@/lib/api/training";
import { useQueryClient } from "@tanstack/react-query";
export function ChatPanel() {
const [activeId, setActiveId] = useState<string | null>(null);
const health = useChatHealth();
return (
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<ConversationsSidebar activeId={activeId} onSelect={setActiveId} />
<div className="space-y-3">
{health.data && !health.data.reachable && (
<ChatServiceWarning health={health.data} />
)}
{activeId ? (
<ChatThread convId={activeId} />
) : (
<Card className="bg-rule-soft/40 border-rule">
<CardContent className="px-6 py-10 text-center text-ink-muted text-sm space-y-2">
<MessageSquare className="w-8 h-8 mx-auto opacity-50" />
<p>בחר שיחה קיימת או פתח חדשה כדי להתחיל לדבר עם סוכן הסגנון.</p>
<p className="text-[0.78rem]">
הסוכן רץ על claude CLI מקומי דרך legal-chat-service. אין עלות API.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}
// ── Sidebar: list + new ────────────────────────────────────────────
function ConversationsSidebar({
activeId, onSelect,
}: {
activeId: string | null;
onSelect: (id: string | null) => void;
}) {
const { data: convs, isPending } = useChatConversations();
const { data: corpus } = useCorpus();
const create = useCreateChat();
const del = useDeleteChat();
const [creating, setCreating] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newCorpusId, setNewCorpusId] = useState<string>("__none__");
const onCreate = async () => {
try {
const conv = await create.mutateAsync({
title: newTitle.trim() || "שיחה חדשה",
style_corpus_id: newCorpusId === "__none__" ? null : newCorpusId,
});
onSelect(conv.id);
setCreating(false);
setNewTitle("");
setNewCorpusId("__none__");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל ביצירת שיחה");
}
};
const onDelete = async (id: string) => {
if (!window.confirm("למחוק את השיחה? פעולה זו לא ניתנת לביטול.")) return;
try {
await del.mutateAsync(id);
if (activeId === id) onSelect(null);
toast.success("השיחה נמחקה");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל במחיקה");
}
};
return (
<Card className="bg-surface border-rule">
<CardContent className="px-3 py-3 space-y-2">
{!creating ? (
<Button
onClick={() => setCreating(true)}
className="w-full bg-navy text-parchment hover:bg-navy-soft"
size="sm"
>
<Plus className="w-4 h-4 me-1" />
שיחה חדשה
</Button>
) : (
<div className="space-y-2 border border-rule rounded p-2 bg-rule-soft/30">
<Textarea
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="כותרת לשיחה (אופציונלי)"
rows={2} dir="rtl"
/>
<Select value={newCorpusId} onValueChange={setNewCorpusId} dir="rtl">
<SelectTrigger>
<SelectValue placeholder="צמד להחלטה (אופציונלי)" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectItem value="__none__"> שיחה כללית </SelectItem>
{corpus?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.decision_number || "—"}
{c.decision_date ? ` · ${c.decision_date}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm"
onClick={() => { setCreating(false); setNewTitle(""); setNewCorpusId("__none__"); }}>
ביטול
</Button>
<Button size="sm" onClick={onCreate} disabled={create.isPending}
className="bg-navy text-parchment hover:bg-navy-soft">
צור
</Button>
</div>
</div>
)}
<ScrollArea className="h-[520px]">
<ul className="space-y-1">
{isPending && (
<>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</>
)}
{convs?.length === 0 && (
<p className="text-center text-ink-muted text-[0.78rem] py-6">
אין עדיין שיחות
</p>
)}
{convs?.map((c) => {
const active = c.id === activeId;
return (
<li key={c.id}>
<button
onClick={() => onSelect(c.id)}
className={
"w-full text-end rounded-md px-2 py-2 transition " +
(active
? "bg-gold-wash border border-gold/40"
: "hover:bg-rule-soft/60 border border-transparent")
}
>
<div className="text-sm text-navy font-semibold truncate">
{c.title}
</div>
<div className="flex items-center gap-1 text-[0.7rem] text-ink-muted">
{c.decision_number && (
<Badge variant="outline"
className="text-[0.65rem] bg-info-bg text-info border-info/40">
{c.decision_number}
</Badge>
)}
<span className="tabular-nums">{c.message_count}</span>
<MessageSquare className="w-3 h-3" />
<span className="grow text-end">
{new Date(c.last_message_at).toLocaleDateString("he-IL")}
</span>
<button
onClick={(e) => { e.stopPropagation(); onDelete(c.id); }}
className="hover:text-danger"
aria-label="מחק שיחה"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</button>
</li>
);
})}
</ul>
</ScrollArea>
</CardContent>
</Card>
);
}
// ── Thread + composer ──────────────────────────────────────────────
function ChatThread({ convId }: { convId: string }) {
const { data, isPending } = useChatConversation(convId);
const qc = useQueryClient();
const [draft, setDraft] = useState("");
const [streaming, setStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamError, setStreamError] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
/* Auto-scroll to bottom when new messages arrive. */
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, [data?.messages.length, streamingText]);
const onSend = async () => {
const text = draft.trim();
if (!text || streaming) return;
setDraft("");
setStreaming(true);
setStreamingText("");
setStreamError("");
try {
const res = await fetch(
`/api/training/chat/conversations/${encodeURIComponent(convId)}/messages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: text }),
},
);
if (!res.ok || !res.body) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`);
}
// Parse SSE line-by-line. EventSource would be cleaner but it
// doesn't support POST bodies; the manual reader is small.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let accumulated = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, nl);
buffer = buffer.slice(nl + 2);
if (!event.startsWith("data: ")) continue;
try {
const payload = JSON.parse(event.slice("data: ".length));
if (payload.type === "text_delta" && payload.text) {
accumulated += payload.text;
setStreamingText(accumulated);
} else if (payload.type === "error") {
setStreamError(String(payload.message || "שגיאה לא ידועה"));
} else if (payload.type === "done") {
if (payload.text && !accumulated) {
accumulated = payload.text;
setStreamingText(accumulated);
}
}
} catch {
/* ignore non-JSON */
}
}
}
} catch (e) {
setStreamError(e instanceof Error ? e.message : "שגיאה בשיחה");
} finally {
setStreaming(false);
setStreamingText("");
// Refetch the conversation so the persisted assistant turn shows up.
qc.invalidateQueries({ queryKey: chatKeys.conversation(convId) });
qc.invalidateQueries({ queryKey: chatKeys.conversations() });
}
};
if (isPending) return <Skeleton className="h-[560px] w-full" />;
if (!data) return null;
return (
<Card className="bg-surface border-rule">
<CardContent className="px-4 py-3 space-y-3">
<header className="flex items-center gap-2 border-b border-rule pb-2">
<Sparkles className="w-4 h-4 text-gold-deep" />
<h3 className="text-navy font-semibold grow">{data.conversation.title}</h3>
{data.conversation.decision_number && (
<Badge variant="outline" className="bg-info-bg text-info border-info/40">
{data.conversation.decision_number}
</Badge>
)}
</header>
<div ref={scrollRef} className="h-[440px] overflow-y-auto space-y-3 pe-1">
{data.messages.length === 0 && !streaming && (
<p className="text-center text-ink-muted text-sm py-8">
התחל בשאלה למשל: &quot;מה מאפיין את הפתיחות של דפנה בעררי 1xxx?&quot;
</p>
)}
{data.messages.map((m) => <MessageBubble key={m.id} message={m} />)}
{streaming && (
<MessageBubble
message={{
id: "streaming",
role: "assistant",
content: streamingText || "(מקליד…)",
created_at: "",
}}
isStreaming
/>
)}
{streamError && (
<div className="rounded-lg border border-danger/40 bg-danger-bg p-3 text-danger text-sm">
{streamError}
</div>
)}
</div>
<div className="border-t border-rule pt-3 space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="שאל את הסוכן… (Shift+Enter לשורה חדשה)"
rows={3} dir="rtl"
disabled={streaming}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void onSend();
}
}}
/>
<div className="flex items-center gap-2">
<p className="text-[0.72rem] text-ink-muted grow">
{data.conversation.claude_session_id
? "שיחה ממשיכה (--resume) — אין צורך לטעון מחדש את ה-system prompt"
: "שיחה חדשה — system prompt ייטען (שני מסמכי ייחוס + רשימת קורפוס)"}
</p>
<Button onClick={onSend} disabled={streaming || !draft.trim()}
className="bg-navy text-parchment hover:bg-navy-soft">
{streaming ? (
<Loader2 className="w-4 h-4 animate-spin me-1" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function MessageBubble({
message, isStreaming = false,
}: { message: ChatMessage; isStreaming?: boolean }) {
const isUser = message.role === "user";
return (
<div className={isUser ? "flex justify-start" : "flex justify-end"}>
<div
className={
"max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap " +
(isUser
? "bg-gold-wash text-ink border border-gold/40"
: "bg-rule-soft text-ink border border-rule")
}
dir="rtl"
>
{message.content}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 bg-navy/60 align-middle ms-1 animate-pulse" />
)}
</div>
</div>
);
}
// ── Service-down warning ──────────────────────────────────────────
function ChatServiceWarning({
health,
}: { health: { reachable: boolean; url: string; error?: string } }) {
return (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-4 py-3 space-y-1">
<div className="flex items-center gap-2 text-danger">
<AlertTriangle className="w-4 h-4" />
<strong>שירות הצ&apos;אט אינו זמין</strong>
</div>
<p className="text-[0.78rem] text-danger">
לא ניתן להגיע ל-legal-chat-service בכתובת
<code className="px-1 mx-1 bg-rule-soft rounded">{health.url}</code>.
{health.error && (<> פירוט: <code className="px-1 bg-rule-soft rounded">{health.error}</code></>)}
</p>
<p className="text-[0.72rem] text-ink-muted">
על המכונה המקומית הפעל:&nbsp;
<code className="px-1 bg-rule-soft rounded">
pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs
</code>
</p>
</CardContent>
</Card>
);
}