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:
173
apps/web/lib/llm/gateway.ts
Normal file
173
apps/web/lib/llm/gateway.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// LLM gateway — single interface used by Socrates' modes.
|
||||
//
|
||||
// Two adapters in M6:
|
||||
// - lmstudio → local LM Studio at LMSTUDIO_BASE_URL (OpenAI-compatible)
|
||||
// - anthropic → hosted Claude via @anthropic-ai/sdk
|
||||
//
|
||||
// Provider is chosen via LLM_PROVIDER env var (defaults to "lmstudio" so a
|
||||
// fresh checkout works against the local model the user already has).
|
||||
|
||||
export type Role = "system" | "user" | "assistant";
|
||||
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
/** When set, the response is constrained to JSON conforming to this schema. */
|
||||
jsonSchema?: { name: string; schema: Record<string, unknown> };
|
||||
/** Force JSON-object mode (looser than schema) — for adapters that don't support strict schemas. */
|
||||
jsonObjectMode?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
text: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface LLMGateway {
|
||||
/** Provider name — for logging / model attribution in DB. */
|
||||
readonly provider: "lmstudio" | "anthropic";
|
||||
/** Model id reported by the provider for the active model. */
|
||||
readonly model: string;
|
||||
chat(messages: Message[], opts?: ChatOptions): Promise<ChatResult>;
|
||||
}
|
||||
|
||||
// ─── Provider selection ──────────────────────────────────────────────────
|
||||
|
||||
export function defaultGateway(): LLMGateway {
|
||||
const provider = (process.env.LLM_PROVIDER ?? "lmstudio").toLowerCase();
|
||||
if (provider === "anthropic") return makeAnthropicGateway();
|
||||
return makeLMStudioGateway();
|
||||
}
|
||||
|
||||
// ─── LM Studio (OpenAI-compatible) ───────────────────────────────────────
|
||||
|
||||
function makeLMStudioGateway(): LLMGateway {
|
||||
// Lazy-import OpenAI SDK so we don't pay the cost when we're using Anthropic.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const OpenAI = require("openai").default ?? require("openai");
|
||||
const baseURL = process.env.LMSTUDIO_BASE_URL ?? "http://localhost:1234/v1";
|
||||
const apiKey = process.env.LMSTUDIO_API_KEY ?? "lm-studio";
|
||||
const model = process.env.LMSTUDIO_MODEL ?? "local-model";
|
||||
|
||||
const client = new OpenAI({ baseURL, apiKey, timeout: 120_000, maxRetries: 0 });
|
||||
|
||||
return {
|
||||
provider: "lmstudio",
|
||||
model,
|
||||
async chat(messages, opts = {}) {
|
||||
const responseFormat = buildOpenAIResponseFormat(opts);
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: messages as { role: Role; content: string }[],
|
||||
temperature: opts.temperature ?? 0.4,
|
||||
max_tokens: opts.maxTokens ?? 1024,
|
||||
...(responseFormat ? { response_format: responseFormat } : {}),
|
||||
});
|
||||
const choice = completion.choices[0];
|
||||
return {
|
||||
text: choice?.message?.content ?? "",
|
||||
inputTokens: completion.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: completion.usage?.completion_tokens ?? 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenAIResponseFormat(opts: ChatOptions): unknown {
|
||||
if (opts.jsonSchema) {
|
||||
return {
|
||||
type: "json_schema",
|
||||
json_schema: { name: opts.jsonSchema.name, schema: opts.jsonSchema.schema, strict: false },
|
||||
};
|
||||
}
|
||||
if (opts.jsonObjectMode) return { type: "json_object" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Anthropic ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAnthropicGateway(): LLMGateway {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Anthropic = require("@anthropic-ai/sdk").default ?? require("@anthropic-ai/sdk");
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic");
|
||||
|
||||
const model = process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6";
|
||||
const client = new Anthropic({ apiKey });
|
||||
|
||||
return {
|
||||
provider: "anthropic",
|
||||
model,
|
||||
async chat(messages, opts = {}) {
|
||||
const systemBlocks = messages.filter(m => m.role === "system").map(m => m.content);
|
||||
const others = messages.filter(m => m.role !== "system");
|
||||
|
||||
const result = await client.messages.create({
|
||||
model,
|
||||
max_tokens: opts.maxTokens ?? 1024,
|
||||
temperature: opts.temperature ?? 0.4,
|
||||
system: systemBlocks.length > 0 ? systemBlocks.join("\n\n---\n\n") : undefined,
|
||||
messages: others.map(m => ({ role: m.role as "user" | "assistant", content: m.content })),
|
||||
});
|
||||
|
||||
// Concatenate text blocks
|
||||
const text = (result.content as Array<{ type: string; text?: string }>)
|
||||
.filter(b => b.type === "text" && typeof b.text === "string")
|
||||
.map(b => b.text!)
|
||||
.join("");
|
||||
|
||||
return {
|
||||
text,
|
||||
inputTokens: result.usage?.input_tokens ?? 0,
|
||||
outputTokens: result.usage?.output_tokens ?? 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── JSON-mode helper with repair-retry (Phase 0 lessons) ────────────────
|
||||
|
||||
export async function chatJSON<T = unknown>(
|
||||
gateway: LLMGateway,
|
||||
messages: Message[],
|
||||
opts: ChatOptions & { maxRepairs?: number } = {}
|
||||
): Promise<{ value: T; result: ChatResult }> {
|
||||
const maxRepairs = opts.maxRepairs ?? 2;
|
||||
const history: Message[] = [...messages];
|
||||
|
||||
for (let attempt = 0; attempt <= maxRepairs; attempt++) {
|
||||
const result = await gateway.chat(history, opts);
|
||||
const cleaned = stripCodeFences(result.text);
|
||||
try {
|
||||
const value = JSON.parse(cleaned) as T;
|
||||
return { value, result };
|
||||
} catch (parseErr) {
|
||||
if (attempt === maxRepairs) {
|
||||
throw new Error(
|
||||
`JSON parse failed after ${maxRepairs + 1} attempts. ` +
|
||||
`Last error: ${(parseErr as Error).message}\n` +
|
||||
`Last response (first 600 chars): ${cleaned.slice(0, 600)}`
|
||||
);
|
||||
}
|
||||
history.push({ role: "assistant", content: result.text });
|
||||
history.push({
|
||||
role: "user",
|
||||
content: `That response was not valid JSON. Error: ${(parseErr as Error).message}\nReturn ONLY a valid JSON object, no prose, no code fences.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new Error("chatJSON exhausted repairs");
|
||||
}
|
||||
|
||||
function stripCodeFences(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
|
||||
if (fenceMatch) return fenceMatch[1].trim();
|
||||
return trimmed;
|
||||
}
|
||||
Reference in New Issue
Block a user