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)
This commit is contained in:
@@ -75,6 +75,7 @@ function EditorShellInner({
|
||||
>
|
||||
<ShellBody
|
||||
data={data}
|
||||
projectId={projectId}
|
||||
density={density}
|
||||
markupStyle={markupStyle}
|
||||
diagramStyle={diagramStyle}
|
||||
@@ -87,6 +88,7 @@ function EditorShellInner({
|
||||
|
||||
interface ShellBodyProps {
|
||||
data: FixtureData;
|
||||
projectId?: string;
|
||||
density: Density;
|
||||
markupStyle: MarkupStyle;
|
||||
diagramStyle: DiagramVariant;
|
||||
@@ -94,7 +96,7 @@ interface ShellBodyProps {
|
||||
breaks: BreakName[];
|
||||
}
|
||||
|
||||
function ShellBody({ data, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) {
|
||||
function ShellBody({ data, projectId, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const { model, issues, issuesByElement } = useModelStore();
|
||||
|
||||
@@ -106,7 +108,7 @@ function ShellBody({ data, density, markupStyle, diagramStyle, presence, breaks
|
||||
<TopBar data={data} />
|
||||
|
||||
<div className="shell-body">
|
||||
<SocratesDock thread={data.socratesThread} presence={presence} density={density} />
|
||||
<SocratesDock projectId={projectId ?? "aristotle"} presence={presence} density={density} />
|
||||
<LeftRail
|
||||
data={data}
|
||||
focusBlockId={focusBlockId}
|
||||
|
||||
@@ -1,25 +1,172 @@
|
||||
// Active-thread dock with Sigil header, conversation bubbles, numbered options.
|
||||
// Ported from docs/design-source/socrata/project/socrates.jsx (SocratesDock).
|
||||
// "subtle" presence renders a floating sigil + count badge.
|
||||
//
|
||||
// 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 (1–3).
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sigil } from "./Sigil";
|
||||
import type { FixtureSocratesTurn } from "../../lib/fixtures/aristotle";
|
||||
|
||||
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 {
|
||||
thread: FixtureSocratesTurn[];
|
||||
projectId: string;
|
||||
presence: SocratesPresence;
|
||||
density: Density;
|
||||
}
|
||||
|
||||
export function SocratesDock({ thread, presence }: SocratesDockProps) {
|
||||
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">
|
||||
<div className="dock dock-subtle" title={meta.model ? `Σ via ${meta.model}` : "Σ Socrates"}>
|
||||
<Sigil size={36} />
|
||||
<div className="dock-subtle-count">3</div>
|
||||
{messages.filter(m => m.role === "assistant").length > 0 && (
|
||||
<div className="dock-subtle-count">{messages.filter(m => m.role === "assistant").length}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,30 +178,47 @@ export function SocratesDock({ thread, presence }: SocratesDockProps) {
|
||||
<div className="dock-header-text">
|
||||
<div className="dock-name">Socrates</div>
|
||||
<div className="dock-status">
|
||||
<span className="dock-dot" /> 2 open threads
|
||||
<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" type="button">
|
||||
<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 · Aristotle</div>
|
||||
<div className="dock-section-label dock-section-label-inline">Active thread</div>
|
||||
<div className="dock-thread">
|
||||
{thread.map((m, i) => (
|
||||
<div key={i} className={`bubble bubble-${m.who}`}>
|
||||
{m.who === "socrates" && <span className="bubble-sigil">Σ</span>}
|
||||
{!loaded && (
|
||||
<div className="bubble bubble-assistant">
|
||||
<span className="bubble-sigil">Σ</span>
|
||||
<span className="bubble-body">
|
||||
<span className="bubble-text">{m.text}</span>
|
||||
{m.options && (
|
||||
<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">
|
||||
<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>
|
||||
<span className="bubble-option-sub">{o.sub}</span>
|
||||
{o.sub && <span className="bubble-option-sub">{o.sub}</span>}
|
||||
</span>
|
||||
<span className="bubble-option-key">{o.n}</span>
|
||||
</button>
|
||||
@@ -67,15 +231,36 @@ export function SocratesDock({ thread, presence }: SocratesDockProps) {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={threadEndRef} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="dock-input-wrap">
|
||||
<div className="dock-input">
|
||||
<form
|
||||
className="dock-input"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<span className="dock-input-prompt">›</span>
|
||||
<span className="dock-input-placeholder">Reply to Socrates…</span>
|
||||
<span className="dock-input-shortcut">⌘↵</span>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user