feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
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>
This commit is contained in:
434
web-ui/src/components/training/chat-panel.tsx
Normal file
434
web-ui/src/components/training/chat-panel.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
"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">
|
||||
התחל בשאלה — למשל: "מה מאפיין את הפתיחות של דפנה בעררי 1xxx?"
|
||||
</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>שירות הצ'אט אינו זמין</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">
|
||||
על המכונה המקומית הפעל:
|
||||
<code className="px-1 bg-rule-soft rounded">
|
||||
pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs
|
||||
</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user