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)
174 lines
6.4 KiB
TypeScript
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;
|
|
}
|