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)
171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
// Project + model persistence layer.
|
|
//
|
|
// Stores the full SysMLModel as a JSON snapshot per version, plus a changelog
|
|
// of applied ops. Reads return the latest snapshot; writes apply ops
|
|
// server-side and write a new snapshot + changelog row atomically.
|
|
|
|
import { prisma } from "./client";
|
|
import { applyOps } from "../sync/applyOps";
|
|
import { fromFixture } from "../sysml/fromFixture";
|
|
import { aristotleFixture } from "../fixtures/aristotle";
|
|
import type { SysMLModel } from "../sysml/model";
|
|
import type { ModelOp } from "../sync/ops";
|
|
|
|
export interface LoadResult {
|
|
model: SysMLModel;
|
|
version: number;
|
|
}
|
|
|
|
export async function loadProject(projectId: string): Promise<LoadResult> {
|
|
// Auto-seed: if the project doesn't exist (or has no snapshot), bootstrap
|
|
// it from the Aristotle fixture so the editor route always has data.
|
|
const seeded = await ensureSeeded(projectId);
|
|
const snapshot = await prisma.modelSnapshot.findFirst({
|
|
where: { projectId: seeded.projectId },
|
|
orderBy: { version: "desc" },
|
|
});
|
|
if (!snapshot) {
|
|
throw new Error(`Project ${projectId} has no snapshots after seed — should not happen`);
|
|
}
|
|
return {
|
|
model: JSON.parse(snapshot.json) as SysMLModel,
|
|
version: snapshot.version,
|
|
};
|
|
}
|
|
|
|
export interface ApplyResult {
|
|
applied: boolean;
|
|
model: SysMLModel;
|
|
version: number;
|
|
idMapping: Record<string, string>;
|
|
errors: Array<{ opIndex: number; code: string; message: string }>;
|
|
}
|
|
|
|
export async function applyOpsToProject(
|
|
projectId: string,
|
|
ops: ModelOp[],
|
|
expectedVersion: number | undefined,
|
|
reason?: string
|
|
): Promise<ApplyResult> {
|
|
return prisma.$transaction(async tx => {
|
|
const latest = await tx.modelSnapshot.findFirst({
|
|
where: { projectId },
|
|
orderBy: { version: "desc" },
|
|
});
|
|
if (!latest) throw new Error(`No snapshot for project ${projectId}`);
|
|
|
|
if (expectedVersion !== undefined && expectedVersion !== latest.version) {
|
|
// Optimistic-concurrency miss; caller must resync.
|
|
return {
|
|
applied: false,
|
|
model: JSON.parse(latest.json) as SysMLModel,
|
|
version: latest.version,
|
|
idMapping: {},
|
|
errors: [{ opIndex: -1, code: "VERSION_MISMATCH", message: `Expected version ${expectedVersion}, server is ${latest.version}` }],
|
|
};
|
|
}
|
|
|
|
const currentModel = JSON.parse(latest.json) as SysMLModel;
|
|
const result = applyOps(currentModel, ops);
|
|
if (!result.applied) {
|
|
return {
|
|
applied: false,
|
|
model: currentModel,
|
|
version: latest.version,
|
|
idMapping: {},
|
|
errors: result.errors,
|
|
};
|
|
}
|
|
|
|
const newVersion = latest.version + 1;
|
|
await tx.modelSnapshot.create({
|
|
data: {
|
|
projectId,
|
|
version: newVersion,
|
|
json: JSON.stringify(result.model),
|
|
},
|
|
});
|
|
await tx.changelogEntry.create({
|
|
data: {
|
|
projectId,
|
|
version: newVersion,
|
|
ops: JSON.stringify(ops),
|
|
reason,
|
|
},
|
|
});
|
|
|
|
return {
|
|
applied: true,
|
|
model: result.model,
|
|
version: newVersion,
|
|
idMapping: result.idMapping,
|
|
errors: [],
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── Seeding ─────────────────────────────────────────────────────────────
|
|
|
|
async function ensureSeeded(projectId: string): Promise<{ projectId: string }> {
|
|
const existing = await prisma.project.findUnique({ where: { id: projectId } });
|
|
if (existing) {
|
|
// Make sure there's at least one snapshot
|
|
const snapCount = await prisma.modelSnapshot.count({ where: { projectId } });
|
|
if (snapCount > 0) return { projectId };
|
|
}
|
|
|
|
// Bootstrap with the Aristotle fixture
|
|
const seedModel = fromFixture(aristotleFixture);
|
|
await prisma.project.upsert({
|
|
where: { id: projectId },
|
|
update: {},
|
|
create: {
|
|
id: projectId,
|
|
name: aristotleFixture.project.name,
|
|
scope: aristotleFixture.project.scope,
|
|
tagline: aristotleFixture.project.tagline,
|
|
},
|
|
});
|
|
await prisma.modelSnapshot.create({
|
|
data: {
|
|
projectId,
|
|
version: 1,
|
|
json: JSON.stringify(seedModel),
|
|
},
|
|
});
|
|
|
|
// Auto-create an opening Socrates thread so the dock has somewhere to talk.
|
|
// Socrates' opening turn is generated lazily on first GET (so we don't
|
|
// pin a model selection to seed time).
|
|
await prisma.socratesThread.create({
|
|
data: {
|
|
projectId,
|
|
title: "Active thread · " + aristotleFixture.project.name,
|
|
status: "open",
|
|
},
|
|
});
|
|
|
|
return { projectId };
|
|
}
|
|
|
|
// ─── Socrates threads ────────────────────────────────────────────────────
|
|
|
|
export async function getActiveThread(projectId: string): Promise<{ id: string; title: string | null; messages: Array<{ id: string; role: string; content: string; createdAt: Date }> } | null> {
|
|
const thread = await prisma.socratesThread.findFirst({
|
|
where: { projectId, status: "open" },
|
|
orderBy: { updatedAt: "desc" },
|
|
include: { messages: { orderBy: { createdAt: "asc" } } },
|
|
});
|
|
if (!thread) return null;
|
|
return {
|
|
id: thread.id,
|
|
title: thread.title,
|
|
messages: thread.messages.map(m => ({
|
|
id: m.id,
|
|
role: m.role,
|
|
content: m.content,
|
|
createdAt: m.createdAt,
|
|
})),
|
|
};
|
|
}
|