diff --git a/CLAUDE.md b/CLAUDE.md index a4849db..6f15df8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,26 @@ big-file mode badge. Files: extension.js + fileOps/lineOps/columnOps/encodingEol - `aiEdit.js` — **edit-with-diff**: select code → `Cmd+Alt+E` → instruction → side-by-side diff → ✓ Keep / ✗ Discard buttons on the diff toolbar (gated on `levelcode.ai.diffActive`). - `inlineReview.js` — **dead code** (an inline per-hunk Keep/Undo attempt that was reverted; nothing imports it). +- `diagram/` — **rich diagrams, phase 1** (`docs/RICH-DIAGRAMS.md`). The agent calls a `render_diagram` tool with + STRUCTURE only (nodes, edges, groups, one accent — never coordinates or colours); the editor validates it, lays it + out in one house style and paints it in the chat, themed, with nodes that link to code. Graph JSON only — Mermaid, + Vega-Lite and raw SVG are later phases and are **not built**. Not yet run in the packaged editor or against a live + model; the eval (`scripts/diagram-eval.js`) exists and has not been run. + - Shared UMD modules (`theme` `schema` `validate` `repair` `layout` `scene` `text` `ascii`) run in Node AND are + inlined into `chat.html` by `diagram/bundle.js` under the page's existing nonce — the CSP is unchanged. Host-only: + `tool` (tool + prompt block + result text), `service` (ids, the one repair pass, records, stubs), `links`, + `exportCheck`, `stats`. + - Repair ladder: lossless auto-fix → every error back to the model ONCE → degrade with a banner, or source + Retry. + Never a blank card, never a second automatic repair. Records are stored in the session log and re-validated (not + re-repaired) on reopen, by the host and by the page. + - Layout is in-house (layered + orthogonal routing), **not ELK** (EPL-2.0, ~1.5 MB, no build step here); + `layout.layout()` is the one swap point. The validator is a small JSON-Schema-subset interpreter, not Ajv. + - Gated per run by `client.render` (`rich`|`ascii`): off via `levelcode.ai.diagrams.enabled`, or per model with + `diagrams: false` in `providers/catalog.js` `CAPS`. Costs ~970 tokens of tool + prompt per request while on. + - Checks: `test/diagram*.test.js` (in the gate), `scripts/diagram-browser-check.js` (real page in headless Chrome, + not in the gate), `scripts/diagram-editor-check.js` (the REAL editor: a throwaway instance of the dev build with + this checkout's extension and a stand-in provider), `scripts/diagram-eval.js --dry-run`. Local counters: command + `AI: Diagram Statistics`. ## Deferred / known limits (don't waste time re-hitting these) @@ -189,4 +209,13 @@ big-file mode badge. Files: extension.js + fileOps/lineOps/columnOps/encodingEol - `// @ts-check` + JSDoc at top of JS files. - Test JS logic with `node --check` and small unit snippets before wiring into the editor. - After any change, `./scripts/run-dev.sh` to verify; package with `./scripts/build-macos.sh`. +- `run-dev.sh` runs the extensions of the checkout that HAS `vscode/`. A git worktree has none, so work in a worktree + is not in the editor until you load it: `./scripts/run-dev.sh --extensionDevelopmentPath=/extensions/levelcode-ai` + (from the main checkout; the dev extension replaces the built-in one). Uncommitted work is not "on the branch" — + checking the branch out somewhere else gets none of it. Run it in the editor before telling anyone to try it. - Commit `extensions/`, `patches/`, `branding/`, `scripts/`, `docs/`, `PLAN.md`, `CLAUDE.md`. Never commit `vscode/`. +- `extensions/levelcode-ai/diagram/` modules listed in `bundle.FILES` are pasted INTO a script block in `chat.html`. + They must never contain the text of a script tag or an HTML comment opener — not even in a comment — or the block + ends early; `bundle.js` refuses to build if one does. Keep them dependency-free and free of `require('vscode')`/`fs`. +- Host suites slice functions out of `extension.js` with a brace matcher (`extract()` in `test/*Host.test.js`). It + does not understand a backtick inside a regex literal: write `String.fromCharCode(96)` there instead. diff --git a/docs/RICH-DIAGRAMS.md b/docs/RICH-DIAGRAMS.md new file mode 100644 index 0000000..5bd13e0 --- /dev/null +++ b/docs/RICH-DIAGRAMS.md @@ -0,0 +1,450 @@ +# Rich diagrams — the model describes, the editor draws + +**Status:** phase 1 is built (Graph JSON through a `render_diagram` tool call). It is verified by +unit and property suites, by the real agent loop against a scripted provider, by the real +`chat.html` in headless Chrome under the real CSP, and in the real editor (the dev build) against a +stand-in provider. It has **not** been run against a live model, and the diagram eval has not been +run on any model — see [What is and is not verified](#what-is-and-is-not-verified). Mermaid, +Vega-Lite and raw SVG (phases 2–4) are not built. + +This is the implementation record for the *LevelCode Rich Diagrams Spec* (2026-10-04), the third +companion to the Context Budget and Mid-Run Messaging specs. It keeps the spec's section names, +because the code cites them (`docs/RICH-DIAGRAMS.md, "Validation and repair"`). Under each heading: +the rule, what the code does about it, and every place the build chose differently — with the reason, +so the choice can be re-argued. + +## Overview + +The agent used to draw flows and routing trees with box-drawing characters. They break when the +font or the panel width changes, cannot follow the theme, cannot be clicked and cannot be exported. + +Now the model sends a small description of **structure** — nodes, edges, optional groups, one +accent — and never a coordinate, a colour or a size. The editor validates it, lays it out in one +house style and paints it. Quality comes from the renderer, so it is the same whichever model drew. + +One diagram, start to finish: + +1. A rich client's request carries the `render_diagram` tool and a short block of rules. +2. The model calls the tool. While its arguments stream, the chat shows a placeholder card with the + title as soon as the title is known. +3. The host runs the repair ladder's first rung and validates the result. Valid: a record is stored + and sent to the webview. Invalid: every error goes back as the tool result, once. +4. The webview validates the record again, measures the text in the real font, lays it out and + paints it from allow-lists. +5. If the model's one repair pass does not produce a valid spec, the editor draws what is valid and + says what it left out — or shows the source, the error and Retry. Never an empty card. + +## Requirements + +| ID | Requirement | Status | Pinned by | +| --- | --- | --- | --- | +| FR-1 | Graph JSON, Mermaid, Vega-Lite, opt-in raw SVG | **Graph JSON only.** The other three are phases 2–4 | — | +| FR-2 | Graph JSON arrives through `render_diagram`; schema validated on every call | Done | `diagramAgent`, `diagramSchema` | +| FR-3 | No unvalidated spec reaches the renderer | Done — validated in the host before a record exists, again when a session's records are read back from disk, and again in the webview before anything is drawn | `diagramRepair`, `diagramHost`, `diagramUi`, browser check | +| FR-4 | Auto-fix, one model pass, graceful degrade; no automatic second repair; no blank output | Done, with one deliberate reordering — see [Validation and repair](#validation-and-repair) | `diagramRepair`, `diagramAgent` | +| FR-5 | House style in light and dark | Done | `diagramScene` snapshots in both palettes; browser check in both themes | +| FR-6 | Nodes link to files and symbols | Done | `diagramLinks`, `diagramHost` | +| FR-7 | Export SVG, PNG and source | Done, plus Mermaid and "insert into a Markdown file" | `diagramHost`, `diagramText`, browser check | +| FR-8 | Terminal clients get ASCII; rich clients never do | Done for the one client that exists (the chat webview is rich); the flag and the ASCII renderer are in place, and the renderer is what the chat falls back to | `diagramAgent`, `diagramText`, `diagramHost` | +| NFR-1 | Sanitized; no script, no network | Done — the page's CSP is unchanged and nothing model-written is ever parsed as markup | `diagramScene`, `diagramUi`, browser check | +| NFR-2 | Render under 300 ms p95 at 12 nodes | **Layout measured, time-to-picture not.** Layout of the 12-node gallery diagram: p95 under 1 ms in Node once warm (the suite asserts under 50 ms); the slowest of 6,000 random specs took 7 ms. The webview reports its own layout-and-paint time to the local counters; there is no figure from a real editor yet, and nothing measures the hop from host to page | `diagramLayout` | +| NFR-3 | Graph JSON median under 600 tokens | Done — gallery diagrams are 21–309 tokens, the twelve-node one included (characters ÷ 4) | `diagramAgent` | +| NFR-4 | Old chats keep rendering after schema changes | Mechanism done: a versioned schema and `migrate`; a stored record that still validates is drawn as stored, and one that no longer does is cut down by the deterministic rungs instead of being refused. Only version 1 exists, so there is nothing yet to migrate from | `diagramSchema`, `diagramRepair`, `diagramSession` | + +## Output formats + +Phase 1 draws boxes and arrows only. The spec's five-step format rule therefore ships as two +steps — structure → `render_diagram`; reads fine as prose → no diagram — plus an instruction for the +shapes that have no renderer yet: numbers to compare go in a Markdown table, steps over time in a +numbered list. Telling a model to emit Mermaid or Vega-Lite that the chat would show as raw source +would be worse than not mentioning them; a test fails if the prompt names either. + +## Architecture + +Everything lives in `extensions/levelcode-ai/diagram/`. The modules are plain UMD: Node `require`s +them, and `bundle.js` inlines the shared ones into `chat.html` under the page's existing nonce. + +| Module | Runs in | Job | +| --- | --- | --- | +| `theme.js` | both | The style guide as numbers and CSS: type scale, box metrics, spacing, theme tokens, the two export palettes | +| `schema.js` | both | The versioned schema and a small interpreter for it. The object the model is shown **is** the object it is validated against | +| `validate.js` | both | Schema, then semantics. Every error at once, each with a JSON Pointer | +| `repair.js` | both | The ladder: `normalize` (rung 1), `degrade` (rung 3), `prepare` (the whole trip), `accept` (re-validation of a stored record) | +| `layout.js` | both | Layered layout with orthogonal routing, and `inspect`, which lists anything overlapping or out of frame | +| `scene.js` | both | The painter: a tree of elements built from two allow-lists; `mount` (DOM) and `toSvg` (string) | +| `text.js` | both | The screen-reader outline, the one-line stub, Mermaid and source export | +| `ascii.js` | both | The fallback: the same layout drawn on a character grid | +| `tool.js` | host | The tool definition, the system-prompt block, the tool-result text | +| `service.js` | host | One conversation's diagrams: ids, the one repair pass, records, stubs | +| `links.js` | host | Resolving a node's link inside the workspace, or refusing | +| `exportCheck.js` | host | Checking an SVG or PNG the webview hands back before it is written to disk | +| `stats.js` | host | The local counters | +| `bundle.js` | host | Inlining the shared modules into the page | + +**Where rendering runs** (an open question in the spec). Validation and the repair ladder run in +the extension host. Layout and painting run in the chat webview itself — not in a worker or a +second frame — because layout needs to measure text in the real font, and because the Graph JSON +painter executes nothing the model wrote: it creates elements from a fixed list and sets text with +`textContent`. Isolation buys nothing there. It will matter for Mermaid and Vega, which run large +third-party parsers over model text; those should get the sandboxed frame the spec describes. + +**Layout engine: not ELK.** The spec names ELK.js. The build uses its own layered (Sugiyama-style) +layout: rank assignment, crossing reduction, coordinate assignment by constraints, one port per +edge, per-gap track assignment for orthogonal connectors, and label placement scored against +nodes, lines and other labels. Reasons: ELK is EPL-2.0 in a repository that is otherwise MIT-clean; +it is about 1.5 MB; and extensions here have no build step. The spec's own open question asks +whether a simpler layered layout is needed as a fallback — this is that layout, as the only one. +`layout.layout(spec, opts)` is the single entry point, so ELK can replace it without touching the +painter. What the trade costs is listed under [Known limits](#known-limits). + +**Validator: not Ajv.** Same reasons, smaller scale. `schema.js` interprets the subset of JSON +Schema the diagram schema uses, and produces the spec's error format directly. + +**Host ↔ webview messages** + +| Direction | Message | Meaning | +| --- | --- | --- | +| host → webview | `diagramPending {key, state, title}` | A call has started (`drawing`) or was sent back (`repairing`) | +| host → webview | `diagram {key, replacesKey?, record}` | The final record for a call; `replacesKey` when it redraws the attempt before it | +| webview → host | `diagramAction {action: 'openLink', id, node}` | A linked node was clicked. The node **id** — never a path | +| webview → host | `diagramAction {action: 'export', id, format, data?}` | Copy or save. `data` only for SVG and PNG, and it is checked before use | +| webview → host | `diagramAction {action: 'retry', id}` | Retry on a degraded or failed diagram | +| webview → host | `diagramAction {action: 'ascii', id, cols}` | The page has no renderer: send the text version. `cols` is how many characters fit the card, clamped to 40–200 | +| host → webview | `diagramAscii {id, text}` | The answer — empty when there is nothing drawable | +| webview → host | `diagramRendered {id, ok, ms, flipped}` | Layout-and-paint time, or a failure, for the local counters | + +## Diagram spec and render_diagram tool + +The fields are the spec's. Two tiers of limits apply: the **house** tier is what the model is held +to; the **hard** tier is what the renderer will draw when a diagram is degraded, so that a +thirteen-node diagram loses nothing rather than everything. + +| Field | House limit | Hard limit | +| --- | --- | --- | +| `title` | 80 characters | 80 | +| `nodes` | 1–12 | 24 | +| `edges` | 30 | 48 | +| `groups` | 8, nested 2 deep | 12, nested 2 deep | +| node `label` / `sub` | 28 / 32 | same; the full text survives as a tooltip | +| edge `label` | 20 | same | +| `accent` | at most one node | one | + +Differences from the spec's tables: + +- **`v` is optional on the wire.** The tool schema leaves it out to save tokens on every request; + the host stamps `v: 1` on the stored record. A call that names a version the editor does not know + is refused. +- **Edges and groups have counts.** The spec caps nodes only. An uncapped edge list is an easy way + to produce an unreadable picture, so edges stop at 30 and groups at 8. +- **`tip` exists.** It is the renderer's field: the full text of a label that had to be shortened, + shown on hover and read in the outline. A `tip` the model sends is kept as hover text (cleaned, + 400 characters at most); it is not in the model-facing schema. +- **Unknown fields are ignored, not errors.** A model that adds `color` or `x` is not sent back for + it — the fields are dropped and counted. The same goes for a stored record: whatever else it + carries, only declared fields reach the renderer, the exports and `get_diagram`. + +The tool returns `{"ok":true,"id":"d-1"}`, optionally with what was changed to fit and which links +did not resolve; or the error list; or, when the diagram was drawn degraded, what was left out and +an instruction not to redraw it unprompted. + +**Cost of offering it.** The tool definition is about 520 tokens and the prompt block about 450, on +every request from a rich client. Both are constant for a session, so they sit in the cached prefix +where the provider caches. `get_diagram` (about 100 tokens) is offered only once a diagram has been +stubbed. + +## Style guide + +`theme.js` is the one place the numbers live: title 15, node name 13 semibold, second lines and +edge labels 11.5, nothing below 10.5; corner radius 8, border 1.25, padding 12; the accent is a +low-opacity fill plus a 2 px border; groups inset their children by 16. Colours are editor theme +tokens (`--vscode-*` behind `--lcd-*`), so a theme switch repaints with no re-render and nothing is +baked in. Shapes: box, diamond, cylinder, pill. + +- Width is the measured longest line plus 24. Text is measured in the page's real font in the + webview; Node-side tests use a calibrated estimate. +- Connectors are orthogonal and run in the gaps between ranks; labels sit beside their line. When + no clear position exists the label gets a background halo instead of being dropped. +- A diagram never grows wider than the chat column: a `right` flow that does not fit is laid out + `down` instead. If it is still too wide it is scaled to fit, and opens full-panel with zoom and + pan. +- A group's name sits in the top-left of its frame, and no connector runs through it. When the + flow runs down that corner is exactly where lines come in, so the layout works through three + answers in order. Slide the name along the frame to the nearest clear stretch — free. If the + frame has none and the lines have less than 72 px to move, move them to the far side of the name + — the frame gets that much wider, and only if the drawing still fits its column. Otherwise leave + the name where it is and draw it over the line with a halo, so the line reads as passing behind + the word. +- Exported SVG and PNG carry one of two fixed palettes (light or dark, picked from the theme at the + moment of export), because a file cannot read the editor's variables. + +The spec's chart rules have nothing to apply to until the Vega-Lite phase. + +## Validation and repair + +**Layers as built** + +| Layer | Checks | Where | +| --- | --- | --- | +| Extraction | The call's arguments are complete. Output cut off by `max_tokens` is re-requested with a "send it again, smaller" result — never repaired | `agent.js`, `service.truncated` | +| Syntax | The arguments parse — leniently: comments, trailing commas, single or smart quotes, bare keys, Python literals, a stray code fence | `repair.parseLenient` | +| Schema | Fields, types, enums, lengths, counts | `schema.check` | +| Semantics | Edge ends exist, ids unique, one accent, groups known, acyclic and at most 2 deep | `validate.js` | +| Layout | Nothing overlaps, overflows or leaves the frame, and no line runs through a node or a group's name. Renderer-only fixes: grow a node, wrap to two lines, flip the direction, halo a label, slide or back a group's name | `layout.js`, `layout.inspect` | + +**The ladder, and the one place it departs from the spec.** The spec's first rung lists "dedupe +ids" and "drop edges to unknown nodes with a warning" among the deterministic fixes — while its own +layer table says semantic errors are fixed by the model, "because intent is needed". Both cannot +hold: an edge to `billing` when the node is called `bill` is a typo the model can fix in one pass, +and dropping it silently changes what the diagram says. + +The build resolves it with one rule: **an auto-fixed diagram never says something different from +what the model wrote; a degraded one always says what it lost.** + +1. **Auto-fix, no model — lossless only.** Lenient parsing; synonyms (`diamond` → `decision`, + `source`/`target` → `from`/`to`, `rankdir: LR` → `right`); ids slugged; an edge that names a + node's *label* pointed at that node's id; long labels shortened with the full text kept as a + tooltip. Silent when it changes nothing visible; a quiet "auto-fixed" badge when it does. +2. **One model pass.** Anything else — an unknown node, a duplicate id, two accents, too many + nodes — returns every error at once as the tool result, in the spec's format + (`/edges/1/to: unknown node "billing". Known ids: in, jev, bill, rev.`). +3. **Degrade, never loop.** If the second attempt is still invalid, the lossy fixes run *now*: edges + to unknown nodes are dropped, duplicates renamed, the first accent kept, deep groups flattened, + counts allowed up to the hard tier. The diagram is drawn with a banner listing each loss, and a + Retry button. If nothing drawable remains, the card shows the errors, the source and Retry. + +"No automatic second repair" is enforced per diagram (one bounce, then the verdict is final) and +per run (three bounces across all diagrams, after which every call is final). A diagram still +waiting on its repair when the run ends — the model gave up, hit its step limit, or was stopped — is +settled from its last attempt before `agentDone`, so no placeholder is left spinning. + +**Storage.** The final record — spec, status, fixes, notes — is written to the session's event log +after the turn. Reopening a chat replays records; no model is asked anything, and a valid record +comes back exactly as it was stored. + +A session file is input too — it can be edited, cut short, or written by an older build — so a +stored spec is checked like a model's: by the host when the session is loaded (before a link can be +opened or a file exported from it) and by the webview before it is drawn. The check is +`repair.accept`: validate against the hard tier and hand on only the fields the schema declares; +if that fails, run the deterministic rungs alone and draw what is left with its banner; if nothing +is left, treat it as a diagram that was never drawn and show the source. + +**Strict tool schemas** are not enabled. Which providers enforce them well enough is an open +question the eval should answer first; semantic rules stay in the validator either way. + +**Fence mode, JSON Patch repair, the cheap repair model** belong to the Mermaid and Vega-Lite +phases and are not built. + +## Safe rendering + +Graph JSON is the easy case, and the build keeps it easy: there is no sanitizer because there is +nothing to sanitize. The painter creates elements from a fixed list (`svg g rect path text title +style`) with attributes from a fixed list that has no `href`, no `style`, no `id` and no event +handlers, and model text goes in through `textContent`. Text is also stripped of control, +zero-width and bidirectional-override characters before it is stored. + +- The page's CSP is unchanged: `default-src 'none'`, scripts by nonce only, no network origin. +- The inlined modules must never contain the text of a script tag or an HTML comment opener, in + code or comments, or they would end the inline block they live in. `bundle.js` refuses to build + if one does, and a suite checks it. +- A linked node carries `data-lc-link` = its node id. A click sends that id; the host looks the + path up in its own record and resolves it again. A path is never taken from the page. + +**The host accepts four actions, not two.** The spec lists `openLink` and `export`. Its own UX +section also puts a Retry button on degraded diagrams and promises a text fallback when rendering +is unavailable, which need a third and a fourth (`retry`, `ascii`). All four name a diagram by id +and are looked up in the host's own records; none carries a path, a URL or a command. The only +other value read from the page is the fallback's column count, used as a clamped number. + +**Link safety.** `links.js` resolves a link against the workspace folders at draw time *and again +at click time*: schemes are refused, `..` is normalized, the real path (symlinks followed) must +still be inside a workspace folder, and the target must be a file. A link that fails is removed +from the node — it is drawn as plain text — and the model is told which ones did not resolve. + +**Exports.** Source, Mermaid and Markdown are generated in the host from the stored spec. SVG and +PNG are produced in the webview (PNG needs a canvas), so the host checks them before writing: the +SVG against the painter's own allow-lists, the PNG by signature and size. File names come from the +title after secret redaction. + +## UX + +| Spec item | As built | +| --- | --- | +| While streaming | A placeholder card appears when the call starts; the title fills in as soon as it has streamed; the picture replaces it | +| Code links | A small file icon; click or Enter opens the file at the symbol (document symbols, then a text search, then the line); hover shows the path | +| Zoom and pan | Diagrams larger than the column are scaled to fit; click opens a full-panel view with zoom, pan, fit and Esc | +| Toolbar | Copy source, save SVG, save PNG, open as Mermaid, insert into a Markdown file | +| Repair states | "auto-fixed" badge with a details popover; degraded banner listing each loss, plus Retry | +| Theme switch | Instant — colours are CSS variables | +| Accessibility | The title is the accessible name; a text outline (nodes, then edges, in reading order) is attached for screen readers; linked nodes are focusable | +| Fallback | Two ways rendering can be unavailable, one answer. If painting throws, the page draws the spec with characters itself. If the diagram modules never loaded, the page asks the host, which draws it from its own record. Either way it is the same layout on a character grid, fitted to the card's width, in a monospaced block, under a line saying that a text version is being shown. Source stays one click away | + +The text fallback is made of plain ASCII — a shortened label ends in three dots there, not an +ellipsis — and it counts East Asian wide characters and emoji as two cells and combining marks as +none, so a box with a Japanese label still closes on one column. + +Dragging nodes is not built (an open question in the spec). + +## Prompting and capability detection + +**Capability flag.** Each agent run carries `client.render`: `rich` or `ascii`. A rich client gets +the tool and the rules; an ASCII client gets neither. Today the chat webview is the only client and +it is rich, unless `levelcode.ai.diagrams.enabled` is off or the model's catalog row says +`diagrams: false`. + +**The prompt block** (`tool.PROMPT`) says when a diagram earns its place, that characters are never +to be drawn with, that a request for a diagram means drawing it in the chat rather than writing a +file of diagram source, that the title states the takeaway, at most one accent, split above 12 +nodes, link nodes to workspace files, and not to restate the picture as a list afterwards. It carries one worked +example — the spec's Jev flow — and a suite checks that the example is itself a valid, fix-free spec. + +**Model differences.** `providers/catalog.js` `diagramSupportForModel` is the registry switch: a +model that fails the eval is turned off with `diagrams: false` in its `CAPS` row. The spec's third +state, "fenced Mermaid only", arrives with the Mermaid phase; until then a switched-off model simply +answers in prose. + +**Chat mode is unchanged.** Diagrams are a tool, and only the agent has tools. + +## Context budget and cost + +- **Collapsing old diagrams.** At compaction — never turn by turn — a diagram call in the + summarized range becomes `[draws a diagram: ]` in the summary input, and the summary is + followed by one stub line per diagram (`diagram: <title>, 4 nodes, id d-17`). From then on the + model is also offered `get_diagram`, which returns the full spec by id. Resuming a session whose + history was shortened does the same. +- **Retention** (an open question). A spec is kept as long as its session file is. There is no + separate expiry. +- **Repair tokens.** The repair pass is an ordinary agent turn, so it is counted, metered and + capped like one. +- **Read-only.** A diagram call touches nothing and never asks for approval. +- **Mid-run updates.** Mid-run steering is not in this branch. Nothing in the diagram path cancels + a call that is streaming; when steering lands, the rule in the spec (the diagram renders, the model + may redraw) needs no change here. + +## Telemetry and evaluation + +**Local counters, not telemetry.** `stats.js` keeps the spec's table — first-pass valid rate, fix +share by rung, error classes per model, render time, tokens per diagram, ASCII leaks, link clicks, +exports — in the editor's own storage. `AI: Diagram Statistics` shows it. Nothing is sent anywhere. +It records enums and numbers only; `record()` copies nothing else out of an event, and a suite +checks that no label, title or path can reach the store. + +**Golden corpus.** `test/fixtures/diagrams/corpus.json` holds 39 broken specs with the exact +outcome of each attempt. They are **seeded** — written from the failure modes models are known +for — not field data. Real broken specs should be added unchanged as they are found. + +**Diagram eval.** `scripts/diagram-eval.js` runs the real agent loop over +`test/fixtures/diagrams/eval-prompts.json` (30 prompts that should draw, 10 that should not) and +scores format choice, first-pass validity, fix share by rung, degraded rate, error classes, tokens, +node-count overruns, title quality and ASCII leaks against the spec's proposed bars (first-pass +valid ≥ 90%, degraded < 2%, no leaks). `--dry-run` uses a scripted model and no network. `--run` +makes billed calls on your key and says how many before the first one. **It has not been run +against any model yet.** + +**Acceptance criteria** + +- [x] Visual regression snapshots pass in light and dark themes +- [x] Zero script execution or network requests in the security checks — for Graph JSON, the only + format built +- [ ] Degraded rate under 2% across the diagram eval on default models — the eval exists; not run +- [ ] No ASCII diagrams in rich-client answers over one week of internal use — the counter exists; + the week has not happened + +## Rollout and open questions + +Phase 1 — Graph JSON — is what is built, and by the spec it alone retires ASCII for flows and +architecture. The spec's rollout figure (four phases, three gates) is not reproduced here. What +stands between this build and calling phase 1 done is the two unchecked boxes above: run the eval +on the default models, then use it for a week and read the counters. + +| Open question | Where it stands | +| --- | --- | +| Which models handle strict tool schemas well enough? | Open. Run the eval per model; strict mode is not enabled | +| Where does rendering run? | Decided for Graph JSON: the chat webview. Revisit for Mermaid and Vega | +| Is ELK fast enough, or is a simpler layered layout needed? | Sidestepped: the simpler layout is the only one. Sub-millisecond at 12 nodes | +| Should users drag nodes? | Not built | +| A simplified chart schema instead of full Vega-Lite? | Open — phase 3 | +| How long are stubbed specs kept? | As long as the session file | + +## Known limits + +- **Dense labelled fan-ins.** Over 6,000 adversarial random graphs, 6.5% of specs have at least one + edge label that falls back to a halo over a line (4.5% of all labels) — 2% of flows that run + right, 11% of flows that run down, where many labelled edges enter one node. Nothing overlaps a + node. The house-size gallery has none. The suite fails above 9%. +- **Lane swaps.** In 0.3% of the same specs two connectors in one gap cross where a perfect router + would not have crossed them. The suite fails above 0.5%. +- **Backed group names.** Of the random specs that have groups, 0.5% of those flowing right and + 4.9% of those flowing down end with a connector passing behind a group's name — long names over + narrow frames. None has a line *through* an unbacked name, and none of the gallery diagrams needs + a backing at any width from 320 px up. Making room instead costs width: grouped top-to-bottom + random specs are 0.6% wider on average and at most 121 px. The suite fails above 10% backed. +- **Text fallback width.** Character widths follow the common Unicode ranges, not the whole + standard; an unusual script or a font with its own ideas can still put a box edge one cell out. +- **Time to picture is unmeasured in a real editor.** Layout is fast; font loading, DOM work and + the webview's message hop are not in any number here. +- **The corpus is seeded** and the eval has not been run, so the first-pass and degraded rates for + real models are unknown. +- **One renderer.** A terminal client does not exist yet, so `client.render = ascii` is exercised + only through the setting, the catalog switch and tests. + +## What is and is not verified + +Verified: + +- 12 suites, 252 tests (`test/diagram*.test.js`), including a 1,500-spec layout fuzz, the corpus, + SVG snapshots in both palettes, the gallery at chat-panel widths, the real `runAgent` loop with a + scripted provider, and the host functions sliced out of `extension.js` against a `vscode` + stand-in. +- The whole gate (`./scripts/test-extensions.sh`, 62 suite files) on macOS arm64 with Node 24, and + on Linux (Ubuntu 22.04 arm64, Node 18) in a container with no network and a read-only checkout. + The diagram suites also pass with every timer delayed by 15 ms. +- `scripts/diagram-browser-check.js`: the real `chat.html` in headless Chrome under the real CSP — + light and dark, hostile labels, links, exports, a painter that throws, and the page with no + diagram modules at all — 155 checks, with no CSP violation and no resource request. Each step + waits for its result rather than for a length of time. +- Mutation testing: 147 single-edit defects seeded across the modules, the host glue, the page and + the eval harness, each run against the suite that should notice (and only after that suite + passed on the unmutated copy). Ten survived at first: three were redundant code (removed or + simplified), seven were gaps in the suites (closed). All 144 that still apply are caught. + +- `scripts/diagram-editor-check.js`: the real editor. A second, throwaway instance of the dev build + loads this checkout's extension, talks to a stand-in provider on localhost, and is driven through + the DevTools protocol — 17 checks: the tool and its rules reach the model, the diagram is painted + in the real webview in the editor's theme, a linked node opens its file at the symbol, the session + on disk holds the diagram, a wider column re-lays it out, and the answer around it renders as + Markdown. (Added after the feature was first called done without ever having run in the editor.) + +Not verified: + +- Any live model. No provider call was made while building this; the editor check's "model" is a + script. What a real model does with the tool is what the eval is for. +- The gate on CI's own runner (ubuntu-latest, x64, Node 24) — it runs there on the pull request. + +## How to check it + +```bash +./scripts/test-extensions.sh # every suite, the release gate +node extensions/levelcode-ai/scripts/diagram-browser-check.js # the real page in headless Chrome +node extensions/levelcode-ai/scripts/diagram-editor-check.js # the real editor, a stand-in provider +node extensions/levelcode-ai/scripts/diagram-eval.js --dry-run # the eval harness, offline +UPDATE_SNAPSHOTS=1 node extensions/levelcode-ai/test/diagramScene.test.js # after a deliberate style change +``` + +The editor check opens a window for about a minute. It is a separate instance with its own +profile, so it neither joins an editor that is already open nor touches your settings or sessions. + +**To try it by hand.** In the checkout that has `vscode/`, `./scripts/run-dev.sh` runs the +extension that is checked out there. From anywhere else — a git worktree has no `vscode/` of its +own — load that checkout's extension into the build that exists: + +```bash +./scripts/run-dev.sh --extensionDevelopmentPath=/absolute/path/to/worktree/extensions/levelcode-ai +``` + +Run it from the checkout that has `vscode/`, and quit a dev editor that is already open first (a +running one is joined, not replaced). The window's title says `[Extension Development Host]`. +Then, in agent mode, ask something whose answer is structure — "how does a request flow through +this app?". The editor check takes the same route (`--vscode <that checkout>/vscode`). + +To measure a model: `node extensions/levelcode-ai/scripts/diagram-eval.js --run --model <id> --limit 8` +first, then without `--limit`. diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 1505cdb..59cdcc2 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -21,6 +21,8 @@ const { loadProjectRules } = require('./projectRules'); const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall, isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch } = require('./mcpConfig'); const { connectAll, getServer } = require('./mcpClient'); +const diagramTool = require('./diagram/tool'); +const diagramRepair = require('./diagram/repair'); const SYSTEM_BASE = [ "You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their", @@ -307,6 +309,13 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs) { }); } +/** + * Can the surface on the other end of this run show a diagram? (docs/RICH-DIAGRAMS.md, "Capability + * flag".) Asked in two places — when the tools and the prompt are assembled, and again when a call + * arrives — and they must agree, or a client could be refused a tool it was offered. + */ +function richClient(ctx) { return !!(ctx.diagrams && ctx.client && ctx.client.render === 'rich'); } + /** Execute one tool call; returns a string result for the model. */ async function runTool(tu, ctx) { const root = ctx.root; @@ -517,6 +526,22 @@ async function runTool(tu, ctx) { try { return String(ctx.recallSessions(query) || 'No matching past sessions in this project.'); } catch (e) { return 'ERROR: recall failed.'; } } + // Rich diagrams (docs/RICH-DIAGRAMS.md). The model describes structure; the host validates it, + // climbs the repair ladder and posts what the chat should show. Read-only and instant — it + // draws in the transcript and touches nothing else — so, like update_plan, it never asks. + if (tu.name === diagramTool.RENDER_DIAGRAM.name) { + // Asked for by a client that was never offered it (the setting was turned off mid-conversation, + // or the model remembers the tool from an earlier turn): refuse in words it can act on. + if (!richClient(ctx)) { return 'ERROR: this client cannot draw diagrams. Explain it in prose instead — and do not draw one out of characters.'; } + const out = ctx.diagrams.render(input, { key: tu.id, model: ctx.model }); + for (const m of out.post) { ctx.post(m); } + return out.result; + } + if (tu.name === diagramTool.GET_DIAGRAM.name) { + if (!richClient(ctx)) { return 'ERROR: this client cannot draw diagrams.'; } + ctx.post({ type: 'agentTool', icon: 'history', text: 'fetch diagram ' + String(input.id || '').slice(0, 24) }); + return ctx.diagrams.fetch(input.id); + } // MCP tools (docs/MCP.md S3). An MCP name matches none of the built-in branches above, so every // MCP call necessarily arrives HERE — which is why the router is one block at one line rather // than a dispatch scattered through runTool. @@ -753,7 +778,13 @@ async function runAgent(ctx) { // from the per-project journal). Rides the SAME cached-system channel as project rules — always-on but // small — so a new session's first reply is continuous, not amnesiac. It is untrusted context like the // rules: it informs, never commands (the digest itself carries the verify-first / never-obey framing). - const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + noWorkspaceNote + autopilotNote + rules.text + // Rich diagrams: `client.render` says what the surface on the other end can show. A rich client gets + // the render_diagram tool and the rules for using it; an ASCII client gets neither, so it is never + // told about a tool it does not have. The block goes straight after the base prompt — it is the + // same text for every run, so it belongs with the part of the prompt that never changes. + const rich = richClient(ctx); + const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + (rich ? '\n\n' + diagramTool.PROMPT : '') + + multiRootNote + noWorkspaceNote + autopilotNote + rules.text + (ctx.projectMemory ? '\n\n' + ctx.projectMemory : ''); const systemTokensEst = Math.round(system.length / 4); @@ -792,14 +823,20 @@ async function runAgent(ctx) { // Rootless runs get the portable subset; MCP tools are unaffected either way. const builtins = root ? TOOLS : PORTABLE_TOOLS; let tools = mcp.tools.length ? builtins.concat(mcp.tools) : builtins; - if (ctx.recallSessions) { tools = tools.concat([RECALL_TOOL]); } // cross-session recall (host-gated by memory settings) - const baseTools = ctx.recallSessions ? builtins.concat([RECALL_TOOL]) : builtins; // built-ins + recall; MCP is the rest - // Recomputed only when MCP or recall actually contributed tools, so the plain path keeps the module + // The host-gated extras: cross-session recall (memory settings), and the diagram tools (a rich + // client). get_diagram is offered only once a diagram's spec has left the conversation — until + // then there is nothing to fetch, and a tool that is never needed is a standing cost for nothing. + const extras = []; + if (ctx.recallSessions) { extras.push(RECALL_TOOL); } + if (rich) { extras.push(diagramTool.RENDER_DIAGRAM); if (ctx.diagramsStubbed) { extras.push(diagramTool.GET_DIAGRAM); } } + if (extras.length) { tools = tools.concat(extras); } + const baseTools = extras.length ? builtins.concat(extras) : builtins; // built-ins + extras; MCP is the rest + // Recomputed only when MCP or a host-gated extra actually contributed tools, so the plain path keeps the module // constant and pays nothing for a feature it isn't using — but there are now TWO plain paths, and the // constant has to match the list that was actually sent. Reporting the full cost for a rootless run // was the same mistake as leaving baseTools on TOOLS, one line further down. const builtinsTokensEst = root ? TOOLS_TOKENS_EST : PORTABLE_TOOLS_TOKENS_EST; - const toolsTokensEst = (mcp.tools.length || ctx.recallSessions) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst; + const toolsTokensEst = (mcp.tools.length || extras.length) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst; // The MCP SHARE of that, reported separately so the context popover can show what these servers cost // (docs/MCP.md S5). Every tool schema rides EVERY turn, so a chatty server is a standing tax on the // window rather than a one-off — and until it has its own segment, that cost is invisible. @@ -812,6 +849,9 @@ async function runAgent(ctx) { : 0; const messages = ctx.messages; + if (ctx.diagrams) { ctx.diagrams.beginRun(); } // nothing owed from an earlier run; repair passes reset + // tool_use ids whose placeholder already carries its title (the spec streams; the title arrives early) + const diagramTitled = new Set(); let step = 0; let reason = 'done'; let nudges = 0; @@ -898,12 +938,26 @@ async function runAgent(ctx) { apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system, messages, tools: tools, signal: ctx.signal, onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); }, - onToolStart: (name) => { + onToolStart: (name, id) => { dbg('tool.start', { name }); const verb = name === 'edit_file' || name === 'write_file' ? 'preparing edit (' + name + ')…' : name === 'delete_file' ? 'deleting a file…' - : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : 'running ' + name + '…'; + : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' + : name === diagramTool.RENDER_DIAGRAM.name ? 'drawing a diagram…' : 'running ' + name + '…'; ctx.post({ type: 'agentStatus', text: verb }); + // A diagram's place in the answer is held from the moment the model starts writing it. + if (rich && id && name === diagramTool.RENDER_DIAGRAM.name) { ctx.post({ type: 'diagramPending', key: id, state: 'drawing', title: '' }); } + }, + // The spec streams in as JSON. Its title is near the front, so the placeholder can say what + // is being drawn long before the last node arrives. Posted once per call. + onToolInput: (id, name, json) => { + if (!rich || !id || name !== diagramTool.RENDER_DIAGRAM.name || diagramTitled.has(id)) { return; } + const m = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(json); + if (!m) { return; } + diagramTitled.add(id); + let title = m[1]; + try { title = JSON.parse('"' + m[1] + '"'); } catch (e) { /* show it as written */ } + ctx.post({ type: 'diagramPending', key: id, state: 'drawing', title: diagramRepair.cleanText(title).slice(0, 120) }); }, // A transient upstream 5xx (502/503/504) is retried once before it can fail the run — surface it // as a status rather than a mystery pause, and log it. Nothing has streamed yet when this fires. @@ -975,7 +1029,22 @@ async function runAgent(ctx) { let cancelled = false; for (const tu of toolUses) { if (cancelled || ctx.signal.aborted) { cancelled = true; dbg('tool.cancelled', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'Cancelled by the user.' }); continue; } - if (turn.malformed && turn.malformed.has(tu.id)) { dbg('tool.malformed', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' }); continue; } + if (turn.malformed && turn.malformed.has(tu.id)) { + dbg('tool.malformed', { name: tu.name }); + // A diagram spec is the one input worth a second look: JSON with a trailing comma or a + // comment is a spec, and the lenient parser reads it. A spec that simply STOPS is not — + // that is the model hitting its length limit, and it is re-requested, never repaired. + if (tu.name === diagramTool.RENDER_DIAGRAM.name && rich) { + const rawArgs = turn.raw && turn.raw.get(tu.id); + const parsed = (turn.stop_reason !== 'max_tokens' && typeof rawArgs === 'string') ? diagramRepair.parseLenient(rawArgs) : null; + const out = (parsed && parsed.ok) ? ctx.diagrams.render(rawArgs, { key: tu.id, model: ctx.model }) : ctx.diagrams.truncated({ key: tu.id, model: ctx.model }); + for (const m of out.post) { ctx.post(m); } + results.push({ type: 'tool_result', tool_use_id: tu.id, content: out.result }); + continue; + } + results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' }); + continue; + } dbg('tool.call', { name: tu.name, input: inputPreview(tu.input, !!(ctx.mcpRoutes && ctx.mcpRoutes.has(tu.name))) }); const out = await runTool(tu, ctx); dbg('tool.result', { name: tu.name, chars: String(out).length, error: String(out).startsWith('ERROR') }); @@ -995,6 +1064,7 @@ async function runAgent(ctx) { // No tool calls this turn. const text = turn.content.filter((c) => c.type === 'text').map((c) => c.text).join(''); + if (rich && text.trim()) { ctx.diagrams.noteAnswer(text); } // "ASCII leaks": counted, never acted on if (turn.stop_reason === 'max_tokens') { // The turn was pure prose cut off at the token cap. Ask the model to CONTINUE exactly where it // left off (not switch strategies) so the full answer streams out across turns instead of being @@ -1059,6 +1129,11 @@ async function runAgent(ctx) { } else { ctx.post({ type: 'agentError', message: msg, code }); reason = 'error'; } } finally { + // A diagram sent back for repair that never came back right still owes the user a picture: + // draw what can be drawn of it now, before the run is declared over. Never a blank placeholder. + if (ctx.diagrams) { + try { for (const m of ctx.diagrams.endRun()) { ctx.post(m); } } catch (e) { dbg('diagram.settle.error', { msg: String((e && e.message) || e) }); } + } dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null }); // [LevelCode] Gateway runs now carry real money: costMicros = what THIS run cost, credits = the // remaining balance — both RETAIL micro-$. BYOK runs send neither (null/0) and the bar omits them. diff --git a/extensions/levelcode-ai/diagram/ascii.js b/extensions/levelcode-ai/diagram/ascii.js new file mode 100644 index 0000000..010dba7 --- /dev/null +++ b/extensions/levelcode-ai/diagram/ascii.js @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the ASCII fallback (docs/RICH-DIAGRAMS.md, "UX → Fallback") + * + * "If rendering is unavailable, the user sees an ASCII rendering generated from the same spec, so + * the model never has to draw ASCII itself." + * + * This is the SAME layout as the picture, not a second one: layout.layout() is run with every + * character one cell wide, and its geometry is rasterised onto a character grid — boxes, connectors + * with their corners and arrowheads, labels beside lines, group frames. So the fallback puts things + * where the picture would have, and a fix to the layout fixes both. + * + * It is drawn by the editor, for a <pre> in a monospaced face — the one place box-and-arrow + * characters hold their shape. That is the difference from a model typing them into prose. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./layout')); } + else { (root.LCDiagram = root.LCDiagram || {}).ascii = factory(root.LCDiagram.layout); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (layoutMod) { + 'use strict'; + + /** One character cell, in the layout's pixels. Tall cells are halved so two lines 12px apart stay on different rows. */ + const CW = 8, CH = 8; + const chars = (s) => Array.from(String(s)); + // How wide text is in a monospaced face, in cells. Ranges are [first, last], from Unicode's East Asian + // Width (Wide and Fullwidth) and its combining blocks — the common cases, not the whole standard. + // Without this a label in Japanese, or one with an emoji in it, pushes the right-hand side of its own + // box — and everything after it on the row — out of line. + const MARKS = [[0x300, 0x36f], [0x1ab0, 0x1aff], [0x1dc0, 0x1dff], [0x200b, 0x200f], [0x20d0, 0x20ff], [0xfe00, 0xfe0f], [0xfe20, 0xfe2f], [0xe0100, 0xe01ef]]; + const WIDE = [[0x1100, 0x115f], [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], + [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], + [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], + [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f], + [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a], [0x1f200, 0x1f2ff], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb], + [0x1f900, 0x1faff], [0x20000, 0x3fffd]]; + const within = (table, c) => table.some((r) => c >= r[0] && c <= r[1]); + /** Text as the units a monospaced face sets: a character with any marks that sit on it, and its width in cells. */ + function units(text) { + const out = []; + for (const ch of chars(text)) { + const c = ch.codePointAt(0); + if (c >= 0x300 && out.length && within(MARKS, c)) { + const last = out[out.length - 1]; + last.text += ch; + if (c === 0xfe0f) { last.w = 2; } // "show the character before me as an emoji" — which is a wide one + continue; + } + out.push({ text: ch, w: c >= 0x1100 && within(WIDE, c) ? 2 : 1 }); + } + return out; + } + const width = (text) => units(text).reduce((a, u) => a + u.w, 0); + /** The layout is told how wide text is in cells, and sizes boxes to match. */ + const measure = (text) => width(text) * CW; + /** The mark a shortened label ends with, in characters every terminal has. */ + const plain = (s) => (typeof s === 'string' ? s.replace(/\u2026/g, '...') : s); + + // Box furniture per shape — different corners so "same kind, same shape" survives without curves. + const FRAME = { + box: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }, + decision: { tl: '/', tr: '\\', bl: '\\', br: '/', h: '-', v: '|' }, + store: { tl: '.', tr: '.', bl: "'", br: "'", h: '=', v: '|' }, + actor: { tl: '.', tr: '.', bl: "'", br: "'", h: '-', v: '(' } + }; + + /** + * Render a validated spec as text. + * @param {any} spec + * @param {{ maxCols?: number, title?: boolean }} [opts] maxCols: the width to fit (a flow asked for left-to-right + * turns downward when it will not). title: false leaves the title line out, for a card that already shows it. + * @returns {string} + */ + function render(spec, opts) { + const o = opts || {}; + // A character grid has no slanted or curved edges for a connector to stop on, so every shape is + // laid out as a rectangle here; what kind it is shows in how its frame is drawn. + const shapeOf = new Map((spec.nodes || []).map((n) => [n.id, n.shape || 'box'])); + const flat = Object.assign({}, spec, { + nodes: (spec.nodes || []).map((n) => Object.assign({}, n, { shape: 'box', label: plain(n.label), sub: plain(n.sub) })), + edges: (spec.edges || []).map((e) => Object.assign({}, e, { label: plain(e.label) })), + groups: (spec.groups || []).map((g) => Object.assign({}, g, { label: plain(g.label) })) + }); + const geo = layoutMod.layout(flat, { + measure, + maxWidth: o.maxCols ? o.maxCols * CW : undefined, + // a little more air than the picture needs: after rounding to cells, neighbours must still be apart + // Every distance is a whole number of cells: two things a fixed distance apart are then the + // same number of cells apart after rounding, wherever on the grid they land. + space: { portGap: 2 * CH, portMin: 2 * CH, labelLane: 2 * CH, track: 2 * CW, labelGap: CH / 2, labelSideGap: CW, labelPad: CW, channelPad: 3 * CW, nodeGap: 3 * CH, rankGap: 6 * CW, lineClear: 2 * CH, groupGap: 2 * CH, groupInset: 2 * CW, groupHeader: 2 * CH, arrow: CW, arrowHalf: 1, margin: CW, selfLoop: 2 * CW }, + type: { name: { size: 13, weight: 400, line: CH }, sub: { size: 13, weight: 400, line: CH }, edge: { size: 13, weight: 400, line: CH }, group: { size: 13, weight: 400, line: CH }, title: { size: 13, weight: 400, line: CH } }, + box: { padX: 1.5 * CW, padY: CH / 2, textGap: 0, minWidth: 5 * CW, linkIcon: 0, storeCap: 0 } + }); + const col = (x) => Math.round(x / CW), row = (y) => Math.round(y / CH); + const W = col(geo.width) + 2, H = row(geo.height) + 2; + const grid = Array.from({ length: H }, () => new Array(W).fill(' ')); + const put = (r, c, ch) => { if (r >= 0 && r < H && c >= 0 && c < W) { grid[r][c] = ch; } }; + const get = (r, c) => (r >= 0 && r < H && c >= 0 && c < W ? grid[r][c] : ' '); + // A wide character fills its own cell and empties the next, so the row still adds up. + const write = (r, c, text) => { + let at = c; + for (const u of units(text)) { put(r, at, u.text); if (u.w === 2) { put(r, at + 1, ''); } at += u.w; } + }; + + // group frames first: everything else is drawn over them + const byDepth = geo.groups.slice().sort((a, b) => a.depth - b.depth); + for (const g of byDepth) { + const c0 = col(g.x), c1 = col(g.x + g.w), r0 = row(g.y), r1 = row(g.y + g.h); + for (let c = c0; c <= c1; c++) { put(r0, c, (c - c0) % 2 ? ' ' : '.'); put(r1, c, (c - c0) % 2 ? ' ' : '.'); } + for (let r = r0 + 1; r < r1; r++) { put(r, c0, ':'); put(r, c1, ':'); } + } + // A group's name sits on its frame's top row, where the layout put it: slid clear of the lines + // that come in through that row when there was room. It is written AFTER the connectors, so one + // that has to share the row with a line is still whole. + const nameGroups = () => { + for (const g of byDepth) { + const c0 = col(g.x), c1 = col(g.x + g.w), r0 = row(g.y); + const slid = col(g.labelX - (g.x + 2 * CW - 4)); // how far the layout moved it from the corner + const text = ' ' + g.label + ' '; + write(r0, Math.max(c0 + 2, Math.min(c0 + 2 + slid, c1 - 1 - width(text))), text); + } + }; + + // connectors: runs, then corners, then the arrowhead + const HORIZ = new Set(['-', '.', '=']), VERT = new Set(['|', ':']); + for (const e of geo.edges) { + const pts = e.points.map((p) => ({ c: col(p.x), r: row(p.y) })); + for (let i = 0; i + 1 < pts.length; i++) { + const a = pts[i], b = pts[i + 1]; + if (a.r === b.r) { + for (let c = Math.min(a.c, b.c); c <= Math.max(a.c, b.c); c++) { + const cur = get(a.r, c); + put(a.r, c, VERT.has(cur) || cur === '+' ? '+' : (e.dashed && c % 2 ? ' ' : '-')); + } + } else { + for (let r = Math.min(a.r, b.r); r <= Math.max(a.r, b.r); r++) { + const cur = get(r, a.c); + put(r, a.c, HORIZ.has(cur) || cur === '+' ? '+' : (e.dashed ? ':' : '|')); + } + } + } + for (let i = 1; i + 1 < pts.length; i++) { put(pts[i].r, pts[i].c, '+'); } + } + nameGroups(); + + // nodes over the connectors that end on them + for (const n of geo.nodes) { + const shape = shapeOf.get(n.id) || 'box'; + const f = FRAME[shape] || FRAME.box; + const c0 = col(n.x), c1 = col(n.x + n.w), r0 = row(n.y), r1 = row(n.y + n.h); + for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { put(r, c, ' '); } } + for (let c = c0 + 1; c < c1; c++) { put(r0, c, n.accent ? '#' : f.h); put(r1, c, n.accent ? '#' : f.h); } + for (let r = r0 + 1; r < r1; r++) { put(r, c0, f.v); put(r, c1, f.v === '(' ? ')' : f.v); } + put(r0, c0, f.tl); put(r0, c1, f.tr); put(r1, c0, f.bl); put(r1, c1, f.br); + if (shape === 'decision') { const mid = Math.round((r0 + r1) / 2); put(mid, c0, '<'); put(mid, c1, '>'); } + const first = Math.round((r0 + r1) / 2 - (n.lines.length - 1) / 2); + n.lines.forEach((line, i) => { + write(first + i, Math.round((c0 + c1) / 2 - (width(line.text) - 1) / 2), line.text); + }); + } + + // arrowheads sit in the last cell BEFORE the node, so they survive the node being drawn + for (const e of geo.edges) { + const a = e.arrow, c = col(a.x) - a.dx, r = row(a.y) - a.dy; + put(r, c, a.dx > 0 ? '>' : a.dx < 0 ? '<' : a.dy > 0 ? 'v' : '^'); + } + + // labels last, each on the row its line box is centred on + for (const e of geo.edges) { + if (!e.label) { continue; } + write(row(e.label.y + e.label.h / 2), col(e.label.x), e.label.text); + } + + const lines = grid.map((r) => r.join('').replace(/\s+$/, '')); + while (lines.length && !lines[0]) { lines.shift(); } + while (lines.length && !lines[lines.length - 1]) { lines.pop(); } + const indent = Math.min.apply(null, lines.filter(Boolean).map((l) => l.search(/\S/)).concat([Infinity])); + const body = lines.map((l) => (Number.isFinite(indent) ? l.slice(indent) : l)); + const title = o.title !== false && spec && spec.title ? [String(spec.tip || spec.title), ''] : []; + return title.concat(body).join('\n') + '\n'; + } + + return { render, width, CW, CH }; +})); diff --git a/extensions/levelcode-ai/diagram/bundle.js b/extensions/levelcode-ai/diagram/bundle.js new file mode 100644 index 0000000..5afeea9 --- /dev/null +++ b/extensions/levelcode-ai/diagram/bundle.js @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the webview's copy (docs/RICH-DIAGRAMS.md, "Architecture") + * + * The chat renders diagrams itself — it has the real font to measure text with and the live theme + * to paint with — using the SAME modules the extension host validates with. There is one copy of + * that code, in this folder; this file hands it to the webview as source text, which extension.js + * inlines into chat.html under the page's own script nonce. + * + * Inlined, not linked: every document this extension serves is self-contained (webviewCsp() allows + * no remote anything), and one nonce'd inline block keeps it that way — no extra origin in the CSP, + * no file to fetch, and the page behaves identically in the test harness. + * + * Host-only. Never shipped to the webview itself: tool.js (what the model is told), service.js + * (conversation state), stats.js and this file stay on the host side. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const theme = require('./theme'); + +/** In dependency order: each module's factory reads the ones before it off `LCDiagram`. */ +const FILES = ['theme.js', 'schema.js', 'validate.js', 'repair.js', 'layout.js', 'scene.js', 'text.js', 'ascii.js']; + +/** + * A script block ends at the first "</script" the HTML parser sees, and "<!--" changes how the rest + * is tokenised — whatever JavaScript thinks they are. None of these files contains either; if one + * ever does, refuse to build the page rather than ship a script that ends early. + */ +const BREAKS_SCRIPT = /<\/script|<!--|<script/i; + +let cached = null; +/** The modules, concatenated, ready to sit inside one <script> element. */ +function webviewSource() { + if (cached !== null) { return cached; } + const parts = FILES.map((f) => { + const src = fs.readFileSync(path.join(__dirname, f), 'utf8'); + if (BREAKS_SCRIPT.test(src)) { throw new Error('diagram/' + f + ' contains a sequence that would end an inline <script> block'); } + return '// ---- diagram/' + f + '\n' + src; + }); + cached = parts.join('\n;\n'); + return cached; +} +/** The stylesheet for the live view: every rule reads an editor theme token. */ +function webviewCss() { return theme.css(); } + +/** + * Put both into the page. chat.html carries two placeholders; a function replacer is used so no "$" + * in the source is read as a replacement pattern. + * @param {string} html + */ +function inject(html) { + return String(html) + .replace('/*__LCD_CSS__*/', () => webviewCss()) + .replace('/*__LCD_JS__*/', () => webviewSource()); +} + +module.exports = { FILES, webviewSource, webviewCss, inject, BREAKS_SCRIPT }; diff --git a/extensions/levelcode-ai/diagram/exportCheck.js b/extensions/levelcode-ai/diagram/exportCheck.js new file mode 100644 index 0000000..241a84c --- /dev/null +++ b/extensions/levelcode-ai/diagram/exportCheck.js @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · what may be written to disk (docs/RICH-DIAGRAMS.md, FR-7 + + * "Safe rendering") + * + * An exported SVG or PNG is produced in the webview — that is where the real font and the live + * theme are, and it is the only way "exports match the rendered view" can be true. So the bytes + * cross the webview boundary, and the host does not take them on trust: before anything is written, + * an SVG must be made of nothing but the painter's own elements and attributes, and a PNG must + * actually be a PNG. + * + * This is an allow-list over structure, the same lists the painter draws from — not a search for + * known-bad strings. + * + * Host-only, pure. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const scene = require('./scene'); + +const MAX_SVG = 2 * 1024 * 1024; +const MAX_PNG = 20 * 1024 * 1024; +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** + * Is this an SVG the painter could have produced? + * @param {any} svg + * @returns {{ ok: true } | { ok: false, reason: string }} + */ +function checkSvg(svg) { + if (typeof svg !== 'string') { return { ok: false, reason: 'not text' }; } + if (svg.length > MAX_SVG) { return { ok: false, reason: 'too large' }; } + const src = svg.trim(); + if (!/^<svg[\s>]/.test(src) || !/<\/svg>$/.test(src)) { return { ok: false, reason: 'not an SVG document' }; } + // One stylesheet at most, in CDATA, and it may not reach outside the file. + const styles = src.match(/<style>[\s\S]*?<\/style>/g) || []; + if (styles.length > 1) { return { ok: false, reason: 'more than one stylesheet' }; } + for (const st of styles) { + const css = st.replace(/^<style><!\[CDATA\[/, '').replace(/\]\]><\/style>$/, ''); + if (css === st || /<|\]\]>|@import|url\s*\(|expression\s*\(|javascript:|\\/i.test(css)) { return { ok: false, reason: 'stylesheet is not the painter\'s' }; } + } + const body = src.replace(/<style>[\s\S]*?<\/style>/g, '<style/>'); + if (/<!|<\?|\]\]>/.test(body)) { return { ok: false, reason: 'declarations and comments are not allowed' }; } + const stack = []; + for (const m of body.matchAll(/<[^>]*>/g)) { + const tag = m[0]; + const end = /^<\/([A-Za-z][\w:-]*)>$/.exec(tag); + if (end) { if (stack.pop() !== end[1]) { return { ok: false, reason: 'tags do not nest' }; } continue; } + const start = /^<([A-Za-z][\w:-]*)((?:\s+[\w:-]+="[^"<>]*")*)\s*(\/?)>$/.exec(tag); + if (!start) { return { ok: false, reason: 'malformed tag' }; } + if (!scene.TAGS.has(start[1])) { return { ok: false, reason: 'element <' + start[1] + '> is not allowed' }; } + for (const a of (start[2].match(/[\w:-]+(?==")/g) || [])) { if (!scene.ATTRS.has(a)) { return { ok: false, reason: 'attribute "' + a + '" is not allowed' }; } } + if (!start[3]) { stack.push(start[1]); } + } + if (stack.length) { return { ok: false, reason: 'unclosed element' }; } + if (/<[^>]*$/.test(body)) { return { ok: false, reason: 'unclosed tag' }; } + return { ok: true }; +} + +/** + * Decode a base64 PNG, checking it is one. + * @param {any} base64 + * @returns {{ ok: true, bytes: Buffer } | { ok: false, reason: string }} + */ +function checkPng(base64) { + if (typeof base64 !== 'string' || !base64) { return { ok: false, reason: 'no image data' }; } + const b64 = base64.replace(/^data:image\/png;base64,/, ''); + if (b64.length > MAX_PNG * 1.4) { return { ok: false, reason: 'too large' }; } + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) { return { ok: false, reason: 'not base64' }; } + const bytes = Buffer.from(b64, 'base64'); + if (bytes.length > MAX_PNG) { return { ok: false, reason: 'too large' }; } + if (bytes.length < 60 || PNG_MAGIC.some((v, i) => bytes[i] !== v)) { return { ok: false, reason: 'not a PNG image' }; } + return { ok: true, bytes }; +} + +/** A file name for a diagram, from its title: lowercase words joined by hyphens, never empty. */ +function fileStem(title) { + return String(title == null ? '' : title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/, '') || 'diagram'; +} + +module.exports = { checkSvg, checkPng, fileStem, MAX_SVG, MAX_PNG }; diff --git a/extensions/levelcode-ai/diagram/layout.js b/extensions/levelcode-ai/diagram/layout.js new file mode 100644 index 0000000..92c9d59 --- /dev/null +++ b/extensions/levelcode-ai/diagram/layout.js @@ -0,0 +1,1317 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · layout (docs/RICH-DIAGRAMS.md, "Architecture" + "Style guide") + * + * Turns a validated Graph JSON spec into geometry: where every box sits, the route every connector + * takes, and where each label goes. The model never supplies a coordinate; this file supplies all + * of them, the same way every time — which is the whole reason a diagram looks the same whichever + * model asked for it. + * + * It is a layered ("Sugiyama") layout with orthogonal routing, written for the house rules: + * + * rank nodes are placed in columns (or rows) along the flow; cycles are broken first, and the + * edge that closes one is drawn running back + * order within a rank, positions are chosen to minimise crossings — with every group kept + * contiguous, and sibling groups in one consistent order so their frames never interleave + * place positions across the flow come from a small constraint system (nothing overlaps, + * every group owns one band through all the ranks it spans) relaxed until connected + * things line up + * route connectors leave and enter on the sides that face the flow, each on its own port, and + * turn in their own track in the gap between ranks — so lines run THROUGH GAPS and + * never share a segment + * label an edge label is placed BESIDE its line, on the best spot that touches nothing else; + * the gap it needs is reserved before routing rather than hoped for after + * + * WHY NOT ELK.js (the spec names it): ELK is EPL-2.0 and a ~1.5 MB bundle. This extension is + * MIT-clean, plain JS and dependency-free (CLAUDE.md, "Conventions"), and the spec's own open + * question allows "a simpler layered layout". With a 12-node cap the problem is small enough to do + * directly. layout(spec, opts) is the entire interface, so swapping ELK in later touches one call. + * + * Everything is computed on two abstract axes — M, along the flow, and C, across it — and mapped to + * x/y at the very end, so `direction: "down"` is the same code as `"right"`, not a second copy. + * + * Pure and deterministic: no randomness, no clock, no DOM. Text is measured by an injected + * `measure(text, role)` — a canvas in the webview, theme.approxMeasure in Node. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./theme')); } + else { (root.LCDiagram = root.LCDiagram || {}).layout = factory(root.LCDiagram.theme); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (theme) { + 'use strict'; + + /** Two cross coordinates this close are "the same line": the connector between them is drawn straight. */ + const STRAIGHT = 1.0; + const r2 = (v) => Math.round(v * 100) / 100; + + // ---- node sizes ----------------------------------------------------------------------------- + + /** Split a decision's label into two balanced lines when it is long enough to make the diamond sprawl. */ + function wrapTwo(text, measure, role) { + const words = String(text).split(' '); + if (words.length < 2 || Array.from(text).length <= 14) { return [text]; } + let best = null; + for (let i = 1; i < words.length; i++) { + const a = words.slice(0, i).join(' '), b = words.slice(i).join(' '); + const w = Math.max(measure(a, role), measure(b, role)); + if (!best || w < best.w) { best = { w, lines: [a, b] }; } + } + return best ? best.lines : [text]; + } + + /** + * The size of a node and the lines of text inside it, in screen terms (w across, h down). + * "Width from the measured longest line plus 24" — plus whatever the shape itself needs. + */ + function nodeSize(node, measure, T, B) { + const shape = node.shape || 'box'; + const nameLines = shape === 'decision' ? wrapTwo(node.label, measure, 'name') : [node.label]; + const lines = nameLines.map((t) => ({ text: t, role: 'name', w: measure(t, 'name'), h: T.name.line })); + if (node.sub) { lines.push({ text: node.sub, role: 'sub', w: measure(node.sub, 'sub'), h: T.sub.line }); } + const textW = Math.max.apply(null, lines.map((l) => l.w)); + const textH = lines.reduce((s, l) => s + l.h, 0) + (node.sub ? B.textGap : 0); + const link = node.link ? B.linkIcon : 0; + let w, h, textDy = 0; + if (shape === 'decision') { + // A rhombus with half-diagonals a (across) and b (down) contains a line of half-width hw whose + // far edge is `far` from the centre when hw/a + far/b <= 1. Fit EACH line rather than the + // block's bounding box — a short line near a tip needs far less room than the widest one — + // and take the smallest diamond that is neither a sliver nor a square. + let yy = -textH / 2; + const rows = lines.map((l, i) => { + if (l.role === 'sub' && i > 0) { yy += B.textGap; } + const far = Math.max(Math.abs(yy), Math.abs(yy + l.h)) + 3; + yy += l.h; + return { hw: l.w / 2 + 6 + (i === 0 ? link : 0), far }; + }); + const farMax = Math.max.apply(null, rows.map((rw) => rw.far)); + let best = null; + for (let b = farMax * 1.2; b <= farMax * 3.2; b += 1) { + const a = Math.max.apply(null, rows.map((rw) => rw.hw / (1 - rw.far / b))); + const ratio = a / b; + if (ratio < 1.35 || ratio > 3.1) { continue; } + if (!best || a * b < best.a * best.b) { best = { a, b }; } + } + if (!best) { const b = farMax * 2; best = { a: Math.max.apply(null, rows.map((rw) => rw.hw / (1 - rw.far / b))), b }; } + w = Math.ceil(2 * best.a); h = Math.ceil(2 * best.b); + } else if (shape === 'store') { + const cap = B.storeCap != null ? B.storeCap : 6; + w = Math.max(B.minWidth, Math.ceil(textW + 2 * B.padX + link)); + h = Math.ceil(textH + 2 * B.padY + cap); + textDy = cap / 2; // the lid pushes the text down a little + } else if (shape === 'actor') { + h = Math.ceil(textH + 2 * B.padY); + w = Math.max(B.minWidth, Math.ceil(textW + 2 * B.padX + link + h * 0.3)); // room for the round ends + } else { + w = Math.max(B.minWidth, Math.ceil(textW + 2 * B.padX + link)); + h = Math.ceil(textH + 2 * B.padY); + } + return { shape, w, h, lines, textH, textDy }; + } + + /** + * How far the outline is from the node's centre, measured along `axis`, at offset `off` on the + * other axis. Connectors stop ON the outline, so a line into a diamond meets its slanted edge + * rather than floating at the corner of its bounding box. + * @param {{shape:string, w:number, h:number}} n screen size + * @param {'x'|'y'} axis + */ + function outline(n, axis, off, B) { + const hw = n.w / 2, hh = n.h / 2, a = Math.abs(off); + if (n.shape === 'decision') { + return axis === 'x' ? hw * Math.max(0, 1 - a / hh) : hh * Math.max(0, 1 - a / hw); + } + if (n.shape === 'actor') { + const r = Math.min(hw, hh); // the pill's end radius + if (axis === 'x') { return hw - r + Math.sqrt(Math.max(0, r * r - Math.min(a, r) * Math.min(a, r))); } + const d = a - (hw - r); + return d <= 0 ? hh : Math.sqrt(Math.max(0, r * r - Math.min(d, r) * Math.min(d, r))); + } + if (n.shape === 'store' && axis === 'y') { + const cap = B.storeCap != null ? B.storeCap : 6; + const t = Math.min(1, a / hw); + return hh - cap * (1 - Math.sqrt(1 - t * t)); + } + return axis === 'x' ? hw : hh; + } + /** How much of a side connectors may spread over, by shape (kept off corners and slants). */ + function portSpan(n, sideLen, S) { + if (n.shape === 'decision') { return sideLen * 0.5; } + if (n.shape === 'actor') { return Math.max(0, sideLen * 0.55); } + return Math.max(0, sideLen - 2 * S.portInset); + } + + /** The least distance between a connector and a group's name. */ + const NAME_CLEAR = 6; + /** How far a group's incoming lines may be moved aside to clear its name before a backed name is the better picture. */ + const NAME_ROOM = 72; + + // ---- the layout ----------------------------------------------------------------------------- + + /** + * @param {any} spec a spec that passed validate() (or repair.accept()) + * @param {{ measure?: (text:string, role:string)=>number, maxWidth?: number, + * space?: object, box?: object, type?: object }} [opts] + * @returns geometry — see the bottom of layoutOnce() + */ + function layout(spec, opts) { + const o = opts || {}; + const asked = spec.direction === 'down' ? 'down' : 'right'; + let geo = layoutDir(spec, asked, o); + // "Diagrams grow downward, never wider than the chat column." A flow that was asked for + // left-to-right but cannot fit is laid out top-to-bottom instead, when that actually helps. + if (o.maxWidth && asked === 'right' && geo.width > o.maxWidth) { + const down = layoutDir(spec, 'down', o); + if (down.width <= o.maxWidth || down.width < geo.width * 0.8) { down.flipped = true; geo = down; } + } + geo.asked = asked; + return geo; + } + + /** + * One direction, laid out — and, when the flow runs down, laid out again if a group's name was left + * with a connector behind it. A name is first slid along its frame to a clear stretch, which costs + * nothing. Where the frame has no such stretch, room can be MADE: the group's incoming lines are + * kept to the far side of its name, which widens the frame by however far the lines have to move. + * That is worth a little width and not a lot — a frame half empty under a long name reads worse + * than a line passing behind the name — so it is done only for groups where the lines have less + * than NAME_ROOM to move, and kept only if the drawing still fits its column. The rest keep their + * backed name. + */ + function layoutDir(spec, dir, o) { + let best = layoutOnce(spec, dir, o, null); + if (dir !== 'down') { return tidy(best); } + const reserve = new Set(); + for (let pass = 0; pass < 2; pass++) { + const more = best.groups.filter((g) => g.labelHalo && !reserve.has(g.id) && g.labelNeed <= NAME_ROOM); + if (!more.length) { break; } + more.forEach((g) => reserve.add(g.id)); + const next = layoutOnce(spec, dir, o, reserve); + // Kept unless the result is wider than its column: a name is not worth shrinking the whole drawing. + if (o.maxWidth && next.width > o.maxWidth) { break; } + best = next; + } + return tidy(best); + } + + /** @param {Set<string>|null} reserve groups whose incoming lines must keep clear of the name (see layoutDir) */ + function layoutOnce(spec, dir, o, reserve) { + const T = Object.assign({}, theme.TYPE, o.type); + const B = Object.assign({}, theme.BOX, o.box); + const S = Object.assign({}, theme.SPACE, o.space); + const measure = o.measure || theme.approxMeasure; + const right = dir === 'right'; + const warnings = []; + + // ---- nodes, groups, edges as indexed records ------------------------------------------ + const specNodes = Array.isArray(spec.nodes) ? spec.nodes : []; + const idIndex = new Map(); + const N = specNodes.map((n, i) => { + const sz = nodeSize(n, measure, T, B); + idIndex.set(n.id, i); + return { i, id: n.id, spec: n, shape: sz.shape, w: sz.w, h: sz.h, lines: sz.lines, textH: sz.textH, textDy: sz.textDy, grp: -1, rank: 0 }; + }); + const specGroups = Array.isArray(spec.groups) ? spec.groups : []; + const gIndex = new Map(); + const G = specGroups.map((g, i) => { gIndex.set(g.id, i); return { i, id: g.id, label: g.label, tip: g.tip, parent: -1, kids: [], path: [], nodes: new Set(), rmin: Infinity, rmax: -Infinity, labelW: measure(g.label, 'group') }; }); + for (const g of G) { + const p = specGroups[g.i].parent; + if (p !== undefined && gIndex.has(p) && gIndex.get(p) !== g.i) { g.parent = gIndex.get(p); } + } + // a parent chain that loops (validate() rejects it; this is belt and braces) is cut + for (const g of G) { let cur = g, hops = 0; while (cur.parent >= 0 && hops++ <= G.length) { cur = G[cur.parent]; } if (hops > G.length) { g.parent = -1; } } + for (const g of G) { if (g.parent >= 0) { G[g.parent].kids.push(g.i); } } + const pathOf = (g) => { const p = []; let cur = g; while (cur) { p.unshift(cur.i); cur = cur.parent >= 0 ? G[cur.parent] : null; } return p; }; + for (const g of G) { g.path = pathOf(g); } + for (const n of N) { + const gi = n.spec.group !== undefined ? gIndex.get(n.spec.group) : undefined; + if (gi !== undefined) { n.grp = gi; for (const a of G[gi].path) { G[a].nodes.add(n.i); } } + } + const liveGroups = G.filter((g) => g.nodes.size > 0); + + const E = []; + (Array.isArray(spec.edges) ? spec.edges : []).forEach((e, k) => { + const u = idIndex.get(e.from), v = idIndex.get(e.to); + if (u === undefined || v === undefined) { return; } + const label = e.label ? { text: e.label, w: measure(e.label, 'edge'), h: T.edge.line } : null; + E.push({ k, u, v, self: u === v, rev: false, dashed: e.style === 'dashed', label, tip: e.tip }); + }); + const flow = E.filter((e) => !e.self); + + // ---- rank: break cycles, then longest path, then pull loose ends in -------------------- + const outE = N.map(() => []), inDeg = N.map(() => 0); + for (const e of flow) { outE[e.u].push(e); inDeg[e.v]++; } + const mark = N.map(() => 0); + const visit = (u) => { + mark[u] = 1; + for (const e of outE[u]) { if (mark[e.v] === 1) { e.rev = true; } else if (mark[e.v] === 0) { visit(e.v); } } + mark[u] = 2; + }; + // Sources first, in the order the model wrote them: a spec lists a flow start-to-finish, so + // the edge that points back at an earlier node is the one to treat as the loop. + for (const n of N) { if (inDeg[n.i] === 0 && mark[n.i] === 0) { visit(n.i); } } + for (const n of N) { if (mark[n.i] === 0) { visit(n.i); } } + for (const e of flow) { e.a = e.rev ? e.v : e.u; e.b = e.rev ? e.u : e.v; } + + const succ = N.map(() => []), pred = N.map(() => []); + for (const e of flow) { succ[e.a].push(e.b); pred[e.b].push(e.a); } + if (!flow.length) { + // Nothing connects anything: a tidy grid reads better than one long line of boxes. + const order = N.slice().sort((p, q) => (p.grp - q.grp) || (p.i - q.i)); + const per = Math.max(1, Math.ceil(order.length / Math.max(1, Math.round(Math.sqrt(order.length))))); + order.forEach((n, j) => { n.rank = Math.floor(j / per); }); + } else { + const deg = N.map((n) => pred[n.i].length); + const queue = N.filter((n) => deg[n.i] === 0).map((n) => n.i); + for (let qi = 0; qi < queue.length; qi++) { + const u = queue[qi]; + for (const v of succ[u]) { N[v].rank = Math.max(N[v].rank, N[u].rank + 1); if (--deg[v] === 0) { queue.push(v); } } + } + // Tighten: move each node, within the slack its neighbours leave, toward the side it has + // more connections on. Stops a source being parked five ranks from its only successor. + for (let pass = 0; pass < 12; pass++) { + let moved = false; + for (const n of N) { + if (!pred[n.i].length && !succ[n.i].length) { continue; } + const lo = pred[n.i].length ? Math.max.apply(null, pred[n.i].map((p) => N[p].rank)) + 1 : 0; + const hi = succ[n.i].length ? Math.min.apply(null, succ[n.i].map((s) => N[s].rank)) - 1 : n.rank; + let want = n.rank; + if (succ[n.i].length > pred[n.i].length) { want = hi; } + else if (pred[n.i].length > succ[n.i].length) { want = lo; } + want = Math.max(lo, Math.min(Math.max(lo, hi), want)); + if (want !== n.rank) { n.rank = want; moved = true; } + } + if (!moved) { break; } + } + const minRank = Math.min.apply(null, N.map((n) => n.rank)); + if (minRank) { for (const n of N) { n.rank -= minRank; } } + } + const R = N.length ? Math.max.apply(null, N.map((n) => n.rank)) + 1 : 1; + for (const g of liveGroups) { for (const ni of g.nodes) { g.rmin = Math.min(g.rmin, N[ni].rank); g.rmax = Math.max(g.rmax, N[ni].rank); } } + + // ---- items: real nodes, the virtual nodes that carry long edges, group spacers --------- + const items = []; + const ms = (n) => (right ? n.w : n.h), cs = (n) => (right ? n.h : n.w); + const nodeItem = N.map((n) => { + const it = { kind: 'node', node: n, rank: n.rank, ms: ms(n), cs: cs(n), grp: n.grp, path: n.grp >= 0 ? G[n.grp].path : [], order: 0, x: 0, extraLo: 0, extraHi: 0, lo: [], hi: [] }; + items.push(it); return it; + }); + /** + * Which group a long edge is "in" as it crosses rank r. An edge into a group enters at the + * group's edge and then travels INSIDE its band to the node it wants — it does not skirt the + * frame and double back. So: the innermost group of either end that spans this rank; when the + * two ends offer unrelated groups, the one whose node is nearer. + */ + const groupAt = (n, r) => { + if (n.grp < 0) { return -1; } + const path = G[n.grp].path; + for (let i = path.length - 1; i >= 0; i--) { const g = G[path[i]]; if (g.rmin <= r && r <= g.rmax) { return g.i; } } + return -1; + }; + const chainGroup = (a, b, r) => { + const ga = groupAt(a, r), gb = groupAt(b, r); + if (ga < 0 || gb < 0) { return Math.max(ga, gb); } + if (ga === gb || G[gb].path.indexOf(ga) >= 0) { return gb; } + if (G[ga].path.indexOf(gb) >= 0) { return ga; } + return (r - a.rank) <= (b.rank - r) ? ga : gb; + }; + const lm = (label) => (right ? label.w : label.h), lc = (label) => (right ? label.h : label.w); + const rankMs = new Array(R).fill(0); + for (const it of nodeItem) { rankMs[it.rank] = Math.max(rankMs[it.rank], it.ms); } + const chains = []; + for (const e of flow) { + const a = nodeItem[e.a], b = nodeItem[e.b]; + const seq = [a]; + for (let r = a.rank + 1; r < b.rank; r++) { + const grp = chainGroup(N[e.a], N[e.b], r); + const v = { kind: 'virt', rank: r, ms: 0, cs: 0, grp, path: grp >= 0 ? G[grp].path : [], order: 0, x: 0, extraLo: 0, extraHi: 0, chain: null }; + items.push(v); seq.push(v); + } + seq.push(b); + const ch = { e, seq, offA: 0, offB: 0, host: null }; + for (const v of seq) { if (v.kind === 'virt') { v.chain = ch; } } + // A long edge's label rides above the line where it crosses a rank that is wide enough for it. + if (e.label) { + const host = seq.find((v) => v.kind === 'virt' && rankMs[v.rank] >= lm(e.label) + 2 * S.labelPad); + if (host) { ch.host = host; host.extraLo = lc(e.label) + S.labelGap + 2; } + } + chains.push(ch); + } + // A group owns one band across EVERY rank it spans. Where it has nothing of its own in a + // rank, a zero-size spacer holds its place, so what else is in that rank is above or below. + const inSubtree = (it, g) => it.path.indexOf(g.i) >= 0; + for (const g of liveGroups.slice().sort((p, q) => q.path.length - p.path.length)) { + for (let r = g.rmin; r <= g.rmax; r++) { + if (!items.some((it) => it.rank === r && inSubtree(it, g))) { + items.push({ kind: 'spacer', rank: r, ms: 0, cs: 0, grp: g.i, path: g.path, order: 0, x: 0, extraLo: 0, extraHi: 0 }); + } + } + } + const ranks = []; + for (let r = 0; r < R; r++) { ranks.push(items.filter((it) => it.rank === r)); } + /** segs[r] = the pieces of chains that cross the gap between rank r and r+1. */ + const segs = []; + for (let r = 0; r < R; r++) { segs.push([]); } + for (const ch of chains) { for (let j = 0; j + 1 < ch.seq.length; j++) { segs[ch.seq[j].rank].push({ a: ch.seq[j], b: ch.seq[j + 1], ch, j }); } } + + orderRanks(ranks, segs, G, N, nodeItem); + + // ---- ports: every connector end gets its own point on its side ------------------------- + // A side's ends are sorted by where the other end sits in the neighbouring rank, so two + // connectors leaving one node never cross on the way out. + for (const ch of chains) { + const first = ch.seq[0], last = ch.seq[ch.seq.length - 1]; + first.hi.push({ ch, other: ch.seq[1], end: 'A' }); + last.lo.push({ ch, other: ch.seq[ch.seq.length - 2], end: 'B' }); + } + const selfLoops = E.filter((e) => e.self); + for (const e of selfLoops) { nodeItem[e.u].hi.push({ self: e, other: { order: -1 }, end: 'S' }); } + for (const it of nodeItem) { + for (const side of ['lo', 'hi']) { + const ends = it[side]; + if (!ends.length) { continue; } + ends.sort((p, q) => (p.other.order - q.other.order) || ((p.ch ? p.ch.e.k : -1) - (q.ch ? q.ch.e.k : -1))); + const n = ends.length; + // A label sits on the stub that carries the arrowhead. Where several connectors arrive + // side by side and one is labelled, they are spread a label-height apart so each label + // has a lane of its own — and the node grows to fit them. Only when the flow runs right: + // run down, a label lies ACROSS the stubs and no sane spacing would hold it. + const arrowEnd = (en) => !!en.ch && !!en.ch.e.label && !en.ch.host && (en.ch.e.rev ? en.end === 'A' : en.end === 'B'); + const labelled = right && n > 1 && ends.some(arrowEnd); + const lane = S.labelLane != null ? S.labelLane : T.edge.line + S.labelGap + 2; + const floor = labelled ? lane : (S.portMin != null ? S.portMin : 6); + let gap = Math.max(S.portGap, labelled ? floor : 0); + const span = portSpan(it.node, it.cs, S); + if (n > 1 && (n - 1) * gap > span) { + gap = Math.max(floor, span / (n - 1)); + if ((n - 1) * gap > span + 1e-6) { + // Still does not fit: the node grows along its side. ("Renderer only: widen, wrap.") + const need = (n - 1) * gap; + const grow = it.node.shape === 'decision' ? need / 0.5 : it.node.shape === 'actor' ? need / 0.55 : need + 2 * S.portInset; + if (right) { it.node.h = Math.ceil(grow); } else { it.node.w = Math.ceil(grow); } + it.cs = cs(it.node); // (the node now holds exactly `need`: there is nothing left to re-measure) + } + } + ends.forEach((en, i) => { + const off = (i - (n - 1) / 2) * gap; + if (en.end === 'A') { en.ch.offA = off; } else if (en.end === 'B') { en.ch.offB = off; } else { en.self.off = off; } + }); + } + // a self-loop steps out above the node; keep that space free + const loop = selfLoops.filter((e) => e.u === it.node.i); + if (loop.length) { it.extraLo = Math.max(it.extraLo, S.selfLoop + (loop.length - 1) * S.track + (loop.some((e) => e.label) ? Math.max.apply(null, loop.filter((e) => e.label).map((e) => lc(e.label))) + S.labelGap : 0) + 2); } + } + + // ---- place across the flow -------------------------------------------------------------- + const crossPadLo = right ? S.groupHeader + S.groupInset - 6 : S.groupInset; + const crossPadHi = S.groupInset; + const mainPadLo = right ? S.groupInset : S.groupHeader + S.groupInset - 6; + const mainPadHi = S.groupInset; + placeCross(items, ranks, liveGroups, G, chains, S, { crossPadLo, crossPadHi, right, reserve: reserve || null }, warnings); + + // ---- gaps between ranks: tracks for the bends, room for the labels ----------------------- + const nestLo = (g) => mainPadLo + Math.max(0, Math.max.apply(null, [0].concat(g.kids.filter((k) => G[k].nodes.size && G[k].rmin === g.rmin).map((k) => nestLo(G[k]))))); + const nestHi = (g) => mainPadHi + Math.max(0, Math.max.apply(null, [0].concat(g.kids.filter((k) => G[k].nodes.size && G[k].rmax === g.rmax).map((k) => nestHi(G[k]))))); + const openPad = new Array(R + 1).fill(0), closePad = new Array(R + 1).fill(0); + for (const g of liveGroups) { + if (g.parent >= 0) { continue; } + openPad[g.rmin] = Math.max(openPad[g.rmin], nestLo(g)); + closePad[g.rmax] = Math.max(closePad[g.rmax], nestHi(g)); + } + const crossAt = (it, ch, end) => it.x + (it.kind === 'node' ? (end === 'A' ? ch.offA : ch.offB) : 0); + const gaps = []; + const hasLoop = new Array(R).fill(0); + const loopCount = new Map(); + for (const e of selfLoops) { loopCount.set(e.u, (loopCount.get(e.u) || 0) + 1); hasLoop[N[e.u].rank] = Math.max(hasLoop[N[e.u].rank], loopCount.get(e.u)); } + for (let r = 0; r + 1 < R; r++) { + const conns = segs[r].map((sg) => { + const ca = crossAt(sg.a, sg.ch, 'A'), cb = crossAt(sg.b, sg.ch, 'B'); + return { sg, ca, cb, lo: Math.min(ca, cb), hi: Math.max(ca, cb), bent: Math.abs(ca - cb) > STRAIGHT, track: -1 }; + }); + const tracks = assignTracks(conns.filter((c) => c.bent), S); + // Which labels live in THIS gap: an edge with no wide-enough rank to ride over puts its + // label on the stub into its target, i.e. in the last gap it crosses. + let tail = 0, straightRoom = 0, crowd = 0; + const arriving = new Map(); + for (const c of conns) { + const ch = c.sg.ch, e = ch.e; + if (!e.label || ch.host || c.sg.j !== ch.seq.length - 2) { continue; } + if (c.bent) { tail = Math.max(tail, lm(e.label) + 2 * S.labelPad); } + else { straightRoom = Math.max(straightRoom, lm(e.label) + 2 * S.labelPad + S.arrow + 4); } + arriving.set(c.sg.b, (arriving.get(c.sg.b) || 0) + 1); + if (arriving.get(c.sg.b) > 2) { crowd = Math.max(crowd, lm(e.label) + 2 * S.labelPad + 2); } + } + // Three or more labelled connectors into one node cannot all be labelled where they arrive; + // a longer stub out of each SOURCE gives the extra ones somewhere clear to sit. + const loopPad = hasLoop[r] ? S.selfLoop + (hasLoop[r] - 1) * S.track + 6 : 0; + const lead = Math.max(S.channelPad, crowd) + loopPad; + const bundle = tracks > 0 ? (tracks - 1) * S.track : 0; + const trail = Math.max(S.channelPad, tail ? tail + S.arrow + 4 : 0); + let core = tracks > 0 ? lead + bundle + trail : Math.max(loopPad + S.channelPad, 0); + core = Math.max(core, straightRoom + loopPad, S.rankGap); + gaps.push({ conns, tracks, lead, bundle, trail, core, hugLo: tail > 0 || loopPad > 0, width: closePad[r] + core + openPad[r + 1] }); + } + + // ---- place along the flow, then route ---------------------------------------------------- + const rankLo = new Array(R).fill(0), rankHi = new Array(R).fill(0); + const placeMain = () => { + let m = openPad[0]; + for (let r = 0; r < R; r++) { + rankLo[r] = m; rankHi[r] = m + rankMs[r]; + m = rankHi[r] + (r + 1 < R ? gaps[r].width : 0); + } + }; + placeMain(); + // A group must be wide enough to carry its own label along the top. + if (right) { + const extra = new Array(R).fill(0); + for (const g of liveGroups) { + const have = (rankHi[g.rmax] + mainPadHi) - (rankLo[g.rmin] - mainPadLo); + const need = g.labelW + 2 * S.groupInset; + if (need > have) { g.extraHi = need - have; extra[g.rmax] = Math.max(extra[g.rmax], g.extraHi); } + } + if (extra.some((v) => v > 0)) { + for (let r = 0; r + 1 < R; r++) { gaps[r].width += extra[r]; } + placeMain(); + } + } + for (const it of items) { it.m = (rankLo[it.rank] + rankHi[it.rank]) / 2; } + + const P = (m, c) => (right ? { x: m, y: c } : { x: c, y: m }); + const mAxis = right ? 'x' : 'y', cAxis = right ? 'y' : 'x'; + for (let r = 0; r + 1 < R; r++) { + const gp = gaps[r]; + const zoneLo = rankHi[r] + closePad[r], zoneHi = rankLo[r + 1] - openPad[r + 1]; + const start = gp.hugLo ? zoneLo + gp.lead : zoneLo + ((zoneHi - zoneLo) - gp.bundle) / 2; + for (const c of gp.conns) { if (c.bent) { c.m = start + c.track * S.track; } } + } + const connOf = new Map(); + for (const gp of gaps) { for (const c of gp.conns) { connOf.set(c.sg.ch.e.k + ':' + c.sg.j, c); } } + + const routes = []; + for (const ch of chains) { + const first = ch.seq[0], last = ch.seq[ch.seq.length - 1]; + let cur = first.x + ch.offA; + const pts = [{ m: first.m + outline(first.node, mAxis, ch.offA, B), c: cur }]; + for (let j = 0; j + 1 < ch.seq.length; j++) { + const next = ch.seq[j + 1]; + const c = connOf.get(ch.e.k + ':' + j); + if (c && c.bent) { pts.push({ m: c.m, c: cur }); cur = c.cb; pts.push({ m: c.m, c: cur }); } + if (next.kind === 'virt') { pts.push({ m: rankLo[next.rank], c: cur }, { m: rankHi[next.rank], c: cur }); } + } + pts.push({ m: last.m - outline(last.node, mAxis, cur - last.x, B), c: cur }); + routes.push({ e: ch.e, ch, pts: simplify(pts) }); + } + const loopsAt = new Map(); + for (const e of selfLoops) { + const it = nodeItem[e.u], n = it.node; + const nth = loopsAt.get(e.u) || 0; loopsAt.set(e.u, nth + 1); + const L = S.selfLoop + nth * S.track; + const top = it.x - it.cs / 2, enter = Math.max(-it.ms / 2 + S.portInset, it.ms / 4 - nth * S.track); + const pts = [ + { m: it.m + outline(n, mAxis, e.off, B), c: it.x + e.off }, + { m: it.m + it.ms / 2 + L, c: it.x + e.off }, + { m: it.m + it.ms / 2 + L, c: top - L }, + { m: it.m + enter, c: top - L }, + { m: it.m + enter, c: it.x - outline(n, cAxis, enter, B) } + ]; + routes.push({ e, ch: null, pts: simplify(pts), loop: true }); + } + for (const rt of routes) { if (rt.e.rev) { rt.pts.reverse(); } } + + // ---- group frames ----------------------------------------------------------------------- + const frames = []; + const frameOf = new Map(); + const frame = (g) => { + if (frameOf.has(g.i)) { return frameOf.get(g.i); } + let mLo = rankLo[g.rmin] - mainPadLo, mHi = rankHi[g.rmax] + mainPadHi + (g.extraHi || 0); + for (const k of g.kids) { + if (!G[k].nodes.size) { continue; } + const f = frame(G[k]); + mLo = Math.min(mLo, f.mLo - mainPadLo); mHi = Math.max(mHi, f.mHi + mainPadHi); + } + const f = { g, mLo, mHi, cLo: g.lo, cHi: g.hi }; + frameOf.set(g.i, f); return f; + }; + for (const g of liveGroups) { frames.push(frame(g)); } + + // ---- everything into screen coordinates ------------------------------------------------- + const rectMC = (mLo, mHi, cLo, cHi) => (right ? { x: mLo, y: cLo, w: mHi - mLo, h: cHi - cLo } : { x: cLo, y: mLo, w: cHi - cLo, h: mHi - mLo }); + const nodesOut = nodeItem.map((it) => { + const n = it.node; + const c = P(it.m, it.x); + return { id: n.id, shape: n.shape, x: c.x - n.w / 2, y: c.y - n.h / 2, w: n.w, h: n.h, cx: c.x, cy: c.y, accent: n.spec.accent === true, link: n.spec.link || null, tip: n.spec.tip || null, group: n.grp >= 0 ? G[n.grp].id : null, lines: n.lines, textH: n.textH, textDy: n.textDy }; + }); + const groupsOut = frames.map((f) => { + const rc = rectMC(f.mLo, f.mHi, f.cLo, f.cHi); + return { id: f.g.id, label: f.g.label, tip: f.g.tip || null, parent: f.g.parent >= 0 ? G[f.g.parent].id : null, depth: f.g.path.length, x: rc.x, y: rc.y, w: rc.w, h: rc.h, labelW: f.g.labelW, labelH: T.group.line }; + }); + for (const g of groupsOut) { g.labelX = g.x + S.groupInset - 4; g.labelY = g.y + 7; g.labelHalo = false; g.labelNeed = 0; } + const edgesOut = routes.map((rt) => { + const points = rt.pts.map((p) => P(p.m, p.c)); + const n = points.length, a = points[n - 2], b = points[n - 1]; + const dx = Math.sign(r2(b.x - a.x)), dy = Math.sign(r2(b.y - a.y)); + const host = rt.ch && rt.ch.host ? P(rt.ch.host.m, rt.ch.host.x) : null; + return { index: rt.e.k, from: N[rt.e.u].id, to: N[rt.e.v].id, dashed: rt.e.dashed, points, arrow: { x: b.x, y: b.y, dx, dy }, label: null, tip: rt.e.tip || null, loop: !!rt.loop, back: !!rt.e.rev, _e: rt.e, _host: host }; + }); + + placeGroupLabels(groupsOut, edgesOut, warnings); + placeLabels(edgesOut, nodesOut, groupsOut, S, warnings, right); + + // ---- frame it: shift so the drawing starts at the margin, and measure -------------------- + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const grow = (x, y) => { if (x < minX) { minX = x; } if (y < minY) { minY = y; } if (x > maxX) { maxX = x; } if (y > maxY) { maxY = y; } }; + for (const n of nodesOut) { grow(n.x - 1, n.y - 1); grow(n.x + n.w + 1, n.y + n.h + 1); } + for (const g of groupsOut) { grow(g.x, g.y); grow(g.x + g.w, g.y + g.h); } + for (const e of edgesOut) { + for (const p of e.points) { grow(p.x - S.arrowHalf, p.y - S.arrowHalf); grow(p.x + S.arrowHalf, p.y + S.arrowHalf); } + if (e.label) { grow(e.label.x, e.label.y); grow(e.label.x + e.label.w, e.label.y + e.label.h); } + } + if (!Number.isFinite(minX)) { minX = minY = 0; maxX = maxY = 0; } + const dx = S.margin - minX, dy = S.margin - minY; + for (const n of nodesOut) { n.x = r2(n.x + dx); n.y = r2(n.y + dy); n.cx = r2(n.cx + dx); n.cy = r2(n.cy + dy); } + for (const g of groupsOut) { g.x = r2(g.x + dx); g.y = r2(g.y + dy); g.w = r2(g.w); g.h = r2(g.h); g.labelX = r2(g.labelX + dx); g.labelY = r2(g.labelY + dy); } + for (const e of edgesOut) { + for (const p of e.points) { p.x = r2(p.x + dx); p.y = r2(p.y + dy); } + e.arrow.x = r2(e.arrow.x + dx); e.arrow.y = r2(e.arrow.y + dy); + if (e.label) { e.label.x = r2(e.label.x + dx); e.label.y = r2(e.label.y + dy); } + delete e._e; delete e._host; + } + const geo = { + direction: dir, flipped: false, asked: dir, + width: Math.ceil(maxX - minX + 2 * S.margin), height: Math.ceil(maxY - minY + 2 * S.margin), + nodes: nodesOut, edges: edgesOut, groups: groupsOut, ranks: R, + crossings: countAllCrossings(ranks, segs), warnings + }; + for (const w of inspect(geo)) { warnings.push(w); } + return geo; + } + /** What layoutDir() needs from a pass and nobody else does. */ + function tidy(geo) { for (const g of geo.groups) { delete g.labelNeed; } return geo; } + + /** Drop repeated points and points in the middle of a straight run. */ + function simplify(pts) { + const out = []; + for (const p of pts) { + const q = out[out.length - 1]; + if (q && Math.abs(q.m - p.m) < 0.01 && Math.abs(q.c - p.c) < 0.01) { continue; } + out.push({ m: p.m, c: p.c }); + } + for (let i = out.length - 2; i > 0; i--) { + const a = out[i - 1], b = out[i], c = out[i + 1]; + if ((Math.abs(a.m - b.m) < 0.01 && Math.abs(b.m - c.m) < 0.01) || (Math.abs(a.c - b.c) < 0.01 && Math.abs(b.c - c.c) < 0.01)) { out.splice(i, 1); } + } + return out; + } + + // ---- ordering ------------------------------------------------------------------------------- + + function crossingsBetween(sgs) { + let c = 0; + for (let i = 0; i < sgs.length; i++) { + for (let j = i + 1; j < sgs.length; j++) { + const p = sgs[i], q = sgs[j]; + const da = p.a.order - q.a.order, db = p.b.order - q.b.order; + if (da * db < 0) { c++; } + } + } + return c; + } + function countAllCrossings(ranks, segs) { let c = 0; for (let r = 0; r + 1 < ranks.length; r++) { c += crossingsBetween(segs[r]); } return c; } + + /** + * Order every rank to minimise crossings. A rank is a TREE, not a list: items that share a group + * stay next to each other at every level, so the order is found by sorting blocks (an item, or a + * whole group) among their siblings and recursing — which keeps frames contiguous by construction. + */ + function orderRanks(ranks, segs, G, N, nodeItem) { + const R = ranks.length; + const number = () => { for (const rk of ranks) { rk.forEach((it, i) => { it.order = i; }); } }; + const blocksOf = (list, depth) => { + const blocks = [], by = new Map(); + for (const it of list) { + const g = it.path[depth]; + if (g === undefined) { blocks.push({ group: -1, items: [it] }); continue; } + let b = by.get(g); + if (!b) { b = { group: g, items: [] }; by.set(g, b); blocks.push(b); } + b.items.push(it); + } + return blocks; + }; + const mean = (arr) => arr.reduce((s, v) => s + v, 0) / arr.length; + /** Sort a rank's tree by each block's mean key (stable — ties keep their order). */ + const arrange = (list, depth, flip) => { + const blocks = blocksOf(list, depth); + blocks.forEach((b, i) => { b.key = mean(b.items.map((it) => it.key)); b.at = i; }); + blocks.sort((p, q) => (p.key - q.key) || (flip ? q.at - p.at : p.at - q.at)); + const out = []; + for (const b of blocks) { if (b.group < 0) { out.push(b.items[0]); } else { for (const it of arrange(b.items, depth + 1, flip)) { out.push(it); } } } + return out; + }; + // Sibling groups must keep ONE order through every rank they share, or their bands would + // have to cross. Decide that order once (by where each group sits on average) and impose it. + const groupKey = new Map(); + const settleGroups = () => { + number(); + const sum = new Map(), cnt = new Map(); + for (const rk of ranks) { + const span = Math.max(1, rk.length - 1); + for (const it of rk) { for (const g of it.path) { sum.set(g, (sum.get(g) || 0) + it.order / span); cnt.set(g, (cnt.get(g) || 0) + 1); } } + } + for (const g of sum.keys()) { groupKey.set(g, sum.get(g) / cnt.get(g)); } + const keep = (list, depth) => { + const blocks = blocksOf(list, depth); + const slots = [], groups = []; + blocks.forEach((b, i) => { if (b.group >= 0) { slots.push(i); groups.push(b); } }); + groups.sort((p, q) => (groupKey.get(p.group) - groupKey.get(q.group)) || (p.group - q.group)); + slots.forEach((s, i) => { blocks[s] = groups[i]; }); + const out = []; + for (const b of blocks) { if (b.group < 0) { out.push(b.items[0]); } else { for (const it of keep(b.items, depth + 1)) { out.push(it); } } } + return out; + }; + for (let r = 0; r < R; r++) { ranks[r] = keep(ranks[r], 0); } + number(); + }; + + // Start from a depth-first walk in the order the model wrote things: a flow drawn the way it + // was described is usually already close to crossing-free. + const adjHi = new Map(); + for (const sg of segs.flat()) { if (!adjHi.has(sg.a)) { adjHi.set(sg.a, []); } adjHi.get(sg.a).push(sg.b); } + let tick = 0; + const seen = new Set(); + const walk = (it) => { if (seen.has(it)) { return; } seen.add(it); it.key = tick++; for (const nx of (adjHi.get(it) || [])) { walk(nx); } }; + for (const it of nodeItem.slice().sort((p, q) => (p.rank - q.rank) || (p.node.i - q.node.i))) { walk(it); } + for (const rk of ranks) { for (const it of rk) { if (!seen.has(it)) { it.key = tick++; } } } + for (let r = 0; r < R; r++) { ranks[r] = arrange(ranks[r], 0, false); } + settleGroups(); + + const nbrLo = new Map(), nbrHi = new Map(); + for (const sg of segs.flat()) { + if (!nbrHi.has(sg.a)) { nbrHi.set(sg.a, []); } nbrHi.get(sg.a).push(sg.b); + if (!nbrLo.has(sg.b)) { nbrLo.set(sg.b, []); } nbrLo.get(sg.b).push(sg.a); + } + const snapshot = () => ranks.map((rk) => rk.slice()); + const restore = (snap) => { for (let r = 0; r < R; r++) { ranks[r] = snap[r].slice(); } number(); }; + let best = snapshot(), bestCost = countAllCrossings(ranks, segs); + for (let it = 0; it < 24 && bestCost > 0; it++) { + const down = it % 2 === 0, flip = it % 4 >= 2; + for (let s = 1; s < R; s++) { + const r = down ? s : R - 1 - s; + const ref = down ? nbrLo : nbrHi; + for (const item of ranks[r]) { + const ns = ref.get(item); + item.key = ns && ns.length ? mean(ns.map((n) => n.order)) : item.order; + } + ranks[r] = arrange(ranks[r], 0, flip); + ranks[r].forEach((x, i) => { x.order = i; }); + } + settleGroups(); + const cost = countAllCrossings(ranks, segs); + if (cost < bestCost) { bestCost = cost; best = snapshot(); } + } + restore(best); + + // Transpose: swap neighbouring siblings wherever that removes a crossing. (Two groups are + // never swapped here — their order is global, settled above.) + const local = (r) => (r > 0 ? crossingsBetween(segs[r - 1]) : 0) + (r + 1 < R ? crossingsBetween(segs[r]) : 0); + const treeOf = (list, depth) => blocksOf(list, depth).map((b) => (b.group < 0 ? { item: b.items[0] } : { group: b.group, kids: treeOf(b.items, depth + 1) })); + const flat = (tree, out) => { for (const t of tree) { if (t.item) { out.push(t.item); } else { flat(t.kids, out); } } return out; }; + for (let pass = 0; pass < 30 && bestCost > 0; pass++) { + let improved = false; + for (let r = 0; r < R; r++) { + const tree = treeOf(ranks[r], 0); + const apply = () => { ranks[r] = flat(tree, []); ranks[r].forEach((x, i) => { x.order = i; }); }; + const sweep = (level) => { + for (let i = 0; i + 1 < level.length; i++) { + if (level[i].group !== undefined && level[i + 1].group !== undefined) { continue; } + const before = local(r); + const t = level[i]; level[i] = level[i + 1]; level[i + 1] = t; apply(); + if (local(r) < before) { improved = true; } + else { level[i + 1] = level[i]; level[i] = t; apply(); } + } + for (const t of level) { if (t.kids) { sweep(t.kids); } } + }; + sweep(tree); + } + if (!improved) { break; } + bestCost = countAllCrossings(ranks, segs); + } + number(); + } + + // ---- placement across the flow ---------------------------------------------------------------- + + /** + * Give every item its cross coordinate, and every group its band. + * + * The hard part is stated as difference constraints (x[b] - x[a] >= d): neighbours in a rank keep + * their gap, a group's band contains its members plus padding, and — because a group's band is ONE + * pair of variables shared by every rank it spans — nothing outside a group can ever sit inside it. + * The soft part is alignment: sweeps along the flow move each item to the median of what it + * connects to, as far as the constraints let it; then anything within a hair of straight is made + * exactly straight. + */ + function placeCross(items, ranks, liveGroups, G, chains, S, pad, warnings) { + const vars = []; + items.forEach((it) => { it.v = vars.length; vars.push(0); }); + for (const g of liveGroups) { g.vLo = vars.length; vars.push(0); g.vHi = vars.length; vars.push(0); } + const X = new Float64Array(vars.length); + const cons = []; + const add = (a, b, d) => { cons.push({ a, b, d }); }; + + const gapBetween = (p, q) => { + if (p.group >= 0 || q.group >= 0) { return S.groupGap; } + const a = p.items[0], b = q.items[0]; + if (a.kind === 'node' && b.kind === 'node') { return S.nodeGap; } + if (a.kind === 'virt' && b.kind === 'virt') { return S.track; } + return S.lineClear; + }; + const blocksOf = (list, depth) => { + const blocks = [], by = new Map(); + for (const it of list) { + const g = it.path[depth]; + if (g === undefined) { blocks.push({ group: -1, items: [it] }); continue; } + let b = by.get(g); + if (!b) { b = { group: g, items: [] }; by.set(g, b); blocks.push(b); } + b.items.push(it); + } + return blocks; + }; + const hiOf = (b) => (b.group >= 0 ? { v: G[b.group].vHi, off: 0 } : { v: b.items[0].v, off: b.items[0].cs / 2 + b.items[0].extraHi }); + const loOf = (b) => (b.group >= 0 ? { v: G[b.group].vLo, off: 0 } : { v: b.items[0].v, off: -(b.items[0].cs / 2 + b.items[0].extraLo) }); + const walk = (list, depth) => { + const blocks = blocksOf(list, depth); + for (let i = 0; i + 1 < blocks.length; i++) { + const hi = hiOf(blocks[i]), lo = loOf(blocks[i + 1]); + // (x[lo.v] + lo.off) - (x[hi.v] + hi.off) >= gap + add(hi.v, lo.v, gapBetween(blocks[i], blocks[i + 1]) + hi.off - lo.off); + } + for (const b of blocks) { if (b.group >= 0) { walk(b.items, depth + 1); } } + }; + for (const rk of ranks) { walk(rk, 0); } + // containment: a group's band holds its direct items and its child groups, plus padding + const kidsOf = new Map(); + for (const it of items) { + if (it.grp < 0) { continue; } + const g = G[it.grp]; + const isSpacer = it.kind === 'spacer'; + add(g.vLo, it.v, isSpacer ? 0 : it.cs / 2 + it.extraLo + (it.kind === 'virt' ? S.lineClear : pad.crossPadLo)); + add(it.v, g.vHi, isSpacer ? 0 : it.cs / 2 + it.extraHi + (it.kind === 'virt' ? S.lineClear : pad.crossPadHi)); + if (!kidsOf.has(g.i)) { kidsOf.set(g.i, []); } kidsOf.get(g.i).push(it); + } + for (const g of liveGroups) { + if (g.parent >= 0 && G[g.parent].nodes.size) { + add(G[g.parent].vLo, g.vLo, pad.crossPadLo); + add(g.vHi, G[g.parent].vHi, pad.crossPadHi); + } + // when the flow runs down, the label lies across the band: the band must be wide enough for it + add(g.vLo, g.vHi, pad.right ? 0 : g.labelW + 2 * S.groupInset); + } + // When the flow runs down, a group's name lies along the very edge its connectors come in by. + // For the groups named in `reserve` — the ones whose name found no clear stretch on the first + // pass — every line entering from above is kept to the far side of the name, so the name stays + // in its corner with nothing through it. + const besideName = new Map(); // group index → [{ v, d }]: x[v] - x[group.vLo] >= d + if (!pad.right && pad.reserve && pad.reserve.size) { + for (const ch of chains) { + for (let j = 0; j + 1 < ch.seq.length; j++) { + const a = ch.seq[j], b = ch.seq[j + 1]; + const port = b.kind === 'node' ? ch.offB : 0; // where on `b` the line lands, from its centre + for (const gi of b.path) { + if (!pad.reserve.has(G[gi].id) || a.path.indexOf(gi) >= 0) { continue; } // (a line already inside does not cross this frame's top) + const d = (S.groupInset - 4) + G[gi].labelW + NAME_CLEAR - port; + add(G[gi].vLo, b.v, d); + if (!besideName.has(gi)) { besideName.set(gi, []); } + besideName.get(gi).push({ v: b.v, d }); + } + } + } + } + + /** Push everything down until every constraint holds. Returns false if they contradict. */ + const settle = () => { + for (let pass = 0; pass <= X.length + 1; pass++) { + let changed = false; + for (const c of cons) { if (X[c.b] < X[c.a] + c.d - 1e-9) { X[c.b] = X[c.a] + c.d; changed = true; } } + if (!changed) { return true; } + } + return false; + }; + if (!settle()) { warnings.push({ cls: 'layout-constraints', message: 'group bands could not all be honoured' }); } + + // What each item would like to line up with: the items its connectors lead to, one rank down + // the flow (`tLo`) and one rank up it (`tHi`), each adjusted for the port the connector uses. + for (const it of items) { it.tLo = []; it.tHi = []; } + const terms = []; + for (const ch of chains) { + for (let j = 0; j + 1 < ch.seq.length; j++) { + const a = ch.seq[j], b = ch.seq[j + 1]; + const off = (a.kind === 'node' ? ch.offA : 0) - (b.kind === 'node' ? ch.offB : 0); // want x[b] - x[a] = off + a.tHi.push({ o: b.v, off: -off }); b.tLo.push({ o: a.v, off: off }); + terms.push({ u: a.v, v: b.v, off }); + } + } + const inc = Array.from({ length: X.length }, () => []), out = Array.from({ length: X.length }, () => []); + for (const c of cons) { inc[c.b].push(c); out[c.a].push(c); } + const lbOf = (v) => { let lb = -Infinity; for (const c of inc[v]) { lb = Math.max(lb, X[c.a] + c.d); } return lb; }; + const ubOf = (v) => { let ub = Infinity; for (const c of out[v]) { ub = Math.min(ub, X[c.b] - c.d); } return ub; }; + const itemOf = new Map(items.map((it) => [it.v, it])); + + /** + * Where an item wants to be, looking at one side: the MEDIAN of what it connects to there. + * A median, not a mean, because a connector is either straight or it has two corners — being + * "nearly" in line buys nothing. Two neighbours: sit exactly between them (the symmetric fork). + * Four or more, an even count: take whichever middle one is nearer the centre of mass, so one + * connector still runs straight. + */ + const target = (it, side) => { + let ts = side === 'lo' ? it.tLo : it.tHi; + if (!ts.length) { ts = side === 'lo' ? it.tHi : it.tLo; } + if (!ts.length) { return null; } + const ds = ts.map((t) => X[t.o] + t.off).sort((p, q) => p - q); + const n = ds.length, lo = ds[Math.floor((n - 1) / 2)], hi = ds[Math.floor(n / 2)]; + const mean = ds.reduce((p, q) => p + q, 0) / n; + if (n >= 4 && n % 2 === 0) { return Math.abs(lo - mean) <= Math.abs(hi - mean) ? lo : hi; } + return Math.max(lo, Math.min(hi, mean)); + }; + const byDepth = liveGroups.slice().sort((p, q) => p.path.length - q.path.length); + /** + * Open every band as far as its surroundings allow, so its members can move inside it. Two + * neighbouring groups cannot both have the free space between them, so who is asked first + * alternates from sweep to sweep. + */ + const loosen = (flip) => { + const order = flip ? byDepth.slice().sort((p, q) => (p.path.length - q.path.length) || (q.i - p.i)) : byDepth; + for (const g of order) { + const lb = lbOf(g.vLo), ub = ubOf(g.vHi); + X[g.vLo] = Number.isFinite(lb) ? Math.min(lb, X[g.vLo]) : X[g.vLo] - 600; + X[g.vHi] = Number.isFinite(ub) ? Math.max(ub, X[g.vHi]) : X[g.vHi] + 600; + } + }; + /** + * Close every band back around what it holds. A band may have to stay wider than its + * contents (when the flow runs down, its label lies across it); the spare width is shared + * out on both sides where there is room, and taken from whichever side has it where there + * is not — it is never taken from a neighbour. + */ + const tighten = () => { + for (const g of byDepth.slice().reverse()) { + let lo = Infinity, hi = -Infinity; + for (const it of (kidsOf.get(g.i) || [])) { + const sp = it.kind === 'spacer', vt = it.kind === 'virt'; + lo = Math.min(lo, X[it.v] - (sp ? 0 : it.cs / 2 + it.extraLo + (vt ? S.lineClear : pad.crossPadLo))); + hi = Math.max(hi, X[it.v] + (sp ? 0 : it.cs / 2 + it.extraHi + (vt ? S.lineClear : pad.crossPadHi))); + } + for (const k of g.kids) { if (G[k].nodes.size) { lo = Math.min(lo, X[G[k].vLo] - pad.crossPadLo); hi = Math.max(hi, X[G[k].vHi] + pad.crossPadHi); } } + // the band also reaches far enough to the low side for its name to sit clear of the lines coming in + for (const c of (besideName.get(g.i) || [])) { lo = Math.min(lo, X[c.v] - c.d); } + if (!Number.isFinite(lo)) { continue; } + const spare = (pad.right ? 0 : g.labelW + 2 * S.groupInset) - (hi - lo); + if (spare > 0) { + // how far each side may move out before it meets something + let roomLo = Infinity, roomHi = Infinity; + for (const c of inc[g.vLo]) { roomLo = Math.min(roomLo, lo - (X[c.a] + c.d)); } + for (const c of out[g.vHi]) { if (c.b !== g.vLo) { roomHi = Math.min(roomHi, (X[c.b] - c.d) - hi); } } + roomLo = Math.max(0, roomLo); roomHi = Math.max(0, roomHi); + let takeLo = Math.min(spare / 2, roomLo), takeHi = Math.min(spare - takeLo, roomHi); + takeLo = Math.min(spare - takeHi, roomLo); + lo -= takeLo; hi += spare - takeLo; // anything still owed goes down-flow; settle() makes the room + } + X[g.vLo] = lo; X[g.vHi] = hi; + } + }; + /** + * Move an item toward `want`. Rank-mates it is pressed against in that direction come along — + * but only as far as that cluster agrees to go (the median of what each member wants), and + * never past the next thing in the way. So two children stacked under one parent slide + * together until the parent is centred on them, instead of the first one hogging the line. + * + * A group's band is a WALL here, not a member: nothing pushes a group, and a group pushes + * nothing. Letting pushes travel through bands links every rank into one cluster that never + * settles; with walls, a group moves only because the things inside it did. + */ + const move = (it, want, side, alone) => { + const delta = want - X[it.v]; + if (Math.abs(delta) < 0.01) { return; } + const dir = delta > 0 ? 1 : -1; + const cluster = new Set([it.v]), stack = [it.v]; + while (!alone && stack.length) { + const a = stack.pop(); + for (const c of (dir > 0 ? out[a] : inc[a])) { + const o = dir > 0 ? c.b : c.a; + if (!cluster.has(o) && itemOf.has(o) && X[c.b] - X[c.a] - c.d <= 1e-6) { cluster.add(o); stack.push(o); } + } + } + let limit = Infinity; + const wishes = []; + for (const v of cluster) { + for (const c of (dir > 0 ? out[v] : inc[v])) { + if (!cluster.has(dir > 0 ? c.b : c.a)) { limit = Math.min(limit, X[c.b] - X[c.a] - c.d); } + } + const m = itemOf.get(v); + if (m.kind !== 'spacer') { const t = target(m, side); if (t !== null) { wishes.push((t - X[v]) * dir); } } + } + wishes.sort((p, q) => p - q); + const n = wishes.length; + let step = n ? (wishes[Math.floor((n - 1) / 2)] + wishes[Math.floor(n / 2)]) / 2 : Math.abs(delta); + step = Math.max(0, Math.min(step, Math.abs(delta), limit)); + if (step > 1e-6) { for (const v of cluster) { X[v] += dir * step; } } + }; + const R = ranks.length; + const sweep = (down, flip, alone) => { + loosen(flip); + for (let s = 0; s < R; s++) { + const rk = ranks[down ? s : R - 1 - s]; + for (const list of [rk, rk.slice().reverse()]) { + for (const it of list) { + if (it.kind === 'spacer') { continue; } + const t = target(it, down ? 'lo' : 'hi'); + if (t !== null) { move(it, t, down ? 'lo' : 'hi', alone); } + } + } + } + tighten(); + }; + // Sweep up the flow lining things up with what they lead to, then down it lining them up + // with what leads to them. The last sweeps run WITH the flow and move one item at a time, so + // a line leaves its source straight, does its turning at the far end, and nothing that was + // just lined up is nudged again. + for (let round = 0; round < 6; round++) { sweep(false, round % 2 === 1, false); sweep(true, round % 2 === 0, false); } + for (let polish = 0; polish < 3; polish++) { sweep(true, polish % 2 === 1, true); } + settle(); + + // Straighten: a connector within a hair of straight is MADE straight, when nothing objects. + const holds = (v) => inc[v].every((c) => X[c.b] - X[c.a] >= c.d - 1e-6) && out[v].every((c) => X[c.b] - X[c.a] >= c.d - 1e-6); + for (let pass = 0; pass < 2; pass++) { + for (const t of terms) { + const delta = X[t.v] - X[t.u] - t.off; + if (Math.abs(delta) < 1e-9 || Math.abs(delta) > 2.5) { continue; } + const keep = X[t.v]; + X[t.v] = X[t.u] + t.off; + if (holds(t.v)) { continue; } + X[t.v] = keep; + const keepU = X[t.u]; + X[t.u] = X[t.v] - t.off; + if (!holds(t.u)) { X[t.u] = keepU; } + } + } + tighten(); + settle(); + for (const it of items) { it.x = X[it.v]; } + for (const g of liveGroups) { g.lo = X[g.vLo]; g.hi = X[g.vHi]; } + } + + // ---- channel tracks ----------------------------------------------------------------------------- + + /** + * Give each bent connector in a gap a track to turn in. Two connectors whose turns overlap never + * share a track; among the orders that satisfy that, the one with the fewest crossings wins. + * Returns the number of tracks used; sets `track` on each connector. + */ + function assignTracks(bent, S) { + const n = bent.length; + if (!n) { return 0; } + // i before j (i turns closer to the source rank): its inbound stub crosses j's turn when it + // lands inside j's span, and j's outbound stub crosses i's turn when it starts inside i's. + const inside = (v, c) => v > c.lo + 0.5 && v < c.hi - 0.5; + // ...and when i's inbound stub and j's outbound stub lie on (nearly) the same line, i turning + // first lays one on top of the other. That is worse than a crossing: two connectors that + // run along each other read as one. + const along = (u, v) => Math.abs(u - v) < 4; + const cost = (order) => { + let x = 0; + for (let i = 0; i < order.length; i++) { + for (let j = i + 1; j < order.length; j++) { + const p = order[i], q = order[j]; + if (inside(p.cb, q)) { x++; } + if (inside(q.ca, p)) { x++; } + if (along(p.cb, q.ca)) { x += 4; } + } + } + return x; + }; + let order = bent.slice().sort((p, q) => (p.lo - q.lo) || (p.hi - q.hi)); + if (n <= 7) { + let best = order, bestCost = cost(order); + const permute = (arr, k) => { + if (bestCost === 0) { return; } + if (k === arr.length) { const c = cost(arr); if (c < bestCost) { bestCost = c; best = arr.slice(); } return; } + for (let i = k; i < arr.length; i++) { + const t = arr[k]; arr[k] = arr[i]; arr[i] = t; + permute(arr, k + 1); + arr[i] = arr[k]; arr[k] = t; + } + }; + permute(order.slice(), 0); + order = best; + } else { + let improved = true, guard = 0; + while (improved && guard++ < 60) { + improved = false; + for (let i = 0; i + 1 < order.length; i++) { + const before = cost(order); + const t = order[i]; order[i] = order[i + 1]; order[i + 1] = t; + if (cost(order) < before) { improved = true; } else { order[i + 1] = order[i]; order[i] = t; } + } + } + } + // Turns that do not overlap may share a track (a fan-out going up and one going down meet on one line). + const clear = S.track * 0.75; + let track = 0, current = []; + for (const c of order) { + const fits = current.every((o) => (c.lo > o.hi + clear || c.hi < o.lo - clear) && !along(c.cb, o.ca) && !along(c.ca, o.cb)); + if (current.length && !fits) { track++; current = []; } + c.track = track; current.push(c); + } + return track + 1; + } + + // ---- labels ------------------------------------------------------------------------------------- + + const overlap = (a, b, padBy) => !(a.x + a.w <= b.x - padBy || b.x + b.w <= a.x - padBy || a.y + a.h <= b.y - padBy || b.y + b.h <= a.y - padBy); + /** Does the axis-aligned segment p→q pass through rectangle r (grown by `padBy`)? */ + function segHitsRect(p, q, r, padBy) { + const x0 = Math.min(p.x, q.x), x1 = Math.max(p.x, q.x), y0 = Math.min(p.y, q.y), y1 = Math.max(p.y, q.y); + return !(x1 <= r.x - padBy || x0 >= r.x + r.w + padBy || y1 <= r.y - padBy || y0 >= r.y + r.h + padBy); + } + /** + * Does a rectangle touch a NODE — its drawn outline, not its bounding box? A diamond's box has + * four empty corners, and a label tucked into one of them is beside the node, not on it. + */ + function rectHitsNode(rc, n, padBy) { + if (!overlap(rc, n, padBy)) { return false; } + if (n.shape !== 'decision') { return true; } + const cx = n.x + n.w / 2, cy = n.y + n.h / 2, hw = n.w / 2 + padBy, hh = n.h / 2 + padBy; + // the point of the rectangle nearest the diamond's centre decides it + const px = Math.max(rc.x, Math.min(cx, rc.x + rc.w)), py = Math.max(rc.y, Math.min(cy, rc.y + rc.h)); + return Math.abs(px - cx) / hw + Math.abs(py - cy) / hh <= 1; + } + + /** + * A group's name sits in the top-left of its frame — which, when the flow runs down, is exactly + * where connectors come in. A name with a line through it is slid along the top of the frame to + * the nearest clear stretch; if the frame has none, it stays where it was and is marked to be + * backed, so the line passes behind it and the name still reads. Runs before the edge labels are + * placed, so they keep clear of wherever the name ends up. + */ + function placeGroupLabels(groups, edges, warnings) { + const CLEAR = NAME_CLEAR - 1; + for (const g of groups) { + const y = g.labelY, h = g.labelH, w = g.labelW; + const lo = g.labelX, hi = g.x + g.w - (g.labelX - g.x) - w; // from the left inset to the same inset on the right + const crossed = (x) => { + const rc = { x, y, w, h }; + for (const e of edges) { for (let i = 0; i + 1 < e.points.length; i++) { if (segHitsRect(e.points[i], e.points[i + 1], rc, 2)) { return true; } } } + return false; + }; + if (!crossed(lo)) { continue; } + // The places worth trying: just past each line that comes through the name's row, and the far end. + const cands = [hi]; + for (const e of edges) { + for (let i = 0; i + 1 < e.points.length; i++) { + const p = e.points[i], q = e.points[i + 1]; + if (Math.abs(p.x - q.x) > 0.02) { continue; } // a line running ALONG the row cannot be stepped past + if (Math.max(p.y, q.y) <= y - 2 || Math.min(p.y, q.y) >= y + h + 2) { continue; } + cands.push(p.x + CLEAR, p.x - CLEAR - w); + } + } + const clear = cands.filter((x) => x >= lo - 0.01 && x <= hi + 0.01).sort((a, b) => a - b).find((x) => !crossed(x)); + if (clear !== undefined) { g.labelX = clear; continue; } + g.labelHalo = true; + // How far the leftmost line through the name would have to move to clear it (layoutDir decides + // whether that is worth doing). A line running along the row cannot be moved aside at all. + let need = 0; + for (const e of edges) { + for (let i = 0; i + 1 < e.points.length; i++) { + const p = e.points[i], q = e.points[i + 1]; + if (!segHitsRect(p, q, { x: lo, y, w, h }, 2)) { continue; } + need = Math.max(need, Math.abs(p.x - q.x) > 0.02 ? Infinity : lo + w + CLEAR - p.x); + } + } + g.labelNeed = need; + warnings.push({ cls: 'group-label-backed', message: 'group ' + g.id + ': a connector runs behind its name' }); + } + } + + /** + * Put each edge label BESIDE its line. Candidates are stepped along every run of the route + * (above and below a horizontal run, left and right of a vertical one) and scored by what they + * would touch; the cleanest wins, with a preference for the run that carries the arrowhead. The + * labels with the fewest clean options choose first. A label that cannot be placed clear of + * everything is still placed — with a halo so it stays legible — and reported, never dropped. + */ + function placeLabels(edges, nodes, groups, S, warnings, right) { + const groupLabels = groups.map((g) => ({ x: g.labelX, y: g.labelY, w: g.labelW, h: g.labelH })); + const STEP = 8; + const side = S.labelSideGap != null ? S.labelSideGap : S.labelGap + 1; // beside a vertical run + const jobs = []; + for (const e of edges) { + const lab = e._e && e._e.label; + if (!lab) { continue; } + const cands = []; + const pts = e.points; + const last = pts.length - 2; + for (let i = 0; i + 1 < pts.length; i++) { + const p = pts[i], q = pts[i + 1]; + const horizontal = Math.abs(p.y - q.y) < 0.01; + const runPen = i === last ? 0 : 4; + // keep clear of the arrowhead on the last run, and of the corner on any run + const headLo = (i === last && (horizontal ? q.x < p.x : q.y < p.y)) ? S.arrow + 4 : 3; + const headHi = (i === last && (horizontal ? q.x > p.x : q.y > p.y)) ? S.arrow + 4 : 3; + if (horizontal) { + const lo = Math.min(p.x, q.x) + headLo, hi = Math.max(p.x, q.x) - headHi; + const room = (hi - lo) - lab.w; + const prefer = i === last ? (q.x > p.x ? hi - lab.w : lo) : lo + room / 2; + const xs = []; + if (room >= 0) { for (let x = lo; x <= lo + room + 0.01; x += STEP) { xs.push(x); } xs.push(lo + room); xs.push(lo + room / 2); } + else { xs.push(lo + room / 2); } + for (const x of xs) { + const far = Math.abs(x - prefer) * 0.02 + (room < 0 ? 40 : 0); + cands.push({ x, y: p.y - S.labelGap - lab.h, pen: runPen + far }); + cands.push({ x, y: p.y + S.labelGap, pen: runPen + far + 6 }); + } + } else { + const lo = Math.min(p.y, q.y) + headLo, hi = Math.max(p.y, q.y) - headHi; + const room = (hi - lo) - lab.h; + if (room < -4) { continue; } + const ys = []; + for (let y = lo; y <= lo + Math.max(0, room) + 0.01; y += STEP) { ys.push(y); } + ys.push(lo + room / 2); + const prefer = lo + room / 2; + for (const y of ys) { + const far = Math.abs(y - prefer) * 0.02; + cands.push({ x: p.x + side, y, pen: runPen + far + (right ? 8 : 1) }); + cands.push({ x: p.x - side - lab.w, y, pen: runPen + far + (right ? 10 : 3) }); + } + } + } + // A long edge reserved clear space above its line in one rank; that spot goes first. + if (e._host) { + cands.push(right + ? { x: e._host.x - lab.w / 2, y: e._host.y - S.labelGap - lab.h, pen: -2 } + : { x: e._host.x - side - lab.w, y: e._host.y - lab.h / 2, pen: -2 }); + } + if (!cands.length) { const p = pts[0]; cands.push({ x: p.x + 4, y: p.y - S.labelGap - lab.h, pen: 50 }); } + // what each candidate touches that will not move: nodes, lines, frames + for (const c of cands) { + const rc = { x: c.x, y: c.y, w: lab.w, h: lab.h }; + let score = c.pen; + for (const n of nodes) { if (rectHitsNode(rc, n, 3)) { score += 1000; } } + for (const g of groupLabels) { if (overlap(rc, g, 3)) { score += 600; } } + for (const g of groups) { + // a label straddling a group's border reads as belonging to neither side + const inside = rc.x >= g.x + 2 && rc.x + rc.w <= g.x + g.w - 2 && rc.y >= g.y + 2 && rc.y + rc.h <= g.y + g.h - 2; + if (!inside && overlap(rc, g, 0)) { score += 40; } + } + for (const o of edges) { + for (let i = 0; i + 1 < o.points.length; i++) { + if (segHitsRect(o.points[i], o.points[i + 1], rc, o === e ? 1 : 2)) { score += 300; } + } + } + c.rc = rc; c.fixed = score; + } + jobs.push({ e, lab, cands, clean: cands.filter((c) => c.fixed < 300).length }); + } + jobs.sort((p, q) => (p.clean - q.clean) || (p.e.index - q.e.index)); + const placed = []; + for (const job of jobs) { + let best = null; + for (const c of job.cands) { + let score = c.fixed; + for (const o of placed) { if (overlap(c.rc, o, 4)) { score += 1000; } } + if (!best || score < best.score) { best = { rc: c.rc, score }; } + } + job.e.label = { text: job.lab.text, x: best.rc.x, y: best.rc.y, w: job.lab.w, h: job.lab.h, halo: best.score >= 300 }; + if (best.score >= 300) { warnings.push({ cls: 'label-collision', message: 'edge ' + job.e.index + ': no clear spot for its label' }); } + placed.push(best.rc); + } + } + + // ---- the layout layer's own check ---------------------------------------------------------------- + + /** + * "No text overflow, overlap or out-of-frame content." Returns what is wrong with a geometry — + * empty when it is clean. The renderer logs these; the tests assert there are none. + */ + function inspect(geo) { + const out = []; + const ns = geo.nodes; + for (let i = 0; i < ns.length; i++) { + for (let j = i + 1; j < ns.length; j++) { + if (overlap(ns[i], ns[j], 1)) { out.push({ cls: 'node-overlap', message: ns[i].id + ' overlaps ' + ns[j].id }); } + } + } + for (const n of ns) { + if (n.x < 0 || n.y < 0 || n.x + n.w > geo.width || n.y + n.h > geo.height) { out.push({ cls: 'out-of-frame', message: 'node ' + n.id }); } + for (const l of n.lines) { if (l.w > n.w + 0.5) { out.push({ cls: 'text-overflow', message: 'node ' + n.id + ': "' + l.text + '"' }); } } + } + for (const e of geo.edges) { + for (const p of e.points) { if (p.x < 0 || p.y < 0 || p.x > geo.width || p.y > geo.height) { out.push({ cls: 'out-of-frame', message: 'edge ' + e.index }); break; } } + for (let i = 0; i + 1 < e.points.length; i++) { + const p = e.points[i], q = e.points[i + 1]; + if (Math.abs(p.x - q.x) > 0.02 && Math.abs(p.y - q.y) > 0.02) { out.push({ cls: 'diagonal', message: 'edge ' + e.index + ' is not orthogonal' }); } + for (const n of ns) { + // A connector touches the two nodes it joins — with its first and last run only — + // and nothing else. + const own = n.id === e.from || n.id === e.to; + const terminal = i === 0 || i === e.points.length - 2; + if (own && (terminal || e.loop)) { continue; } + if (segHitsRect(p, q, n, -1)) { out.push({ cls: 'edge-through-node', message: 'edge ' + e.index + ' crosses ' + n.id }); } + } + } + if (e.label) { + for (const n of ns) { if (rectHitsNode(e.label, n, 0)) { out.push({ cls: 'label-on-node', message: 'edge ' + e.index + ' label on ' + n.id }); } } + if (e.label.x < 0 || e.label.y < 0 || e.label.x + e.label.w > geo.width || e.label.y + e.label.h > geo.height) { out.push({ cls: 'out-of-frame', message: 'label of edge ' + e.index }); } + } + } + // "Never share a segment": two connectors may cross, but never run along each other. + const runs = []; + for (const e of geo.edges) { + for (let i = 0; i + 1 < e.points.length; i++) { + const p = e.points[i], q = e.points[i + 1]; + const hz = Math.abs(p.y - q.y) < 0.02; + runs.push({ e: e.index, hz, at: hz ? p.y : p.x, lo: Math.min(hz ? p.x : p.y, hz ? q.x : q.y), hi: Math.max(hz ? p.x : p.y, hz ? q.x : q.y) }); + } + } + for (let i = 0; i < runs.length; i++) { + for (let j = i + 1; j < runs.length; j++) { + const a = runs[i], b = runs[j]; + if (a.e === b.e || a.hz !== b.hz || Math.abs(a.at - b.at) > 0.75) { continue; } + if (Math.min(a.hi, b.hi) - Math.max(a.lo, b.lo) > 1.5) { out.push({ cls: 'shared-segment', message: 'edges ' + a.e + ' and ' + b.e + ' run along each other' }); } + } + } + const gs = geo.groups; + const inside = (inner, outer) => inner.x >= outer.x - 0.5 && inner.y >= outer.y - 0.5 && inner.x + inner.w <= outer.x + outer.w + 0.5 && inner.y + inner.h <= outer.y + outer.h + 0.5; + for (let i = 0; i < gs.length; i++) { + for (let j = i + 1; j < gs.length; j++) { + if (overlap(gs[i], gs[j], 0) && !inside(gs[i], gs[j]) && !inside(gs[j], gs[i])) { out.push({ cls: 'group-overlap', message: gs[i].id + ' overlaps ' + gs[j].id }); } + } + if (gs[i].labelW + 8 > gs[i].w) { out.push({ cls: 'text-overflow', message: 'group ' + gs[i].id + ' label' }); } + else if (gs[i].labelX < gs[i].x - 0.5 || gs[i].labelX + gs[i].labelW > gs[i].x + gs[i].w + 0.5) { out.push({ cls: 'out-of-frame', message: 'the name of group ' + gs[i].id }); } + } + // A group's name is text like any other: no connector runs through it. (One marked to be backed + // is drawn over the line on purpose, and reported separately.) + for (const g of gs) { + if (g.labelHalo) { continue; } + const rc = { x: g.labelX, y: g.labelY, w: g.labelW, h: g.labelH }; + for (const e of geo.edges) { + for (let i = 0; i + 1 < e.points.length; i++) { + if (segHitsRect(e.points[i], e.points[i + 1], rc, 1)) { out.push({ cls: 'group-label-crossed', message: 'edge ' + e.index + ' runs through the name of group ' + g.id }); break; } + } + } + } + // a node is inside every group it belongs to, and outside every other + const parentOf = new Map(gs.map((g) => [g.id, g.parent])); + for (const n of ns) { + const mine = new Set(); + for (let g = n.group, hops = 0; g && hops < 16; g = parentOf.get(g), hops++) { mine.add(g); } + for (const g of gs) { + if (mine.has(g.id)) { if (!inside(n, g)) { out.push({ cls: 'member-outside-group', message: n.id + ' is outside ' + g.id }); } } + else if (overlap(n, g, 0)) { out.push({ cls: 'stranger-in-group', message: n.id + ' sits inside ' + g.id }); } + } + } + return out; + } + + return { layout, inspect, nodeSize, outline, assignTracks, simplify }; +})); diff --git a/extensions/levelcode-ai/diagram/links.js b/extensions/levelcode-ai/diagram/links.js new file mode 100644 index 0000000..206c417 --- /dev/null +++ b/extensions/levelcode-ai/diagram/links.js @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · code links (docs/RICH-DIAGRAMS.md, "Safe rendering → Link safety") + * + * "openLink only resolves paths inside the current workspace, after normalizing `..` segments and + * symlinks. Anything else is shown as plain text." + * + * A node's link is model output — possibly copied from a file or a web page the model read — so the + * path in it is a claim, not a location. resolveLink() is the only thing that turns one into a file: + * + * • it must be a plain path. Anything with a URL scheme (http:, file:, javascript:, vscode:) is + * refused before the file system is touched; + * • it is resolved against the workspace folders, which collapses every `..`; + * • the result must exist and be a regular file; + * • and its REAL path — every symlink followed — must still be inside the real path of a workspace + * folder. A link inside the repo that points at ~/.ssh is refused here. + * + * It runs twice: when a diagram is drawn (a link that fails is dropped, so the node is plain text) + * and again at the moment of a click (the file may have moved, or become a symlink, since). + * + * Host-only, vscode-free: the workspace folders and the file system are passed in. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const nodeFs = require('fs'); +const path = require('path'); + +const inside = (root, p) => p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep); +/** "C:\x" and "C:/x" are paths; "http://x", "file:///x", "javascript:x", "vscode://x" are not. */ +const hasScheme = (s) => /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s) && !/^[a-zA-Z]:[\\/]/.test(s); + +/** + * @param {{ path?: any }} link + * @param {Array<{ name: string, root: string }>} folders the workspace folders + * @param {{ fs?: any }} [opts] fs: a stand-in for tests + * @returns {{ ok: true, path: string, abs: string } | { ok: false, reason: string }} + */ +function resolveLink(link, folders, opts) { + const fs = (opts && opts.fs) || nodeFs; + const raw = link && typeof link.path === 'string' ? link.path.trim() : ''; + if (!raw) { return { ok: false, reason: 'no path' }; } + if (raw.indexOf('\u0000') >= 0) { return { ok: false, reason: 'not a path' }; } + if (hasScheme(raw)) { return { ok: false, reason: 'not a file path' }; } + const list = (Array.isArray(folders) ? folders : []).filter((f) => f && typeof f.root === 'string' && f.root); + if (!list.length) { return { ok: false, reason: 'no folder is open' }; } + + // The same reading the agent's own file tools give a path: in a multi-root workspace a leading + // folder NAME picks that folder; otherwise the first folder that has the file wins. + const candidates = []; + if (path.isAbsolute(raw)) { candidates.push(path.resolve(raw)); } + else { + const seg = raw.split(/[\\/]/)[0]; + const named = list.length > 1 ? list.find((f) => f.name === seg) : null; + if (named) { candidates.push(path.resolve(named.root, raw.slice(seg.length).replace(/^[\\/]+/, ''))); } + for (const f of list) { candidates.push(path.resolve(f.root, raw)); } + } + let sawOutside = false; + for (const abs of candidates) { + const home = list.find((f) => inside(path.resolve(f.root), abs)); + if (!home) { sawOutside = true; continue; } // `..` walked out of the workspace + let stat; + try { stat = fs.statSync(abs); } catch (e) { continue; } + if (!stat.isFile()) { continue; } + let real; + try { real = fs.realpathSync(abs); } catch (e) { continue; } + const realHome = list.find((f) => { try { return inside(fs.realpathSync(f.root), real); } catch (e) { return false; } }); + if (!realHome) { return { ok: false, reason: 'it is a link to somewhere outside this workspace' }; } + const rel = path.relative(path.resolve(home.root), abs).split(path.sep).join('/'); + return { ok: true, path: list.length > 1 ? home.name + '/' + rel : rel, abs }; + } + return { ok: false, reason: sawOutside ? 'outside this workspace' : 'no such file in this workspace' }; +} + +/** + * Where in a file a symbol is, by plain text search — the fallback when the language has no symbol + * provider. Prefers a line that DEFINES the name over one that merely mentions it. + * @param {string} content + * @param {string} symbol + * @returns {number | null} 1-based line, or null + */ +function findSymbolLine(content, symbol) { + // "Agent.runAgent()" names runAgent. Only identifier characters ever reach the pattern below. + const name = String(symbol || '').split(/[^\w$]+/).filter(Boolean).pop(); + if (!name) { return null; } + const lines = String(content).split('\n'); + const esc = name.replace(/\$/g, '\\$'); + const word = new RegExp('(^|[^\\w$])' + esc + '($|[^\\w$])'); + // A definition: a declaring keyword DIRECTLY before the name, the name assigned a function, or a + // method written as `name(args) {`. "const x = name" mentions the name; it does not define it. + const defines = new RegExp( + '\\b(function\\*?|class|def|fn|func|interface|type|struct|enum|module|const|let|var)\\s+\\*?\\s*' + esc + '(?![\\w$])' + + '|(^|[^\\w$.])' + esc + '\\s*[:=]\\s*(async\\s*)?(function\\b|\\(|[\\w$]+\\s*=>)' + + '|^\\s*((async|static|public|private|protected|export)\\s+)*' + esc + '\\s*\\([^)]*\\)\\s*\\{'); + let first = null; + for (let i = 0; i < lines.length; i++) { + if (!word.test(lines[i])) { continue; } + if (defines.test(lines[i])) { return i + 1; } + if (first === null) { first = i + 1; } + } + return first; +} + +module.exports = { resolveLink, findSymbolLine, hasScheme }; diff --git a/extensions/levelcode-ai/diagram/repair.js b/extensions/levelcode-ai/diagram/repair.js new file mode 100644 index 0000000..d1da189 --- /dev/null +++ b/extensions/levelcode-ai/diagram/repair.js @@ -0,0 +1,691 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the repair ladder (docs/RICH-DIAGRAMS.md, "Validation and repair") + * + * Most broken specs are broken MECHANICALLY — a trailing comma, `"shape": "diamond"`, an edge that + * names a node by its label — so most should never cost a model call. The ladder reaches for a + * model only when deterministic fixes run out, and it never loops: + * + * rung 1 normalize() deterministic, no model. Tidies everything that has ONE obvious reading: + * lenient JSON, synonyms ("diamond" → decision), an edge that names a node by + * its label, a label over the limit (shortened; the full text survives as a + * tooltip and in the screen-reader outline). + * rung 2 (the caller) ONE model pass: whatever needs intent — an edge to a node that does not + * exist, two accents, 17 nodes, groups three deep — goes back as an error list. + * rung 3 normalize({lossy}) + degrade() still broken after that pass: drop the edges that point + * nowhere, rename the colliding ids, keep one accent, flatten the nesting — + * and draw what is left under a banner that says what was lost. Or give up + * honestly (`failed`) so the UI can show the source and a Retry. + * + * THE RULE THAT ORDERS THE RUNGS: an "auto-fixed" diagram never says something different from what + * the model wrote, and a "degraded" one always says what it lost. So a fix that changes MEANING + * (a dropped edge, a renamed duplicate) is never applied before the model has had its one chance + * to do it properly — while a fix that only changes PRESENTATION never costs a model call. + * + * prepare() runs the ladder for one call and says which rung it ended on. It is pure: the caller + * decides what "the repair pass was already spent" means (`final`) and what to do with the answer. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./schema'), require('./validate')); } + else { (root.LCDiagram = root.LCDiagram || {}).repair = factory(root.LCDiagram.schema, root.LCDiagram.validate); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (schema, validator) { + 'use strict'; + + const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + const q = (s) => JSON.stringify(String(s)); + const HOUSE = schema.HOUSE, HARD = schema.HARD; + + // ---- text hygiene --------------------------------------------------------------------------- + // Control characters and bidirectional overrides have no business in a label: the first break + // layout, the second can make a node READ as something other than what it says. + // eslint-disable-next-line no-control-regex + const UNSAFE_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f​-‏‪-‮⁠-⁩]/g; + /** One clean line: unsafe characters gone, runs of whitespace collapsed. */ + function cleanText(s) { return String(s == null ? '' : s).replace(UNSAFE_CHARS, '').replace(/\s+/g, ' ').trim(); } + const count = (s) => Array.from(s).length; + + /** + * Cut `text` to `max` characters, ending in an ellipsis. Prefers a word boundary when one is + * close, so "Validate the incoming request" shortens to "Validate the incoming…", not "…incoming reque…". + */ + function truncate(text, max) { + const chars = Array.from(String(text)); + if (chars.length <= max) { return String(text); } + let cut = chars.slice(0, Math.max(1, max - 1)).join(''); + const sp = cut.lastIndexOf(' '); + if (sp >= Math.floor(max * 0.6)) { cut = cut.slice(0, sp); } + return cut.replace(/[\s,;:.\-–—(]+$/, '') + '…'; + } + + /** A model-supplied name → a lowercase slug. '' when nothing sluggable is left (the caller invents one). */ + function slugify(s) { + let t = String(s == null ? '' : s); + try { t = t.normalize('NFKD'); } catch (e) { /* no ICU — fine, non-ASCII just becomes '-' */ } + t = t.replace(/[̀-ͯ]/g, '').toLowerCase().trim() + .replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, ''); + return t.slice(0, HOUSE.id).replace(/[-_]+$/, ''); + } + + // ---- lenient JSON --------------------------------------------------------------------------- + const QUOTES = { '"': '"', "'": "'", '“': '”', '”': '”' }; + + /** + * Parse JSON the way models actually write it: comments, trailing commas, single or "smart" + * quotes, bare keys, Python's True/False/None, a ```json fence around the lot. + * + * What it will NOT do is finish a spec that stops mid-structure. Truncated output means the + * model hit its token cap, and guessing at the rest would draw a diagram nobody wrote — so that + * comes back as `truncated`, and the caller re-requests instead of repairing. + * @param {string} text + * @returns {{ ok: boolean, value?: any, lenient?: boolean, truncated?: boolean, error?: string }} + */ + function parseLenient(text) { + let src = String(text == null ? '' : text).replace(/^/, '').trim(); + try { return { ok: true, value: JSON.parse(src), lenient: false }; } catch (e) { /* fall through to the tolerant path */ } + const fence = /^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```\s*$/.exec(src); + if (fence) { src = fence[1].trim(); } + const start = src.search(/[{[]/); + if (start < 0) { return { ok: false, error: 'no JSON object found' }; } + src = src.slice(start); + + let out = '', i = 0, depth = 0; + const n = src.length; + const skipSpaceAndComments = (j) => { + for (;;) { + while (j < n && /\s/.test(src[j])) { j++; } + if (src[j] === '/' && src[j + 1] === '/') { const e = src.indexOf('\n', j); j = e < 0 ? n : e; continue; } + if (src[j] === '/' && src[j + 1] === '*') { const e = src.indexOf('*/', j + 2); j = e < 0 ? n : e + 2; continue; } + return j; + } + }; + while (i < n) { + const c = src[i]; + if (QUOTES[c]) { + const close = QUOTES[c], smart = c !== '"' && c !== "'"; + let j = i + 1, buf = '', closed = false; + while (j < n) { + const d = src[j]; + if (d === '\\') { + if (j + 1 >= n) { break; } + buf += (c === "'" && src[j + 1] === "'") ? "'" : d + src[j + 1]; + j += 2; continue; + } + if (d === close || (smart && d === '“')) { closed = true; j++; break; } + if (d === '"') { buf += '\\"'; j++; continue; } // a bare " inside '…' or “…” + if (d === '\n') { buf += '\\n'; j++; continue; } + if (d === '\r') { j++; continue; } + if (d === '\t') { buf += '\\t'; j++; continue; } + buf += d; j++; + } + if (!closed) { return { ok: false, truncated: true, error: 'unterminated string' }; } + out += '"' + buf + '"'; i = j; continue; + } + if (c === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) { + const j = skipSpaceAndComments(i); + if (j >= n && src[i + 1] === '*' && src.indexOf('*/', i + 2) < 0) { return { ok: false, truncated: true, error: 'unterminated comment' }; } + i = j; continue; + } + if (c === ',') { + const j = skipSpaceAndComments(i + 1); + if (src[j] === '}' || src[j] === ']') { i++; continue; } // trailing comma + } + if (/[A-Za-z_$]/.test(c)) { + let j = i; while (j < n && /[\w$]/.test(src[j])) { j++; } + const word = src.slice(i, j); + const k = skipSpaceAndComments(j); + if (src[k] === ':') { out += '"' + word + '"'; } // bare key + else if (word === 'True') { out += 'true'; } + else if (word === 'False') { out += 'false'; } + else if (word === 'None' || word === 'undefined') { out += 'null'; } + else { out += word; } + i = j; continue; + } + if (c === '{' || c === '[') { depth++; } + else if (c === '}' || c === ']') { + depth--; + if (depth === 0) { out += c; break; } // ignore prose after the object + } + out += c; i++; + } + if (depth > 0) { return { ok: false, truncated: true, error: 'the JSON stops before it is complete' }; } + try { return { ok: true, value: JSON.parse(out), lenient: true }; } + catch (e) { return { ok: false, error: String((e && e.message) || e) }; } + } + + // ---- rung 1: normalize ---------------------------------------------------------------------- + const DIRECTION_SYNONYMS = { + right: 'right', lr: 'right', 'left-to-right': 'right', 'left-right': 'right', ltr: 'right', horizontal: 'right', east: 'right', row: 'right', + rl: 'right', 'right-to-left': 'right', left: 'right', + down: 'down', tb: 'down', td: 'down', 'top-to-bottom': 'down', 'top-down': 'down', 'top-bottom': 'down', vertical: 'down', south: 'down', column: 'down', + bt: 'down', 'bottom-to-top': 'down', up: 'down' + }; + const SHAPE_SYNONYMS = { + box: 'box', rect: 'box', rectangle: 'box', square: 'box', process: 'box', step: 'box', node: 'box', default: 'box', task: 'box', service: 'box', component: 'box', + decision: 'decision', diamond: 'decision', rhombus: 'decision', condition: 'decision', choice: 'decision', branch: 'decision', 'if': 'decision', gateway: 'decision', + store: 'store', database: 'store', db: 'store', cylinder: 'store', storage: 'store', datastore: 'store', 'data-store': 'store', table: 'store', cache: 'store', queue: 'store', bucket: 'store', + actor: 'actor', person: 'actor', user: 'actor', human: 'actor', external: 'actor', pill: 'actor', stadium: 'actor', terminal: 'actor', terminator: 'actor', round: 'actor', rounded: 'actor', start: 'actor', end: 'actor', client: 'actor' + }; + const STYLE_SYNONYMS = { + solid: 'solid', line: 'solid', normal: 'solid', plain: 'solid', thick: 'solid', bold: 'solid', + dashed: 'dashed', dash: 'dashed', dashes: 'dashed', dotted: 'dashed', dots: 'dashed', dot: 'dashed', broken: 'dashed', optional: 'dashed', async: 'dashed' + }; + const WRAPPERS = ['spec', 'diagram', 'graph', 'input', 'json', 'arguments', 'data']; + const key = (s) => String(s == null ? '' : s).toLowerCase().trim().replace(/[\s_]+/g, '-'); + const pick = (obj, names) => { for (const n of names) { if (obj[n] !== undefined && obj[n] !== null) { return obj[n]; } } return undefined; }; + const asBool = (v) => (v === true || v === 1 || (typeof v === 'string' && /^(true|yes|1)$/i.test(v.trim()))); + const scalar = (v) => (typeof v === 'string' || typeof v === 'number') ? String(v) : undefined; + + /** + * Rung 1. Turn whatever the model sent into a clean spec in canonical form, recording each thing + * it changed. + * + * Three kinds of fix come out of here, and the difference is what the user is told: + * (plain) a tidy-up with one obvious reading — `"shape": "diamond"`, a trailing comma, an id + * with a space in it. The drawing is what the model meant; nobody needs a badge. + * `show` the drawing differs from what was written, but says the same thing: a label over + * the limit was shortened, a shape nobody knows became a box. "auto-fixed" badge. + * `lossy` it changed what the diagram SAYS — an edge dropped, a duplicate id renamed. Only + * ever applied when `opts.lossy` is set, which the ladder does on its last rung. + * + * Never throws. A value it cannot make sense of is passed through for validate() to name. + * @param {any} input + * @param {{ lossy?: boolean }} [opts] + * @returns {{ spec: any, fixes: Array<{pointer:string, cls:string, message:string, show?:boolean, lossy?:boolean, shortened?:boolean}>, truncated?: boolean, syntax?: string }} + */ + function normalize(input, opts) { + const allowLossy = !!(opts && opts.lossy); + /** @type {Array<{pointer:string, cls:string, message:string, show?:boolean, lossy?:boolean, shortened?:boolean}>} */ + const fixes = []; + const fix = (pointer, cls, message, flags) => { fixes.push(Object.assign({ pointer, cls, message }, flags || {})); }; + + // -- syntax: text → object, and unwrap a spec that arrived inside an envelope + let raw = input; + for (let hop = 0; hop < 3; hop++) { + if (typeof raw === 'string') { + const p = parseLenient(raw); + if (!p.ok) { return p.truncated ? { spec: null, fixes, truncated: true } : { spec: null, fixes, syntax: p.error || 'not valid JSON' }; } + fix('', p.lenient ? 'lenient-json' : 'stringified', p.lenient ? 'JSON repaired (comments, quotes or trailing commas).' : 'spec arrived as a JSON string; parsed it.'); + raw = p.value; continue; + } + if (isObj(raw) && raw.nodes === undefined) { + const w = WRAPPERS.find((k) => raw[k] !== undefined && (isObj(raw[k]) || typeof raw[k] === 'string')); + if (w) { fix('', 'unwrapped', 'spec was nested under "' + w + '"; unwrapped it.'); raw = raw[w]; continue; } + } + break; + } + if (!isObj(raw)) { return { spec: raw, fixes }; } + // A structural copy: plain data only, and nothing shared with the caller's object. + try { raw = JSON.parse(JSON.stringify(raw)); } catch (e) { return { spec: null, fixes, syntax: 'spec is not plain JSON data' }; } + + const spec = {}; + let dropped = 0; // unknown fields, counted not listed + // `tip` is the renderer's own field (the full text of a label that was shortened). A spec that + // has been through here before carries them, and going through again must not lose them. + const keepTip = (from, to) => { if (typeof from.tip === 'string' && cleanText(from.tip)) { to.tip = truncate(cleanText(from.tip), HARD.tip); } }; + keepTip(raw, spec); + + // -- v + if (raw.v === undefined || raw.v === null) { spec.v = schema.VERSION; } + else if (typeof raw.v === 'string' && /^\d+$/.test(raw.v.trim())) { spec.v = Number(raw.v); fix('/v', 'coerced', 'version given as a string; read it as a number.'); } + else { spec.v = raw.v; } + + // -- title + const title = scalar(pick(raw, ['title', 'name', 'caption', 'heading'])); + if (title !== undefined) { spec.title = cleanText(title); } + if (raw.title === undefined && title !== undefined) { fix('/title', 'renamed-field', 'used the "name"/"caption" field as the title.'); } + + // -- direction + const dirRaw = pick(raw, ['direction', 'dir', 'rankdir', 'orientation', 'flow']); + if (dirRaw === undefined) { spec.direction = 'right'; } + else { + const d = DIRECTION_SYNONYMS[key(dirRaw)]; + if (d) { spec.direction = d; if (d !== dirRaw) { fix('/direction', 'synonym', JSON.stringify(dirRaw) + ' read as "' + d + '".'); } } + else { spec.direction = 'right'; fix('/direction', 'enum', JSON.stringify(dirRaw) + ' is not a direction; used "right".', { show: true }); } + } + + // -- groups first: nodes refer to them + const groupIdOf = new Map(); // what the model wrote → the slug we settled on + let groupsIn = raw.groups; + if (isObj(groupsIn)) { + groupsIn = Object.keys(groupsIn).map((k) => (isObj(groupsIn[k]) ? Object.assign({ id: k }, groupsIn[k]) : { id: k, label: scalar(groupsIn[k]) })); + fix('/groups', 'coerced', 'groups given as an object; read as a list.'); + } + let groups; + if (Array.isArray(groupsIn)) { + groups = []; + groupsIn.forEach((g, i) => { + if (typeof g === 'string') { g = { id: g, label: g }; } + if (!isObj(g)) { groups.push(g); return; } + const out = {}; + const rawId = scalar(pick(g, ['id', 'key', 'name'])); + const label = scalar(pick(g, ['label', 'title', 'name', 'text'])); + let id = rawId !== undefined ? slugify(rawId) : ''; + if (!id) { id = slugify(label || '') || ('g' + (i + 1)); } + if (rawId !== undefined) { groupIdOf.set(rawId, id); groupIdOf.set(key(rawId), id); } + if (rawId !== id && rawId !== undefined) { fix('/groups/' + i + '/id', 'slug', q(rawId) + ' written as the slug "' + id + '".'); } + out.id = id; + out.label = label !== undefined && cleanText(label) ? cleanText(label) : (rawId !== undefined ? cleanText(rawId) : id); + if (label === undefined) { fix('/groups/' + i + '/label', 'defaulted', 'group had no label; used its id.'); } + const parent = scalar(pick(g, ['parent', 'in', 'group'])); + if (parent !== undefined && cleanText(parent)) { out.parent = parent; } + keepTip(g, out); + dropped += Object.keys(g).filter((k) => ['id', 'key', 'name', 'label', 'title', 'text', 'parent', 'in', 'group', 'tip'].indexOf(k) < 0).length; + groups.push(out); + }); + const gid = (ref) => (groupIdOf.has(ref) ? groupIdOf.get(ref) : groupIdOf.has(key(ref)) ? groupIdOf.get(key(ref)) : (slugify(ref) || String(ref))); + for (const g of groups) { if (isObj(g) && g.parent !== undefined) { g.parent = gid(g.parent); } } + } else if (groupsIn !== undefined && groupsIn !== null) { groups = groupsIn; } + const groupRef = (ref) => (groupIdOf.has(ref) ? groupIdOf.get(ref) : groupIdOf.has(key(ref)) ? groupIdOf.get(key(ref)) : (slugify(ref) || String(ref))); + + // -- nodes + const idOf = new Map(); // what the model wrote → the slug we settled on + const idByLabel = new Map(); // lowercased label → id (null when two nodes share a label) + let nodesIn = raw.nodes; + if (isObj(nodesIn)) { + nodesIn = Object.keys(nodesIn).map((k) => (isObj(nodesIn[k]) ? Object.assign({ id: k }, nodesIn[k]) : { id: k, label: scalar(nodesIn[k]) })); + fix('/nodes', 'coerced', 'nodes given as an object; read as a list.'); + } + let nodes; + if (Array.isArray(nodesIn)) { + nodes = []; + nodesIn.forEach((n, i) => { + const at = '/nodes/' + i; + if (typeof n === 'string' || typeof n === 'number') { n = { id: String(n), label: String(n) }; fix(at, 'coerced', 'node given as a bare name; used it as both id and label.'); } + if (!isObj(n)) { nodes.push(n); return; } + const out = {}; + const rawId = scalar(pick(n, ['id', 'key'])); + let labelRaw = scalar(pick(n, ['label', 'name', 'title', 'text'])); + let subRaw = scalar(pick(n, ['sub', 'subtitle', 'sublabel', 'description', 'desc', 'detail', 'note'])); + if (n.label === undefined && labelRaw !== undefined) { fix(at + '/label', 'renamed-field', 'used the "name"/"title" field as the label.'); } + if (n.sub === undefined && subRaw !== undefined) { fix(at + '/sub', 'renamed-field', 'used the "description"/"subtitle" field as the second line.'); } + // "Jev\nreturns probabilities" is a label and a sub written in one field. + if (labelRaw !== undefined && subRaw === undefined && /\r?\n|<br\s*\/?>/i.test(labelRaw)) { + const parts = labelRaw.split(/\r?\n|<br\s*\/?>/i).map(cleanText).filter(Boolean); + if (parts.length > 1) { labelRaw = parts[0]; subRaw = parts.slice(1).join(' '); fix(at + '/label', 'split-label', 'two-line label split into label and sub.'); } + } + const label = labelRaw !== undefined ? cleanText(labelRaw) : undefined; + let id = rawId !== undefined ? slugify(rawId) : ''; + if (!id) { id = slugify(label || '') || ('n' + (i + 1)); if (rawId === undefined) { fix(at + '/id', 'defaulted', 'node had no id; made "' + id + '" from its label.'); } } + if (rawId !== undefined && rawId !== id) { fix(at + '/id', 'slug', q(rawId) + ' written as the slug "' + id + '".'); } + if (rawId !== undefined) { if (!idOf.has(rawId)) { idOf.set(rawId, id); } if (!idOf.has(key(rawId))) { idOf.set(key(rawId), id); } } + out.id = id; + if (label !== undefined && label) { out.label = label; } + else if (rawId !== undefined && cleanText(rawId)) { out.label = cleanText(rawId); fix(at + '/label', 'defaulted', 'node had no label; used its id.'); } + if (out.label) { const lk = out.label.toLowerCase(); idByLabel.set(lk, idByLabel.has(lk) ? null : id); } + const sub = subRaw !== undefined ? cleanText(subRaw) : ''; + if (sub) { out.sub = sub; } + const shapeRaw = pick(n, ['shape', 'type', 'kind']); + if (shapeRaw !== undefined) { + const s = SHAPE_SYNONYMS[key(shapeRaw)]; + if (s) { if (s !== 'box') { out.shape = s; } if (s !== shapeRaw) { fix(at + '/shape', 'synonym', JSON.stringify(shapeRaw) + ' read as "' + s + '".'); } } + else { fix(at + '/shape', 'enum', JSON.stringify(shapeRaw) + ' is not a shape; drew a box.', { show: true }); } + } + const accentRaw = pick(n, ['accent', 'highlight', 'primary', 'emphasis']); + if (accentRaw !== undefined) { + if (asBool(accentRaw)) { out.accent = true; } + if (typeof accentRaw !== 'boolean') { fix(at + '/accent', 'coerced', 'accent read as ' + asBool(accentRaw) + '.'); } + } + const groupRaw = scalar(pick(n, ['group', 'parent', 'cluster', 'in'])); + if (groupRaw !== undefined && cleanText(groupRaw)) { out.group = groupRef(groupRaw); } + const link = normalizeLink(pick(n, ['link', 'file', 'href', 'path'])); + if (link) { out.link = link; } + keepTip(n, out); + dropped += Object.keys(n).filter((k) => NODE_KEYS.indexOf(k) < 0).length; + nodes.push(out); + }); + } else if (nodesIn !== undefined) { nodes = nodesIn; } + + // -- duplicate ids. The same node listed twice is one node. Two DIFFERENT nodes under one id + // is a question only the model can answer (which one do the edges mean?), so that is + // left for validate() to report — and settled by renaming only on the last rung. + if (Array.isArray(nodes)) { + const seen = new Map(); + const kept = []; + nodes.forEach((n, i) => { + if (!isObj(n) || typeof n.id !== 'string') { kept.push(n); return; } + if (!seen.has(n.id)) { seen.set(n.id, n); kept.push(n); return; } + if (JSON.stringify(seen.get(n.id)) === JSON.stringify(n)) { + fix('/nodes/' + i, 'duplicate-node', 'node "' + n.id + '" was listed twice; kept one.'); + return; + } + if (!allowLossy) { kept.push(n); return; } + let k = 2, id = n.id + '-' + k; + const taken = (x) => seen.has(x) || nodes.some((m) => isObj(m) && m !== n && m.id === x); + while (taken(id)) { id = n.id + '-' + (++k); } + fix('/nodes/' + i + '/id', 'duplicate-id', 'duplicate id "' + n.id + '" renamed to "' + id + '"; edges to "' + n.id + '" point at the first one.', { lossy: true }); + n.id = id; seen.set(id, n); kept.push(n); + }); + nodes = kept; + } + if (Array.isArray(groups)) { + const seen = new Set(); + groups = groups.filter((g, i) => { + if (!isObj(g) || typeof g.id !== 'string') { return true; } + if (!seen.has(g.id)) { seen.add(g.id); return true; } + const twin = groups.find((x) => isObj(x) && x.id === g.id); + if (JSON.stringify(twin) === JSON.stringify(g)) { fix('/groups/' + i, 'duplicate-node', 'group "' + g.id + '" was listed twice; kept one.'); return false; } + if (!allowLossy) { return true; } + fix('/groups/' + i, 'duplicate-id', 'duplicate group id "' + g.id + '"; kept the first.', { lossy: true }); + return false; + }); + } + const known = new Set((Array.isArray(nodes) ? nodes : []).filter((n) => isObj(n) && typeof n.id === 'string').map((n) => n.id)); + const knownList = Array.from(known); + + // -- edges + const nodeRef = (ref) => { + if (idOf.has(ref)) { return idOf.get(ref); } + if (idOf.has(key(ref))) { return idOf.get(key(ref)); } + const s = slugify(ref); + if (known.has(s)) { return s; } + // Models often point an edge at a node's LABEL. When exactly one node has that label, + // that is not a guess — it is a lookup. + const byLabel = idByLabel.get(cleanText(ref).toLowerCase()); + if (byLabel) { fix('/edges', 'ref-by-label', 'an edge named a node by its label (' + q(ref) + '); matched it to "' + byLabel + '".'); return byLabel; } + return s || String(ref); + }; + let edgesIn = raw.edges !== undefined ? raw.edges : pick(raw, ['links', 'connections', 'arrows']); + if (raw.edges === undefined && edgesIn !== undefined) { fix('/edges', 'renamed-field', 'used the "links"/"connections" field as the edges.'); } + let edges; + if (edgesIn === undefined || edgesIn === null) { edges = []; if (raw.edges === undefined) { fix('/edges', 'defaulted', 'no edges given; drew the nodes alone.'); } } + else if (Array.isArray(edgesIn)) { + edges = []; + const seenEdge = new Set(); + edgesIn.forEach((e, i) => { + const at = '/edges/' + i; + if (typeof e === 'string') { + const m = /^\s*(.+?)\s*(-{1,3}|={1,3}|\.{1,3}-?)>\s*(.+?)(?:\s*:\s*(.+))?$/.exec(e); + if (m) { e = { from: m[1], to: m[3], label: m[4], style: m[2][0] === '.' ? 'dashed' : undefined }; fix(at, 'coerced', 'edge written as "a -> b"; read it as from/to.'); } + } else if (Array.isArray(e) && e.length >= 2) { e = { from: e[0], to: e[1], label: e[2] }; fix(at, 'coerced', 'edge given as a list; read it as from/to.'); } + if (!isObj(e)) { edges.push(e); return; } + const out = {}; + const fromRaw = scalar(pick(e, ['from', 'source', 'src', 'start', 'a'])); + const toRaw = scalar(pick(e, ['to', 'target', 'dst', 'dest', 'end', 'b'])); + if (e.from === undefined && fromRaw !== undefined) { fix(at + '/from', 'renamed-field', 'used "source" as the from end.'); } + if (fromRaw !== undefined) { out.from = nodeRef(fromRaw); } + if (toRaw !== undefined) { out.to = nodeRef(toRaw); } + const label = scalar(pick(e, ['label', 'text', 'name', 'title'])); + if (label !== undefined && cleanText(label)) { out.label = cleanText(label); } + const styleRaw = pick(e, ['style', 'type', 'line', 'kind']); + if (styleRaw !== undefined) { + const s = STYLE_SYNONYMS[key(styleRaw)]; + if (s === 'dashed') { out.style = 'dashed'; } + if (!s) { fix(at + '/style', 'enum', JSON.stringify(styleRaw) + ' is not a line style; drew it solid.', { show: true }); } + else if (s !== styleRaw) { fix(at + '/style', 'synonym', JSON.stringify(styleRaw) + ' read as "' + s + '".'); } + } else if (e.dashed === true) { out.style = 'dashed'; } + keepTip(e, out); + dropped += Object.keys(e).filter((k) => EDGE_KEYS.indexOf(k) < 0).length; + // An edge to a node that does not exist. On the last rung it is dropped (and the banner + // says so); before that it stays, so validate() can hand the model the list of known ids. + const bad = ['from', 'to'].filter((end) => typeof out[end] === 'string' && !known.has(out[end])); + if (bad.length && known.size && allowLossy) { + fix(at, 'unknown-node', 'dropped: unknown node ' + bad.map((end) => q(end === 'from' ? fromRaw : toRaw)).join(' and ') + '. Known ids: ' + validator.listIds(knownList) + '.', { lossy: true }); + return; + } + const sig = out.from + '\u0000' + out.to + '\u0000' + (out.label || '') + '\u0000' + (out.style || ''); + if (out.from !== undefined && out.to !== undefined && seenEdge.has(sig)) { fix(at, 'duplicate-edge', 'the same edge was listed twice; kept one.'); return; } + seenEdge.add(sig); + edges.push(out); + }); + } else { edges = edgesIn; } + + // -- long text: the spec's "truncate long labels with a tooltip". Presentation, not meaning — + // the whole label is still there on hover and in the outline — so it never costs a model call. + const shorten = (obj, field, max, pointer) => { + if (!isObj(obj) || typeof obj[field] !== 'string' || count(obj[field]) <= max) { return null; } + const full = obj[field]; + obj[field] = truncate(full, max); + fix(pointer, 'length', count(full) + ' chars, max ' + max + ' — shortened to ' + q(obj[field]) + ' (the full text is kept as a tooltip).', { shortened: true, show: true }); + return full; + }; + const tip = (s) => truncate(s, HARD.tip); + const fullTitle = shorten(spec, 'title', HOUSE.title, '/title'); + if (fullTitle) { spec.tip = tip(fullTitle); } + if (Array.isArray(nodes)) { + nodes.forEach((n, i) => { + if (!isObj(n)) { return; } + const before = [n.label, n.sub]; + const a = shorten(n, 'label', HOUSE.label, '/nodes/' + i + '/label'); + const b = shorten(n, 'sub', HOUSE.sub, '/nodes/' + i + '/sub'); + if (a || b) { n.tip = tip([before[0], before[1]].filter(Boolean).join(' — ')); } + }); + } + if (Array.isArray(edges)) { edges.forEach((e, i) => { const f = shorten(e, 'label', HOUSE.edgeLabel, '/edges/' + i + '/label'); if (f) { e.tip = tip(f); } }); } + if (Array.isArray(groups)) { groups.forEach((g, i) => { const f = shorten(g, 'label', HOUSE.groupLabel, '/groups/' + i + '/label'); if (f) { g.tip = tip(f); } }); } + + // -- a group nothing lives in has no geometry; drop it rather than draw an empty frame + if (Array.isArray(groups) && Array.isArray(nodes)) { + const used = new Set(nodes.filter((n) => isObj(n) && typeof n.group === 'string').map((n) => n.group)); + let changed = true; + while (changed) { + changed = false; + for (const g of groups) { if (isObj(g) && used.has(g.id) && typeof g.parent === 'string' && !used.has(g.parent)) { used.add(g.parent); changed = true; } } + } + const declared = new Set(groups.filter(isObj).map((g) => g.id)); + const before = groups.length; + groups = groups.filter((g) => !isObj(g) || used.has(g.id) || (typeof g.parent === 'string' && !declared.has(g.parent))); + if (groups.length !== before) { fix('/groups', 'empty-group', (before - groups.length) + ' empty group(s) left out.'); } + } + + spec.nodes = nodes; + spec.edges = edges; + if (Array.isArray(groups) ? groups.length : groups !== undefined) { spec.groups = groups; } + const KNOWN_TOP = ['v', 'title', 'name', 'caption', 'heading', 'direction', 'dir', 'rankdir', 'orientation', 'flow', 'nodes', 'edges', 'links', 'connections', 'arrows', 'groups', 'tip']; + dropped += Object.keys(raw).filter((k) => KNOWN_TOP.indexOf(k) < 0).length; + if (dropped) { fix('', 'unknown-field', dropped + ' unrecognised field(s) ignored.'); } + return { spec, fixes }; + } + const NODE_KEYS = ['id', 'key', 'label', 'name', 'title', 'text', 'sub', 'subtitle', 'sublabel', 'description', 'desc', 'detail', 'note', 'shape', 'type', 'kind', 'accent', 'highlight', 'primary', 'emphasis', 'group', 'parent', 'cluster', 'in', 'link', 'file', 'href', 'path', 'tip']; + const EDGE_KEYS = ['from', 'source', 'src', 'start', 'a', 'to', 'target', 'dst', 'dest', 'end', 'b', 'label', 'text', 'name', 'title', 'style', 'type', 'line', 'kind', 'dashed', 'tip']; + + /** + * `link` in any of the ways a model writes one — an object, "src/app.js:42", "src/app.js#render". + * Only shapes it; WHETHER the path may be opened is decided by the host against the workspace. + */ + function normalizeLink(v) { + if (v === undefined || v === null) { return null; } + let p, symbol, line; + if (typeof v === 'string') { + const m = /^(.*?)(?::(\d+)(?::\d+)?|#(.+))?$/.exec(v.trim()); + p = m ? m[1] : v; line = m && m[2] ? Number(m[2]) : undefined; symbol = m && m[3] ? m[3] : undefined; + } else if (isObj(v)) { + p = scalar(pick(v, ['path', 'file', 'uri', 'href'])); + symbol = scalar(pick(v, ['symbol', 'name', 'function', 'fn'])); + const l = pick(v, ['line', 'lineNumber', 'row']); + line = (typeof l === 'number' || (typeof l === 'string' && /^\d+$/.test(l.trim()))) ? Number(l) : undefined; + } else { return null; } + p = cleanText(p == null ? '' : p).replace(/^file:\/\//i, ''); + if (!p) { return null; } + const out = { path: Array.from(p).slice(0, HOUSE.path).join('') }; + if (symbol !== undefined && cleanText(symbol)) { out.symbol = Array.from(cleanText(symbol)).slice(0, HOUSE.symbol).join(''); } + if (Number.isInteger(line) && line >= 1) { out.line = line; } + return out; + } + + // ---- rung 3: degrade ------------------------------------------------------------------------ + /** + * The repair pass is spent and the spec is still invalid. Cut it down to the part that IS valid, + * and say exactly what was cut — or return null when nothing drawable is left. + * + * Counts relax here and nowhere else: a 17-node diagram the model twice declined to split is + * drawn whole under a banner, because showing 12 of 17 would be a different diagram presented as + * the answer. The HARD ceiling still applies; past it, the tail really is dropped. + * @param {any} spec a normalize()d spec + * @returns {{ spec: any, notes: string[] } | null} + */ + function degrade(spec) { + if (!isObj(spec) || !Array.isArray(spec.nodes)) { return null; } + const notes = []; + const plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's'); + const out = { v: schema.VERSION, title: '', direction: schema.DIRECTIONS.indexOf(spec.direction) >= 0 ? spec.direction : 'right', nodes: [], edges: [] }; + if (typeof spec.title === 'string' && cleanText(spec.title)) { out.title = truncate(cleanText(spec.title), HOUSE.title); } + else { out.title = 'Untitled diagram'; notes.push('no title given'); } + if (typeof spec.tip === 'string') { out.tip = truncate(spec.tip, HARD.tip); } + // A spec from a NEWER editor than this one: draw what this schema understands, and say so. + if (spec.v !== undefined && !schema.SCHEMAS[spec.v]) { notes.push('written for schema v' + String(spec.v).slice(0, 8) + '; drawn as v' + schema.VERSION); } + + // nodes: keep the well-formed ones, up to the hard ceiling + const ids = new Set(); + let badNodes = 0; + for (const n of spec.nodes) { + if (!isObj(n) || typeof n.id !== 'string' || !schema.ID_RE.test(n.id) || ids.has(n.id) || typeof n.label !== 'string' || !n.label) { badNodes++; continue; } + if (out.nodes.length >= HARD.nodesMax) { badNodes++; continue; } + const m = { id: n.id.slice(0, HOUSE.id), label: truncate(n.label, HOUSE.label) }; + if (typeof n.sub === 'string' && n.sub) { m.sub = truncate(n.sub, HOUSE.sub); } + if (schema.SHAPES.indexOf(n.shape) >= 0 && n.shape !== 'box') { m.shape = n.shape; } + if (n.accent === true) { m.accent = true; } + if (typeof n.group === 'string') { m.group = n.group; } + if (isObj(n.link) && typeof n.link.path === 'string' && n.link.path) { + m.link = { path: n.link.path.slice(0, HOUSE.path) }; + if (typeof n.link.symbol === 'string' && n.link.symbol) { m.link.symbol = n.link.symbol.slice(0, HOUSE.symbol); } + if (Number.isInteger(n.link.line) && n.link.line >= 1) { m.link.line = n.link.line; } + } + if (typeof n.tip === 'string') { m.tip = truncate(n.tip, HARD.tip); } + ids.add(m.id); out.nodes.push(m); + } + if (!out.nodes.length) { return null; } + if (badNodes) { notes.push(plural(badNodes, 'node') + ' dropped: malformed or over the limit'); } + if (out.nodes.length > HOUSE.nodesMax) { notes.push(out.nodes.length + ' nodes — over the ' + HOUSE.nodesMax + '-node limit, drawn anyway'); } + + // one accent + const accented = out.nodes.filter((n) => n.accent); + if (accented.length > 1) { accented.slice(1).forEach((n) => { delete n.accent; }); notes.push(plural(accented.length - 1, 'extra accent') + ' removed'); } + + // groups: unknown parents and cycles lose their parent; anything deeper than the limit is lifted + const groups = []; + const gids = new Set(); + for (const g of (Array.isArray(spec.groups) ? spec.groups : [])) { + if (!isObj(g) || typeof g.id !== 'string' || !schema.ID_RE.test(g.id) || gids.has(g.id) || groups.length >= HARD.groupsMax) { continue; } + const m = { id: g.id.slice(0, HOUSE.id), label: truncate(typeof g.label === 'string' && g.label ? g.label : g.id, HOUSE.groupLabel) }; + if (typeof g.parent === 'string') { m.parent = g.parent; } + if (typeof g.tip === 'string') { m.tip = truncate(g.tip, HARD.tip); } + gids.add(m.id); groups.push(m); + } + let regrouped = 0; + for (const g of groups) { if (g.parent !== undefined && (!gids.has(g.parent) || g.parent === g.id)) { delete g.parent; regrouped++; } } + let depths = validator.groupDepths(groups); + for (const g of groups) { const d = depths.get(g.id); if (d && d.cycle && g.parent !== undefined) { delete g.parent; regrouped++; depths = validator.groupDepths(groups); } } + // too deep: fold the group into its parent (its nodes move up a level) until everything fits + const byId = new Map(groups.map((g) => [g.id, g])); + const folded = new Map(); + for (let guard = 0; guard < 32; guard++) { + depths = validator.groupDepths(groups.filter((g) => !folded.has(g.id))); + const deep = groups.find((g) => !folded.has(g.id) && (depths.get(g.id) || { depth: 1 }).depth > HOUSE.groupDepth); + if (!deep) { break; } + folded.set(deep.id, deep.parent); + for (const g of groups) { if (g.parent === deep.id) { g.parent = deep.parent; } } + } + const live = groups.filter((g) => !folded.has(g.id)); + const resolveGroup = (id) => { let cur = id, guard = 0; while (folded.has(cur) && guard++ < 32) { cur = folded.get(cur); } return cur; }; + let ungrouped = 0; + for (const n of out.nodes) { + if (n.group === undefined) { continue; } + const g = resolveGroup(n.group); + if (g !== undefined && byId.has(g) && !folded.has(g)) { n.group = g; } else { delete n.group; ungrouped++; } + } + if (folded.size) { notes.push(plural(folded.size, 'group') + ' flattened: nested too deep'); } + if (regrouped || ungrouped) { notes.push('group references that did not resolve were removed'); } + // groups left with nothing in them are not drawn + const used = new Set(out.nodes.map((n) => n.group).filter(Boolean)); + for (let changed = true; changed;) { changed = false; for (const g of live) { if (used.has(g.id) && g.parent && !used.has(g.parent)) { used.add(g.parent); changed = true; } } } + const keptGroups = live.filter((g) => used.has(g.id)); + if (keptGroups.length) { out.groups = keptGroups; } + + // edges: both ends must exist + let droppedEdges = 0; + for (const e of (Array.isArray(spec.edges) ? spec.edges : [])) { + if (!isObj(e) || !ids.has(e.from) || !ids.has(e.to) || out.edges.length >= HARD.edgesMax) { droppedEdges++; continue; } + const m = { from: e.from, to: e.to }; + if (typeof e.label === 'string' && e.label) { m.label = truncate(e.label, HOUSE.edgeLabel); } + if (e.style === 'dashed') { m.style = 'dashed'; } + if (typeof e.tip === 'string') { m.tip = truncate(e.tip, HARD.tip); } + out.edges.push(m); + } + if (droppedEdges) { notes.push(plural(droppedEdges, 'edge') + ' dropped: no valid ends'); } + return { spec: out, notes }; + } + + // ---- the ladder ----------------------------------------------------------------------------- + /** What the "auto-fixed" popover and the tool result list: the fixes a person might care about. */ + function visibleFixes(fixes) { return (fixes || []).filter((f) => f.show || f.lossy); } + const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many); + /** "2 edges dropped: unknown nodes" — what a degraded diagram's banner says it lost. */ + function lossNotes(fixes) { + const lossy = (fixes || []).filter((f) => f.lossy); + const edges = lossy.filter((f) => f.cls === 'unknown-node').length; + const ids = lossy.filter((f) => f.cls === 'duplicate-id').length; + const notes = []; + if (edges) { notes.push(plural(edges, 'edge', 'edges') + ' dropped: unknown nodes'); } + if (ids) { notes.push(plural(ids, 'duplicate id', 'duplicate ids') + ' renamed'); } + return notes; + } + /** "2 labels shortened" — the short form that sits beside the auto-fixed badge. */ + function fixSummary(fixes) { + const n = (fixes || []).filter((f) => f.shortened).length; + return n ? plural(n, 'label', 'labels') + ' shortened' : ''; + } + + /** + * Run the ladder for ONE render_diagram call. + * + * status ok valid as written (defaults aside) → draw it + * fixed rung 1 tidied something, meaning untouched → draw it, "auto-fixed" badge + * errors needs the model's one repair pass (only when !final) → return the error list + * degraded repair pass spent; the valid part is drawn → draw it, banner + Retry + * failed nothing drawable → source + errors + Retry + * truncated the JSON stops mid-structure → re-request, never repair + * + * @param {any} input the tool call's arguments (an object), or raw text + * @param {{ final?: boolean }} [opts] final: the model has had its repair pass (or there is no + * model to ask — a replay, a fence) so `errors` is not an option; degrade instead. + */ + function prepare(input, opts) { + const final = !!(opts && opts.final); + const n = normalize(input); + if (n.truncated) { return { status: 'truncated', fixes: n.fixes, errors: [{ pointer: '', cls: 'truncated', message: 'the spec was cut off before it was complete.' }], notes: [] }; } + if (n.syntax) { + const errors = [{ pointer: '', cls: 'syntax', message: 'not valid JSON (' + n.syntax + '). Send one JSON object with "title", "nodes" and "edges".' }]; + return { status: final ? 'failed' : 'errors', fixes: n.fixes, errors, notes: [] }; + } + const first = validator.validate(n.spec, { tier: 'house' }); + if (first.ok) { + const summary = fixSummary(n.fixes); + return { status: visibleFixes(n.fixes).length ? 'fixed' : 'ok', spec: n.spec, fixes: n.fixes, errors: [], notes: summary ? [summary] : [] }; + } + if (!final) { return { status: 'errors', fixes: n.fixes, errors: first.errors, notes: [] }; } + + // Rung 3. The same tidy-up again, this time allowed to drop and rename; then cut away + // whatever is still invalid. `first.errors` is kept: it is what the banner's details show. + const last = normalize(input, { lossy: true }); + const d = degrade(last.spec); + if (d) { + const again = validator.validate(d.spec, { tier: 'hard' }); + if (again.ok) { return { status: 'degraded', spec: d.spec, fixes: last.fixes, errors: first.errors, notes: lossNotes(last.fixes).concat(d.notes) }; } + } + return { status: 'failed', fixes: last.fixes, errors: first.errors, notes: [] }; + } + + /** + * Accept a spec the RENDERER was handed — from the host, or from a session written by an older + * build. It has already been through the ladder once, so this is the cheap re-check that makes + * "no unvalidated spec reaches the renderer" true at the last possible moment: migrate, validate + * against the HARD tier, and if (and only if) that fails, run the ladder with no model to ask. + * @returns {{ ok: boolean, spec?: any, notes?: string[], errors?: any[] }} + */ + function accept(spec) { + const m = validator.migrate(spec); + const r = validator.validate(m, { tier: 'hard' }); + if (r.ok) { + // Valid — but the validator checks the fields it knows and is silent about any others, and a + // record from a session file can carry anything. What is handed on is the declared shape only. + const declared = schema.project(schema.SCHEMAS[m.v].hard, m); + return { ok: true, spec: Object.assign({ v: m.v }, declared), notes: [] }; + } + const p = prepare(spec, { final: true }); + if (p.spec) { return { ok: true, spec: p.spec, notes: p.notes }; } + return { ok: false, errors: p.errors }; + } + + return { parseLenient, normalize, degrade, prepare, accept, truncate, slugify, cleanText, visibleFixes, lossNotes, fixSummary }; +})); diff --git a/extensions/levelcode-ai/diagram/scene.js b/extensions/levelcode-ai/diagram/scene.js new file mode 100644 index 0000000..6761b94 --- /dev/null +++ b/extensions/levelcode-ai/diagram/scene.js @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the painter (docs/RICH-DIAGRAMS.md, "Safe rendering") + * + * LevelCode builds the SVG itself. A diagram is a tree of plain objects — { tag, attrs, text, + * children } — produced here from a laid-out spec, and turned into pixels one of two ways: + * + * mount(vnode, document) the chat: real DOM nodes via createElementNS. A label becomes a TEXT + * NODE (textContent) — never markup, never innerHTML — so nothing a model + * writes into a label can become an element, an attribute or a script. + * toSvg(vnode) an export / a test: a string, every text and attribute value escaped. + * + * Both walk the same tree, so "exports match the rendered view" holds by construction. + * + * The tree is drawn from two short allow-lists (TAGS, ATTRS). There is no script element, no + * foreignObject, no href, no style attribute and no event handler in them, and mount() refuses + * anything else — the allow-list is the guarantee, not the care taken by whoever built the tree. + * + * (This file is inlined into the chat page's own script block, so it must never spell an HTML + * script tag or comment opener, even in a comment like this one — diagram/bundle.js refuses to + * build the page if it does, and test/diagramHost.test.js checks every module that ships there.) + * + * Colour never appears here. Elements carry classes; theme.css() gives the classes their paint from + * editor theme tokens, which is why a theme switch restyles a diagram without rebuilding it. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./theme')); } + else { (root.LCDiagram = root.LCDiagram || {}).scene = factory(root.LCDiagram.theme); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (theme) { + 'use strict'; + + const SVG_NS = 'http://www.w3.org/2000/svg'; + /** Every element a diagram may contain. `style` exists only in exported files (toSvg), never in the chat. */ + const TAGS = new Set(['svg', 'g', 'rect', 'path', 'text', 'title', 'style']); + /** Every attribute a diagram may carry. Note what is absent: href, style, on*, xlink:*, src. */ + const ATTRS = new Set([ + 'class', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'viewBox', 'transform', 'text-anchor', + 'role', 'aria-label', 'aria-hidden', 'tabindex', 'focusable', 'data-lc-link', 'data-lc-node', 'xmlns' + ]); + + const n2 = (v) => String(Math.round(v * 100) / 100); + const h = (tag, attrs, kids) => { + const node = { tag, attrs: attrs || {} }; + if (typeof kids === 'string') { node.text = kids; } else if (kids) { node.children = kids.filter(Boolean); } + return node; + }; + + /** Where a line of text's baseline sits, given the centre of its line box. Avoids `dominant-baseline`, which exporters disagree about. */ + const baseline = (centerY, size) => centerY + size * 0.355; + + /** + * An orthogonal polyline as a path with softly rounded corners. `trim` shortens the final run so + * the stroke ends at the base of the arrowhead instead of poking through its tip. + */ + function edgePath(points, radius, trim) { + const pts = points.map((p) => ({ x: p.x, y: p.y })); + const n = pts.length; + if (n < 2) { return ''; } + if (trim) { + const a = pts[n - 2], b = pts[n - 1]; + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (len > trim + 0.5) { b.x -= (b.x - a.x) / len * trim; b.y -= (b.y - a.y) / len * trim; } + } + let d = 'M' + n2(pts[0].x) + ',' + n2(pts[0].y); + for (let i = 1; i < n; i++) { + const p = pts[i]; + if (i === n - 1) { d += ' L' + n2(p.x) + ',' + n2(p.y); break; } + const a = pts[i - 1], b = pts[i + 1]; + const inLen = Math.hypot(p.x - a.x, p.y - a.y), outLen = Math.hypot(b.x - p.x, b.y - p.y); + const r = Math.min(radius, inLen / 2, outLen / 2); + if (r < 0.75) { d += ' L' + n2(p.x) + ',' + n2(p.y); continue; } + const s = { x: p.x - (p.x - a.x) / inLen * r, y: p.y - (p.y - a.y) / inLen * r }; + const e = { x: p.x + (b.x - p.x) / outLen * r, y: p.y + (b.y - p.y) / outLen * r }; + d += ' L' + n2(s.x) + ',' + n2(s.y) + ' Q' + n2(p.x) + ',' + n2(p.y) + ' ' + n2(e.x) + ',' + n2(e.y); + } + return d; + } + /** A filled triangle whose tip is the end of the connector. */ + function arrowPath(arrow, S) { + const L = S.arrow, W = S.arrowHalf; + const bx = arrow.x - arrow.dx * L, by = arrow.y - arrow.dy * L; + const px = -arrow.dy, py = arrow.dx; // perpendicular + return 'M' + n2(arrow.x) + ',' + n2(arrow.y) + ' L' + n2(bx + px * W) + ',' + n2(by + py * W) + ' L' + n2(bx - px * W) + ',' + n2(by - py * W) + ' Z'; + } + + /** The outline(s) of one node, by shape. "Same kind, same shape." */ + function shapeNodes(n, B) { + const x = n.x, y = n.y, w = n.w, ht = n.h; + if (n.shape === 'decision') { + return [h('path', { class: 'lcd-shape', d: 'M' + n2(x + w / 2) + ',' + n2(y) + ' L' + n2(x + w) + ',' + n2(y + ht / 2) + ' L' + n2(x + w / 2) + ',' + n2(y + ht) + ' L' + n2(x) + ',' + n2(y + ht / 2) + ' Z' })]; + } + if (n.shape === 'store') { + const ry = B.storeCap != null ? B.storeCap : 6, rx = w / 2; + const body = 'M' + n2(x) + ',' + n2(y + ry) + ' A' + n2(rx) + ',' + n2(ry) + ' 0 0 1 ' + n2(x + w) + ',' + n2(y + ry) + + ' V' + n2(y + ht - ry) + ' A' + n2(rx) + ',' + n2(ry) + ' 0 0 1 ' + n2(x) + ',' + n2(y + ht - ry) + ' Z'; + const lid = 'M' + n2(x) + ',' + n2(y + ry) + ' A' + n2(rx) + ',' + n2(ry) + ' 0 0 0 ' + n2(x + w) + ',' + n2(y + ry); + return [h('path', { class: 'lcd-shape', d: body }), h('path', { class: 'lcd-shape lcd-lid', d: lid })]; + } + const r = n.shape === 'actor' ? Math.min(w, ht) / 2 : B.radius; + return [h('rect', { class: 'lcd-shape', x: n2(x), y: n2(y), width: n2(w), height: n2(ht), rx: n2(r), ry: n2(r) })]; + } + /** A 9×11 "file" glyph — the mark of a node that opens code. */ + function fileGlyph(x, y) { + return h('path', { class: 'lcd-link-icon', 'aria-hidden': 'true', d: 'M' + n2(x) + ',' + n2(y) + ' h5.5 l3.5,3.5 v7.5 h-9 Z M' + n2(x + 5.5) + ',' + n2(y) + ' v3.5 h3.5' }); + } + + /** Greedy word wrap to a pixel width — used for the title of an exported file. */ + function wrapText(text, maxWidth, measure, role) { + const words = String(text).split(' '); + const lines = []; + let cur = ''; + for (const w of words) { + const next = cur ? cur + ' ' + w : w; + if (cur && measure(next, role) > maxWidth) { lines.push(cur); cur = w; } else { cur = next; } + } + if (cur) { lines.push(cur); } + return lines; + } + + /** + * Build the tree for one diagram. + * @param {any} spec the validated spec (for the title) + * @param {any} geo layout.layout(spec, …) + * @param {{ title?: boolean, css?: string, measure?: (t:string, role:string)=>number, standalone?: boolean, background?: boolean }} [opts] + * title: draw the spec's title inside the SVG (exports — in the chat it is HTML above the picture) + * css: a stylesheet to embed (exports) standalone: add xmlns (a file, not inline SVG) + * background: paint the editor background behind the drawing (exports — a file has no page behind it) + */ + function build(spec, geo, opts) { + const o = opts || {}; + const T = theme.TYPE, B = theme.BOX, S = theme.SPACE; + const measure = o.measure || theme.approxMeasure; + const kids = []; + let top = 0, width = geo.width; + if (o.css) { kids.push(h('style', {}, o.css)); } + if (o.title && spec.title) { + const lines = wrapText(spec.title, Math.max(geo.width - 2 * S.margin, 260), measure, 'title'); + width = Math.max(width, Math.ceil(Math.max.apply(null, lines.map((l) => measure(l, 'title')))) + 2 * S.margin); + lines.forEach((line, i) => { + kids.push(h('text', { class: 'lcd-title', x: n2(S.margin), y: n2(baseline(S.margin + T.title.line * (i + 0.5), T.title.size)) }, line)); + }); + top = S.margin + lines.length * T.title.line + 6; + } + + // A group's name is drawn with its frame — unless a connector has to run behind it (the layout + // found no clear stretch for it). Then it is drawn after the connectors, backed like an edge label. + const groupName = (g) => h('text', { class: 'lcd-group-label' + (g.labelHalo ? ' lcd-halo' : ''), x: n2(g.labelX), y: n2(baseline(g.labelY + g.labelH / 2, T.group.size)) }, g.label); + const groups = geo.groups.slice().sort((a, b) => a.depth - b.depth).map((g) => h('g', { class: 'lcd-group' }, [ + h('rect', { class: 'lcd-group-box', x: n2(g.x), y: n2(g.y), width: n2(g.w), height: n2(g.h), rx: '10', ry: '10' }), + g.labelHalo ? null : groupName(g), + g.tip ? h('title', {}, g.tip) : null + ])); + const backedNames = geo.groups.filter((g) => g.labelHalo).map(groupName); + + const edges = geo.edges.map((e) => h('g', { class: 'lcd-edge-g' }, [ + h('path', { class: 'lcd-edge' + (e.dashed ? ' lcd-dashed' : ''), d: edgePath(e.points, 5, S.arrow - 1) }), + h('path', { class: 'lcd-arrow', d: arrowPath(e.arrow, S) }), + e.tip ? h('title', {}, e.tip) : null + ])); + + const nodes = geo.nodes.map((n) => { + const cls = 'lcd-node lcd-' + n.shape + (n.accent ? ' lcd-accent' : '') + (n.link ? ' lcd-linked' : ''); + const attrs = { class: cls, 'data-lc-node': n.id }; + if (n.link) { + // The attribute carries the NODE ID, not the path: the host looks the link up in its own + // copy of the spec, so nothing the webview says can choose which file is opened. + attrs['data-lc-link'] = n.id; attrs.tabindex = '0'; attrs.role = 'link'; + attrs['aria-label'] = 'Open ' + n.link.path + (n.link.symbol ? ' at ' + n.link.symbol : n.link.line ? ' line ' + n.link.line : ''); + } + const parts = shapeNodes(n, B); + let yy = n.cy - n.textH / 2 + (n.textDy || 0); + let firstCenter = null, nameW = 0; + n.lines.forEach((line, i) => { + if (line.role === 'sub' && i > 0) { yy += B.textGap; } + const cy = yy + line.h / 2; + if (firstCenter === null) { firstCenter = cy; nameW = line.w; } + parts.push(h('text', { class: line.role === 'sub' ? 'lcd-sub' : 'lcd-name', x: n2(n.cx), y: n2(baseline(cy, T[line.role].size)), 'text-anchor': 'middle' }, line.text)); + yy += line.h; + }); + if (n.link) { parts.push(fileGlyph(n.cx + nameW / 2 + 4, (firstCenter || n.cy) - 5.5)); } + const tips = []; + if (n.tip) { tips.push(n.tip); } + if (n.link) { tips.push(n.link.path + (n.link.symbol ? ' · ' + n.link.symbol : n.link.line ? ':' + n.link.line : '')); } + if (tips.length) { parts.push(h('title', {}, tips.join('\n'))); } + return h('g', attrs, parts); + }); + + const labels = geo.edges.filter((e) => e.label).map((e) => h('text', { + class: 'lcd-edge-label' + (e.label.halo ? ' lcd-halo' : ''), + x: n2(e.label.x), y: n2(baseline(e.label.y + e.label.h / 2, T.edge.size)) + }, e.label.text)); + + const body = [h('g', { class: 'lcd-groups' }, groups), h('g', { class: 'lcd-edges' }, edges), h('g', { class: 'lcd-nodes' }, nodes), h('g', { class: 'lcd-labels' }, backedNames.concat(labels))]; + kids.push(top ? h('g', { transform: 'translate(0,' + n2(top) + ')' }, body) : h('g', {}, body)); + + const height = geo.height + top; + if (o.background) { kids.splice(o.css ? 1 : 0, 0, h('rect', { class: 'lcd-bg', x: '0', y: '0', width: String(width), height: String(height) })); } + const attrs = { class: 'lcd-svg', viewBox: '0 0 ' + width + ' ' + height, width: String(width), height: String(height), role: 'img', 'aria-label': spec.title || 'Diagram', focusable: 'false' }; + if (o.standalone) { attrs.xmlns = SVG_NS; } + const svg = h('svg', attrs, kids); + svg.width = width; svg.height = height; + return svg; + } + + // ---- two ways to draw the same tree ----------------------------------------------------------- + + const escText = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + const escAttr = (s) => escText(s).replace(/"/g, '"').replace(/'/g, '''); + /** + * Characters XML 1.0 does not allow at all. repair.cleanText() already strips them from labels; + * this is the same rule again at the last moment, so an exported file is always well-formed. + */ + // eslint-disable-next-line no-control-regex + const xmlSafe = (s) => String(s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]/g, ''); + + /** Serialize a tree to an SVG string. Unknown tags and attributes are a bug, so they throw. */ + function toSvg(vnode) { + if (!TAGS.has(vnode.tag)) { throw new Error('diagram: element <' + vnode.tag + '> is not allowed'); } + let out = '<' + vnode.tag; + for (const k of Object.keys(vnode.attrs || {})) { + if (!ATTRS.has(k)) { throw new Error('diagram: attribute "' + k + '" is not allowed'); } + out += ' ' + k + '="' + escAttr(xmlSafe(vnode.attrs[k])) + '"'; + } + if (vnode.text == null && !(vnode.children && vnode.children.length)) { return out + '/>'; } + out += '>'; + if (vnode.text != null) { + // A stylesheet is ours (theme.css) and CDATA keeps it readable; text is escaped. + out += vnode.tag === 'style' ? '<![CDATA[' + String(vnode.text).replace(/]]>/g, ']] >') + ']]>' : escText(xmlSafe(vnode.text)); + } + for (const c of (vnode.children || [])) { out += toSvg(c); } + return out + '</' + vnode.tag + '>'; + } + + /** + * Build real DOM for the chat. Refuses any tag or attribute off the allow-lists, and refuses + * <style> outright — the live view is styled by the page's own stylesheet. + * @param {any} vnode + * @param {Document} doc + */ + function mount(vnode, doc) { + if (!TAGS.has(vnode.tag) || vnode.tag === 'style') { throw new Error('diagram: element <' + vnode.tag + '> is not allowed'); } + const el = doc.createElementNS(SVG_NS, vnode.tag); + for (const k of Object.keys(vnode.attrs || {})) { + if (!ATTRS.has(k) || k === 'xmlns') { if (k === 'xmlns') { continue; } throw new Error('diagram: attribute "' + k + '" is not allowed'); } + el.setAttribute(k, String(vnode.attrs[k])); + } + if (vnode.text != null) { el.textContent = String(vnode.text); } + for (const c of (vnode.children || [])) { el.appendChild(mount(c, doc)); } + return el; + } + + return { SVG_NS, TAGS, ATTRS, build, toSvg, mount, edgePath, arrowPath, wrapText }; +})); diff --git a/extensions/levelcode-ai/diagram/schema.js b/extensions/levelcode-ai/diagram/schema.js new file mode 100644 index 0000000..bb263b1 --- /dev/null +++ b/extensions/levelcode-ai/diagram/schema.js @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the Graph JSON schema (docs/RICH-DIAGRAMS.md, "Diagram spec") + * + * The model describes STRUCTURE only — nodes, edges, groups and one accent. It never sets + * coordinates, colours or font sizes, so it cannot violate the style guide; the renderer owns those. + * + * This file is the single definition of what a spec may contain. The SAME object is: + * • sent to the model as `render_diagram`'s input schema (diagram/tool.js), and + * • interpreted here to validate every spec that comes back (check()), + * so the contract the model is shown and the contract it is held to cannot drift apart. + * + * Two tiers of limits, deliberately: + * HOUSE — what the model is asked for (12 nodes, 28-char labels …). Breaking one is an error the + * repair ladder deals with. + * HARD — what the renderer will ever accept, even from a degraded spec. These exist because a + * spec is untrusted input: they bound layout time and DOM size no matter what arrives. + * + * Versioned: `v` names the schema a spec was written against, and SCHEMAS keeps every version this + * editor can still read, so a chat saved today renders after the schema moves on (NFR-4). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(); } + else { (root.LCDiagram = root.LCDiagram || {}).schema = factory(); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + /** The schema version new specs are written against. */ + const VERSION = 1; + + const SHAPES = ['box', 'decision', 'store', 'actor']; + const DIRECTIONS = ['right', 'down']; + const EDGE_STYLES = ['solid', 'dashed']; + /** "Lowercase slug": starts alphanumeric, then letters, digits, `_` or `-`. */ + const ID_RE = /^[a-z0-9][a-z0-9_-]*$/; + const ID_PATTERN = '^[a-z0-9][a-z0-9_-]*$'; + + /** What the model is asked for — the numbers in the spec's field tables. */ + const HOUSE = Object.freeze({ + title: 80, label: 28, sub: 32, edgeLabel: 20, groupLabel: 28, + nodesMin: 1, nodesMax: 12, groupDepth: 2, + // Not in the field tables, but a spec is untrusted and these have to be SOME number. Chosen + // so no diagram that respects the 12-node cap can run into them. + edgesMax: 30, groupsMax: 8, id: 32, path: 260, symbol: 80 + }); + /** + * What the renderer accepts at all. A degraded spec may exceed a HOUSE count (it is drawn anyway, + * under a banner) but never these; text limits do not relax, because truncation always applies. + */ + const HARD = Object.freeze(Object.assign({}, HOUSE, { nodesMax: 24, edgesMax: 48, groupsMax: 12, tip: 400 })); + + /** + * Build the JSON Schema for one tier. + * + * `house` is the wire schema — plain JSON Schema with nothing a provider could reject (no custom + * keywords, no `additionalProperties`: a model that adds a stray field is tidied, not failed). + * `v` is deliberately not in it: the version is checked by validate() before any schema is + * chosen, and a model writing a new spec never needs to state it. + * `hard` is the render-ready shape: the same fields under the HARD limits, plus `tip` — the full + * text of a label the auto-fixer had to truncate, which the renderer shows as a tooltip. `tip` is + * ours; the model is never told about it. + * @param {'house'|'hard'} tier + */ + function build(tier) { + const L = tier === 'hard' ? HARD : HOUSE; + const hard = tier === 'hard'; + const tip = hard ? { tip: { type: 'string', maxLength: HARD.tip } } : {}; + // Only a DECLARED id carries the slug rule. A reference (an edge end, a node's group, a group's + // parent) needs no rule of its own: the semantic layer requires it to equal a declared id, so it + // is a slug or it is an error either way — and every keyword here is paid for on each request. + const id = { type: 'string', maxLength: L.id, pattern: ID_PATTERN }; + const ref = { type: 'string' }; + return { + type: 'object', + properties: Object.assign({ + title: { type: 'string', minLength: 1, maxLength: L.title, description: 'The takeaway, not the topic.' }, + direction: { type: 'string', enum: DIRECTIONS, description: 'Default right.' }, + nodes: { + type: 'array', minItems: L.nodesMin, maxItems: L.nodesMax, + items: { + type: 'object', + properties: Object.assign({ + id: Object.assign({ description: 'Unique lowercase slug.' }, id), + label: { type: 'string', minLength: 1, maxLength: L.label }, + sub: { type: 'string', maxLength: L.sub, description: 'Second line.' }, + shape: { type: 'string', enum: SHAPES, description: 'box=step (default), decision=branch, store=data at rest, actor=person or outside system.' }, + accent: { type: 'boolean', description: 'At most one node.' }, + group: Object.assign({ description: 'Group id.' }, ref), + link: { + type: 'object', + description: 'Workspace file to open on click.', + properties: { + path: { type: 'string', minLength: 1, maxLength: L.path }, + symbol: { type: 'string', maxLength: L.symbol }, + line: { type: 'integer', minimum: 1 } + }, + required: ['path'] + } + }, tip), + required: ['id', 'label'] + } + }, + edges: { + type: 'array', maxItems: L.edgesMax, + items: { + type: 'object', + properties: Object.assign({ + from: ref, to: ref, + label: { type: 'string', maxLength: L.edgeLabel }, + style: { type: 'string', enum: EDGE_STYLES } + }, tip), + required: ['from', 'to'] + } + }, + groups: { + type: 'array', maxItems: L.groupsMax, + items: { + type: 'object', + properties: Object.assign({ + id: id, + label: { type: 'string', minLength: 1, maxLength: L.groupLabel }, + parent: Object.assign({ description: 'Enclosing group (depth 2 max).' }, ref) + }, tip), + required: ['id', 'label'] + } + } + }, tip), + required: ['title', 'nodes', 'edges'] + }; + } + + /** + * Every schema version this editor can read, each in both tiers. Adding v2 means adding a row + * here and a step in validate.migrate() — never editing v1, which stored chats still point at. + */ + const deepFreeze = (o) => { if (o && typeof o === 'object' && !Object.isFrozen(o)) { Object.freeze(o); for (const k of Object.keys(o)) { deepFreeze(o[k]); } } return o; }; + const SCHEMAS = deepFreeze({ + 1: { house: build('house'), hard: build('hard') } + }); + const KNOWN_VERSIONS = Object.keys(SCHEMAS).map(Number); + + // ---- a JSON-Schema-subset interpreter ------------------------------------------------------- + // Exactly the keywords build() uses, and no more: type, properties, required, items, enum, + // minLength/maxLength, minItems/maxItems, pattern, minimum. Ajv would do this too, but it would be + // the extension's first runtime dependency for ~80 lines of work — and its messages are not the + // ones the repair ladder needs. Each error names WHAT WAS EXPECTED and THE VALID OPTIONS, because + // that is what turns a model's repair into a lookup instead of a guess. + + /** JSON's own type names, with `integer` and `array` told apart from number/object. */ + function typeOf(v) { + if (v === null) { return 'null'; } + if (Array.isArray(v)) { return 'array'; } + if (typeof v === 'number') { return Number.isInteger(v) ? 'integer' : 'number'; } + return typeof v; + } + /** Escape one JSON Pointer segment (RFC 6901). */ + function seg(s) { return String(s).replace(/~/g, '~0').replace(/\//g, '~1'); } + + /** + * What a too-long / too-many error should tell the model to DO. Keyed by the tail of the + * pointer, so `/nodes/3/label` and `/nodes/7/label` share one hint. + */ + const HINTS = { + 'title': 'Say the takeaway in fewer words.', + 'label': 'Shorten or move detail to prose.', + 'sub': 'Shorten or move detail to prose.', + 'nodes': 'Draw an overview, then one diagram per sub-flow.', + 'edges': 'Keep the connections that carry the point.', + 'groups': 'Use fewer containers.' + }; + function hintFor(pointer) { + const tail = String(pointer).split('/').pop() || ''; + return HINTS[tail] ? ' ' + HINTS[tail] : ''; + } + const NOUN = { nodes: 'nodes', edges: 'edges', groups: 'groups' }; + + /** + * Check `value` against `schema`, appending every violation to `errors`. + * @param {any} schema + * @param {any} value + * @param {string} pointer JSON Pointer of `value` ('' for the root) + * @param {Array<{pointer:string, cls:string, message:string}>} errors + */ + function check(schema, value, pointer, errors) { + const got = typeOf(value); + const want = schema.type; + const typeOk = want === got || (want === 'number' && got === 'integer'); + if (!typeOk) { + errors.push({ pointer, cls: 'type', message: 'expected ' + (want === 'object' ? 'an object' : want === 'array' ? 'an array' : want === 'integer' ? 'an integer' : 'a ' + want) + ', got ' + got + '.' }); + return; // nothing below means anything on a value of the wrong type + } + if (schema.enum && schema.enum.indexOf(value) < 0) { + errors.push({ pointer, cls: pointer === '/v' ? 'version' : 'enum', message: JSON.stringify(value) + ' is not allowed. Use one of: ' + schema.enum.join(', ') + '.' }); + } + if (want === 'string') { + const n = Array.from(value).length; // code points — an emoji is one character, not two + if (schema.maxLength != null && n > schema.maxLength) { + errors.push({ pointer, cls: 'length', message: n + ' chars, max ' + schema.maxLength + '.' + hintFor(pointer) }); + } + if (schema.minLength != null && n < schema.minLength) { + errors.push({ pointer, cls: 'required', message: 'must not be empty.' }); + } else if (schema.pattern && n <= (schema.maxLength != null ? schema.maxLength : n) && !new RegExp(schema.pattern).test(value)) { + errors.push({ pointer, cls: 'pattern', message: JSON.stringify(value) + ' is not a lowercase slug (letters, digits, "-" or "_"; e.g. "auth-check").' }); + } + } + if ((want === 'integer' || want === 'number') && schema.minimum != null && value < schema.minimum) { + errors.push({ pointer, cls: 'range', message: value + ' is below the minimum ' + schema.minimum + '.' }); + } + if (want === 'array') { + const noun = NOUN[String(pointer).split('/').pop() || ''] || 'items'; + if (schema.maxItems != null && value.length > schema.maxItems) { + errors.push({ pointer, cls: 'count', message: value.length + ' ' + noun + ', max ' + schema.maxItems + '.' + hintFor(pointer) }); + } + if (schema.minItems != null && value.length < schema.minItems) { + errors.push({ pointer, cls: 'count', message: value.length + ' ' + noun + ', min ' + schema.minItems + '.' }); + } + if (schema.items) { + for (let i = 0; i < value.length; i++) { check(schema.items, value[i], pointer + '/' + i, errors); } + } + } + if (want === 'object') { + for (const key of (schema.required || [])) { + if (value[key] === undefined) { errors.push({ pointer: pointer + '/' + seg(key), cls: 'required', message: 'missing. This field is required.' }); } + } + const props = schema.properties || {}; + for (const key of Object.keys(props)) { + if (value[key] !== undefined) { check(props[key], value[key], pointer + '/' + seg(key), errors); } + } + } + } + + /** + * A copy of `value` holding only what `schema` declares, in the order the value had it. + * check() says whether the declared fields are right; it does not object to fields it has never + * heard of — a model that adds `color` should not be sent back for it. So after a spec passes, + * this is what makes "validated" mean "exactly this shape, and nothing that came along with it". + */ + function project(schema, value) { + if (schema.type === 'object' && typeOf(value) === 'object') { + const props = schema.properties || {}; + const out = {}; + for (const key of Object.keys(value)) { + if (Object.prototype.hasOwnProperty.call(props, key) && value[key] !== undefined) { out[key] = project(props[key], value[key]); } + } + return out; + } + if (schema.type === 'array' && Array.isArray(value)) { return value.map((v) => (schema.items ? project(schema.items, v) : v)); } + return value; + } + + return { VERSION, KNOWN_VERSIONS, SHAPES, DIRECTIONS, EDGE_STYLES, ID_RE, ID_PATTERN, HOUSE, HARD, SCHEMAS, build, check, project, typeOf, seg }; +})); diff --git a/extensions/levelcode-ai/diagram/service.js b/extensions/levelcode-ai/diagram/service.js new file mode 100644 index 0000000..7702e29 --- /dev/null +++ b/extensions/levelcode-ai/diagram/service.js @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · one conversation's diagrams (docs/RICH-DIAGRAMS.md, + * "Validation and repair → Repair ladder" + "Storage") + * + * repair.prepare() climbs the ladder for ONE call. This is what remembers across calls: + * + * • whether the model has already had its one repair pass for a diagram — so the second failure + * degrades instead of bouncing again ("no automatic second repair"), + * • that a diagram sent back for repair still owes the user a picture — so if the model never + * answers, the run does not end on an empty placeholder ("no blank output"), + * • every diagram this conversation has drawn, by id — so a node can be clicked, a picture + * exported, a spec fetched back after the conversation is compacted, and a reopened chat shows + * exactly what it showed before, without running the ladder again ("re-opening a chat never + * re-runs repair"). + * + * vscode-free and deterministic: the host injects how a link is resolved and where numbers go, and + * gets back the tool result to return to the model plus the messages to post to the chat. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const repair = require('./repair'); +const validator = require('./validate'); +const text = require('./text'); +const tool = require('./tool'); + +/** A model that keeps sending broken specs gets this many repair passes in one run, then no more. */ +const MAX_BOUNCES_PER_RUN = 3; +/** How much of an undrawable input is kept so the user can see what was sent. */ +const SOURCE_CAP = 8000; + +const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim(); +const idsOf = (input) => { + const nodes = input && typeof input === 'object' && Array.isArray(input.nodes) ? input.nodes : []; + return nodes.map((n) => norm(n && typeof n === 'object' ? (n.id != null ? n.id : n.label) : n)).filter(Boolean); +}; +const titleOf = (input) => norm(input && typeof input === 'object' ? input.title : ''); +/** Is this call another go at the same diagram? Same title, or mostly the same nodes. */ +function sameDiagram(a, b) { + if (a.title && a.title === b.title) { return true; } + if (!a.ids.length || !b.ids.length) { return false; } + const set = new Set(a.ids); + const shared = b.ids.filter((id) => set.has(id)).length; + return shared / Math.max(a.ids.length, b.ids.length) >= 0.5; +} +/** The title a (possibly unparseable) input claims, cleaned and bounded — or ''. */ +function titleFrom(input) { + let t = input && typeof input === 'object' ? input.title : null; + if (typeof input === 'string') { const m = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(input); if (m) { try { t = JSON.parse('"' + m[1] + '"'); } catch (e) { t = m[1]; } } } + return typeof t === 'string' ? repair.cleanText(t).slice(0, 120) : ''; +} +const countBy = (items, key) => { const out = {}; for (const it of items) { out[it[key]] = (out[it[key]] || 0) + 1; } return out; }; +function sourceOf(input) { + let s; + try { s = typeof input === 'string' ? input : JSON.stringify(input, null, 2); } catch (e) { s = String(input); } + s = String(s == null ? '' : s); + return s.length > SOURCE_CAP ? s.slice(0, SOURCE_CAP) + '\n… (' + (s.length - SOURCE_CAP) + ' more characters)' : s; +} + +/** + * @param {{ resolveLink?: (link: {path:string, symbol?:string, line?:number}) => ({ ok: true, path: string } | { ok: false, reason: string }), + * onStat?: (ev: any) => void, now?: () => Date }} [opts] + */ +function createDiagrams(opts) { + const o = opts || {}; + const stat = (ev) => { if (typeof o.onStat === 'function') { try { o.onStat(ev); } catch (e) { /* counting must never break drawing */ } } }; + const iso = () => (o.now ? o.now() : new Date()).toISOString(); + + /** @type {Map<string, any>} */ + const records = new Map(); + /** @type {Map<string, string>} */ + const byKey = new Map(); + let seq = 0; + let fresh = []; // records made since the host last took them (to persist with the turn) + let run = { pending: null, bounces: 0, drawn: [] }; + + const nextId = () => { let id; do { id = 'd-' + (++seq); } while (records.has(id)); return id; }; + + /** Keep only the links the host says may be opened; the rest become plain text. */ + function resolveLinks(spec) { + const unlinked = []; + for (const n of spec.nodes) { + if (!n.link) { continue; } + let r = null; + try { r = typeof o.resolveLink === 'function' ? o.resolveLink(n.link) : { ok: false, reason: 'links are unavailable here' }; } catch (e) { r = { ok: false, reason: 'could not be checked' }; } + if (r && r.ok) { + const link = { path: String(r.path) }; + if (n.link.symbol) { link.symbol = n.link.symbol; } + if (n.link.line) { link.line = n.link.line; } + n.link = link; + } else { + unlinked.push(n.id + ': ' + ((r && r.reason) || 'not a file in this workspace')); + delete n.link; + } + } + return unlinked; + } + + /** File a finished diagram (drawn, cut down, or failed) and build the message that shows it. */ + function commit(prepared, meta, extra) { + const id = nextId(); + const record = { + id, key: String(meta.key || id), v: prepared.spec ? prepared.spec.v : null, at: iso(), + status: prepared.status, + spec: prepared.spec || null, + fixes: repair.visibleFixes(prepared.fixes).map((f) => (f.pointer || '/') + ': ' + f.message), + notes: prepared.notes.slice(), + errors: validator.formatErrors(prepared.errors).split('\n').filter(Boolean) + }; + if (meta.model) { record.model = String(meta.model); } + if (!prepared.spec) { + // Nothing was drawn, so there is no spec to read a title from; keep what the model called it. + record.source = sourceOf(extra && extra.input); + const t = titleFrom(extra && extra.input); + if (t) { record.title = t; } + } + if (extra && extra.repaired) { record.repaired = true; } // "the repaired spec is stored and marked as repaired" + if (extra && extra.replaces) { record.replaces = extra.replaces; } + records.set(id, record); + byKey.set(record.key, id); + fresh.push(record); + const msg = { type: 'diagram', key: record.key, record }; + if (extra && extra.replacesKey) { msg.replacesKey = extra.replacesKey; } + return { record, msg }; + } + + /** A diagram that was sent back and never came back right: draw what can be drawn of the original. */ + function settlePending() { + const p = run.pending; + if (!p) { return []; } + run.pending = null; + const prepared = repair.prepare(p.input, { final: true }); + let unlinked = []; + if (prepared.spec) { unlinked = resolveLinks(prepared.spec); } + if (unlinked.length) { prepared.notes = prepared.notes.concat([unlinked.length + ' link' + (unlinked.length === 1 ? '' : 's') + ' removed: not files in this workspace']); } + stat({ type: 'call', model: p.model, format: 'graph', outcome: prepared.status === 'degraded' ? 'degraded' : prepared.status === 'failed' ? 'failed' : 'fixed', errorClasses: validator.errorClasses(prepared.errors), fixClasses: countBy(prepared.fixes, 'cls') }); + return [commit(prepared, { key: p.key, model: p.model }, { input: p.input }).msg]; + } + + /** A run begins: nothing is owed, and the model has its repair passes back. */ + function beginRun() { run = { pending: null, bounces: 0, drawn: [] }; } + /** A run ends. Returns the messages that settle anything still owed. */ + function endRun() { const post = settlePending(); run = { pending: null, bounces: 0, drawn: [] }; return post; } + + /** + * One render_diagram call. + * @param {any} input the tool call's arguments + * @param {{ key: string, model?: string }} meta key: the tool_use id (the chat placeholder is keyed by it) + * @returns {{ result: string, post: any[] }} the tool result for the model, and what to show + */ + function render(input, meta) { + const m = meta || { key: '' }; + const me = { title: titleOf(input), ids: idsOf(input) }; + const post = []; + let pending = run.pending; + // Something else was sent back for repair and the model has moved on to a different diagram: + // settle the old one now, so its placeholder does not wait for a call that is not coming. + if (pending && !sameDiagram(pending, me)) { for (const msg of settlePending()) { post.push(msg); } pending = null; } + + const isRepair = !!pending; + const final = isRepair || run.bounces >= MAX_BOUNCES_PER_RUN; + const prepared = repair.prepare(input, { final }); + const tokens = Math.round(sourceOf(input).length / 4); + const classes = { errorClasses: validator.errorClasses(prepared.errors), fixClasses: countBy(prepared.fixes, 'cls') }; + + if (prepared.status === 'truncated') { + stat(Object.assign({ type: 'call', model: m.model, format: 'graph', outcome: 'truncated', tokens }, classes)); + return { result: tool.result(prepared), post }; // re-requested, never repaired; whatever was pending stays pending + } + if (prepared.status === 'errors') { + // Rung 2: the model's one repair pass. Remember what it owes, and say so in the chat. + run.pending = { key: String(m.key), input, model: m.model, title: me.title, ids: me.ids }; + run.bounces++; + stat(Object.assign({ type: 'call', model: m.model, format: 'graph', outcome: 'bounced', tokens }, classes)); + post.push({ type: 'diagramPending', key: String(m.key), state: 'repairing', title: typeof (input && input.title) === 'string' ? repair.cleanText(input.title).slice(0, 120) : '' }); + return { result: tool.result(prepared), post }; + } + + run.pending = null; + let unlinked = []; + if (prepared.spec) { unlinked = resolveLinks(prepared.spec); } + // A second drawing of the same thing in the same run takes the first one's place in the chat. + const earlier = prepared.spec ? run.drawn.find((d) => d.title === me.title && me.title) : null; + const { record, msg } = commit(prepared, m, { input, repaired: isRepair && !!prepared.spec, replaces: earlier ? earlier.id : undefined, replacesKey: isRepair ? pending.key : (earlier ? earlier.key : undefined) }); + if (prepared.spec) { run.drawn = run.drawn.filter((d) => d !== earlier).concat([{ id: record.id, key: record.key, title: me.title }]); } + post.push(msg); + + const outcome = prepared.status === 'degraded' || prepared.status === 'failed' ? prepared.status + : isRepair ? 'repaired' + : prepared.status === 'fixed' ? 'fixed' + : prepared.fixes.length ? 'tidied' : 'clean'; + stat(Object.assign({ type: 'call', model: m.model, format: 'graph', outcome, tokens }, classes)); + return { result: tool.result(prepared, { id: record.id, unlinked }), post }; + } + + /** The tool arguments were cut off (malformed JSON at the token cap): re-request, never repair. */ + function truncated(meta) { + stat({ type: 'call', model: meta && meta.model, format: 'graph', outcome: 'truncated' }); + return { result: tool.result({ status: 'truncated', fixes: [], errors: [], notes: [] }), post: [] }; + } + + const get = (id) => records.get(String(id)) || null; + const byToolUse = (key) => (byKey.has(String(key)) ? records.get(byKey.get(String(key))) : null); + const list = () => Array.from(records.values()); + /** Records made since the last call — what the host persists alongside the turn. */ + const takeNew = () => { const out = fresh; fresh = []; return out; }; + + /** + * A session was reopened: take its stored diagrams back. A record on disk is input too — a session + * file can be edited, cut short or written by an older build — so each spec passes the validator on + * the way in, exactly as a model's does. What the host then acts on (a link to open, a file to + * export, a stub, get_diagram) is only ever a spec that was accepted; one that is not is kept as a + * diagram that was never drawn. Nothing here calls a model, and a valid record comes back unchanged. + */ + function load(stored) { + records.clear(); byKey.clear(); fresh = []; seq = 0; + for (const r of (Array.isArray(stored) ? stored : [])) { + if (!r || typeof r !== 'object' || typeof r.id !== 'string' || !/^d-\d+$/.test(r.id)) { continue; } + let rec = r; + if (r.spec != null) { + let ok = null; + try { ok = repair.accept(r.spec); } catch (e) { ok = null; } + rec = Object.assign({}, r, ok && ok.ok ? { spec: ok.spec } : { spec: null, status: 'failed' }); + } + records.set(rec.id, rec); + if (rec.key) { byKey.set(String(rec.key), rec.id); } + seq = Math.max(seq, Number(rec.id.slice(2)) || 0); + } + beginRun(); + } + function reset() { records.clear(); byKey.clear(); fresh = []; seq = 0; beginRun(); } + + /** get_diagram: the spec as the model would write it, or a plain reason there is none. */ + function fetch(id) { + const want = String(id == null ? '' : id).trim(); + const r = get(want); + if (!r) { + const known = list().filter((x) => x.spec).map((x) => x.id); + return 'ERROR: no diagram with id "' + want.slice(0, 40) + '" in this session.' + (known.length ? ' Known ids: ' + validator.listIds(known) + '.' : ' None has been drawn yet.'); + } + if (!r.spec) { return 'ERROR: diagram ' + r.id + ' was never drawn (its spec was invalid). Draw it again with render_diagram.'; } + return text.toSource(r.spec); + } + + /** + * The one-line stubs for every diagram whose spec is in `messages` — what replaces those specs + * when that stretch of the conversation is compacted. Nothing here runs turn by turn. + */ + function stubsFor(messages) { + const out = []; + for (const msg of (Array.isArray(messages) ? messages : [])) { + if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.content)) { continue; } + for (const b of msg.content) { + if (!b || b.type !== 'tool_use' || b.name !== tool.RENDER_DIAGRAM.name) { continue; } + const r = byToolUse(b.id); + if (r && r.spec) { out.push(text.stub(r)); } + } + } + return out; + } + + /** One finished answer to a client that can render: did it draw with characters anyway? */ + function noteAnswer(answer) { stat({ type: 'answer', asciiArt: tool.looksLikeAsciiArt(answer) }); } + + return { beginRun, endRun, render, truncated, noteAnswer, get, byToolUse, list, takeNew, load, reset, fetch, stubsFor, get pending() { return run.pending ? run.pending.key : null; } }; +} + +module.exports = { createDiagrams, sameDiagram, MAX_BOUNCES_PER_RUN, SOURCE_CAP }; diff --git a/extensions/levelcode-ai/diagram/stats.js b/extensions/levelcode-ai/diagram/stats.js new file mode 100644 index 0000000..8d5aef6 --- /dev/null +++ b/extensions/levelcode-ai/diagram/stats.js @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · measurement (docs/RICH-DIAGRAMS.md, "Telemetry and evaluation") + * + * The spec's rollout is gated on numbers: how often a spec is valid first time, how the fixes split + * across the rungs of the ladder, which errors each model makes, how long a render takes, how many + * tokens a diagram costs, and whether an answer ever drew with characters. + * + * This is the counter behind those numbers. It is LOCAL — a plain object the host keeps in the + * editor's own storage and shows on request; nothing here is sent anywhere. + * + * What it records: formats, outcome classes, error classes, timings and token counts, per model. + * What it never records: a label, a title, a path, a symbol, or any other text from a spec. record() + * takes an event made of enums and numbers, and copies nothing else out of it. + * + * Pure: no clock, no storage, no vscode. The host supplies the date and persists the object. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const VERSION = 1; +/** How one call ended. `clean` is the spec's "first-pass valid": every layer passed with no fix at all. */ +const OUTCOMES = ['clean', 'tidied', 'fixed', 'repaired', 'degraded', 'failed', 'truncated', 'bounced']; +/** Upper bounds of the histogram buckets (the last bucket is "more"). */ +const TOKEN_BUCKETS = [100, 200, 300, 400, 600, 800, 1200, 2000]; +const MS_BUCKETS = [8, 16, 33, 50, 100, 200, 300, 1000]; +/** A model id is kept as written but bounded, so a pathological id cannot bloat the store. */ +const MAX_MODELS = 40, MAX_CLASSES = 60; +const CLASS_RE = /^[a-z][a-z0-9-]{0,39}$/; + +function empty(since) { + return { v: VERSION, since: since || null, calls: 0, byModel: {}, errors: {}, fixes: {}, tokens: TOKEN_BUCKETS.map(() => 0).concat([0]), renderMs: MS_BUCKETS.map(() => 0).concat([0]), rendered: 0, renderFailed: 0, flipped: 0, asciiLeaks: 0, answers: 0, linkClicks: 0, exports: {} }; +} +const bucket = (bounds, value) => { let i = 0; while (i < bounds.length && value > bounds[i]) { i++; } return i; }; +const inc = (obj, key, cap) => { if (obj[key] !== undefined || Object.keys(obj).length < cap) { obj[key] = (obj[key] || 0) + 1; } }; + +/** + * Fold one event in. + * { type: 'call', model, format, outcome, errorClasses?, fixClasses?, tokens? } + * { type: 'render', ms, ok, flipped? } reported by the webview once the picture is on screen + * { type: 'answer', asciiArt } one assistant answer to a rich client + * { type: 'link' } / { type: 'export', format } + * Anything it does not recognise is ignored; nothing but the fields named above is ever read. + */ +function record(stats, ev) { + const s = stats && stats.v === VERSION ? stats : empty(stats && stats.since); + if (!ev || typeof ev !== 'object') { return s; } + if (ev.type === 'call') { + const outcome = OUTCOMES.indexOf(ev.outcome) >= 0 ? ev.outcome : null; + if (!outcome) { return s; } + s.calls++; + const model = String(ev.model || 'unknown').slice(0, 80); + if (!s.byModel[model] && Object.keys(s.byModel).length >= MAX_MODELS) { return s; } + const m = s.byModel[model] || (s.byModel[model] = { calls: 0, outcomes: {}, errors: {} }); + m.calls++; + m.outcomes[outcome] = (m.outcomes[outcome] || 0) + 1; + for (const cls of Object.keys(ev.errorClasses || {})) { if (CLASS_RE.test(cls)) { inc(s.errors, cls, MAX_CLASSES); inc(m.errors, cls, MAX_CLASSES); } } + for (const cls of Object.keys(ev.fixClasses || {})) { if (CLASS_RE.test(cls)) { inc(s.fixes, cls, MAX_CLASSES); } } + if (Number.isFinite(ev.tokens) && ev.tokens > 0) { s.tokens[bucket(TOKEN_BUCKETS, ev.tokens)]++; } + } else if (ev.type === 'render') { + if (ev.ok === false) { s.renderFailed++; return s; } + s.rendered++; + if (ev.flipped) { s.flipped++; } + if (Number.isFinite(ev.ms) && ev.ms >= 0) { s.renderMs[bucket(MS_BUCKETS, ev.ms)]++; } + } else if (ev.type === 'answer') { + s.answers++; + if (ev.asciiArt) { s.asciiLeaks++; } + } else if (ev.type === 'link') { + s.linkClicks++; + } else if (ev.type === 'export') { + const f = String(ev.format || ''); + if (['svg', 'png', 'source', 'mermaid', 'markdown'].indexOf(f) >= 0) { s.exports[f] = (s.exports[f] || 0) + 1; } + } + return s; +} + +/** The value below which `q` of a histogram's samples fall — reported as its bucket's upper bound. */ +function quantile(hist, bounds, q) { + const total = hist.reduce((a, b) => a + b, 0); + if (!total) { return null; } + let seen = 0; + for (let i = 0; i < hist.length; i++) { + seen += hist[i]; + if (seen >= total * q) { return i < bounds.length ? bounds[i] : Infinity; } + } + return Infinity; +} +const pct = (num, den) => (den ? Math.round(1000 * num / den) / 10 : null); + +/** + * The table from the spec's "Telemetry and evaluation", computed. Rates are percentages of the calls + * that reached a verdict (a `bounced` call — one sent back for its repair pass — is not a verdict: + * the call that answers it is). + */ +function summarize(stats) { + const s = stats && stats.v === VERSION ? stats : empty(); + const rows = (map) => { + const o = Object.assign({}, map); + const verdicts = OUTCOMES.filter((k) => k !== 'bounced' && k !== 'truncated').reduce((a, k) => a + (o[k] || 0), 0); + return { + verdicts, + firstPassValid: pct(o.clean || 0, verdicts), + autoFixed: pct((o.tidied || 0) + (o.fixed || 0), verdicts), + modelRepaired: pct(o.repaired || 0, verdicts), + degraded: pct((o.degraded || 0) + (o.failed || 0), verdicts), + truncated: o.truncated || 0 + }; + }; + const all = {}; + for (const m of Object.values(s.byModel)) { for (const [k, v] of Object.entries(m.outcomes)) { all[k] = (all[k] || 0) + v; } } + const top = (map) => Object.entries(map).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8).map(([cls, count]) => ({ cls, count })); + return { + since: s.since, calls: s.calls, + overall: rows(all), + byModel: Object.keys(s.byModel).sort().map((model) => Object.assign({ model, topErrors: top(s.byModel[model].errors) }, rows(s.byModel[model].outcomes))), + topErrors: top(s.errors), + tokens: { median: quantile(s.tokens, TOKEN_BUCKETS, 0.5), p95: quantile(s.tokens, TOKEN_BUCKETS, 0.95) }, + renderMs: { p50: quantile(s.renderMs, MS_BUCKETS, 0.5), p95: quantile(s.renderMs, MS_BUCKETS, 0.95), rendered: s.rendered, failed: s.renderFailed, flipped: s.flipped }, + asciiLeaks: { answers: s.answers, leaks: s.asciiLeaks }, + linkClicks: s.linkClicks, exports: Object.assign({}, s.exports) + }; +} + +module.exports = { VERSION, OUTCOMES, TOKEN_BUCKETS, MS_BUCKETS, empty, record, summarize, quantile }; diff --git a/extensions/levelcode-ai/diagram/text.js b/extensions/levelcode-ai/diagram/text.js new file mode 100644 index 0000000..9dec93e --- /dev/null +++ b/extensions/levelcode-ai/diagram/text.js @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · a diagram as words (docs/RICH-DIAGRAMS.md, "UX" + "Context budget") + * + * Three text forms of one spec, each for a reader a picture cannot serve: + * + * outline(spec) a screen reader. The nodes and the connections, in the order the model wrote + * them, as sentences — carried next to the picture so the diagram has an + * accessible name AND a description that says what it shows. + * stub(record) the model, later. "diagram: Jev routing, 4 nodes, id d-17" — the one line that + * stands in for a spec once the conversation is compacted. + * toMermaid(spec) another tool. A flowchart GitHub, GitLab and most docs sites render natively — + * what "Open as Mermaid" and "Insert into Markdown" produce, and what a session + * exported to Markdown carries in place of the picture. + * + * All three read the full text of a label that was shortened for display (`tip`), because none of + * them has a tooltip to hover. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(); } + else { (root.LCDiagram = root.LCDiagram || {}).text = factory(); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const arr = (v) => (Array.isArray(v) ? v : []); + const SHAPE_WORD = { decision: 'decision', store: 'data store', actor: 'actor' }; + + /** A node's name as it should be READ: the full label and second line, not the shortened ones. */ + function nodeName(n) { + if (n.tip) { return String(n.tip); } + return n.sub ? n.label + ' — ' + n.sub : String(n.label); + } + const shortName = (n) => String(n.tip ? String(n.tip).split(' — ')[0] : n.label); + + /** + * The screen-reader outline. + * @returns {{ title: string, summary: string, nodes: string[], edges: string[], text: string }} + */ + function outline(spec) { + const nodes = arr(spec && spec.nodes), edges = arr(spec && spec.edges), groups = arr(spec && spec.groups); + const byId = new Map(nodes.map((n) => [n.id, n])); + const groupLabel = new Map(groups.map((g) => [g.id, g.tip || g.label])); + const nodeLines = nodes.map((n) => { + const bits = [nodeName(n)]; + if (SHAPE_WORD[n.shape]) { bits.push(SHAPE_WORD[n.shape]); } + if (n.group && groupLabel.has(n.group)) { bits.push('in ' + groupLabel.get(n.group)); } + if (n.accent) { bits.push('highlighted'); } + if (n.link && n.link.path) { bits.push('opens ' + n.link.path); } + return bits.join(', '); + }); + const edgeLines = edges.filter((e) => byId.has(e.from) && byId.has(e.to)).map((e) => { + const label = e.tip || e.label; + return shortName(byId.get(e.from)) + ' to ' + shortName(byId.get(e.to)) + (label ? ', ' + label : '') + (e.style === 'dashed' ? ' (dashed)' : ''); + }); + const count = (k, one, many) => k + ' ' + (k === 1 ? one : many); + const title = String((spec && (spec.tip || spec.title)) || 'Diagram'); + const summary = count(nodeLines.length, 'node', 'nodes') + ', ' + count(edgeLines.length, 'connection', 'connections') + '.'; + const text = title + '. ' + summary + + (nodeLines.length ? ' Nodes: ' + nodeLines.join('; ') + '.' : '') + + (edgeLines.length ? ' Connections: ' + edgeLines.join('; ') + '.' : ''); + return { title, summary, nodes: nodeLines, edges: edgeLines, text }; + } + + /** + * The one-line stand-in for a diagram once its spec has left the conversation. + * @param {{ id: string, spec: any }} record + */ + function stub(record) { + const spec = (record && record.spec) || {}; + const k = arr(spec.nodes).length; + // One line, always: the title is the only free text in it, so that is where a newline could hide. + const title = String(spec.title || 'untitled').replace(/\s+/g, ' ').trim(); + return 'diagram: ' + title + ', ' + k + ' node' + (k === 1 ? '' : 's') + ', id ' + String((record && record.id) || '?'); + } + + // ---- Mermaid ------------------------------------------------------------------------------------ + // The source is written to be pasted somewhere else, so it is written defensively: every label is + // quoted and entity-escaped, and ids are rewritten wherever Mermaid would read them as something + // else — `end` closes a subgraph, and a leading digit or a hyphen is not an id at all. + const RESERVED = new Set(['end', 'subgraph', 'graph', 'flowchart', 'class', 'classdef', 'click', 'style', 'linkstyle', 'default', 'direction', 'call', 'href', 'interpolate']); + const mmText = (s) => String(s).replace(/&/g, '#amp;').replace(/"/g, '#quot;').replace(/</g, '#lt;').replace(/>/g, '#gt;').replace(/[\r\n]+/g, ' '); + + /** Map every node and group id to one Mermaid accepts, keeping them readable and unique. */ + function mermaidIds(ids) { + const used = new Set(), out = new Map(); + for (const id of ids) { + let m = String(id).replace(/[^A-Za-z0-9_]/g, '_'); + if (!m || /^[0-9_]/.test(m) || RESERVED.has(m.toLowerCase())) { m = 'n_' + m; } + let k = 2, candidate = m; + while (used.has(candidate)) { candidate = m + '_' + (k++); } + used.add(candidate); out.set(id, candidate); + } + return out; + } + + /** + * The spec as a Mermaid flowchart. + * @param {any} spec + * @param {{ title?: boolean }} [opts] title: emit the spec's title as Mermaid front matter (default true) + */ + function toMermaid(spec, opts) { + const nodes = arr(spec && spec.nodes), edges = arr(spec && spec.edges), groups = arr(spec && spec.groups); + const ids = mermaidIds(nodes.map((n) => n.id).concat(groups.map((g) => 'group:' + g.id))); + const gid = (id) => ids.get('group:' + id); + const lines = []; + if ((!opts || opts.title !== false) && spec && spec.title) { + lines.push('---', 'title: ' + JSON.stringify(String(spec.tip || spec.title)), '---'); + } + lines.push('flowchart ' + (spec && spec.direction === 'down' ? 'TD' : 'LR')); + const shape = (n) => { + const label = '"' + mmText(n.tip ? String(n.tip).replace(' — ', '<br/>') : (n.sub ? n.label + '<br/>' + n.sub : n.label)).replace(/#lt;br\/#gt;/g, '<br/>') + '"'; + const id = ids.get(n.id); + if (n.shape === 'decision') { return id + '{' + label + '}'; } + if (n.shape === 'store') { return id + '[(' + label + ')]'; } + if (n.shape === 'actor') { return id + '([' + label + '])'; } + return id + '[' + label + ']'; + }; + const groupIds = new Set(groups.map((g) => g.id)); + const emitGroup = (g, depth) => { + const pad = ' '.repeat(depth); + lines.push(pad + 'subgraph ' + gid(g.id) + '["' + mmText(g.tip || g.label) + '"]'); + for (const n of nodes) { if (n.group === g.id) { lines.push(pad + ' ' + shape(n)); } } + for (const k of groups) { if (k.parent === g.id && k.id !== g.id) { emitGroup(k, depth + 1); } } + lines.push(pad + 'end'); + }; + for (const n of nodes) { if (!n.group || !groupIds.has(n.group)) { lines.push(' ' + shape(n)); } } + for (const g of groups) { if (!g.parent || !groupIds.has(g.parent) || g.parent === g.id) { emitGroup(g, 1); } } + for (const e of edges) { + if (!ids.has(e.from) || !ids.has(e.to)) { continue; } + const label = e.tip || e.label; + lines.push(' ' + ids.get(e.from) + (e.style === 'dashed' ? ' -.->' : ' -->') + (label ? '|"' + mmText(label) + '"| ' : ' ') + ids.get(e.to)); + } + const accent = nodes.filter((n) => n.accent).map((n) => ids.get(n.id)); + if (accent.length) { lines.push(' classDef accent stroke-width:2px', ' class ' + accent.join(',') + ' accent'); } + return lines.join('\n') + '\n'; + } + + /** The spec as it should be shown or copied: stable key order, two-space indent, no renderer-only fields. */ + function toSource(spec) { + const strip = (o) => { const c = Object.assign({}, o); delete c.tip; return c; }; + const clean = Object.assign({}, spec); + delete clean.tip; + if (Array.isArray(clean.nodes)) { clean.nodes = clean.nodes.map(strip); } + if (Array.isArray(clean.edges)) { clean.edges = clean.edges.map(strip); } + if (Array.isArray(clean.groups)) { clean.groups = clean.groups.map(strip); } + return JSON.stringify(clean, null, 2); + } + + return { outline, stub, toMermaid, toSource, mermaidIds, nodeName }; +})); diff --git a/extensions/levelcode-ai/diagram/theme.js b/extensions/levelcode-ai/diagram/theme.js new file mode 100644 index 0000000..f9227c8 --- /dev/null +++ b/extensions/levelcode-ai/diagram/theme.js @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the house style (docs/RICH-DIAGRAMS.md, "Style guide") + * + * ONE module owns every visual decision a diagram makes: the type scale, the box geometry, the + * spacing the layout reserves, and the colours. The layout reads the numbers, the painter reads the + * class names, the webview injects css(), and an exported file embeds css(resolved) — so the rule + * "the same style whichever model drew it" has exactly one place it can drift. + * + * Colours are EDITOR THEME TOKENS, never literals: a diagram is drawn with `var(--lcd-*)`, and those + * are defined from `--vscode-*`. Switching the editor theme therefore restyles every diagram already + * on screen without re-rendering anything — nothing is baked in until the moment a file is exported. + * + * Loads in Node (require) and in the chat webview (inlined by diagram/bundle.js) — no dependencies. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(); } + else { (root.LCDiagram = root.LCDiagram || {}).theme = factory(); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + /** + * The type scale. Three sizes carry a diagram (title 15, node name 13 semibold, secondary 11.5); + * edge and group labels are "secondary lines". `line` is the line box the layout reserves. + */ + const TYPE = { + title: { size: 15, weight: 600, line: 20 }, + name: { size: 13, weight: 600, line: 18 }, + sub: { size: 11.5, weight: 400, line: 16 }, + edge: { size: 11.5, weight: 400, line: 16 }, + group: { size: 11.5, weight: 600, line: 16 } + }; + /** Nothing in a diagram is set smaller than this — the floor the style guide names. */ + const MIN_TEXT = 10.5; + + /** Box geometry: corner radius 8, border 1.25 (2 on the accent), padding 12. */ + const BOX = { + radius: 8, border: 1.25, accentBorder: 2, + padX: 12, padY: 12, + textGap: 2, // between the name line and the secondary line + minWidth: 64, // a one-letter label is still a box, not a pill of padding + linkIcon: 14 // extra width a linked node reserves for its file glyph + }; + + /** + * Everything the layout reserves between things. Kept here, not in layout.js, because these ARE + * style decisions — "connectors routed through gaps" is only true if the gaps exist. + */ + const SPACE = { + margin: 8, // around the whole drawing, so a 2px accent border is never clipped + nodeGap: 22, // between neighbours in the same rank (across the flow) + rankGap: 44, // minimum between ranks (along the flow) before channels/labels widen it + track: 12, // between parallel connector segments in a channel + channelPad: 16, // from a node's side to the first channel track + portGap: 12, // between connectors leaving the same side of a node + portInset: 8, // keep ports off the rounded corners + lineClear: 12, // a connector passing a node keeps this far from its border + groupInset: 16, // children sit this far inside their group + groupHeader: 20, // the strip a group's label occupies above its children + groupGap: 14, // between a group's border and anything outside it + labelGap: 5, // "labels beside lines with a clear gap" + labelPad: 6, // breathing room around an edge label when checking for collisions + arrow: 7, // arrowhead length + arrowHalf: 3.6, // arrowhead half-width + selfLoop: 16 // how far a self-loop steps out of its node + }; + + /** + * The live token map: `--lcd-*` custom properties, each defined from editor theme variables. + * Neutral boxes and ONE accent — the accent is the theme's link colour because that is the one + * hue every theme guarantees is legible on the editor background. + */ + const TOKENS = { + fg: 'var(--vscode-foreground)', + bg: 'var(--vscode-editor-background)', + muted: 'var(--vscode-descriptionForeground, color-mix(in srgb, var(--vscode-foreground) 68%, transparent))', + line: 'color-mix(in srgb, var(--vscode-foreground) 56%, transparent)', + 'node-fill': 'color-mix(in srgb, var(--vscode-foreground) 5%, transparent)', + 'node-stroke': 'color-mix(in srgb, var(--vscode-foreground) 36%, transparent)', + accent: 'var(--vscode-textLink-foreground, var(--vscode-focusBorder, #4c8dff))', + 'accent-fill': 'color-mix(in srgb, var(--vscode-textLink-foreground, var(--vscode-focusBorder, #4c8dff)) 14%, transparent)', + 'group-fill': 'color-mix(in srgb, var(--vscode-foreground) 3.5%, transparent)', + 'group-stroke': 'color-mix(in srgb, var(--vscode-foreground) 17%, transparent)', + font: 'var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)' + }; + const TOKEN_NAMES = Object.keys(TOKENS); + + /** + * Concrete palettes — used ONLY where there is no editor to ask: the Node-side snapshot tests, the + * docs, and as the fallback when an export cannot read computed styles. The live view never + * touches these. + */ + const PALETTES = { + light: { + fg: '#1f2328', bg: '#ffffff', muted: '#59636e', line: '#7d8590', + 'node-fill': '#f4f5f6', 'node-stroke': '#aeb4bb', + accent: '#0969da', 'accent-fill': '#ddebfb', + 'group-fill': '#f8f9fa', 'group-stroke': '#d5d9de', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif' + }, + dark: { + fg: '#d4d7dc', bg: '#1e1f22', muted: '#9198a1', line: '#8b929b', + 'node-fill': '#27292d', 'node-stroke': '#5f666f', + accent: '#4c9aff', 'accent-fill': '#1f3350', + 'group-fill': '#232528', 'group-stroke': '#3a3e44', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif' + } + }; + + /** + * The stylesheet, generated from the tables above. + * + * css() live: rules read `var(--lcd-*)`, and `.lcd` defines them from the theme. + * css({ resolved: {...} }) export: every token replaced by a concrete value, scoped to `svg.lcd-svg` + * so the block can sit inside a standalone SVG file. + * + * Selectors are all class-based and prefixed `lcd-`, so nothing here can reach the rest of the chat. + * @param {{ resolved?: Record<string,string> }} [opts] + */ + function css(opts) { + const resolved = opts && opts.resolved; + const v = (name) => (resolved ? String(resolved[name] != null ? resolved[name] : PALETTES.light[name]) : 'var(--lcd-' + name + ')'); + const scope = resolved ? 'svg.lcd-svg' : '.lcd'; + const t = TYPE; + const out = []; + if (!resolved) { + out.push(scope + ' {' + TOKEN_NAMES.map((n) => ' --lcd-' + n + ': ' + TOKENS[n] + ';').join('') + ' }'); + // High-contrast themes ask for real borders, not tints. + out.push('body.vscode-high-contrast ' + scope + ', body.vscode-high-contrast-light ' + scope + + ' { --lcd-node-stroke: var(--vscode-contrastBorder, var(--vscode-foreground));' + + ' --lcd-group-stroke: var(--vscode-contrastBorder, var(--vscode-foreground));' + + ' --lcd-line: var(--vscode-foreground); --lcd-node-fill: transparent; --lcd-group-fill: transparent; }'); + } + const text = (cls, type, fill) => scope + ' .' + cls + ' { font-family: ' + v('font') + '; font-size: ' + type.size + 'px; font-weight: ' + + type.weight + '; fill: ' + v(fill) + '; }'; + out.push( + scope + ' .lcd-bg { fill: ' + v('bg') + '; }', + scope + ' text { font-family: ' + v('font') + '; }', + text('lcd-title', t.title, 'fg'), + text('lcd-name', t.name, 'fg'), + text('lcd-sub', t.sub, 'muted'), + text('lcd-edge-label', t.edge, 'muted'), + text('lcd-group-label', t.group, 'muted'), + scope + ' .lcd-shape { fill: ' + v('node-fill') + '; stroke: ' + v('node-stroke') + '; stroke-width: ' + BOX.border + 'px; stroke-linejoin: round; }', + scope + ' .lcd-accent .lcd-shape { fill: ' + v('accent-fill') + '; stroke: ' + v('accent') + '; stroke-width: ' + BOX.accentBorder + 'px; }', + scope + ' .lcd-node .lcd-lid { fill: none; }', + scope + ' .lcd-group-box { fill: ' + v('group-fill') + '; stroke: ' + v('group-stroke') + '; stroke-width: 1px; }', + scope + ' .lcd-edge { fill: none; stroke: ' + v('line') + '; stroke-width: 1.25px; stroke-linejoin: round; stroke-linecap: round; }', + scope + ' .lcd-edge.lcd-dashed { stroke-dasharray: 5 4; }', + scope + ' .lcd-arrow { fill: ' + v('line') + '; stroke: ' + v('line') + '; stroke-width: 1px; stroke-linejoin: round; }', + scope + ' .lcd-link-icon { fill: none; stroke: ' + v('muted') + '; stroke-width: 1px; stroke-linejoin: round; }', + // The halo is the LAST resort of label placement (a label that had nowhere clear to sit): + // it keeps the text legible over a line it could not avoid. + scope + ' .lcd-halo { paint-order: stroke; stroke: ' + v('bg') + '; stroke-width: 4px; stroke-linejoin: round; }' + ); + if (!resolved) { + out.push( + scope + ' .lcd-linked { cursor: pointer; }', + scope + ' .lcd-linked:hover .lcd-shape, ' + scope + ' .lcd-linked:focus-visible .lcd-shape { stroke: ' + v('accent') + '; }', + scope + ' .lcd-linked:hover .lcd-link-icon, ' + scope + ' .lcd-linked:focus-visible .lcd-link-icon { stroke: ' + v('accent') + '; }', + scope + ' .lcd-linked:focus { outline: none; }' + ); + } + return out.join('\n'); + } + + // ---- text measurement without a browser ---------------------------------------------------- + // The webview measures with a canvas and the real UI font. Node has neither, so tests, the ASCII + // renderer and the host's size estimates use this table: per-character advance in em, calibrated + // against a system UI sans. It errs slightly WIDE, because the failure that matters is text + // overflowing its box, not a box two pixels too roomy. + const NARROW = new Set(Array.from("iIl.,:;'|!jtfr()[]{}` ")); + const WIDE = new Set(Array.from('mwMW@%&')); + function charEm(ch) { + if (NARROW.has(ch)) { return 0.33; } + if (WIDE.has(ch)) { return 0.88; } + const c = ch.codePointAt(0) || 0; + if (c >= 0x30 && c <= 0x39) { return 0.60; } // digits + if (c >= 0x41 && c <= 0x5a) { return 0.69; } // A–Z + if (c > 0xffff) { return 1.1; } // emoji and everything else beyond the basic plane — asked first: all of it is above U+2E80 too + if (c >= 0x2e80) { return 1.0; } // CJK and other full-width scripts + return 0.57; + } + /** + * Approximate rendered width, in px, of `text` set in the role's type. + * @param {string} text + * @param {keyof typeof TYPE} role + */ + function approxMeasure(text, role) { + const ty = TYPE[role] || TYPE.sub; + let em = 0; + for (const ch of Array.from(String(text == null ? '' : text))) { em += charEm(ch); } + return Math.ceil(em * ty.size * (ty.weight >= 600 ? 1.045 : 1) * 10) / 10; + } + + return { TYPE, MIN_TEXT, BOX, SPACE, TOKENS, TOKEN_NAMES, PALETTES, css, approxMeasure }; +})); diff --git a/extensions/levelcode-ai/diagram/tool.js b/extensions/levelcode-ai/diagram/tool.js new file mode 100644 index 0000000..c7324ef --- /dev/null +++ b/extensions/levelcode-ai/diagram/tool.js @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · what the model is told (docs/RICH-DIAGRAMS.md, "Diagram spec + * and render_diagram tool" + "Prompting and capability detection") + * + * Host-only (never shipped to the webview). Three things live here, and they are the whole of the + * model's side of the feature: + * + * RENDER_DIAGRAM the tool. Its input schema is schema.SCHEMAS[v].house — the same object the + * validator checks against, so what the model is shown is what it is held to. + * PROMPT the rules a rich client adds to the system prompt: when a diagram earns its + * place, that characters are never to be drawn with, and one worked example. + * result() what a call answers: {"ok":true,"id":"d-1"}, or every error at once. + * + * Both cost tokens on every request, so both are kept tight and their size is pinned by a test. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const schema = require('./schema'); +const validator = require('./validate'); +const repair = require('./repair'); + +/** + * The tool definition (Anthropic shape; translate.js renames it for OpenAI-compatible providers). + * Read-only and instant — it draws in the chat and touches nothing — so it never asks for approval. + */ +const RENDER_DIAGRAM = Object.freeze({ + name: 'render_diagram', + description: 'Draw a diagram in the chat (flow, architecture, pipeline, decision tree, state machine). ' + + 'Describe STRUCTURE only — nodes, edges, optional groups, one accent; the editor does layout and style, so never send coordinates, colours or sizes. ' + + 'Returns {"ok":true,"id":"d-1"} or a list of problems to fix (then call it once more).', + input_schema: schema.SCHEMAS[schema.VERSION].house +}); + +/** + * Fetch the spec of a diagram drawn earlier. Offered only once a diagram's spec has left the + * conversation (after a compaction), so it costs nothing until there is something to fetch. + */ +const GET_DIAGRAM = Object.freeze({ + name: 'get_diagram', + description: 'Fetch the full spec of a diagram drawn earlier in this session, by its id (e.g. "d-17"), so you can edit it and call render_diagram again. ' + + 'Use it when the conversation only shows a one-line "diagram: …, id d-17" stub.', + input_schema: { type: 'object', properties: { id: { type: 'string', description: 'The diagram id from the stub, e.g. "d-17".' } }, required: ['id'] } +}); + +/** + * The system-prompt block for a client that can render (`client.render === 'rich'`). An ASCII client + * gets neither this nor the tool. + * + * Phase 1 draws boxes-and-arrows only. The block therefore says what to do INSTEAD of a chart or a + * sequence diagram — telling a model to emit a format the client would show as raw source would be + * worse than not mentioning it. + */ +const PROMPT = [ + 'DIAGRAMS. This client renders real diagrams. When STRUCTURE is the point — a flow, an architecture, a pipeline, a routing or decision tree, a state machine — call render_diagram instead of describing it in a wall of prose. When the answer reads fine as prose, draw nothing.', + '- NEVER draw boxes, arrows, trees or bars out of characters (no ASCII art, no box-drawing glyphs): they break in this client. render_diagram is the only way to draw.', + '- Asked for a diagram, DRAW it here with render_diagram. Writing one into a file instead (a diagram language in a document, an image) is only for when the user asks for a file.', + '- The title states the TAKEAWAY ("Jev classifies; your code decides the action"), not the topic. Labels are short — a name plus at most one line; detail goes in the prose around the diagram, not inside it.', + '- At most ONE accent: the node the title is about. Same kind of thing, same shape.', + '- More than 12 nodes: draw an overview, then one diagram per sub-flow.', + '- When the diagram describes code in this workspace, give nodes a link {path, symbol or line} so the user can click through.', + '- Numbers to compare go in a Markdown table and steps over time in a numbered list (charts and sequence diagrams are not available yet).', + '- After it returns ok the user is looking at the picture: do not restate it as a list. Say what it shows in a sentence and continue.', + 'Example call: {"title":"Jev classifies; your code decides the action","nodes":[{"id":"in","label":"Customer message","sub":"plus account details"},{"id":"jev","label":"Jev","sub":"returns probabilities","accent":true},{"id":"bill","label":"Route to billing"},{"id":"rev","label":"Human review"}],"edges":[{"from":"in","to":"jev"},{"from":"jev","to":"bill","label":"0.90 or more"},{"from":"jev","to":"rev","label":"under 0.90"}]}' +].join('\n'); + +/** How many problem / fix lines one tool result carries. Past this they are counted, not listed. */ +const MAX_LINES = 8; +const capLines = (lines) => (lines.length > MAX_LINES ? lines.slice(0, MAX_LINES).concat(['… and ' + (lines.length - MAX_LINES) + ' more.']) : lines); +const fixLine = (f) => (f.pointer || '/') + ': ' + f.message.replace(' (the full text is kept as a tooltip)', ''); + +/** + * The tool result for one prepared call. + * @param {ReturnType<typeof repair.prepare>} prepared + * @param {{ id?: string, unlinked?: string[] }} [info] id: the diagram id it was drawn under; unlinked: links that did not resolve + * @returns {string} + */ +function result(prepared, info) { + const i = info || {}; + const shown = repair.visibleFixes(prepared.fixes).map(fixLine); + if (prepared.status === 'ok' || prepared.status === 'fixed') { + const out = { ok: true, id: i.id }; + if (shown.length) { out.fixed = capLines(shown); } + if (i.unlinked && i.unlinked.length) { out.unlinked = capLines(i.unlinked); } + if (shown.length || (i.unlinked && i.unlinked.length)) { out.note = 'Drawn. Redraw only if one of these changed what you meant.'; } + return JSON.stringify(out); + } + if (prepared.status === 'errors') { + const errs = capLines(validator.formatErrors(prepared.errors).split('\n')); + const k = prepared.errors.length; + return 'ERROR: the diagram was not drawn — ' + k + ' problem' + (k === 1 ? '' : 's') + ' in the spec. Fix ' + (k === 1 ? 'it' : 'them') + ' and call render_diagram once more.\n' + + errs.join('\n') + + (shown.length ? '\nAlso changed to fit (reword them yourself if the wording matters):\n' + capLines(shown).join('\n') : ''); + } + if (prepared.status === 'truncated') { + return 'ERROR: the diagram spec was cut off before it was complete (your output hit its length limit). Send the whole spec again, smaller: at most 12 nodes, short labels, no detail in the diagram that belongs in prose.'; + } + if (prepared.status === 'degraded') { + return JSON.stringify({ ok: true, id: i.id, degraded: capLines(prepared.notes), note: 'Drawn without the parts that were still invalid; the user can see what was left out and has a Retry button. Do NOT call render_diagram again for this diagram unless the user asks.' }); + } + // failed + const first = prepared.errors.length ? validator.formatError(prepared.errors[0]) : 'nothing drawable in the spec'; + return JSON.stringify({ ok: false, id: i.id, error: 'not drawn: ' + first, note: 'The user sees the source, the error and a Retry button. Do NOT call render_diagram again for this diagram unless the user asks; explain it in prose instead.' }); +} + +/** + * Did an answer DRAW with characters? The "ASCII leaks" metric: box-drawing glyphs, or frames built + * from +---+ and | |, in text sent to a client that can render. Conservative on purpose — a table, a + * file tree in a code block and a single "->" are not diagrams. + * @param {string} text + */ +function looksLikeAsciiArt(text) { + const s = String(text || ''); + const boxGlyphs = (s.match(/[─-╿▶◀▲▼]/g) || []).length; + if (boxGlyphs >= 8) { return true; } + const lines = s.split('\n'); + const frames = lines.filter((l) => /\+-{3,}\+/.test(l)).length; + const walls = lines.filter((l) => /^\s*\|.*\|\s*(-+>|<-+)?\s*(\|.*\|)?\s*$/.test(l) && !/\|\s*:?-{3,}/.test(l)).length; + if (frames >= 2 && walls >= 1) { return true; } + const arrows = lines.filter((l) => /(\]|\)|\w)\s*(-{2,}>|={2,}>)\s*(\[|\(|\w)/.test(l) && /[\[(|]/.test(l)).length; + return arrows >= 3 && frames >= 1; +} + +module.exports = { RENDER_DIAGRAM, GET_DIAGRAM, PROMPT, result, looksLikeAsciiArt, MAX_LINES }; diff --git a/extensions/levelcode-ai/diagram/validate.js b/extensions/levelcode-ai/diagram/validate.js new file mode 100644 index 0000000..1b45819 --- /dev/null +++ b/extensions/levelcode-ai/diagram/validate.js @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · layered validation (docs/RICH-DIAGRAMS.md, "Validation and repair") + * + * THE ONE GATE. Every spec — whichever model wrote it, whether it arrived a second ago or was read + * back from a session saved last month — passes through validate() before anything lays it out. + * That is what keeps the quality of a diagram independent of the model that asked for it. + * + * Layers, cheapest first: + * version `v` names a schema this editor still reads (migrate() lifts old ones forward) + * schema fields, types, enums, lengths, counts — schema.check(), driven by the wire schema + * semantics the things a schema cannot say: edge ends exist, ids are unique, one accent at most, + * groups nest two deep and never in a circle + * + * EVERY error comes back at once, each as <JSON Pointer>: <what was expected + the valid options>. + * A model handed "unknown node" has to guess; handed the list of known ids it only has to look. + * + * Pure, synchronous, dependency-free — it runs in the extension host (the tool result) and again in + * the webview (the renderer refuses anything this rejects). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./schema')); } + else { (root.LCDiagram = root.LCDiagram || {}).validate = factory(root.LCDiagram.schema); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (schema) { + 'use strict'; + + const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + const q = (s) => JSON.stringify(String(s)); + /** "a, b, c" — capped, so one error line can never carry a whole oversized spec back to the model. */ + function listIds(ids, max) { + const cap = max || 16; + const shown = ids.slice(0, cap).join(', '); + return ids.length > cap ? shown + ', … (' + (ids.length - cap) + ' more)' : (shown || '(none)'); + } + + /** + * Lift a spec written against an older schema to the current one. Today there is only v1, so + * this is the identity — but it is the single place a future v2 teaches the editor to keep + * reading v1, and render paths call it so that promise is exercised from day one. + * A spec with no `v` at all is read as v1 (the tool marks the field optional). + */ + function migrate(spec) { + if (!isObj(spec)) { return spec; } + if (spec.v === undefined) { return Object.assign({ v: 1 }, spec); } + return spec; + } + + /** + * The group each group id sits in, walking `parent` links. Returns { depth, cycle } per id. + * Depth 1 = a top-level group. A cycle is reported once, on every member of it. + */ + function groupDepths(groups) { + const byId = new Map(); + for (const g of groups) { if (isObj(g) && typeof g.id === 'string' && !byId.has(g.id)) { byId.set(g.id, g); } } + const out = new Map(); + for (const id of byId.keys()) { + let depth = 1, cur = byId.get(id), cycle = false; + const seen = new Set([id]); + while (cur && typeof cur.parent === 'string' && byId.has(cur.parent)) { + if (seen.has(cur.parent)) { cycle = true; break; } + seen.add(cur.parent); + cur = byId.get(cur.parent); + depth++; + } + out.set(id, { depth, cycle }); + } + return out; + } + + /** The semantic layer. Assumes nothing about shape — it skips whatever the schema layer already flagged. */ + function checkSemantics(spec, errors, limits) { + const nodes = Array.isArray(spec.nodes) ? spec.nodes : []; + const edges = Array.isArray(spec.edges) ? spec.edges : []; + const groups = Array.isArray(spec.groups) ? spec.groups : []; + + // ids are unique + const firstAt = new Map(); + const ids = []; + nodes.forEach((n, i) => { + if (!isObj(n) || typeof n.id !== 'string') { return; } + if (firstAt.has(n.id)) { + errors.push({ pointer: '/nodes/' + i + '/id', cls: 'duplicate-id', message: 'duplicate id ' + q(n.id) + ' (already used by /nodes/' + firstAt.get(n.id) + '). Every node needs its own id.' }); + } else { firstAt.set(n.id, i); ids.push(n.id); } + }); + + // one accent at most + const accented = nodes.filter((n) => isObj(n) && n.accent === true).map((n) => String(n.id)); + if (accented.length > 1) { + errors.push({ pointer: '/nodes', cls: 'accent-count', message: accented.length + ' nodes have accent=true, max 1 (' + listIds(accented) + '). Keep it on the node the title is about.' }); + } + + // groups: unique ids, parents exist, no cycles, depth within the limit + const groupAt = new Map(); + const groupIds = []; + groups.forEach((g, i) => { + if (!isObj(g) || typeof g.id !== 'string') { return; } + if (groupAt.has(g.id)) { + errors.push({ pointer: '/groups/' + i + '/id', cls: 'duplicate-id', message: 'duplicate group id ' + q(g.id) + ' (already used by /groups/' + groupAt.get(g.id) + ').' }); + } else { groupAt.set(g.id, i); groupIds.push(g.id); } + }); + const depths = groupDepths(groups); + groups.forEach((g, i) => { + if (!isObj(g) || typeof g.id !== 'string' || groupAt.get(g.id) !== i) { return; } + if (typeof g.parent === 'string') { + if (g.parent === g.id) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'group-cycle', message: 'a group cannot contain itself. Remove "parent" or name another group.' }); + return; + } + if (!groupAt.has(g.parent)) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'unknown-group', message: 'unknown group ' + q(g.parent) + '. Known groups: ' + listIds(groupIds) + '.' }); + return; + } + } + const d = depths.get(g.id); + if (d && d.cycle) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'group-cycle', message: 'groups contain each other in a circle (' + q(g.id) + ' → ' + q(g.parent) + ' → …). Nesting must be a tree.' }); + } else if (d && d.depth > limits.groupDepth) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'group-depth', message: 'nested ' + d.depth + ' deep, max ' + limits.groupDepth + '. Flatten it.' }); + } + }); + nodes.forEach((n, i) => { + if (isObj(n) && typeof n.group === 'string' && !groupAt.has(n.group)) { + errors.push({ pointer: '/nodes/' + i + '/group', cls: 'unknown-group', message: 'unknown group ' + q(n.group) + '. Known groups: ' + listIds(groupIds) + '.' }); + } + }); + + // each end of an edge names an existing node + edges.forEach((e, i) => { + if (!isObj(e)) { return; } + for (const end of ['from', 'to']) { + if (typeof e[end] === 'string' && !firstAt.has(e[end])) { + errors.push({ pointer: '/edges/' + i + '/' + end, cls: 'unknown-node', message: 'unknown node ' + q(e[end]) + '. Known ids: ' + listIds(ids) + '.' }); + } + } + }); + } + + /** + * Validate a parsed spec. + * @param {any} spec + * @param {{ tier?: 'house'|'hard' }} [opts] `house` (default) is what the model is held to; `hard` + * is what the renderer accepts — see schema.js. + * @returns {{ ok: boolean, errors: Array<{pointer:string, cls:string, message:string}> }} + */ + function validate(spec, opts) { + const tier = opts && opts.tier === 'hard' ? 'hard' : 'house'; + /** @type {Array<{pointer:string, cls:string, message:string}>} */ + const errors = []; + if (!isObj(spec)) { + errors.push({ pointer: '', cls: 'type', message: 'expected an object with "title", "nodes" and "edges", got ' + schema.typeOf(spec) + '.' }); + return { ok: false, errors }; + } + const v = spec.v === undefined ? schema.VERSION : spec.v; + const known = schema.SCHEMAS[v]; + if (!known) { + errors.push({ pointer: '/v', cls: 'version', message: 'unknown schema version ' + JSON.stringify(spec.v) + '. This editor reads: ' + schema.KNOWN_VERSIONS.join(', ') + '.' }); + return { ok: false, errors }; + } + schema.check(known[tier], spec, '', errors); + checkSemantics(spec, errors, tier === 'hard' ? schema.HARD : schema.HOUSE); + return { ok: errors.length === 0, errors }; + } + + /** One error as the line the model (and the details popover) reads. */ + function formatError(e) { return (e.pointer || '/') + ': ' + e.message; } + /** Every error, one per line — the spec's "Error format". */ + function formatErrors(errors) { return (errors || []).map(formatError).join('\n'); } + /** Error classes with counts — what telemetry records (classes, never the text). */ + function errorClasses(errors) { + const out = {}; + for (const e of (errors || [])) { out[e.cls] = (out[e.cls] || 0) + 1; } + return out; + } + + return { validate, migrate, groupDepths, formatError, formatErrors, errorClasses, listIds }; +})); diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index b759023..b4e7e13 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -37,6 +37,14 @@ const { openCustomize } = require('./customize'); const { importFromVscode } = require('./importVscode'); const { reapMcp, listActive, getServer } = require('./mcpClient'); const { userScopedSetting, isNamespacedToolName, safeCopy, loadServerConfig, summarizeMcp, parseArgv, UNSAFE_KEYS } = require('./mcpConfig'); +const { createDiagrams } = require('./diagram/service'); +const diagramBundle = require('./diagram/bundle'); +const diagramLinks = require('./diagram/links'); +const diagramExport = require('./diagram/exportCheck'); +const diagramText = require('./diagram/text'); +const diagramStats = require('./diagram/stats'); +const diagramTool = require('./diagram/tool'); +const diagramAscii = require('./diagram/ascii'); const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat) const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}'; @@ -1025,6 +1033,198 @@ function workspaceMapBlock(allFiles) { // /sessions modal + the sidebar view. Everything here is best-effort: a persistence failure must never // disturb a chat turn — callers guard, and the manager swallows index errors (the index is a rebuildable // cache). Off entirely when levelcode.ai.sessions.enabled is false. +// ---- Rich diagrams (docs/RICH-DIAGRAMS.md) -------------------------------------------------------- +// The model emits a small spec through `render_diagram`; diagram/service.js validates it, climbs the +// repair ladder and decides what the chat shows. This block is the editor's half: which files a +// node may open, where an export is written, and where the numbers are kept. + +const DIAGRAM_STATS_KEY = 'levelcode.ai.diagrams.stats'; +let _diagramStats = null, _diagramStatsTimer = null; +/** The local counters behind the spec's telemetry table. Kept in the editor's own storage; never sent anywhere. */ +function diagramStatsNow() { + if (!_diagramStats) { + const stored = ctx ? ctx.globalState.get(DIAGRAM_STATS_KEY) : null; + _diagramStats = stored && stored.v === diagramStats.VERSION ? stored : diagramStats.empty(new Date().toISOString().slice(0, 10)); + } + return _diagramStats; +} +function recordDiagramStat(ev) { + _diagramStats = diagramStats.record(diagramStatsNow(), ev); + if (ev && ev.type === 'call') { dbg('diagram.call', { outcome: ev.outcome, errors: ev.errorClasses, fixes: ev.fixClasses, tokens: ev.tokens }); } + if (_diagramStatsTimer) { return; } + _diagramStatsTimer = setTimeout(() => { + _diagramStatsTimer = null; + try { if (ctx) { ctx.globalState.update(DIAGRAM_STATS_KEY, _diagramStats); } } catch (e) { /* counters are best-effort */ } + }, 2000); +} +const diagramFolders = () => (vscode.workspace.workspaceFolders || []).map((f) => ({ name: f.name, root: f.uri.fsPath })); +/** This conversation's diagrams. One for the life of the extension; reset with the conversation. */ +const diagrams = createDiagrams({ + resolveLink: (link) => diagramLinks.resolveLink(link, diagramFolders()), + onStat: recordDiagramStat +}); +/** True once a diagram's spec has left the model's context (a compaction, or a resume that did not fit) — + * which is the moment `get_diagram` starts being offered. */ +let diagramsStubbed = false; + +/** + * What the chat on the other end can show: `rich` (it renders diagrams) or `ascii` (it does not). + * The chat webview is rich; the setting is the user's way to say otherwise, and a model whose catalog + * row opts out of diagrams is treated as an ASCII client — it is given neither the tool nor the rules. + */ +function clientRender(providerId, modelId) { + if (!aiConfig().get('diagrams.enabled', true)) { return 'ascii'; } + return catalog.diagramSupportForModel(providerId, modelId) === 'tool' ? 'rich' : 'ascii'; +} + +/** A node was clicked. The webview says WHICH node; the file comes from the host's own record. */ +async function openDiagramLink(id, nodeId) { + const record = diagrams.get(id); + const node = record && record.spec ? record.spec.nodes.find((n) => n.id === String(nodeId)) : null; + if (!node || !node.link) { dbg('diagram.link.unknown', { id }); return; } + // Checked again at the moment of the click: the file may have moved, or become a link, since it was drawn. + const r = diagramLinks.resolveLink(node.link, diagramFolders()); + if (!r.ok) { vscode.window.showWarningMessage('LevelCode: that link cannot be opened — ' + r.reason + '.'); dbg('diagram.link.refused', { reason: r.reason }); return; } + recordDiagramStat({ type: 'link' }); + try { + const uri = vscode.Uri.file(r.abs); + const doc = await vscode.workspace.openTextDocument(uri); + let line = Number.isInteger(node.link.line) ? node.link.line : null; + if (node.link.symbol) { + let found = null; + try { + const symbols = await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', uri); + const want = String(node.link.symbol).split(/[^\w$]+/).filter(Boolean).pop(); + const walk = (list) => { for (const sym of (list || [])) { if (!found && sym && sym.name && String(sym.name).split(/[^\w$]+/).filter(Boolean).pop() === want) { found = sym; } if (!found && sym && sym.children) { walk(sym.children); } } }; + walk(symbols); + } catch (e) { /* no symbol provider for this language */ } + if (found) { line = ((found.selectionRange || found.range || (found.location && found.location.range)).start.line) + 1; } + else { line = diagramLinks.findSymbolLine(doc.getText(), node.link.symbol) || line; } + } + const at = new vscode.Position(Math.max(0, Math.min(doc.lineCount - 1, (line || 1) - 1)), 0); + await vscode.window.showTextDocument(doc, { preview: false, selection: new vscode.Range(at, at) }); + } catch (e) { dbg('diagram.link.error', { msg: String((e && e.message) || e) }); } +} + +/** + * The fenced Mermaid block for a diagram — what goes into a Markdown file. The fence is one tick + * longer than any run of ticks inside, so nothing in a label can close it early. (The tick is built + * by code, never typed: a literal one here would be read as a template string by the brace matcher + * the host test suites slice this file with.) + */ +function diagramMarkdown(record) { + const tick = String.fromCharCode(96); + const body = diagramText.toMermaid(record.spec).replace(/\n+$/, ''); + let longest = 0, run = 0; + for (const ch of body) { run = ch === tick ? run + 1 : 0; if (run > longest) { longest = run; } } + const fence = tick.repeat(Math.max(3, longest + 1)); + return fence + 'mermaid\n' + body + '\n' + fence + '\n'; +} + +/** + * Export a diagram. `source`, `mermaid` and `markdown` are produced HERE from the stored spec; only + * `svg` and `png` carry bytes from the webview (that is where the real font and theme are), and those + * are checked structurally before a single byte is written — see diagram/exportCheck.js. + */ +async function exportDiagram(id, format, data) { + const record = diagrams.get(id); + if (!record || !record.spec) { return; } + const fmt = String(format); + try { + if (fmt === 'source') { + await vscode.env.clipboard.writeText(diagramText.toSource(record.spec)); + } else if (fmt === 'mermaid') { + const langs = await vscode.languages.getLanguages(); + const doc = await vscode.workspace.openTextDocument({ content: diagramText.toMermaid(record.spec), language: langs.includes('mermaid') ? 'mermaid' : 'plaintext' }); + await vscode.window.showTextDocument(doc, { preview: false }); + } else if (fmt === 'markdown') { + const block = '\n' + diagramMarkdown(record); + const ed = vscode.window.activeTextEditor; + let target = ed && ed.document.languageId === 'markdown' && isWorkspaceFile(ed.document.uri) ? ed : null; + if (!target) { + const files = await vscode.workspace.findFiles('**/*.{md,markdown}', FILE_EXCLUDES, 300); + if (!files.length) { vscode.window.showInformationMessage('LevelCode: there is no Markdown file in this workspace to insert into. Use “Open as Mermaid” instead.'); return; } + const pick = await vscode.window.showQuickPick(files.map((u) => ({ label: vscode.workspace.asRelativePath(u), uri: u })).sort((a, b) => a.label.localeCompare(b.label)), { placeHolder: 'Insert the diagram at the end of…', matchOnDescription: false }); + if (!pick) { return; } + const doc = await vscode.workspace.openTextDocument(pick.uri); + target = await vscode.window.showTextDocument(doc, { preview: false }); + const end = doc.lineAt(doc.lineCount - 1).range.end; + target.selection = new vscode.Selection(end, end); + } + const where = target.selection.active; + await target.edit((b) => b.insert(where, block)); + } else if (fmt === 'svg' || fmt === 'png') { + const checked = fmt === 'svg' ? diagramExport.checkSvg(data) : diagramExport.checkPng(data); + if (!checked.ok) { dbg('diagram.export.refused', { format: fmt, reason: checked.reason }); vscode.window.showWarningMessage('LevelCode: the diagram could not be exported (' + checked.reason + ').'); return; } + // The title becomes a file name, and a title is free text: redact it the way a session export does. + const stem = diagramExport.fileStem(sessionMemory.redactSecrets(String(record.spec.title || 'diagram'))); + const dir = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]) ? vscode.workspace.workspaceFolders[0].uri.fsPath : os.homedir(); + const target = await vscode.window.showSaveDialog({ filters: fmt === 'svg' ? { 'SVG image': ['svg'] } : { 'PNG image': ['png'] }, defaultUri: vscode.Uri.file(path.join(dir, stem + '.' + fmt)) }); + if (!target) { return; } + await vscode.workspace.fs.writeFile(target, fmt === 'svg' ? Buffer.from(String(data), 'utf8') : checked.bytes); + vscode.window.showInformationMessage('Saved ' + path.basename(target.fsPath) + '.'); + } else { return; } + recordDiagramStat({ type: 'export', format: fmt }); + } catch (e) { + dbg('diagram.export.error', { format: fmt, msg: String((e && e.message) || e) }); + vscode.window.showErrorMessage('LevelCode: could not export the diagram — ' + String((e && e.message) || e)); + } +} + +/** The Retry button on a degraded or failed diagram: the USER asks for another go. Never automatic. */ +async function retryDiagram(id) { + const record = diagrams.get(id); + if (!record || abort) { return; } + const what = record.spec ? '“' + record.spec.title + '” (id ' + record.id + ')' : 'you tried to draw (id ' + record.id + ')'; + const problems = (record.errors || []).concat(record.notes || []).slice(0, diagramTool.MAX_LINES); // the same cap a tool result uses + const keep = lastAgentGoal; + await agentFlow('Redraw the diagram ' + what + ' with render_diagram.' + (problems.length ? ' The last attempt had these problems:\n- ' + problems.join('\n- ') : '') + '\nSend a corrected, complete spec. Say nothing else unless it fails again.'); + lastAgentGoal = keep; // Retry/Continue on the response bar still mean the user's own goal +} + +/** + * The chat could not load its renderer. The fallback the spec asks for: "an ASCII rendering generated + * from the same spec, so the model never has to draw ASCII itself" — made here, from the host's own + * record, by the same layout. Empty when there is nothing to draw; the chat then keeps the source open. + */ +function diagramAsciiText(id, cols) { + const record = diagrams.get(id); + if (!record || !record.spec) { return ''; } + // How wide the chat's card is, in characters — the one thing the page knows and the host does not. + // A number, bounded; anything else is the default. + const maxCols = Math.max(40, Math.min(200, Math.floor(Number(cols)) || 110)); + // (Every spec the service holds has passed the validator — drawn this session, or checked on load.) + try { return diagramAscii.render(record.spec, { maxCols, title: false }); } // the card already shows the title + catch (e) { + dbg('diagram.ascii.error', { msg: String((e && e.message) || e) }); + try { return diagramText.outline(record.spec).text; } catch (e2) { return ''; } // it still says what connects to what + } +} + +/** + * The whole of what a diagram may ask of the editor. Each branch looks the diagram up in the host's + * own records by id — an id that names no record does nothing (or, for the text fallback, answers + * with no text) — and no branch reads a path, a URL or a command from the message. + */ +async function handleDiagramAction(msg) { + const action = String((msg && msg.action) || ''); + const id = String((msg && msg.id) || ''); + if (action === 'openLink') { await openDiagramLink(id, msg.node); } + else if (action === 'export') { await exportDiagram(id, msg.format, msg.data); } + else if (action === 'retry') { await retryDiagram(id); } + else if (action === 'ascii') { post({ type: 'diagramAscii', id, text: diagramAsciiText(id, msg.cols) }); } +} + +/** "LevelCode: AI: Diagram Statistics" — the numbers the rollout gates are read from. */ +async function showDiagramStats() { + const summary = diagramStats.summarize(diagramStatsNow()); + const doc = await vscode.workspace.openTextDocument({ language: 'json', content: JSON.stringify({ + note: 'Local counters for rich diagrams (docs/RICH-DIAGRAMS.md, "Telemetry and evaluation"). Kept in this editor only; nothing here is sent anywhere, and nothing here is text from a diagram.', + summary, raw: diagramStatsNow() + }, null, 2) }); + await vscode.window.showTextDocument(doc, { preview: true }); +} + let _sessionsMgr = null, _sessionsSlug = null; function sessionsRoot() { const dir = String(aiConfig().get('sessions.dir', '') || '').trim(); @@ -1347,7 +1547,12 @@ async function exportSession(id) { const m = sessionsManager(); if (!m || !id) { return; } const entry = (m.list().find((e) => e.id === id)) || {}; - const md = sessionEvents.toMarkdown(entry, m.transcript(id), { redact: sessionMemory.redactSecrets }); + const md = sessionEvents.toMarkdown(entry, m.transcript(id), { + redact: sessionMemory.redactSecrets, + // A diagram the session drew is exported as a Mermaid block, which a pull request renders. + diagrams: m.diagrams(id), + diagram: (r) => ({ lang: 'mermaid', body: diagramText.toMermaid(r.spec) }) + }); try { await vscode.env.clipboard.writeText(md); } catch (e) { @@ -1399,9 +1604,15 @@ async function resumeSession(id) { // note so the model KNOWS earlier turns exist (a real head-summary is a later refinement) — the user // still sees the whole transcript in the replay. Ephemeral: never persisted (recordTurn appends only // new turns from the goal onward). + // The session's diagrams come back exactly as stored — never re-validated, never re-repaired. + diagrams.load(r.diagrams); + const omittedHead = (r.plan && r.plan.tier > 1) ? (Array.isArray(r.full) ? r.full : []).slice(0, Math.max(0, (Array.isArray(r.full) ? r.full.length : 0) - agentMessages.length)) : []; + const lostDiagrams = diagrams.stubsFor(omittedHead); + diagramsStubbed = lostDiagrams.length > 0; if (r.plan && r.plan.tier > 1) { const omitted = Math.max(0, (Array.isArray(r.full) ? r.full.length : 0) - agentMessages.length); - agentMessages.unshift({ role: 'user', content: '[Resumed session — the earlier part of this conversation (' + omitted + ' message' + (omitted === 1 ? '' : 's') + ') was omitted to fit the context window. It is shown above in the transcript but not included here; ask if you need details from it.]' }); + agentMessages.unshift({ role: 'user', content: '[Resumed session — the earlier part of this conversation (' + omitted + ' message' + (omitted === 1 ? '' : 's') + ') was omitted to fit the context window. It is shown above in the transcript but not included here; ask if you need details from it.' + + (lostDiagrams.length ? '\nDiagrams drawn in that part (call get_diagram with the id before editing one):\n- ' + lostDiagrams.join('\n- ') : '') + ']' }); } conversation = []; checkpoints.length = 0; currentCheckpoint = null; // old file-snapshots can't be restored @@ -1479,6 +1690,8 @@ function resetConversationState() { clearQuestions(); conversation = []; agentMessages = []; + diagrams.reset(); // the next conversation starts at d-1, owing nothing + diagramsStubbed = false; checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack pendingContext = null; contextFiles = []; @@ -1849,6 +2062,8 @@ function serializeMsgForSummary(m) { const parts = []; for (const c of m.content) { if (c.type === 'text' && c.text) { parts.push(c.text.slice(0, 4000)); } + // A diagram's spec is structure the summarizer cannot use and should not pay for: one line. + else if (c.type === 'tool_use' && c.name === diagramTool.RENDER_DIAGRAM.name) { parts.push('[draws a diagram: ' + String((c.input && c.input.title) || 'untitled').slice(0, 120) + ']'); } else if (c.type === 'tool_use') { parts.push('[calls ' + c.name + ' ' + JSON.stringify(c.input || {}).slice(0, 400) + ']'); } else if (c.type === 'tool_result') { const t = typeof c.content === 'string' ? c.content : JSON.stringify(c.content); parts.push('[tool result: ' + String(t).slice(0, 800) + ']'); } } @@ -1920,10 +2135,18 @@ async function compactAgentMemory() { // is unharmed and the user can compact again once the turn ends. if (agentMessages !== msgs || msgs[cut] !== anchor || abort) { dbg('compact.stale', { cut, running: !!abort }); return { ok: false, reason: 'changed' }; } + // Rich diagrams: the specs in the head are about to leave the conversation. Each is replaced by a + // one-line stub (the full spec stays on file; get_diagram fetches it). This is the ONLY place a + // spec is stubbed — at compaction, never turn by turn — so the cached prefix is rebuilt once, here, + // along with everything else this splice already invalidates. + const stubs = diagrams.stubsFor(msgs.slice(0, cut)); + if (stubs.length) { diagramsStubbed = true; } + const stubNote = stubs.length ? '\n\nDiagrams drawn earlier (their specs are no longer in this conversation — call get_diagram with the id before editing one):\n- ' + stubs.join('\n- ') : ''; + // Replace the head with [summary(user) → ack(assistant)]; the kept tail begins with the user goal at // `cut`, so role alternation (user → assistant → user …) holds across the seam. msgs.splice(0, cut, - { role: 'user', content: '[Summary of the earlier conversation, compacted to save context]\n\n' + summary.trim() }, + { role: 'user', content: '[Summary of the earlier conversation, compacted to save context]\n\n' + summary.trim() + stubNote }, { role: 'assistant', content: 'Got it — I have that summary of the work so far and will continue from here.' } ); // Drop checkpoints whose goal message was summarized away: restoreCheckpoint can no longer truncate the @@ -2026,6 +2249,12 @@ async function agentFlow(text, imageBlocks) { sessionExpiredMessage: session.SESSION_EXPIRED_MESSAGE, skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver) projectMemory: projectMemoryMarkdown(), // cross-session memory: a verify-first digest of past sessions, injected like project rules + // Rich diagrams: what this surface can show, and the service that draws. An `ascii` client is + // offered neither the tool nor its rules; `diagramsStubbed` adds get_diagram once a spec has + // left the conversation. + client: { render: clientRender(req.providerId, capsModel(req.model)) }, + diagrams: diagrams, + diagramsStubbed: diagramsStubbed, // The recall_sessions tool: search past-session outcomes on demand. Off → the tool isn't offered at all. recallSessions: (aiConfig().get('sessions.memory.enabled', true) && aiConfig().get('sessions.memory.recallTool', true)) ? recallSessionsTool : undefined, @@ -2073,7 +2302,9 @@ async function agentFlow(text, imageBlocks) { // into the chat, and never re-throws past this turn's own error. try { const m = sessionsManager(); - if (m && sessTurnStart >= 0) { m.recordTurn(agentMessages.slice(sessTurnStart), req.model); } + // The diagrams drawn this turn are stored with it, as rendered — so reopening the session + // replays the same pictures without running the repair ladder again. + if (m && sessTurnStart >= 0) { m.recordTurn(agentMessages.slice(sessTurnStart), req.model, { diagrams: diagrams.takeNew() }); } } catch (e) { dbg('sessions.record.error', { msg: String((e && e.message) || e) }); } } } @@ -2882,6 +3113,10 @@ class ChatViewProvider { case 'sessionAction': await handleSessionAction(msg.action, msg.id); break; case 'feedback': dbg('feedback', { value: msg.value, model: msg.model }); await recordFeedback(msg.value, msg.model); break; case 'openFile': await openWorkspaceFile(msg.path); break; + // Rich diagrams: the only two things a diagram can ask the editor for are to open a node's + // file and to export itself (plus the user's own Retry). Nothing else is routed. + case 'diagramAction': await handleDiagramAction(msg); break; + case 'diagramRendered': recordDiagramStat({ type: 'render', ok: msg.ok !== false, ms: Number(msg.ms), flipped: !!msg.flipped }); if (msg.ok === false) { dbg('diagram.render.failed', { message: String(msg.message || '').slice(0, 200) }); } break; case 'reviewKeepFile': dbg('review.keep', { id: msg.id }); review.keepFile(msg.id, 'kept'); break; case 'reviewUndoFile': dbg('review.undo', { id: msg.id }); await review.undoFile(msg.id); break; case 'reviewKeepAll': review.keepAll(); break; @@ -3026,7 +3261,7 @@ function replayLiveTranscript(tag) { const id = m.liveId(); if (!id) { return; } // nothing said yet — an empty chat is the honest state let turns = []; - try { turns = sessionEvents.toDisplayTurns(m.transcript(id)); } + try { turns = sessionEvents.toDisplayTurns(m.transcript(id), m.diagrams(id)); } catch (e) { dbg('chat.replay.failed', { msg: String((e && e.message) || e) }); return; } if (!turns.length) { return; } const entry = m.list().find((e) => e.id === id) || {}; @@ -3056,7 +3291,11 @@ function webviewCsp() { function getHtml() { const { nonce, csp } = webviewCsp(); - const html = fs.readFileSync(path.join(ctx.extensionPath, 'media', 'chat.html'), 'utf8'); + let html = fs.readFileSync(path.join(ctx.extensionPath, 'media', 'chat.html'), 'utf8'); + // Rich diagrams: the renderer is the host's own modules, inlined under this page's nonce — the CSP + // above is unchanged, and the page still loads nothing from anywhere. If the bundle cannot be + // built the placeholders stay as the comments they are, and the chat falls back to showing source. + try { html = diagramBundle.inject(html); } catch (e) { dbg('diagram.bundle.failed', { msg: String((e && e.message) || e) }); } return html.replace(/__CSP__/g, csp).replace(/__NONCE__/g, nonce); } @@ -3426,6 +3665,7 @@ function activate(context) { }), vscode.commands.registerCommand('levelcode.import.vscode', () => importFromVscode(context)), vscode.commands.registerCommand('levelcode.ai.newChat', newChat), + vscode.commands.registerCommand('levelcode.ai.diagramStats', showDiagramStats), vscode.commands.registerCommand('levelcode.ai.pickModel', pickModel), vscode.commands.registerCommand('levelcode.ai.manageMcp', manageMcpServers), // Wrapped, NOT passed by reference: a menu invocation hands the command its context as the first diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 4a125c1..dd74a23 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -1402,6 +1402,81 @@ .sesscard.pinned [data-act="pin"] { color: var(--cc-accent); border-color: color-mix(in srgb, var(--cc-accent) 45%, var(--cc-line)); } .sesscard .ci { width: 14px; height: 14px; } @media (prefers-reduced-motion: reduce) { .sesscard { transition: none; } .sesscard:hover, .sesscard:focus-visible, .sesscard:focus-within { transform: none; } } + /* ---- rich diagrams (docs/RICH-DIAGRAMS.md) --------------------------------------------------- + Two layers, kept apart on purpose. + INSIDE the picture: every rule comes from diagram/theme.js — the one module that owns the house + style — and is injected over the placeholder below when the page is built. Colours there are + editor theme tokens, so switching theme restyles a diagram already on screen; nothing is baked in. + AROUND the picture: the card chrome that follows (title, badge, banner, toolbar, placeholder, + full-size view). That part is ordinary chat UI and uses the same variables as everything else. */ + /*__LCD_CSS__*/ + .lcd { margin: 6px 0 2px; padding: 0; min-width: 0; } + .lcd-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; margin: 0 0 8px; } + /* "Title: rendered above the diagram, left-aligned" — and at the title size of the type scale. */ + .lcd-title-text { font-size: 15px; font-weight: 600; line-height: 20px; color: var(--vscode-foreground); text-wrap: balance; } + .lcd-badge { + cursor: pointer; font: inherit; font-size: 10.5px; line-height: 1.5; padding: 0 7px; white-space: nowrap; + border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--muted); + } + .lcd-badge:hover, .lcd-badge[aria-expanded="true"] { color: var(--vscode-foreground); background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); } + .lcd-badge:focus-visible, .lcd-btn:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; } + .lcd-pop { margin: 0 0 8px; padding: 6px 9px; font-size: 11.5px; line-height: 1.5; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; overflow-wrap: anywhere; } + .lcd-pop[hidden] { display: none; } + .lcd-pop div + div { margin-top: 3px; } + /* A degraded diagram says what it lost — in the same warning voice the composer uses. */ + .lcd-banner { + display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin: 0 0 8px; padding: 5px 8px; + font-size: 11.5px; line-height: 1.45; border-radius: 6px; + color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); + background: var(--vscode-inputValidation-warningBackground, rgba(224,160,48,.14)); + border: 1px solid var(--vscode-inputValidation-warningBorder, rgba(224,160,48,.45)); + } + .lcd-banner-text { flex: 1 1 12em; min-width: 0; } + .lcd-stage { cursor: zoom-in; border-radius: 8px; outline-offset: 3px; } + .lcd-stage:focus-visible { outline: 1px solid var(--accent); } + /* "Never wider than the chat column": the picture is laid out to fit, and scales down if the column narrows. */ + .lcd-stage svg { display: block; max-width: 100%; height: auto; } + .lcd-btn { + cursor: pointer; font: inherit; font-size: 11px; line-height: 1.4; padding: 2px 8px; + border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--muted); + transition: color .12s, background .12s; + } + .lcd-btn:hover { color: var(--vscode-foreground); background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); } + .lcd-btn.done { color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); border-color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); } + .lcd-banner .lcd-btn { color: inherit; border-color: currentColor; opacity: .85; } + .lcd-banner .lcd-btn:hover { opacity: 1; } + /* The toolbar is revealed on hover or keyboard focus, like the Copy button under an answer. */ + .lcd-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; opacity: 0; transition: opacity .12s; } + .lcd:hover .lcd-tools, .lcd:focus-within .lcd-tools { opacity: 1; } + @media (hover: none) { .lcd-tools { opacity: 1; } } + /* The placeholder that holds a diagram's place while its spec streams in. */ + .lcd-pending .lcd-title-text { color: var(--muted); } + .lcd-skel { + display: flex; align-items: center; gap: 10px; height: 64px; padding: 0 14px; + border: 1px dashed var(--border); border-radius: 8px; color: var(--muted); font-size: 11.5px; + } + .lcd-skel-bar { + flex: 0 0 72px; height: 3px; border-radius: 2px; + background: linear-gradient(90deg, rgba(127,127,127,.14) 25%, rgba(127,127,127,.42) 37%, rgba(127,127,127,.14) 63%); + background-size: 300% 100%; animation: imgskel 1.1s ease-in-out infinite; + } + @media (prefers-reduced-motion: reduce) { .lcd-skel-bar { animation: none; } .lcd-tools, .lcd-btn { transition: none; } } + /* The fallbacks: an ASCII drawing when the picture cannot be rendered, the source when nothing can. */ + .lcd-ascii pre, .lcd-source pre { overflow-x: auto; white-space: pre; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 12px; line-height: 1.25; } + .lcd-errors { margin: 0 0 8px; padding-left: 18px; font-size: 11.5px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; } + .lcd-source summary { cursor: pointer; font-size: 11.5px; color: var(--muted); margin-bottom: 6px; } + /* The text outline a screen reader gets. Out of sight, never out of the accessibility tree — + the same treatment, and the same reason, as `.msg .role`. */ + .lcd-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } + /* Full-size view: zoom, pan, fit. */ + #lcdZoom { position: fixed; inset: 0; z-index: 60; display: flex; flex-direction: column; background: var(--vscode-editor-background, #1e1e1e); } + #lcdZoom[hidden] { display: none; } + .lcdz-bar { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; } + .lcdz-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } + .lcdz-sp { flex: 1; } + .lcdz-view { flex: 1; position: relative; overflow: hidden; cursor: grab; touch-action: none; } + .lcdz-view.dragging { cursor: grabbing; } + .lcdz-view svg { position: absolute; left: 0; top: 0; max-width: none; transform-origin: 0 0; } </style> </head> <body> @@ -1445,6 +1520,18 @@ <div id="imgzoom" hidden role="dialog" aria-modal="true" aria-label="Attached image"><img alt=""></div> + <div id="lcdZoom" hidden role="dialog" aria-modal="true" aria-label="Diagram, full size"> + <div class="lcdz-bar"> + <span class="lcdz-title"></span><span class="lcdz-sp"></span> + <button class="lcd-btn" type="button" data-z="out" title="Zoom out (−)" aria-label="Zoom out">−</button> + <button class="lcd-btn" type="button" data-z="in" title="Zoom in (+)" aria-label="Zoom in">+</button> + <button class="lcd-btn" type="button" data-z="fit" title="Fit to the window (0)">Fit</button> + <button class="lcd-btn" type="button" data-z="one" title="Actual size (1)">100%</button> + <button class="lcd-btn" type="button" data-z="close" title="Close (Esc)" aria-label="Close">✕</button> + </div> + <div class="lcdz-view lcd"></div> + </div> + <div id="composer"> <div id="chips"></div> <div id="imgnote" role="status" aria-live="polite" hidden></div> @@ -1541,6 +1628,10 @@ </div> </div> +<!-- Rich diagrams: the host's own diagram modules (extensions/levelcode-ai/diagram/*.js), inlined when the + page is built. Same nonce, same CSP, nothing fetched. If this block is empty the chat shows a + diagram's source instead of a picture. --> +<script nonce="__NONCE__">/*__LCD_JS__*/</script> <script nonce="__NONCE__"> const vscode = acquireVsCodeApi(); window.onerror = function(msg){ @@ -1841,14 +1932,35 @@ s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>'); return s; } + // The runs of backticks on one line: [{ at, len }], left to right. + function tickRuns(line){ + const runs = []; let i = 0; + while (i < line.length){ + if (line[i] !== TICK){ i++; continue; } + let j = i; while (j < line.length && line[j] === TICK){ j++; } + runs.push({ at: i, len: j - i }); i = j; + } + return runs; + } // Split text into inline-code spans (escaped, never formatted) and formatted text — no sentinels. + // A run of N backticks opens a span that the next run of exactly N closes, on the same line — which + // is how three backticks are quoted (inside four). A run with no partner is just backticks. function mdInline(text){ - const re = new RegExp(TICK + '([^' + TICK + '\\n]+)' + TICK, 'g'); - let out = '', last = 0, m; - while ((m = re.exec(text))){ - const code = m[1], rel = resolveFile(code.trim()); - out += mdFmt(text.slice(last, m.index)) + (rel ? fileLinkChip(rel, esc(code)) : '<code>' + esc(code) + '</code>'); - last = m.index + m[0].length; + let out = '', last = 0, lineAt = 0; + for (const line of String(text).split('\n')){ + const runs = tickRuns(line); + for (let a = 0; a < runs.length; a++){ + let b = a + 1; while (b < runs.length && runs[b].len !== runs[a].len){ b++; } + if (b >= runs.length){ continue; } + const open = lineAt + runs[a].at, from = open + runs[a].len, to = lineAt + runs[b].at; + let code = text.slice(from, to); + // Padding that exists only to keep the content's own backticks off the delimiters is not content. + if (runs[a].len > 1 && code.length > 2 && code[0] === ' ' && code[code.length - 1] === ' ' && code.trim()){ code = code.slice(1, -1); } + const rel = resolveFile(code.trim()); + out += mdFmt(text.slice(last, open)) + (rel ? fileLinkChip(rel, esc(code)) : '<code>' + esc(code) + '</code>'); + last = to + runs[b].len; a = b; + } + lineAt += line.length + 1; } return out + mdFmt(text.slice(last)); } @@ -1899,28 +2011,72 @@ } return html; } - function render(md){ - const parts = String(md == null ? '' : md).split(FENCE); - let out = ''; - for (let i = 0; i < parts.length; i++){ - if (i % 2 === 1){ - const body = parts[i].replace(/^[a-zA-Z0-9_+-]*\n/, ''); - out += '<pre><code>' + highlight(body) + '</code></pre>'; + // ---- Fenced code ---- + // A fence is a LINE: indentation, three or more backticks, a language (or anything else without a + // backtick in it), and the end of the line. Three backticks in the middle of a sentence are text — + // usually one half of an inline span quoting a fence — and reading them as a fence used to turn the + // rest of the paragraph into a code block and, while it streamed, freeze it one fragment per line. + const FENCE_LINE = new RegExp('^[ \\t]*(' + TICK + '{3,})([^' + TICK + ']*)$'); + // Tolerated, because models do it: a fence stuck to the END of a line of prose ("Run: ```bash"). + // Only when nothing but a one-word language follows it, and it is not closing an inline span. + const FENCE_TAIL = new RegExp('^[ \\t]*[A-Za-z0-9_+.#-]*[ \\t]*$'); + // The end of a block: a run at least as long as the one that opened it, with nothing after it. + const FENCE_END = new RegExp('(' + TICK + '{3,})[ \\t]*$'); + /** + * Text as prose and code: [{ code: false, text } | { code: true, text, closed, end }]. `end` is the + * offset just past a closed block's last line, set only once that line has its newline — until + * then more could still arrive on it, so the block is not finished. + */ + function mdSegments(src){ + const text = String(src == null ? '' : src); + const out = []; let proseFrom = 0, pos = 0, open = null; + while (pos < text.length){ + const nl = text.indexOf('\n', pos), lineEnd = nl < 0 ? text.length : nl, next = nl < 0 ? text.length : nl + 1; + const line = text.slice(pos, lineEnd); + if (!open){ + let at = -1, ticks = 0; + const whole = FENCE_LINE.exec(line); + if (whole){ at = pos; ticks = whole[1].length; } + else if (line.indexOf(FENCE) >= 0){ + const runs = tickRuns(line), last = runs[runs.length - 1]; + // the last run on the line, if it pairs with nothing before it (a pair is an inline span) + let paired = false; + for (let a = 0; a < runs.length - 1 && !paired; a++){ + let b = a + 1; while (b < runs.length && runs[b].len !== runs[a].len){ b++; } + if (b < runs.length){ if (b === runs.length - 1){ paired = true; } a = b; } + } + if (last.len >= 3 && !paired && FENCE_TAIL.test(line.slice(last.at + last.len))){ at = pos + last.at; ticks = last.len; } + } + if (at >= 0){ + if (at > proseFrom){ out.push({ code: false, text: text.slice(proseFrom, at) }); } + open = { ticks: ticks, bodyFrom: next }; + } } else { - out += mdBlocks(parts[i]); + const m = FENCE_END.exec(line); + if (m && m[1].length >= open.ticks){ + out.push({ code: true, text: text.slice(open.bodyFrom, pos + m.index), closed: true, end: nl < 0 ? -1 : next }); + open = null; proseFrom = next; + } } + pos = next; + } + if (open){ out.push({ code: true, text: text.slice(Math.min(open.bodyFrom, text.length)), closed: false, end: -1 }); } + else if (proseFrom < text.length){ out.push({ code: false, text: text.slice(proseFrom) }); } + return out; + } + function render(md){ + let out = ''; + for (const seg of mdSegments(md)){ + out += seg.code ? '<pre><code>' + highlight(seg.text) + '</code></pre>' : mdBlocks(seg.text); } return out; } - // Index just past the last fully-closed code block. Everything before it is stable forever. + // Index just past the last FINISHED code block. Everything before it is stable forever — and + // nothing else is: text after a block stays live until the next block finishes. function lastStableIndex(text){ - const pos = []; let p = text.indexOf(FENCE); - while (p >= 0){ pos.push(p); p = text.indexOf(FENCE, p + 3); } - const stableFences = pos.length - (pos.length % 2); - if (stableFences === 0) { return 0; } - const end = pos[stableFences - 1] + 3; - const nl = text.indexOf('\n', end); - return nl >= 0 ? nl + 1 : text.length; + let stable = 0; + for (const seg of mdSegments(text)){ if (seg.code && seg.closed && seg.end > stable){ stable = seg.end; } } + return stable; } // A streaming renderer: completed code/diff blocks are FROZEN into immutable child nodes // (never re-rendered), so later-streamed text can never truncate or corrupt them. @@ -4112,6 +4268,8 @@ log.appendChild(banner); turnLabeled = false; for (const t of (Array.isArray(m.turns) ? m.turns : [])){ + // A diagram comes back as the record that was stored — drawn again, never repaired again. + if (t.role === 'diagram'){ lcdShow({ key: t.key, record: t.record }); continue; } add(t.role, t.role === 'user' ? esc(t.text) : render(t.text)); } forceStick(); @@ -4136,6 +4294,369 @@ document.addEventListener('keydown', (e) => { const o = document.getElementById('sessOverlay'); if (e.key === 'Escape' && o && o.style.display !== 'none'){ e.preventDefault(); closeSessions(); } }); })(); + + // ── Rich diagrams (docs/RICH-DIAGRAMS.md) ──────────────────────────────────────────────────────── + // The host validates a spec and posts the record; this draws it. Layout and painting are the host's + // own modules (the files under diagram/, inlined above as LCDiagram) — the chat adds the two things only it + // has: the real font to measure text with, and the live theme to paint with. + // + // SAFETY, in one place: a label reaches the page through LCDiagram.scene.mount(), which creates + // SVG elements from a fixed allow-list and sets text with textContent. The card chrome below is + // built with createElement + textContent too. Nothing from a spec is ever assigned to innerHTML. + const LCD = window.LCDiagram || null; // null if the modules did not load → the host's text version is shown instead + const lcdCards = {}; // tool-call key → the card that shows (or will show) that diagram + let lcdSeq = 0; + const lcdCtx = (function(){ try { return document.createElement('canvas').getContext('2d'); } catch (e) { return null; } })(); + function lcdFont(){ try { return getComputedStyle(document.body).fontFamily || 'sans-serif'; } catch (e) { return 'sans-serif'; } } + /** Text width in the diagram's own type. "Width from the MEASURED longest line plus 24." */ + function lcdMeasure(text, role){ + const T = LCD.theme.TYPE[role] || LCD.theme.TYPE.sub; + if (lcdCtx){ + lcdCtx.font = T.weight + ' ' + T.size + 'px ' + lcdFont(); + // A font string the canvas cannot parse leaves it on its previous font — measuring with that + // would size every box for the wrong type. Only trust the canvas if it took the size we asked for. + if (String(lcdCtx.font).indexOf(T.size + 'px') >= 0){ return Math.ceil(lcdCtx.measureText(String(text)).width * 10) / 10 + 0.6; } + } + return LCD.theme.approxMeasure(text, role); + } + function lcdEl(tag, cls, text){ const e = document.createElement(tag); if (cls){ e.className = cls; } if (text != null){ e.textContent = text; } return e; } + function lcdBtn(label, title, fn){ const b = lcdEl('button', 'lcd-btn', label); b.type = 'button'; if (title){ b.title = title; } b.onclick = fn; return b; } + function lcdFlash(btn, label){ const was = btn.textContent; btn.textContent = label; btn.classList.add('done'); setTimeout(function(){ btn.textContent = was; btn.classList.remove('done'); }, 1200); } + function lcdAct(id, action, extra){ vscode.postMessage(Object.assign({ type: 'diagramAction', action: action, id: id }, extra || {})); } + /** How many characters of the fallback's monospaced block fit across the card (it is set in 12px). */ + function lcdCols(card){ + let cw = 7.3; + try { + if (lcdCtx){ + lcdCtx.font = '12px ' + (getComputedStyle(document.body).getPropertyValue('--vscode-editor-font-family') || 'monospace'); + const w = lcdCtx.measureText('0000000000').width / 10; + if (String(lcdCtx.font).indexOf('12px') >= 0 && w > 3 && w < 20){ cw = w; } + } + } catch (e) { /* keep the estimate */ } + return Math.max(40, Math.min(200, Math.floor((lcdAvail(card) - 30) / cw))); + } + /** How wide the picture may be: the card's own column. */ + function lcdAvail(card){ const w = card.clientWidth || (log.clientWidth - 48); return w > 0 ? Math.max(240, Math.floor(w)) : 0; } + /** A new card, in the assistant's own column, at the point in the answer the model reached. */ + function lcdHost(key){ + finishAgentBubble(); + const body = add('assistant', ''); + const card = lcdEl('figure', 'lcd'); + card.dataset.key = key; + body.appendChild(card); + lcdCards[key] = card; + return card; + } + /** The placeholder: holds the diagram's place from the moment the model starts writing its spec. */ + function lcdPending(m){ + if (!m || !m.key){ return; } + let card = lcdCards[m.key]; + if (card && !card.isConnected){ card = null; } + if (!card){ + // The model's repair call takes over the placeholder of the diagram it is repairing. + const waiting = Object.keys(lcdCards).map(function(k){ return lcdCards[k]; }).find(function(c){ return c.isConnected && c.dataset.state === 'repairing'; }); + if (waiting){ card = waiting; lcdCards[m.key] = card; } else { card = lcdHost(m.key); } + } + if (card.dataset.id){ return; } // already drawn: a late placeholder must never blank a picture + const repairing = m.state === 'repairing' || card.dataset.state === 'repairing'; + const title = m.title || card.dataset.title || ''; + card.className = 'lcd lcd-pending'; + card.dataset.state = repairing ? 'repairing' : 'drawing'; + card.dataset.title = title; + card.setAttribute('role', 'status'); card.setAttribute('aria-live', 'polite'); + card.textContent = ''; + const head = lcdEl('div', 'lcd-head'); head.appendChild(lcdEl('span', 'lcd-title-text', title || 'Diagram')); card.appendChild(head); + const skel = lcdEl('div', 'lcd-skel'); + const bar = lcdEl('span', 'lcd-skel-bar'); bar.setAttribute('aria-hidden', 'true'); skel.appendChild(bar); + skel.appendChild(lcdEl('span', 'lcd-skel-text', repairing ? 'Fixing the diagram…' : 'Drawing…')); + card.appendChild(skel); + scrollIfStuck(); + } + /** A finished record from the host: fill the placeholder that was waiting for it, or make a card. */ + function lcdShow(m){ + const record = m && m.record; if (!record || !record.id){ return; } + const key = m.key || record.key || record.id; + let card = lcdCards[key] || (m.replacesKey ? lcdCards[m.replacesKey] : null); + if (card && !card.isConnected){ card = null; } + if (!card){ card = lcdHost(key); } + lcdCards[key] = card; + lcdDraw(card, record, false); + scrollIfStuck(); + } + /** A details list that a badge or a banner button opens. */ + function lcdPop(lines){ + const pop = lcdEl('div', 'lcd-pop'); pop.hidden = true; + lines.forEach(function(l){ pop.appendChild(lcdEl('div', '', l)); }); + return pop; + } + function lcdToggle(btn, pop){ btn.setAttribute('aria-expanded', 'false'); btn.onclick = function(){ pop.hidden = !pop.hidden; btn.setAttribute('aria-expanded', String(!pop.hidden)); }; } + function lcdDraw(card, record, quiet){ + card.className = 'lcd'; card.textContent = ''; + card.dataset.id = record.id; card.dataset.status = record.status; + delete card.dataset.state; card.removeAttribute('aria-live'); card.setAttribute('role', 'group'); + card._record = record; card._spec = null; card._geo = null; + let spec = null, late = []; + if (record.spec && LCD){ + // The renderer's own last check: whatever sent this record, nothing reaches layout unvalidated. + try { const a = LCD.repair.accept(record.spec); if (a.ok){ spec = a.spec; late = a.notes || []; } } catch (e) { spec = null; } + } + const title = (spec && spec.title) || (record.spec && record.spec.title) || record.title || 'Diagram'; + card.setAttribute('aria-label', 'Diagram: ' + title); + const head = lcdEl('figcaption', 'lcd-head'); + const tt = lcdEl('span', 'lcd-title-text', title); if (spec && spec.tip){ tt.title = spec.tip; } + head.appendChild(tt); card.appendChild(head); + if (!spec){ + if (record.spec && !LCD && !quiet){ vscode.postMessage({ type: 'diagramRendered', id: record.id, ok: false, message: 'the diagram renderer did not load' }); } + lcdFailed(card, record); + return; + } + + const notes = (record.notes || []).concat(late), fixes = record.fixes || []; + if (record.status === 'degraded' || late.length){ + // Degraded: say what was lost, where it cannot be missed, and offer another go. + const ban = lcdEl('div', 'lcd-banner'); ban.setAttribute('role', 'note'); + ban.appendChild(lcdEl('span', 'lcd-banner-text', notes.join(' · ') || 'Drawn without the parts that were not valid.')); + const lines = (record.errors || []).concat(fixes); + const pop = lcdPop(lines); + if (lines.length){ const d = lcdBtn('Details', 'What was wrong with the spec', null); lcdToggle(d, pop); ban.appendChild(d); } + ban.appendChild(lcdBtn('Retry', 'Ask for this diagram again', function(){ lcdAct(record.id, 'retry'); })); + card.appendChild(ban); card.appendChild(pop); + } else if (fixes.length || record.repaired){ + // Auto-fixed: quiet, and one click from the detail. + const badge = lcdEl('button', 'lcd-badge', 'auto-fixed' + (notes.length ? ' · ' + notes[0] : '')); badge.type = 'button'; + badge.title = 'This diagram needed a fix before it could be drawn. Click for details.'; + const pop = lcdPop(fixes.length ? fixes : ['The first attempt was not valid; the model corrected it.']); + lcdToggle(badge, pop); + head.appendChild(badge); card.appendChild(pop); + } + + const stage = lcdEl('div', 'lcd-stage'); card.appendChild(stage); + const t0 = performance.now(); + try { + const avail = lcdAvail(card); + const geo = LCD.layout.layout(spec, { measure: lcdMeasure, maxWidth: avail || undefined }); + const svg = LCD.scene.mount(LCD.scene.build(spec, geo, {}), document); + const descId = 'lcd-desc-' + (++lcdSeq); + svg.setAttribute('aria-describedby', descId); + stage.appendChild(svg); + stage.tabIndex = 0; stage.setAttribute('role', 'button'); stage.setAttribute('aria-label', 'Open the diagram full size'); stage.title = 'Click to open full size'; + const desc = lcdEl('p', 'lcd-sr', LCD.text.outline(spec).text); desc.id = descId; card.appendChild(desc); + card._spec = spec; card._geo = geo; + card.appendChild(lcdTools(card, record, false)); + if (!quiet){ vscode.postMessage({ type: 'diagramRendered', id: record.id, ok: true, ms: Math.round((performance.now() - t0) * 10) / 10, flipped: !!geo.flipped }); } + } catch (e) { + // Rendering is unavailable. The fallback is an ASCII drawing generated from the same spec — + // by the editor, in a monospaced block — so the model never has to draw one itself. + stage.className = 'lcd-ascii'; stage.textContent = ''; + // Say so: an unexplained character drawing looks like the model's doing, and it is not. + const why = lcdEl('div', 'lcd-banner'); why.setAttribute('role', 'note'); + why.appendChild(lcdEl('span', 'lcd-banner-text', 'The picture could not be drawn, so a text version is shown instead.')); + card.insertBefore(why, stage); + const pre = lcdEl('pre'); + try { pre.textContent = LCD.ascii.render(spec, { maxCols: lcdCols(card), title: false }); } catch (e2) { pre.textContent = LCD.text.toSource(spec); } + stage.appendChild(pre); + card._spec = spec; + card.appendChild(lcdTools(card, record, true)); + if (!quiet){ vscode.postMessage({ type: 'diagramRendered', id: record.id, ok: false, message: String((e && e.message) || e) }); } + } + } + /** Nothing drawable: the errors, the source, and a way out. Never a blank space. */ + function lcdFailed(card, record){ + card.dataset.status = 'failed'; + const ban = lcdEl('div', 'lcd-banner'); ban.setAttribute('role', 'note'); + // No renderer on this side (the modules did not load): the spec is fine, the page is not. The + // fallback is the same diagram drawn with characters — by the host, from its own record. + const noRenderer = !!(record.spec && !LCD); + // A degraded diagram still says what it lost, whichever way it ends up being shown. + const lost = noRenderer && (record.notes || []).length ? ' ' + record.notes.join(' · ') : ''; + ban.appendChild(lcdEl('span', 'lcd-banner-text', noRenderer ? 'The diagram renderer did not load, so a text version is shown instead.' + lost : 'This diagram could not be drawn.')); + if (!noRenderer){ ban.appendChild(lcdBtn('Retry', 'Ask for this diagram again', function(){ lcdAct(record.id, 'retry'); })); } + card.appendChild(ban); + if (noRenderer){ + const stage = lcdEl('div', 'lcd-ascii'); stage.hidden = true; + card._asciiPre = lcdEl('pre'); stage.appendChild(card._asciiPre); card.appendChild(stage); + lcdAct(record.id, 'ascii', { cols: lcdCols(card) }); + } + const errs = record.errors || []; + if (errs.length){ const ul = lcdEl('ul', 'lcd-errors'); errs.forEach(function(l){ ul.appendChild(lcdEl('li', '', l)); }); card.appendChild(ul); } + let source = record.source || ''; + if (!source && record.spec){ try { source = JSON.stringify(record.spec, null, 2); } catch (e) { source = ''; } } + if (source){ + const det = lcdEl('details', 'lcd-source'); det.open = !errs.length; + det.appendChild(lcdEl('summary', '', 'What was sent')); + det.appendChild(lcdEl('pre', '', source)); + card.appendChild(det); + const bar = lcdEl('div', 'lcd-tools'); + const copy = lcdBtn('Copy source', 'Copy what the model sent', function(){ vscode.postMessage({ type: 'copy', text: source }); lcdFlash(copy, 'Copied'); }); + bar.appendChild(copy); card.appendChild(bar); + } + } + /** The colours on screen right now, as plain values — the only moment a colour is baked in. */ + function lcdResolved(card){ + const probe = lcdEl('span'); probe.style.display = 'none'; card.appendChild(probe); + const out = {}; + LCD.theme.TOKEN_NAMES.forEach(function(name){ + if (name === 'font'){ out.font = lcdFont(); return; } + probe.style.color = 'var(--lcd-' + name + ')'; + let c = getComputedStyle(probe).color; + // A canvas normalises any CSS colour (color-mix results included) to #rrggbb or rgba(). + if (lcdCtx){ lcdCtx.fillStyle = '#000000'; lcdCtx.fillStyle = c; c = lcdCtx.fillStyle; } + out[name] = c; + }); + probe.remove(); + return out; + } + /** The diagram as a standalone SVG file: same tree as on screen, plus its title, background and colours. */ + function lcdSvg(card){ + const vnode = LCD.scene.build(card._spec, card._geo, { title: true, background: true, standalone: true, measure: lcdMeasure, css: LCD.theme.css({ resolved: lcdResolved(card) }) }); + return { text: LCD.scene.toSvg(vnode), width: vnode.width, height: vnode.height }; + } + function lcdPng(card){ + return new Promise(function(resolve, reject){ + const svg = lcdSvg(card); + const img = new Image(); + img.onload = function(){ + try { + const scale = 2, c = document.createElement('canvas'); + c.width = Math.ceil(svg.width * scale); c.height = Math.ceil(svg.height * scale); + const g = c.getContext('2d'); g.scale(scale, scale); g.drawImage(img, 0, 0, svg.width, svg.height); + resolve(c.toDataURL('image/png').replace(/^data:image\/png;base64,/, '')); + } catch (e) { reject(e); } + }; + img.onerror = function(){ reject(new Error('the picture could not be turned into an image')); }; + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.text); + }); + } + /** Copy source · SVG · PNG · Open as Mermaid · Insert into Markdown. */ + function lcdTools(card, record, textOnly){ + const bar = lcdEl('div', 'lcd-tools'); + const fail = function(e){ vscode.postMessage({ type: 'notice', text: 'The diagram could not be exported: ' + String((e && e.message) || e) }); }; + const copy = lcdBtn('Copy source', 'Copy the diagram’s spec', function(){ lcdAct(record.id, 'export', { format: 'source' }); lcdFlash(copy, 'Copied'); }); + bar.appendChild(copy); + if (!textOnly){ + bar.appendChild(lcdBtn('SVG', 'Save as an SVG image', function(){ try { lcdAct(record.id, 'export', { format: 'svg', data: lcdSvg(card).text }); } catch (e) { fail(e); } })); + bar.appendChild(lcdBtn('PNG', 'Save as a PNG image', function(){ lcdPng(card).then(function(b64){ lcdAct(record.id, 'export', { format: 'png', data: b64 }); }, fail); })); + } + bar.appendChild(lcdBtn('Open as Mermaid', 'Open the same diagram as Mermaid source', function(){ lcdAct(record.id, 'export', { format: 'mermaid' }); })); + bar.appendChild(lcdBtn('Insert into Markdown…', 'Insert it into a Markdown file in this workspace', function(){ lcdAct(record.id, 'export', { format: 'markdown' }); })); + return bar; + } + /** The host's answer when the renderer did not load here: the same spec, drawn with characters. */ + function lcdAscii(m){ + const text = String((m && m.text) || ''); + if (!text){ return; } // nothing to show: the source stays open + Object.keys(lcdCards).forEach(function(k){ + const card = lcdCards[k]; + if (!card._asciiPre || card.dataset.id !== String(m.id)){ return; } + card._asciiPre.textContent = text; card._asciiPre.parentElement.hidden = false; + const det = card.querySelector('details.lcd-source'); if (det){ det.open = false; } + }); + } + /** A run ended: a placeholder that never got its picture goes (the host settles the ones it owes first). */ + function lcdSweep(){ + Object.keys(lcdCards).forEach(function(k){ + const card = lcdCards[k]; + if (card.dataset.id){ return; } + const body = card.parentElement, msg = body && body.parentElement; + card.remove(); delete lcdCards[k]; + if (body && !body.childElementCount && !body.textContent.trim() && msg && msg.classList.contains('msg')){ msg.remove(); } + }); + } + function lcdReset(){ Object.keys(lcdCards).forEach(function(k){ delete lcdCards[k]; }); lcdZoomClose(); } + + // Full-size view: zoom about the pointer, drag to pan, fit and 100%. + const lcdZ = { el: document.getElementById('lcdZoom'), view: null, svg: null, card: null, k: 1, x: 0, y: 0, w: 0, h: 0, back: null }; + lcdZ.view = lcdZ.el.querySelector('.lcdz-view'); + function lcdZoomApply(){ if (lcdZ.svg){ lcdZ.svg.style.transform = 'translate(' + lcdZ.x + 'px,' + lcdZ.y + 'px) scale(' + lcdZ.k + ')'; } } + function lcdZoomFit(){ + const r = lcdZ.view.getBoundingClientRect(); + lcdZ.k = Math.max(0.1, Math.min((r.width - 32) / lcdZ.w, (r.height - 32) / lcdZ.h, 2)); + lcdZ.x = (r.width - lcdZ.w * lcdZ.k) / 2; lcdZ.y = (r.height - lcdZ.h * lcdZ.k) / 2; lcdZoomApply(); + } + function lcdZoomBy(f, cx, cy){ + const k = Math.max(0.1, Math.min(8, lcdZ.k * f)), r = k / lcdZ.k; + lcdZ.x = cx - (cx - lcdZ.x) * r; lcdZ.y = cy - (cy - lcdZ.y) * r; lcdZ.k = k; lcdZoomApply(); + } + function lcdZoomOpen(card){ + const src = card.querySelector('.lcd-stage svg'); if (!src || !card._geo){ return; } + lcdZ.view.textContent = ''; + const svg = src.cloneNode(true); + svg.removeAttribute('aria-describedby'); + lcdZ.w = card._geo.width; lcdZ.h = card._geo.height; lcdZ.svg = svg; lcdZ.card = card; lcdZ.back = document.activeElement; + svg.style.width = lcdZ.w + 'px'; svg.style.height = lcdZ.h + 'px'; + lcdZ.view.appendChild(svg); + lcdZ.el.querySelector('.lcdz-title').textContent = (card._spec && card._spec.title) || 'Diagram'; + lcdZ.el.hidden = false; + lcdZoomFit(); + const close = lcdZ.el.querySelector('[data-z="close"]'); if (close){ close.focus(); } + } + function lcdZoomClose(){ + if (!lcdZ.el || lcdZ.el.hidden){ return; } + lcdZ.el.hidden = true; lcdZ.view.textContent = ''; lcdZ.svg = null; lcdZ.card = null; + try { if (lcdZ.back && lcdZ.back.focus){ lcdZ.back.focus(); } } catch (e) {} + } + lcdZ.el.querySelector('.lcdz-bar').addEventListener('click', function(e){ + const b = e.target.closest && e.target.closest('[data-z]'); if (!b){ return; } + const r = lcdZ.view.getBoundingClientRect(), z = b.getAttribute('data-z'); + if (z === 'close'){ lcdZoomClose(); } + else if (z === 'fit'){ lcdZoomFit(); } + else if (z === 'one'){ lcdZoomBy(1 / lcdZ.k, r.width / 2, r.height / 2); } + else { lcdZoomBy(z === 'in' ? 1.25 : 0.8, r.width / 2, r.height / 2); } + }); + lcdZ.view.addEventListener('wheel', function(e){ + e.preventDefault(); + const r = lcdZ.view.getBoundingClientRect(); + lcdZoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - r.left, e.clientY - r.top); + }, { passive: false }); + (function(){ + let drag = null; + lcdZ.view.addEventListener('pointerdown', function(e){ drag = { x: e.clientX, y: e.clientY, moved: 0, target: e.target }; lcdZ.view.setPointerCapture(e.pointerId); lcdZ.view.classList.add('dragging'); }); + lcdZ.view.addEventListener('pointermove', function(e){ if (!drag){ return; } const dx = e.clientX - drag.x, dy = e.clientY - drag.y; drag.moved += Math.abs(dx) + Math.abs(dy); drag.x = e.clientX; drag.y = e.clientY; lcdZ.x += dx; lcdZ.y += dy; lcdZoomApply(); }); + lcdZ.view.addEventListener('pointerup', function(e){ + const d = drag; drag = null; lcdZ.view.classList.remove('dragging'); + // A press that did not travel is a click — and on a linked node, a click opens its file. + const link = d && d.moved < 4 && d.target && d.target.closest ? d.target.closest('[data-lc-link]') : null; + if (link && lcdZ.card){ lcdAct(lcdZ.card.dataset.id, 'openLink', { node: link.getAttribute('data-lc-link') }); } + }); + lcdZ.view.addEventListener('pointercancel', function(){ drag = null; lcdZ.view.classList.remove('dragging'); }); + })(); + document.addEventListener('keydown', function(e){ + if (lcdZ.el.hidden){ return; } + const r = lcdZ.view.getBoundingClientRect(); + if (e.key === 'Escape'){ e.stopPropagation(); e.preventDefault(); lcdZoomClose(); } + else if (e.key === '+' || e.key === '='){ lcdZoomBy(1.25, r.width / 2, r.height / 2); } + else if (e.key === '-'){ lcdZoomBy(0.8, r.width / 2, r.height / 2); } + else if (e.key === '0'){ lcdZoomFit(); } + else if (e.key === '1'){ lcdZoomBy(1 / lcdZ.k, r.width / 2, r.height / 2); } + }, true); + // In the transcript: a linked node opens its file; anywhere else on the picture opens it full size. + // The webview only ever names the NODE — which file that means is the host's own record. + function lcdActivate(e){ + const t = e.target; if (!t || !t.closest){ return false; } + const card = t.closest('.lcd'); if (!card || !card.dataset.id || !log.contains(card)){ return false; } + const link = t.closest('[data-lc-link]'); + if (link){ lcdAct(card.dataset.id, 'openLink', { node: link.getAttribute('data-lc-link') }); return true; } + if (t.closest('.lcd-stage')){ lcdZoomOpen(card); return true; } + return false; + } + log.addEventListener('click', function(e){ if (lcdActivate(e)){ e.preventDefault(); } }); + log.addEventListener('keydown', function(e){ if ((e.key === 'Enter' || e.key === ' ') && e.target && e.target.closest && e.target.closest('[data-lc-link], .lcd-stage') && lcdActivate(e)){ e.preventDefault(); } }); + // The column changed width: re-lay out any diagram that would now turn (or stop needing to). + let lcdResizeT = null; + window.addEventListener('resize', function(){ + clearTimeout(lcdResizeT); + lcdResizeT = setTimeout(function(){ + if (!LCD){ return; } + const seen = new Set(); + Object.keys(lcdCards).forEach(function(k){ + const c = lcdCards[k]; + if (seen.has(c) || !c.isConnected || !c._spec || !c._geo){ return; } + seen.add(c); + try { const g = LCD.layout.layout(c._spec, { measure: lcdMeasure, maxWidth: lcdAvail(c) || undefined }); if (g.direction !== c._geo.direction){ lcdDraw(c, c._record, true); } } catch (e) {} + }); + }, 250); + }); + window.addEventListener('message', ev => { const m = ev.data; if (m.type === 'config'){ @@ -4191,6 +4712,9 @@ else if (m.type === 'agentDelta'){ clearStatus(); setWork('Responding…'); if (!agentBubble){ agentBubble = makeStream(add('assistant', '')); agentRaw = ''; } agentRaw += m.text; streamFeed(agentBubble, agentRaw); } else if (m.type === 'agentTurnEnd'){ finishAgentBubble(); } else if (m.type === 'agentTool'){ finishAgentBubble(); addAgentLine(m.icon, m.text, m.label, m.kind, m.path); } + else if (m.type === 'diagramPending'){ lcdPending(m); } + else if (m.type === 'diagram'){ lcdShow(m); } + else if (m.type === 'diagramAscii'){ lcdAscii(m); } else if (m.type === 'agentApproval'){ addApproval(m); } else if (m.type === 'agentQuestions'){ addQuestions(m); } else if (m.type === 'termRun'){ addTermRun(m); } @@ -4225,8 +4749,8 @@ // then, so a card still asking them to sign in would be wrong. if (!m.expired && sessionCard && sessionCard.isConnected){ sessionCard.remove(); sessionCard = null; } renderAccount(m); if (m.open) openAccount(); } else if (m.type === 'fileIndex'){ setFileIndex(m.files || []); } - else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (isSessionExpired(m)){ addSignInCard(m); } else if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '<span class="err">' + esc(m.message) + '</span>'); } } - else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); } + else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); lcdSweep(); const cap = capReachedInfo(m.message); if (isSessionExpired(m)){ addSignInCard(m); } else if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '<span class="err">' + esc(m.message) + '</span>'); } } + else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); lcdSweep(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); } else if (m.type === 'context'){ selLabel = m.label; renderChips(); } else if (m.type === 'clearContext'){ selLabel = null; renderChips(); } else if (m.type === 'reset'){ @@ -4236,6 +4760,7 @@ // otherwise groupAppend() appends into a disconnected .groupbody (nothing shows), and a stale // turnLabeled makes the first assistant line render as an unlabeled continuation. curGroup = null; turnLabeled = false; + lcdReset(); // the cards went with the log; drop the references to them selLabel = null; ctxFiles = []; renderChips(); for (const k in editCards) { delete editCards[k]; } for (const k in termCards) { delete termCards[k]; } for (const k in verifyCards) { delete verifyCards[k]; } for (const k in bgTasks) { delete bgTasks[k]; } for (const k in editSteps) { delete editSteps[k]; } for (const k in termSteps) { delete termSteps[k]; } for (const k in verifySteps) { delete verifySteps[k]; } // step maps ride with their cards diff --git a/extensions/levelcode-ai/package.json b/extensions/levelcode-ai/package.json index ac75eee..e25a89d 100644 --- a/extensions/levelcode-ai/package.json +++ b/extensions/levelcode-ai/package.json @@ -124,6 +124,11 @@ "title": "AI: Manage MCP Servers…", "category": "LevelCode" }, + { + "command": "levelcode.ai.diagramStats", + "title": "AI: Diagram Statistics", + "category": "LevelCode" + }, { "command": "levelcode.ai.openChatInEditor", "title": "AI: Open Chat in Editor", @@ -486,6 +491,11 @@ "default": false, "description": "Autopilot: let the agent run commands without asking, and prefer verifying its own work over pausing to ask you. It still stops and asks before anything hard to undo — deleting files, sudo, force-push / history rewrite, piping a remote script into a shell, publishing a package, or writing outside the project. File edits stay reviewable with Keep/Undo either way. Off by default." }, + "levelcode.ai.diagrams.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Let the agent draw diagrams in the chat. When structure is the point — a flow, an architecture, a decision tree, a state machine — the model sends a small description (nodes, edges, groups) and the editor lays it out, styles it to your theme and renders it; nodes can link to the code they describe. Turn this off and the agent is not offered the drawing tool at all: it answers in prose. Costs about 1,000 tokens of standing prompt per request while on." + }, "levelcode.ai.skills.enabled": { "type": "boolean", "default": true, diff --git a/extensions/levelcode-ai/providers/anthropic.js b/extensions/levelcode-ai/providers/anthropic.js index cc87d78..31c2103 100644 --- a/extensions/levelcode-ai/providers/anthropic.js +++ b/extensions/levelcode-ai/providers/anthropic.js @@ -122,6 +122,10 @@ async function claudeAgentTurn(opts) { */ function finalizeAgentBlocks(blocks) { const malformed = new Set(); + // The raw argument text of every malformed call, by id. A caller that knows a tool's input is + // worth a second look (a diagram spec with a trailing comma) can try a lenient parse; nothing + // here does, and the block itself still carries input:{}. + const raw = new Map(); const content = []; for (const blk of (blocks || [])) { if (!blk) { continue; } @@ -132,8 +136,8 @@ function finalizeAgentBlocks(blocks) { try { const parsed = JSON.parse(blk._json); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { input = parsed; } - else { input = {}; malformed.add(blk.id); } // valid JSON but not a tool-input object - } catch { input = {}; malformed.add(blk.id); } // truncated / corrupt partial JSON + else { input = {}; malformed.add(blk.id); raw.set(blk.id, blk._json); } // valid JSON but not a tool-input object + } catch { input = {}; malformed.add(blk.id); raw.set(blk.id, blk._json); } // truncated / corrupt partial JSON } else { input = {}; } // genuinely no args (not "cut off") } content.push({ type: 'tool_use', id: blk.id, name: blk.name, input: input }); @@ -146,7 +150,7 @@ function finalizeAgentBlocks(blocks) { content.push(out); } } - return { content, malformed }; + return { content, malformed, raw }; } /** @@ -154,7 +158,8 @@ function finalizeAgentBlocks(blocks) { * and returns the full content + stop_reason for the agent loop. * @param {{apiKey:string, model:string, maxTokens:number, system:string, * messages:any[], tools?:any[], signal?:AbortSignal, onText:(t:string)=>void, - * onToolStart?:(name:string)=>void}} opts + * onToolStart?:(name:string, id?:string)=>void, + * onToolInput?:(id:string, name:string, partialJson:string)=>void}} opts * @returns {Promise<{content:any[], stop_reason:string}>} */ /** @@ -230,7 +235,7 @@ async function streamClaudeAgentTurn(opts) { const b = ev.content_block || {}; if (b.type === 'tool_use') { blocks[ev.index] = { type: 'tool_use', id: b.id, name: b.name, _json: '' }; - if (opts.onToolStart) { opts.onToolStart(b.name); } + if (opts.onToolStart) { opts.onToolStart(b.name, b.id); } } else { blocks[ev.index] = { type: 'text', text: '' }; } @@ -238,7 +243,11 @@ async function streamClaudeAgentTurn(opts) { const blk = blocks[ev.index]; if (!blk) { return; } if (ev.delta.type === 'text_delta') { blk.text += ev.delta.text; opts.onText(ev.delta.text); } - else if (ev.delta.type === 'input_json_delta') { blk._json += ev.delta.partial_json || ''; } + else if (ev.delta.type === 'input_json_delta') { + blk._json += ev.delta.partial_json || ''; + // The arguments so far, for a caller that shows progress while a long input streams. + if (opts.onToolInput) { opts.onToolInput(blk.id, blk.name, blk._json); } + } } else if (ev.type === 'message_delta') { if (ev.delta && ev.delta.stop_reason) { stopReason = ev.delta.stop_reason; } if (ev.usage && ev.usage.output_tokens) { usage.output_tokens = ev.usage.output_tokens; } @@ -247,8 +256,8 @@ async function streamClaudeAgentTurn(opts) { } }); // Build API-clean content + the malformed-id set in one pass (the only place input is resolved). - const { content, malformed } = finalizeAgentBlocks(blocks); - return { content: content, stop_reason: stopReason, usage: usage, malformed: malformed }; + const { content, malformed, raw } = finalizeAgentBlocks(blocks); + return { content: content, stop_reason: stopReason, usage: usage, malformed: malformed, raw: raw }; } module.exports = { streamClaude, completeClaude, claudeAgentTurn, streamClaudeAgentTurn, finalizeAgentBlocks, withRollingCacheBreakpoint }; diff --git a/extensions/levelcode-ai/providers/catalog.js b/extensions/levelcode-ai/providers/catalog.js index dc31580..eda0c98 100644 --- a/extensions/levelcode-ai/providers/catalog.js +++ b/extensions/levelcode-ai/providers/catalog.js @@ -126,6 +126,21 @@ function supportsToolsForModel(providerId, modelId) { return modelCaps(modelId).tools !== false; } +/** + * How this provider+model draws diagrams (docs/RICH-DIAGRAMS.md, "Model differences"). + * + * 'tool' it is offered `render_diagram` — the default for every model the agent can run on. + * 'none' it is not: the model is treated as an ASCII client and answers in prose. + * + * A model opts out with `diagrams: false` in its CAPS row. That is the registry switch the spec asks + * for: a model that fails the diagram eval is turned off here, in one line, without touching code. + * (The spec's third state — fenced Mermaid only — arrives with the Mermaid phase.) + */ +function diagramSupportForModel(providerId, modelId) { + if (!supportsToolsForModel(providerId, modelId)) { return 'none'; } + return modelCaps(modelId).diagrams === false ? 'none' : 'tool'; +} + /** * Whether an image may be attached for this provider+model. * @@ -280,7 +295,7 @@ async function getModelChoices(providerId, opts) { module.exports = { CAPS, modelCaps, baseName, heuristicCaps, - supportsToolsForModel, supportsVisionForModel, contextWindowFor, fastCompletionModel, + supportsToolsForModel, diagramSupportForModel, supportsVisionForModel, contextWindowFor, fastCompletionModel, describeCaps, describeModel, mapOpenRouterModels, mapModelIds, fetchModels, getModelChoices }; diff --git a/extensions/levelcode-ai/providers/index.js b/extensions/levelcode-ai/providers/index.js index bd5df62..7f799df 100644 --- a/extensions/levelcode-ai/providers/index.js +++ b/extensions/levelcode-ai/providers/index.js @@ -199,7 +199,8 @@ function supportsTools(id) { * {content, stop_reason, usage, malformed} shape for both, so agent.js is provider-agnostic. * @param {{providerId:string, apiKey?:string, baseURL?:string, label?:string, model:string, maxTokens?:number, * system:string, messages:any[], tools?:any[], signal?:AbortSignal, - * onText?:(t:string)=>void, onToolStart?:(name:string)=>void, + * onText?:(t:string)=>void, onToolStart?:(name:string, id?:string)=>void, + * onToolInput?:(id:string, name:string, partialJson:string)=>void, * onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} o */ async function streamAgentTurn(o) { @@ -208,13 +209,14 @@ async function streamAgentTurn(o) { if (p.kind === 'anthropic') { return anthropic.streamClaudeAgentTurn({ apiKey: o.apiKey, model: o.model, maxTokens: o.maxTokens, system: o.system, - messages: o.messages, tools: o.tools, signal: o.signal, onText: o.onText, onToolStart: o.onToolStart + messages: o.messages, tools: o.tools, signal: o.signal, onText: o.onText, onToolStart: o.onToolStart, + onToolInput: o.onToolInput }); } return openai.streamOpenAIAgentTurn({ baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label, model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages, tools: o.tools, - signal: o.signal, onText: o.onText, onToolStart: o.onToolStart, onRetry: o.onRetry + signal: o.signal, onText: o.onText, onToolStart: o.onToolStart, onToolInput: o.onToolInput, onRetry: o.onRetry }); } diff --git a/extensions/levelcode-ai/providers/openaiCompat.js b/extensions/levelcode-ai/providers/openaiCompat.js index 54043b0..9c8f818 100644 --- a/extensions/levelcode-ai/providers/openaiCompat.js +++ b/extensions/levelcode-ai/providers/openaiCompat.js @@ -250,9 +250,10 @@ async function listOpenAIModels(opts) { * to {type:'text'} / {type:'tool_use', id, name, input} blocks. * @param {{baseURL:string, apiKey?:string, headers?:object, label?:string, model:string, * maxTokens?:number, system:string, messages:any[], tools?:any[], signal?:AbortSignal, - * onText?:(t:string)=>void, onToolStart?:(name:string)=>void, + * onText?:(t:string)=>void, onToolStart?:(name:string, id?:string)=>void, + * onToolInput?:(id:string, name:string, partialJson:string)=>void, * onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} opts - * @returns {Promise<{content:any[], stop_reason:string, usage:any, malformed:Set<string>}>} + * @returns {Promise<{content:any[], stop_reason:string, usage:any, malformed:Set<string>, raw:Map<string,string>}>} */ async function streamOpenAIAgentTurn(opts) { const label = opts.label || 'OpenAI-compatible'; @@ -301,14 +302,14 @@ async function streamOpenAIAgentTurn(opts) { if (!c) { return; } const d = c.delta || {}; if (typeof d.content === 'string' && d.content) { text += d.content; if (opts.onText) { opts.onText(d.content); } } - if (Array.isArray(d.tool_calls)) { translate.accumulateToolCalls(acc, d.tool_calls, opts.onToolStart); } + if (Array.isArray(d.tool_calls)) { translate.accumulateToolCalls(acc, d.tool_calls, opts.onToolStart, opts.onToolInput); } if (c.finish_reason) { finish = c.finish_reason; } }); - const { content, malformed } = translate.finalizeOpenAIBlocks(text, acc); + const { content, malformed, raw } = translate.finalizeOpenAIBlocks(text, acc); // If tool calls were assembled, the effective stop is tool_use even when a provider reports 'stop'. let stopReason = translate.fromOpenAIFinishReason(finish); if (stopReason !== 'max_tokens' && content.some((b) => b.type === 'tool_use')) { stopReason = 'tool_use'; } - return { content, stop_reason: stopReason, usage, malformed }; + return { content, stop_reason: stopReason, usage, malformed, raw }; } module.exports = { streamOpenAI, completeOpenAI, listOpenAIModels, streamOpenAIAgentTurn, buildChatBody, deltaFromEvent, isReasoningModel, isAnthropicFamily, splitOutCachedTokens, extractApiError, httpError, postChat }; diff --git a/extensions/levelcode-ai/providers/translate.js b/extensions/levelcode-ai/providers/translate.js index 89e414d..dd57dee 100644 --- a/extensions/levelcode-ai/providers/translate.js +++ b/extensions/levelcode-ai/providers/translate.js @@ -194,9 +194,10 @@ function fromOpenAIFinishReason(reason) { * Fold one streamed delta.tool_calls[] fragment array into an accumulator keyed by tool-call index. * OpenAI sends id + function.name only on the FIRST fragment per index, then incremental * function.arguments string fragments. `acc` is a sparse array indexed by tool-call index. - * onNew(name) fires the first time an index gains a name (drives onToolStart). Mutates + returns acc. + * onNew(name, id) fires the first time an index gains a name (drives onToolStart); onArgs(id, name, + * argsSoFar) fires whenever an index's arguments grow (drives onToolInput). Mutates + returns acc. */ -function accumulateToolCalls(acc, deltaToolCalls, onNew) { +function accumulateToolCalls(acc, deltaToolCalls, onNew, onArgs) { for (const tc of (deltaToolCalls || [])) { if (!tc) { continue; } const i = (tc.index != null) ? tc.index : acc.length; @@ -204,8 +205,11 @@ function accumulateToolCalls(acc, deltaToolCalls, onNew) { if (!slot) { slot = acc[i] = { id: tc.id || '', name: '', args: '' }; } if (tc.id && !slot.id) { slot.id = tc.id; } const fn = tc.function || {}; - if (fn.name && !slot.name) { slot.name = fn.name; if (onNew) { onNew(fn.name); } } - if (typeof fn.arguments === 'string') { slot.args += fn.arguments; } + if (fn.name && !slot.name) { slot.name = fn.name; if (onNew) { onNew(fn.name, slot.id); } } + if (typeof fn.arguments === 'string') { + slot.args += fn.arguments; + if (onArgs && fn.arguments && slot.name) { onArgs(slot.id, slot.name, slot.args); } + } } return acc; } @@ -216,10 +220,12 @@ function accumulateToolCalls(acc, deltaToolCalls, onNew) { * provider-agnostic. Truncated/invalid tool arguments → input:{} and the id is added to `malformed` * (the caller answers it with a "retry smaller" error instead of executing it). Empty args (a tool * with no inputs) → input:{} and is NOT malformed. - * @returns {{content:any[], malformed:Set<string>}} + * `raw` carries the argument text of each malformed call by id — see anthropic.finalizeAgentBlocks. + * @returns {{content:any[], malformed:Set<string>, raw:Map<string,string>}} */ function finalizeOpenAIBlocks(text, acc) { const malformed = new Set(); + const rawArgs = new Map(); const content = []; if (text) { content.push({ type: 'text', text: text }); } let n = 0; @@ -240,12 +246,12 @@ function finalizeOpenAIBlocks(text, acc) { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { input = parsed; } - else { input = {}; malformed.add(id); } // valid JSON but not an args object - } catch { input = {}; malformed.add(id); } // truncated / corrupt partial JSON + else { input = {}; malformed.add(id); rawArgs.set(id, raw); } // valid JSON but not an args object + } catch { input = {}; malformed.add(id); rawArgs.set(id, raw); } // truncated / corrupt partial JSON } content.push({ type: 'tool_use', id: id, name: slot.name || '', input: input }); } - return { content, malformed }; + return { content, malformed, raw: rawArgs }; } module.exports = { diff --git a/extensions/levelcode-ai/scripts/diagram-browser-check.js b/extensions/levelcode-ai/scripts/diagram-browser-check.js new file mode 100755 index 0000000..4fa3b8a --- /dev/null +++ b/extensions/levelcode-ai/scripts/diagram-browser-check.js @@ -0,0 +1,459 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * LevelCode — rich diagrams, checked in a real browser (docs/RICH-DIAGRAMS.md, acceptance criteria) + * + * The unit suites prove the pieces: the validator, the ladder, the layout's geometry, the painter's + * allow-lists. What they cannot prove is the thing the spec actually asks for — that in the CHAT, in + * a browser, under the page's real Content-Security-Policy, a diagram is drawn, looks right in both + * themes, and that a hostile spec executes nothing and fetches nothing. + * + * So this loads the shipped media/chat.html in headless Chrome exactly as the editor would build it + * (the same bundle injection, the same CSP, a stand-in for acquireVsCodeApi), plays host messages at + * it — records made by the real diagram service — and then asks the page what happened: + * + * • every diagram became an <svg> made only of the painter's elements; no script, link, image or + * foreignObject exists anywhere inside a diagram card, and no element carries an event handler + * • the hostile labels are on the page AS TEXT + * • zero CSP violations, zero resource requests, zero uncaught errors + * • the placeholder → picture swap, the auto-fixed badge, the degraded banner, the failed view + * • a clicked node asks the host to open a link by NODE ID only + * • SVG and PNG export produce data the host's own structural check accepts + * • with the diagram modules MISSING from the page, each diagram is shown as the host's text + * version of the same spec — the spec's fallback — and still nothing runs + * + * …and it writes a screenshot per theme, because "house style in light and dark" is finally a thing + * someone has to look at. + * + * RUN: node extensions/levelcode-ai/scripts/diagram-browser-check.js [--out <dir>] + * NEEDS: Google Chrome or Chromium (set CHROME=/path/to/binary if it is somewhere unusual). + * EXIT: 0 all checks pass · 1 a check failed · 2 no browser found (nothing was checked). + * + * Not part of scripts/test-extensions.sh: that gate is plain Node, and stays that way. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const cp = require('child_process'); + +const EXT = path.join(__dirname, '..'); +const bundle = require(path.join(EXT, 'diagram', 'bundle')); +const { createDiagrams } = require(path.join(EXT, 'diagram', 'service')); +const exportCheck = require(path.join(EXT, 'diagram', 'exportCheck')); +const repair = require(path.join(EXT, 'diagram', 'repair')); +const ascii = require(path.join(EXT, 'diagram', 'ascii')); + +function findChrome() { + const candidates = [process.env.CHROME, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser']; + return candidates.find((c) => c && fs.existsSync(c)) || null; +} + +// ---- the records, made by the real service ------------------------------------------------------ +const gallery = JSON.parse(fs.readFileSync(path.join(EXT, 'test', 'fixtures', 'diagrams', 'gallery.json'), 'utf8')); +const specOf = (name) => JSON.parse(JSON.stringify(gallery.find((g) => g.name === name).spec)); +const HOSTILE = { + title: '', + nodes: [ + { id: 'a', label: '', sub: '">' }, + { id: 'b', label: "'>", link: { path: 'javascript:window.__pwned=5' } }, + { id: 'c', label: '', sub: 'x', link: { path: 'agent.js' } }, + { id: 'd', label: '