diff --git a/apps/web/app/api/projects/[projectId]/socrates/propose/route.ts b/apps/web/app/api/projects/[projectId]/socrates/propose/route.ts new file mode 100644 index 0000000..37e9909 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/socrates/propose/route.ts @@ -0,0 +1,51 @@ +// POST /api/projects/[projectId]/socrates/propose +// +// Body: { userPrompt?: string } +// Returns: { reasoning, ops, impactSummary, meta } +// +// Does NOT apply the ops — that's a follow-up call to /apply once the user +// approves. The proposal card UI shows reasoning + impactSummary + ops so +// the user can review before committing. + +import { NextResponse } from "next/server"; +import { loadProject } from "../../../../../../lib/db/repo"; +import { proposeChange } from "../../../../../../lib/llm/proposeChange"; +import { analyzeImpact } from "../../../../../../lib/sysml/impact"; +import { validate } from "../../../../../../lib/sysml/validate"; + +export async function POST(req: Request, { params }: { params: Promise<{ projectId: string }> }) { + const { projectId } = await params; + let body: { userPrompt?: string }; + try { + body = await req.json(); + } catch { + body = {}; + } + + const { model } = await loadProject(projectId); + const issues = validate(model); + + try { + const proposal = await proposeChange({ + model, + issues, + userPrompt: body.userPrompt, + }); + + const impact = analyzeImpact(model, proposal.ops); + + return NextResponse.json({ + reasoning: proposal.reasoning, + ops: proposal.ops, + impactSummary: impact, + meta: { + provider: proposal.provider, + model: proposal.model, + inputTokens: proposal.inputTokens, + outputTokens: proposal.outputTokens, + }, + }); + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 500 }); + } +} diff --git a/apps/web/components/diagram-canvas/DiagramCanvas.tsx b/apps/web/components/diagram-canvas/DiagramCanvas.tsx index e06e2bd..ada9e13 100644 --- a/apps/web/components/diagram-canvas/DiagramCanvas.tsx +++ b/apps/web/components/diagram-canvas/DiagramCanvas.tsx @@ -118,11 +118,14 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram const byId = new Map(currentNodes.map(n => [n.id, n])); const next: Node[] = []; - // Existing model elements first — preserve position & selection from RF state + // For new elements added by the model (e.g. accepted proposals), drop + // them at a sensible spot near the existing centroid so they're + // immediately visible — not at (0,0) offscreen. + const placeNew = makePlaceNewPosition(currentNodes); + for (const b of model.blocks) { const existing = byId.get(b.id); if (existing) { - // Update data only if it changed (cheap structural compare) const propNames = b.properties.map(p => p.name); const dataChanged = existing.data?.label !== b.label || @@ -134,8 +137,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram next.push(existing); } } else { - // New block from outside (rare in M5; mostly self-originated drops) - next.push(makeNodeForBlock(b)); + next.push({ ...makeNodeForBlock(b), position: placeNew() }); } } for (const c of model.constraints) { @@ -151,7 +153,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram next.push(existing); } } else { - next.push(makeNodeForConstraint(c)); + next.push({ ...makeNodeForConstraint(c), position: placeNew() }); } } return next; @@ -521,6 +523,28 @@ function makeEdgeForApplies(constraintId: string, targetBlockId: string): Edge { x: number; y: number } { + let baseX = 100; + let baseY = 100; + if (existing.length > 0) { + const maxX = Math.max(...existing.map(n => n.position.x)); + const minY = Math.min(...existing.map(n => n.position.y)); + baseX = maxX + 240; + baseY = minY; + } + let i = 0; + return () => { + const pos = { x: baseX, y: baseY + i * 140 }; + i++; + return pos; + }; +} + function snapshotIds(model: SysMLModel): { blocks: Set; assocs: Set; constraints: Set } { return { blocks: new Set(model.blocks.map(b => b.id)), diff --git a/apps/web/components/socrates/ProposalCard.tsx b/apps/web/components/socrates/ProposalCard.tsx new file mode 100644 index 0000000..fe48ce8 --- /dev/null +++ b/apps/web/components/socrates/ProposalCard.tsx @@ -0,0 +1,208 @@ +// In-dock card showing a Socrates-proposed change. +// +// Displays: reasoning, op summary, impact summary (added/removed/changed + +// validation diff), and Accept / Reject controls. Accept calls into the +// ModelStore which routes through the same applyOps + persistence pipeline +// as user-originated edits. + +"use client"; + +import { useState } from "react"; +import type { ImpactSummary } from "../../lib/sysml/impact"; +import type { ModelOp } from "../../lib/sync/ops"; + +export interface ProposalPayload { + reasoning: string; + ops: ModelOp[]; + impactSummary: ImpactSummary; + meta?: { provider?: string; model?: string }; +} + +interface ProposalCardProps { + proposal: ProposalPayload; + onAccept: () => Promise | void; + onReject: () => void; + pending?: boolean; +} + +export function ProposalCard({ proposal, onAccept, onReject, pending }: ProposalCardProps) { + const [busy, setBusy] = useState(false); + const { reasoning, ops, impactSummary } = proposal; + + async function accept() { + if (busy) return; + setBusy(true); + try { + await onAccept(); + } finally { + setBusy(false); + } + } + + return ( +
+
+ PROPOSAL + + {summarizeStats(impactSummary)} + +
+ +
{reasoning}
+ + {ops.length > 0 && ( +
+ Ops · {ops.length} +
    + {ops.map((op, i) => ( +
  • {summarizeOp(op)}
  • + ))} +
+
+ )} + + {(impactSummary.added.length > 0 || impactSummary.removed.length > 0 || impactSummary.changed.length > 0) && ( +
+
Impact
+
+ {impactSummary.added.length > 0 && ( +
+ + added + + {impactSummary.added.map(e => `${e.label} (${e.kind})`).join(", ")} + +
+ )} + {impactSummary.removed.length > 0 && ( +
+ − removed + + {impactSummary.removed.map(e => `${e.label} (${e.kind})`).join(", ")} + +
+ )} + {impactSummary.changed.length > 0 && ( +
+ ~ changed + + {impactSummary.changed.map(e => `${e.label} (${e.kind})`).join(", ")} + +
+ )} +
+
+ )} + + {(impactSummary.issuesCreated.length > 0 || impactSummary.issuesResolved.length > 0) && ( +
+
Validation
+ {impactSummary.issuesResolved.length > 0 && ( +
    + {impactSummary.issuesResolved.slice(0, 4).map((i, idx) => ( +
  • ✓ resolves {i.code}: {i.message}
  • + ))} +
+ )} + {impactSummary.issuesCreated.length > 0 && ( +
    + {impactSummary.issuesCreated.slice(0, 4).map((i, idx) => ( +
  • ⚠ creates {i.code}: {i.message}
  • + ))} +
+ )} +
+ )} + + {!impactSummary.ok && ( +
+
Cannot apply
+
    + {impactSummary.errors.map((e, i) => ( +
  • {e.code}: {e.message}
  • + ))} +
+
+ )} + +
+ + +
+ + {proposal.meta?.model && ( +
+ via {proposal.meta.provider} · {proposal.meta.model} +
+ )} +
+ ); +} + +function summarizeStats(impact: ImpactSummary): string { + if (!impact.ok) return "would not apply"; + const parts: string[] = []; + const { stats } = impact; + if (stats.blocksDelta) parts.push(`${signed(stats.blocksDelta)} block${Math.abs(stats.blocksDelta) === 1 ? "" : "s"}`); + if (stats.associationsDelta) parts.push(`${signed(stats.associationsDelta)} assoc`); + if (stats.constraintsDelta) parts.push(`${signed(stats.constraintsDelta)} constraint${Math.abs(stats.constraintsDelta) === 1 ? "" : "s"}`); + if (stats.requirementsDelta) parts.push(`${signed(stats.requirementsDelta)} req${Math.abs(stats.requirementsDelta) === 1 ? "" : "s"}`); + if (stats.issuesDelta) parts.push(`${signed(stats.issuesDelta)} issue${Math.abs(stats.issuesDelta) === 1 ? "" : "s"}`); + if (parts.length === 0) parts.push("structural rearrangement"); + return parts.join(" · "); +} +function signed(n: number): string { + return n > 0 ? `+${n}` : `${n}`; +} + +function summarizeOp(op: ModelOp): string { + switch (op.kind) { + case "add-block": + return `+ block "${op.block.label}" (${op.block.kind})`; + case "remove-block": + return `− block ${op.blockId}`; + case "update-block": + return `~ block ${op.blockId}`; + case "add-association": + return `+ assoc ${op.association.fromBlockId}→${op.association.toBlockId} (${op.association.kind})`; + case "remove-association": + return `− assoc ${op.associationId}`; + case "update-association": + return `~ assoc ${op.associationId}`; + case "add-constraint": + return `+ constraint "${op.constraint.label}"`; + case "remove-constraint": + return `− constraint ${op.constraintId}`; + case "update-constraint": + return `~ constraint ${op.constraintId}`; + case "add-requirement": + return `+ req ${op.requirement.tag}`; + case "remove-requirement": + return `− req ${op.requirementId}`; + case "update-requirement": + return `~ req ${op.requirementId}`; + case "add-property": + return `+ property ${op.blockId}.${op.property.name}`; + case "update-property": + return `~ property ${op.blockId}.${op.propertyId}`; + case "remove-property": + return `− property ${op.blockId}.${op.propertyId}`; + case "add-relation": + return `+ relation ${op.requirementId}.${op.relation.kind}`; + case "remove-relation": + return `− relation ${op.requirementId}[${op.relationIndex}]`; + } +} diff --git a/apps/web/components/socrates/SocratesDock.tsx b/apps/web/components/socrates/SocratesDock.tsx index e21dd37..d16a74d 100644 --- a/apps/web/components/socrates/SocratesDock.tsx +++ b/apps/web/components/socrates/SocratesDock.tsx @@ -8,16 +8,19 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Sigil } from "./Sigil"; +import { ProposalCard, type ProposalPayload } from "./ProposalCard"; +import { useApply } from "../../lib/sync/ModelStore"; export type SocratesPresence = "subtle" | "default" | "prominent"; export type Density = "comfortable" | "compact"; export interface DockMessage { id: string; - role: "user" | "assistant"; + role: "user" | "assistant" | "proposal" | "system"; text: string; options?: Array<{ n: number; label: string; sub?: string }>; pending?: boolean; + proposal?: ProposalPayload; } interface SocratesDockProps { @@ -38,9 +41,11 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); + const [proposing, setProposing] = useState(false); const [loaded, setLoaded] = useState(false); const [meta, setMeta] = useState<{ provider?: string; model?: string }>({}); const threadEndRef = useRef(null); + const apply = useApply(); // Load thread on mount and trigger an opening turn if the thread is empty. useEffect(() => { @@ -141,6 +146,76 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { await sendImpl(text); }, [sending, sendImpl]); + // Ask Socrates to propose a model change. + const requestProposal = useCallback(async () => { + if (proposing || sending) return; + setProposing(true); + const pendingId = `tmp-p-${Date.now()}`; + setMessages(curr => [ + ...curr, + { id: pendingId, role: "proposal", text: "Drafting a proposal…", pending: true }, + ]); + try { + const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates/propose`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error ?? `${res.status}`); + } + const data = (await res.json()) as ProposalPayload; + setMessages(curr => curr.map(m => + m.id === pendingId + ? { id: pendingId, role: "proposal", text: data.reasoning, proposal: data } + : m + )); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setMessages(curr => curr.map(m => + m.id === pendingId + ? { ...m, text: `⚠ propose failed: ${msg}`, pending: false } + : m + )); + } finally { + setProposing(false); + } + }, [proposing, sending, projectId]); + + const acceptProposal = useCallback(async (messageId: string, proposal: ProposalPayload) => { + const result = apply(proposal.ops); + if (!result.applied) { + setMessages(curr => [ + ...curr, + { + id: `tmp-sys-${Date.now()}`, + role: "system", + text: `⚠ Couldn't apply: ${result.errors.map(e => `${e.code}: ${e.message}`).join("; ")}`, + }, + ]); + return; + } + // Mark the proposal accepted (drop the live card; keep a summary line) + setMessages(curr => curr.map(m => + m.id === messageId + ? { + id: messageId, + role: "system", + text: `✓ Applied · ${proposal.ops.length} op${proposal.ops.length === 1 ? "" : "s"} · ${truncate(proposal.reasoning, 90)}`, + } + : m + )); + }, [apply]); + + const rejectProposal = useCallback((messageId: string) => { + setMessages(curr => curr.map(m => + m.id === messageId + ? { id: messageId, role: "system", text: "Proposal dismissed." } + : m + )); + }, []); + // Number-key shortcuts on the most recent assistant turn with options useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -198,39 +273,74 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { )} - {messages.map(m => ( -
- {m.role === "assistant" && Σ} - - - {m.text} - - {m.options && m.options.length > 0 && ( -
- {m.options.map(o => ( - - ))} -
- Press 1{m.options.length}, or type a reply + {messages.map(m => { + // Proposal card — render in place of a normal bubble + if (m.role === "proposal") { + if (m.proposal) { + return ( + acceptProposal(m.id, m.proposal!)} + onReject={() => rejectProposal(m.id)} + /> + ); + } + // Pending or errored proposal — show a thin status line + return ( +
+ Σ + + + {m.text} + + +
+ ); + } + // System notes (apply confirmation, errors) + if (m.role === "system") { + return ( +
+ {m.text} +
+ ); + } + // Normal user / assistant bubble + return ( +
+ {m.role === "assistant" && Σ} + + + {m.text} + + {m.options && m.options.length > 0 && ( +
+ {m.options.map(o => ( + + ))} +
+ Press 1{m.options.length}, or type a reply +
-
- )} - -
- ))} + )} + +
+ ); + })}
@@ -250,8 +360,16 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { value={input} onChange={e => setInput(e.target.value)} disabled={sending} - autoFocus /> +