Files
Socrates/apps/web/lib/llm/gateway.ts
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

174 lines
6.4 KiB
TypeScript

// 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;
}