The dock now has a "propose" button that asks Socrates to suggest one
high-value structural change. He returns a structured payload (reasoning
+ ops + impactSummary) which renders as a ProposalCard inline in the
thread. Accept routes the ops through the same useApply() pipeline as
user-originated edits (optimistic local + persisted POST + server
reconciliation).
apps/web/lib/llm/prompts/socrates/propose.md
- Promoted verbatim from phase-0/src/prompts/.
apps/web/lib/llm/proposeChange.ts
- Server-side proposeChange() — calls the LLM with character + propose
prompts + trimmed model + active issues. JSON Schema uses oneOf per
op kind (Phase 0 lesson: small models need this to produce real op
shapes instead of cramming everything into the kind name).
- normalizeOps() converts the LLM-emitted op shapes into canonical
ModelOp[] (fills in property ids/multiplicity defaults, expands
satisfiedBy[] into RequirementRelation[], splits PropertyType union
per kind).
apps/web/lib/sysml/impact.ts
- Pure pre-apply impact analysis. Runs the ops through the same
applyOps() reducer locally, then diffs:
- structural: added / removed / changed elements (per kind)
- validation: issues created vs resolved (by canonical issue key)
- dep-graph blast radius (closure of touched element ids on the
post-apply graph)
Headline stats summarized as deltas (+1 block, −1 assoc, etc.) for
the proposal card.
apps/web/app/api/projects/[projectId]/socrates/propose
- POST: returns { reasoning, ops, impactSummary, meta }. Pure read of
the model — does not apply anything; client must POST /apply with the
same ops to commit.
apps/web/components/socrates/ProposalCard.tsx
- In-dock card: PROPOSAL tag + delta stats / reasoning / collapsible
ops list / Impact section (added/removed/changed) / Validation diff
(resolves ✓ / creates ⚠) / Accept + Reject. Disabled when impact
analysis flagged the apply as illegal.
apps/web/components/socrates/SocratesDock.tsx
- New "propose" button between the input field and send. Renders
ProposalCard for assistant turns of role "proposal". Accept calls
useApply(); on success the card collapses to a "✓ Applied" system
bubble. On apply failure the dock shows the structured error
messages.
- New "system" bubble role for apply confirmations + dismissals.
apps/web/components/diagram-canvas/DiagramCanvas.tsx
- Bug fix: new blocks/constraints arriving via the model→RF sync (e.g.
from accepted proposals) are now positioned to the right of the
existing layout instead of stacking at (0, 0) offscreen.
apps/web/lib/sync/ModelStore.tsx
- Bug fix / observability: background-POST failures and version
mismatches now log to the console with structured context instead of
being silently swallowed. The server's authoritative state still
replaces the optimistic local state on response, but the user can now
see why their accept appeared to do nothing (typically: page tab
was at version N but server had advanced to N+1).
135 lines
4.9 KiB
TypeScript
135 lines
4.9 KiB
TypeScript
// React provider exposing the canonical SysMLModel + applyOps.
|
|
// Used by both canvases (and the rail) so they stay in sync.
|
|
//
|
|
// M5.9: optimistic-local + server-persistence pattern.
|
|
// - Local state updates immediately on apply() so the UI feels instant.
|
|
// - The same ops are POSTed to /api/projects/[projectId]/apply in the
|
|
// background. On success, the server-resolved model + version replaces
|
|
// local state (idempotent if no changes; reconciles tempId rewrites).
|
|
// - On version-mismatch, we fall back to the server's model + version.
|
|
|
|
"use client";
|
|
|
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
|
import { applyOps, type ApplyResult as LocalApplyResult } from "./applyOps";
|
|
import { validate, type ValidationIssue } from "../sysml/validate";
|
|
import type { SysMLModel } from "../sysml/model";
|
|
import type { ModelOp } from "./ops";
|
|
|
|
export interface ModelStoreValue {
|
|
model: SysMLModel;
|
|
version: number;
|
|
apply: (ops: ModelOp[]) => LocalApplyResult;
|
|
issues: ValidationIssue[];
|
|
issuesByElement: Map<string, ValidationIssue[]>;
|
|
}
|
|
|
|
const ModelStoreContext = createContext<ModelStoreValue | null>(null);
|
|
|
|
interface ModelStoreProviderProps {
|
|
initialModel: SysMLModel;
|
|
initialVersion: number;
|
|
/** When set, apply() POSTs ops to /api/projects/[projectId]/apply for persistence. */
|
|
projectId?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function ModelStoreProvider({ initialModel, initialVersion, projectId, children }: ModelStoreProviderProps) {
|
|
const [model, setModel] = useState<SysMLModel>(initialModel);
|
|
const [version, setVersion] = useState<number>(initialVersion);
|
|
// Latest version we've seen from the server. Used as expectedVersion on POST.
|
|
const versionRef = useRef<number>(initialVersion);
|
|
|
|
const apply = useCallback((ops: ModelOp[]): LocalApplyResult => {
|
|
// 1. Optimistic-local: apply immediately to component state.
|
|
let result: LocalApplyResult = { model, idMapping: {}, errors: [], applied: false };
|
|
setModel(current => {
|
|
result = applyOps(current, ops);
|
|
return result.applied ? result.model : current;
|
|
});
|
|
|
|
// 2. Background: POST to the server (server is authoritative).
|
|
if (projectId && result.applied) {
|
|
const expectedVersion = versionRef.current;
|
|
void fetch(`/api/projects/${encodeURIComponent(projectId)}/apply`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ops, expectedVersion }),
|
|
})
|
|
.then(async res => {
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
console.error("[ModelStore] /apply HTTP", res.status, body.slice(0, 200));
|
|
return;
|
|
}
|
|
const data = (await res.json()) as {
|
|
applied: boolean;
|
|
model: SysMLModel;
|
|
version: number;
|
|
idMapping: Record<string, string>;
|
|
errors?: Array<{ opIndex: number; code: string; message: string }>;
|
|
};
|
|
if (!data.applied) {
|
|
console.warn(
|
|
"[ModelStore] server rejected ops; reverting to server state.",
|
|
data.errors,
|
|
"expected", expectedVersion, "got", data.version
|
|
);
|
|
}
|
|
versionRef.current = data.version;
|
|
setVersion(data.version);
|
|
setModel(data.model);
|
|
})
|
|
.catch(err => {
|
|
console.error("[ModelStore] /apply network error:", err);
|
|
});
|
|
}
|
|
return result;
|
|
}, [model, projectId]);
|
|
|
|
const { issues, issuesByElement } = useMemo(() => {
|
|
const issues = validate(model);
|
|
const issuesByElement = new Map<string, ValidationIssue[]>();
|
|
for (const i of issues) {
|
|
const k = anchorKey(i.anchor);
|
|
if (!k) continue;
|
|
const prev = issuesByElement.get(k) ?? [];
|
|
prev.push(i);
|
|
issuesByElement.set(k, prev);
|
|
}
|
|
return { issues, issuesByElement };
|
|
}, [model]);
|
|
|
|
const value: ModelStoreValue = useMemo(
|
|
() => ({ model, version, apply, issues, issuesByElement }),
|
|
[model, version, apply, issues, issuesByElement]
|
|
);
|
|
|
|
return <ModelStoreContext.Provider value={value}>{children}</ModelStoreContext.Provider>;
|
|
}
|
|
|
|
export function useModelStore(): ModelStoreValue {
|
|
const ctx = useContext(ModelStoreContext);
|
|
if (!ctx) throw new Error("useModelStore must be used inside ModelStoreProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function useModel(): SysMLModel {
|
|
return useModelStore().model;
|
|
}
|
|
|
|
export function useApply(): (ops: ModelOp[]) => LocalApplyResult {
|
|
return useModelStore().apply;
|
|
}
|
|
|
|
function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
|
|
switch (anchor.kind) {
|
|
case "block": return anchor.id;
|
|
case "association": return `assoc:${anchor.id}`;
|
|
case "constraint": return `constraint:${anchor.id}`;
|
|
case "requirement": return `req:${anchor.id}`;
|
|
case "property": return anchor.blockId;
|
|
case "model": return null;
|
|
}
|
|
}
|