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)
88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
// Socrates dock API.
|
|
//
|
|
// GET → returns { threadId, messages: [{ role, text, options? }] } for the
|
|
// active thread on this project.
|
|
// POST → body { text }. Sends a user turn (or "open" if text is empty),
|
|
// invokes the LLM, persists user + assistant messages, returns the
|
|
// new assistant turn.
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { getActiveThread, loadProject } from "../../../../../lib/db/repo";
|
|
import { sendUserTurn } from "../../../../../lib/llm/socrates";
|
|
import { validate } from "../../../../../lib/sysml/validate";
|
|
|
|
export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
|
const { projectId } = await params;
|
|
// Touch loadProject so a missing project gets seeded (also creates the thread).
|
|
await loadProject(projectId);
|
|
const thread = await getActiveThread(projectId);
|
|
if (!thread) return NextResponse.json({ error: "no active thread" }, { status: 404 });
|
|
|
|
return NextResponse.json({
|
|
threadId: thread.id,
|
|
title: thread.title,
|
|
messages: thread.messages.map(m => {
|
|
try {
|
|
const parsed = JSON.parse(m.content);
|
|
return {
|
|
id: m.id,
|
|
role: m.role as "user" | "assistant",
|
|
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
options: Array.isArray(parsed.options) ? parsed.options : undefined,
|
|
createdAt: m.createdAt.toISOString(),
|
|
};
|
|
} catch {
|
|
return { id: m.id, role: m.role as "user" | "assistant", text: m.content, createdAt: m.createdAt.toISOString() };
|
|
}
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function POST(req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
|
const { projectId } = await params;
|
|
let body: { text?: string };
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
|
}
|
|
|
|
// Make sure the project + thread exist
|
|
const { model } = await loadProject(projectId);
|
|
const issues = validate(model);
|
|
let thread = await getActiveThread(projectId);
|
|
if (!thread) {
|
|
return NextResponse.json({ error: "no active thread to post to" }, { status: 404 });
|
|
}
|
|
|
|
try {
|
|
const result = await sendUserTurn({
|
|
threadId: thread.id,
|
|
model,
|
|
issues,
|
|
userText: body.text ?? "",
|
|
});
|
|
return NextResponse.json({
|
|
threadId: thread.id,
|
|
assistant: {
|
|
id: result.assistant.id,
|
|
text: result.assistant.turn.text,
|
|
options: result.assistant.turn.options,
|
|
createdAt: result.assistant.createdAt.toISOString(),
|
|
},
|
|
user: result.user.id !== "(skipped)" ? {
|
|
id: result.user.id,
|
|
createdAt: result.user.createdAt.toISOString(),
|
|
} : null,
|
|
meta: {
|
|
provider: result.assistant.provider,
|
|
model: result.assistant.model,
|
|
inputTokens: result.assistant.inputTokens,
|
|
outputTokens: result.assistant.outputTokens,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
|
}
|
|
}
|