Files
Socrates/apps/web/components/socrates/SocratesDock.tsx
dtoro 78faca9968 MVP M6: live Socrates dock — LLM gateway + persisted thread + reply loop
The dock now talks to a real LLM. On first load it asks Socrates for an
opening turn that's grounded in the project's actual SysML model + active
validation issues. User replies and option-picks send turns through the
same channel. Thread + every message persist in SQLite so refresh keeps
the conversation.

apps/web/lib/llm
- gateway.ts: LLMGateway interface (chat + ChatOptions). Two adapters:
    - lmstudio: OpenAI SDK against LMSTUDIO_BASE_URL (default
      http://localhost:1234/v1)
    - anthropic: @anthropic-ai/sdk against claude-sonnet-4-6 (set
      ANTHROPIC_API_KEY when LLM_PROVIDER=anthropic)
  Provider chosen via LLM_PROVIDER env (default: lmstudio).
- chatJSON(): JSON-mode helper with parse-error repair-retry — the same
  defensive pattern proven against gemma-4-e4b in Phase 0.
- prompts.ts: server-only loader that caches .md prompts.
- prompts/socrates/character.md + review.md: ported verbatim from
  phase-0/src/prompts/ (Phase 0 corpus validated these 10/10).
- socrates.ts: sendUserTurn() — builds the system prompt (character +
  review + project context with trimmed model + active issues), runs
  chatJSON against the gateway, persists user + assistant turns,
  returns the structured turn. SocratesTurn schema is { text, options? }
  with up to 3 numbered options matching the prototype.

apps/web/prisma
- SocratesThread + SocratesMessage tables. Auto-create one open thread
  per project on first load.

apps/web/app/api/projects/[projectId]/socrates
- GET: returns active thread + parsed messages.
- POST: body { text }. Empty text triggers an opening turn. Persists user
  + assistant turns, returns assistant turn + provider metadata.

apps/web/components/socrates/SocratesDock.tsx
- Replaces the static thread prop with a projectId. Loads from API on
  mount, auto-triggers an opening turn if the thread is empty, sends
  user replies via POST. Numbered options click-to-pick or 1–3 keyboard
  shortcut (skipped when focus is in an input). Status line shows the
  active provider + model. Optimistic-local: user message appears
  instantly, "thinking…" placeholder shows while the LLM works, errors
  surface inline.

apps/web/.env.example + .env.local
- LLM_PROVIDER, LMSTUDIO_BASE_URL/MODEL/API_KEY, ANTHROPIC_API_KEY/MODEL.
- .env.local committed only with the local default (no real secrets);
  user supplies their own per-machine.

What's not yet here (next iterations):
- Streaming responses (currently waits for full response, ~5-15s)
- Multi-thread switcher (one auto-thread per project)
- Socrates-proposes-ops flow (M7)
2026-04-29 07:50:34 +02:00

268 lines
9.5 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.
// Active-thread dock with Sigil header, conversation bubbles, numbered options.
//
// M6: live thread loaded from /api/projects/[id]/socrates. The reply input
// POSTs each user turn and renders Socrates' response when it arrives.
// Numbered options pre-fill the input when clicked / pressed (13).
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Sigil } from "./Sigil";
export type SocratesPresence = "subtle" | "default" | "prominent";
export type Density = "comfortable" | "compact";
export interface DockMessage {
id: string;
role: "user" | "assistant";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
pending?: boolean;
}
interface SocratesDockProps {
projectId: string;
presence: SocratesPresence;
density: Density;
}
interface ApiMessage {
id: string;
role: "user" | "assistant";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
createdAt: string;
}
export function SocratesDock({ projectId, presence }: SocratesDockProps) {
const [messages, setMessages] = useState<DockMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loaded, setLoaded] = useState(false);
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
const threadEndRef = useRef<HTMLDivElement | null>(null);
// Load thread on mount and trigger an opening turn if the thread is empty.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`);
if (!res.ok) throw new Error(`load thread: ${res.status}`);
const data = (await res.json()) as { messages?: ApiMessage[] };
if (cancelled) return;
const initial: DockMessage[] = (data.messages ?? []).map(m => ({
id: m.id,
role: m.role,
text: m.text,
options: m.options,
}));
setMessages(initial);
setLoaded(true);
if (initial.length === 0 && !cancelled) {
// Trigger Socrates' opening turn
await sendImpl("", true);
}
} catch {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Auto-scroll on new turns
useEffect(() => {
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const sendImpl = useCallback(
async (text: string, isOpening = false) => {
setSending(true);
try {
// Add the user bubble locally (optimistic) — unless this is the opening
if (!isOpening) {
setMessages(curr => [
...curr,
{ id: `tmp-u-${Date.now()}`, role: "user", text },
]);
}
// Show a pending Socrates bubble
const pendingId = `tmp-a-${Date.now()}`;
setMessages(curr => [
...curr,
{ id: pendingId, role: "assistant", text: "thinking…", pending: true },
]);
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = await res.json();
const a = data.assistant as { id: string; text: string; options?: Array<{ n: number; label: string; sub?: string }> };
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
// Replace the pending bubble with the real one
setMessages(curr => curr.map(m =>
m.id === pendingId
? { id: a.id, role: "assistant", text: a.text, options: a.options }
: m
));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setMessages(curr => curr.map(m =>
m.pending ? { ...m, text: `${msg}`, pending: false } : m
));
} finally {
setSending(false);
}
},
[projectId]
);
const onSubmit = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput("");
await sendImpl(text);
}, [input, sending, sendImpl]);
const pickOption = useCallback(async (option: { n: number; label: string; sub?: string }) => {
if (sending) return;
const text = `[${option.n}] ${option.label}${option.sub ? `${option.sub}` : ""}`;
await sendImpl(text);
}, [sending, sendImpl]);
// Number-key shortcuts on the most recent assistant turn with options
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Don't hijack number keys when typing in any input/contenteditable
const target = e.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (sending) return;
const last = [...messages].reverse().find(m => m.role === "assistant" && m.options?.length);
if (!last?.options) return;
const n = parseInt(e.key, 10);
if (Number.isNaN(n) || n < 1 || n > last.options.length) return;
e.preventDefault();
const opt = last.options.find(o => o.n === n);
if (opt) void pickOption(opt);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [messages, pickOption, sending]);
if (presence === "subtle") {
return (
<div className="dock dock-subtle" title={meta.model ? `Σ via ${meta.model}` : "Σ Socrates"}>
<Sigil size={36} />
{messages.filter(m => m.role === "assistant").length > 0 && (
<div className="dock-subtle-count">{messages.filter(m => m.role === "assistant").length}</div>
)}
</div>
);
}
return (
<aside className={`dock ${presence === "prominent" ? "dock-prominent" : "dock-default"}`}>
<header className="dock-header">
<Sigil size={28} />
<div className="dock-header-text">
<div className="dock-name">Socrates</div>
<div className="dock-status">
<span className="dock-dot" />
{meta.provider ? `${meta.provider}${meta.model ? ` · ${meta.model.split("/").pop()}` : ""}` : "ready"}
</div>
</div>
<button className="dock-header-action" title="New thread (coming soon)" type="button" disabled>
+
</button>
</header>
<section className="dock-thread-wrap">
<div className="dock-section-label dock-section-label-inline">Active thread</div>
<div className="dock-thread">
{!loaded && (
<div className="bubble bubble-assistant">
<span className="bubble-sigil">Σ</span>
<span className="bubble-body">
<span className="bubble-text" style={{ opacity: 0.6 }}>loading</span>
</span>
</div>
)}
{messages.map(m => (
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
{m.options && m.options.length > 0 && (
<div className="bubble-options">
{m.options.map(o => (
<button
key={o.n}
className="bubble-option"
type="button"
onClick={() => pickOption(o)}
disabled={sending}
>
<span className="bubble-option-num">{o.n}</span>
<span className="bubble-option-text">
<span className="bubble-option-label">{o.label}</span>
{o.sub && <span className="bubble-option-sub">{o.sub}</span>}
</span>
<span className="bubble-option-key">{o.n}</span>
</button>
))}
<div className="bubble-options-hint">
Press <kbd>1</kbd><kbd>{m.options.length}</kbd>, or type a reply
</div>
</div>
)}
</span>
</div>
))}
<div ref={threadEndRef} />
</div>
</section>
<footer className="dock-input-wrap">
<form
className="dock-input"
onSubmit={e => {
e.preventDefault();
void onSubmit();
}}
>
<span className="dock-input-prompt"></span>
<input
className="dock-input-field"
placeholder={sending ? "Socrates is thinking…" : "Reply to Socrates…"}
value={input}
onChange={e => setInput(e.target.value)}
disabled={sending}
autoFocus
/>
<button
type="submit"
className="dock-input-send"
disabled={sending || !input.trim()}
title="Send (⌘↵)"
>
</button>
</form>
</footer>
</aside>
);
}