diff --git a/pyproject.toml b/pyproject.toml
index 9ddde2c2..4c09a652 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -127,6 +127,7 @@ version = {attr = "iac_code.__version__"}
"**/*.png",
"**/*.svg",
"**/*.LICENSE",
+ "**/*.NOTICE",
"**/*.rego",
"**/*.mo",
"**/*.po",
diff --git a/scripts/README.md b/scripts/README.md
index 118ab052..04382bba 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -22,6 +22,7 @@ repository root with `uv run python ...` unless a script-specific README says ot
| `rendering/test_diagram_render.py` | Manual diagram rendering check. |
| `rendering/preview_template_architecture_llm.py` | Manual ROS template architecture preview with a real LLM semantic pass. |
| `repl/e2e/` | Real PTY-driven REPL pipeline end-to-end scenario runner. POSIX-only because it uses `pexpect`. |
+| `web/` | Web asset maintenance and visual evaluation tools. See `web/README.md`. |
## Common Commands
diff --git a/scripts/README.zh-CN.md b/scripts/README.zh-CN.md
index f74d1833..8959d8dd 100644
--- a/scripts/README.zh-CN.md
+++ b/scripts/README.zh-CN.md
@@ -22,6 +22,7 @@
| `provider-update-guide.zh-CN.md` | Provider 模型调研、代码更新、测试与提交的中文维护指南。 |
| `rendering/test_diagram_render.py` | 手工图表渲染检查。 |
| `repl/e2e/` | 基于真实 PTY 驱动的 REPL pipeline 端到端场景 runner。因为使用 `pexpect`,仅支持 POSIX 环境。 |
+| `web/` | Web 静态资源维护和视觉评估工具,详见 `web/README.zh-CN.md`。 |
## 常用命令
diff --git a/scripts/web/README.md b/scripts/web/README.md
new file mode 100644
index 00000000..2f3ef1f2
--- /dev/null
+++ b/scripts/web/README.md
@@ -0,0 +1,67 @@
+# Web Development Utilities
+
+The files in this directory support local Web development, asset maintenance, and manual visual evaluation. They are
+repository tools: normal Python, Web, and Desktop builds do not execute or package them.
+
+Runtime artifacts generated or checked by these tools live under `src/iac_code/web/static/`. Setuptools includes the
+committed JavaScript, license, and notice files in Python distributions, while the Desktop sidecar copies the same
+static directory. A release therefore consumes the committed artifacts and does not require Node.js, pnpm, an Eraser
+source checkout, or the ROS template corpus.
+
+## Eraser browser bundle
+
+`build_eraser_vendor.mjs` creates the browser-only Eraser bundle. `eraser/browser-entry.js` is its source entry point;
+it registers local fonts and icons and exposes the resolver/render adapter used by the sandboxed Web frame. Neither
+file is loaded by the product at runtime.
+
+The build is pinned to Eraser commit `6d377f296b94abf63481a07128884066e4930321`. The Eraser checkout requires Node
+22.12 or newer and pnpm 10.33. From the two repository roots:
+
+```bash
+cd /path/to/eraser-diagrams
+corepack pnpm install --frozen-lockfile
+corepack pnpm build
+
+cd /path/to/iac-code
+node scripts/web/build_eraser_vendor.mjs /path/to/eraser-diagrams
+```
+
+The command updates these committed release inputs:
+
+- `src/iac_code/web/static/js/vendor/eraser-diagrams.min.js`
+- `src/iac_code/web/static/js/vendor/eraser-diagrams.NOTICE`
+
+The Eraser MIT license is maintained separately at
+`src/iac_code/web/static/js/vendor/eraser-diagrams.LICENSE`. Review all three files when changing the pinned Eraser
+revision. The script rejects any source checkout whose HEAD differs from the pinned commit and fails when it cannot
+collect a bundled dependency's license text.
+
+This command is intentionally outside the normal release build. Rebuilding during every package build would add an
+external Git checkout and a Node toolchain to an otherwise self-contained release. Update and commit the vendor files
+explicitly instead.
+
+## Fixed-corpus layout evaluation
+
+`evaluate_eraser_corpus.py` checks the fixed 30-template ROS corpus through the public preview API and the same layout
+Worker used by the browser. It reports sibling overlap, failed containment, canvas overflow, edge/node intersections,
+and extreme aspect ratios. It is a deterministic geometry check and does not call an LLM.
+
+Start the local Web service, then run:
+
+```bash
+uv run iac-code web --host 127.0.0.1 --port 8766
+
+uv run python scripts/web/evaluate_eraser_corpus.py \
+ --templates-root /path/to/ros-templates \
+ --base-url http://127.0.0.1:8766 \
+ --output /tmp/iac-code-diagram-eval/run
+```
+
+The corpus manifest pins the expected `ros-templates` revision. Use `--allow-template-revision` only for an intentional
+comparison with another revision. The evaluator reads templates and POSTs their contents to `--base-url`; keep the
+default loopback URL unless sending those files to another service is intentional. The output path receives JSON and
+Markdown reports and is not packaged.
+
+The evaluation script remains outside `make test` because it needs a separate template checkout, Node.js, and a
+running Web service. Unit and integration coverage for the preview API, graph projection, layout Worker, static assets,
+and packaging stays under `tests/`.
diff --git a/scripts/web/README.zh-CN.md b/scripts/web/README.zh-CN.md
new file mode 100644
index 00000000..d56396ef
--- /dev/null
+++ b/scripts/web/README.zh-CN.md
@@ -0,0 +1,59 @@
+# Web 开发工具
+
+本目录用于 Web 本地开发、静态资源维护和手工视觉评估。正常的 Python、Web 和 Desktop 构建既不会执行,
+也不会打包这里的脚本。
+
+这些工具生成或检查的运行时文件位于 `src/iac_code/web/static/`。Python 发布包通过 setuptools 收录已提交的
+JavaScript、许可证和 NOTICE,Desktop sidecar 则复制同一静态资源目录。因此正常发布只使用仓库里已经提交的
+产物,不需要安装 Node.js、pnpm,也不需要准备 Eraser 源码仓库或 ROS 模板语料库。
+
+## Eraser 浏览器 Bundle
+
+`build_eraser_vendor.mjs` 生成仅供浏览器使用的 Eraser bundle。`eraser/browser-entry.js` 是构建入口,负责注册
+本地字体和图标,并暴露 sandbox Web frame 使用的解析、渲染适配器。这两个文件都不会被产品运行时直接加载。
+
+构建固定使用 Eraser 提交 `6d377f296b94abf63481a07128884066e4930321`。Eraser 仓库要求 Node 22.12 或更高
+版本及 pnpm 10.33。在两个仓库根目录依次执行:
+
+```bash
+cd /path/to/eraser-diagrams
+corepack pnpm install --frozen-lockfile
+corepack pnpm build
+
+cd /path/to/iac-code
+node scripts/web/build_eraser_vendor.mjs /path/to/eraser-diagrams
+```
+
+该命令更新以下需要提交的发布输入:
+
+- `src/iac_code/web/static/js/vendor/eraser-diagrams.min.js`
+- `src/iac_code/web/static/js/vendor/eraser-diagrams.NOTICE`
+
+Eraser 的 MIT 许可证单独维护在 `src/iac_code/web/static/js/vendor/eraser-diagrams.LICENSE`。升级固定版本时应同时
+检查这三个文件。源码仓库 HEAD 与固定提交不一致,或者无法收集某个打包依赖的许可证文本时,脚本会直接失败。
+
+这个命令有意不接入正常发布构建。否则每次打包都会额外依赖外部 Git checkout 和 Node 工具链。升级 Eraser 时应
+显式重新生成并提交 vendor 文件。
+
+## 固定语料布局评估
+
+`evaluate_eraser_corpus.py` 通过公开预览 API 和浏览器使用的同一个布局 Worker 检查固定的 30 个 ROS 模板,输出
+同级节点重叠、容器包含失败、画布越界、连线穿过节点和极端长宽比等指标。它是确定性几何检查,不调用 LLM。
+
+先启动本地 Web 服务,再运行:
+
+```bash
+uv run iac-code web --host 127.0.0.1 --port 8766
+
+uv run python scripts/web/evaluate_eraser_corpus.py \
+ --templates-root /path/to/ros-templates \
+ --base-url http://127.0.0.1:8766 \
+ --output /tmp/iac-code-diagram-eval/run
+```
+
+语料清单固定了预期的 `ros-templates` 提交。只有明确需要比较其他版本时才使用
+`--allow-template-revision`。评估器会读取模板并把内容 POST 到 `--base-url`;除非明确需要发送给其他服务,否则应
+保留默认的本机回环地址。JSON 和 Markdown 报告写入指定输出目录,不进入发布包。
+
+评估脚本没有接入 `make test`,因为它依赖单独的模板仓库、Node.js 和正在运行的 Web 服务。预览 API、图投影、
+布局 Worker、静态资源与打包规则的单元及集成测试仍位于 `tests/`。
diff --git a/scripts/web/build_eraser_vendor.mjs b/scripts/web/build_eraser_vendor.mjs
new file mode 100644
index 00000000..977d94f1
--- /dev/null
+++ b/scripts/web/build_eraser_vendor.mjs
@@ -0,0 +1,146 @@
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ERASER_COMMIT = "6d377f296b94abf63481a07128884066e4930321";
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const repositoryRoot = resolve(scriptDir, "../..");
+const sourceRoot = resolve(process.argv[2] || "");
+if (!process.argv[2]) {
+ throw new Error("Usage: node scripts/web/build_eraser_vendor.mjs /path/to/eraser-diagrams");
+}
+const actualCommit = execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: sourceRoot,
+ encoding: "utf8",
+}).trim();
+if (actualCommit !== ERASER_COMMIT) {
+ throw new Error(`Expected eraser-diagrams ${ERASER_COMMIT}, found ${actualCommit}`);
+}
+const require = createRequire(join(sourceRoot, "package.json"));
+const { build } = require("esbuild");
+const eraserModules = new Map([
+ ["@eraserlabs/diagrams/library", "packages/diagrams/dist/library/index.js"],
+ ["@eraserlabs/diagrams/normalizers", "packages/diagrams/dist/library/normalizers.js"],
+ ["@eraserlabs/layout", "packages/layout/dist/index.js"],
+ ["@eraserlabs/protocol", "packages/protocol/dist/index.js"],
+ ["@eraserlabs/protocol/schema", "packages/protocol/dist/schema.js"],
+ ["@eraserlabs/protocol/schemas/tag-schema", "packages/protocol/schemas/tag-schema.schema.json"],
+ ["@eraserlabs/render", "packages/render/dist/index.js"],
+ ["@eraserlabs/render/browser", "packages/render/dist/browser/index.js"],
+ ["@eraserlabs/resolve", "packages/resolve/dist/index.js"],
+ ["@eraserlabs/resolve/schema", "packages/resolve/dist/schema/index.js"],
+ ["@eraserlabs/utils", "packages/utils/dist/index.js"],
+]);
+const result = await build({
+ absWorkingDir: "/",
+ entryPoints: [join(scriptDir, "eraser", "browser-entry.js")],
+ bundle: true,
+ platform: "browser",
+ format: "iife",
+ minify: true,
+ define: { "process.env": "{}" },
+ nodePaths: [join(sourceRoot, "node_modules")],
+ plugins: [
+ {
+ name: "eraser-workspace-modules",
+ setup(builder) {
+ builder.onResolve({ filter: /^@eraserlabs\// }, (args) => {
+ const relative = eraserModules.get(args.path);
+ return relative ? { path: join(sourceRoot, relative) } : null;
+ });
+ },
+ },
+ ],
+ outfile: join(repositoryRoot, "src/iac_code/web/static/js/vendor/eraser-diagrams.min.js"),
+ legalComments: "none",
+ metafile: true,
+});
+const mitFallback = (owner) => `MIT License
+
+Copyright (c) ${owner}
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.`;
+const bundledPackages = [...new Set(
+ Object.keys(result.metafile.inputs)
+ .map((input) => input.match(/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)/)?.[1])
+ .filter(Boolean),
+)].sort();
+const thirdPartyLicenses = new Map();
+function addPackageLicense(directory) {
+ const packageMetadata = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
+ const name = String(packageMetadata.name || "");
+ if (!name || name.startsWith("@eraserlabs/")) return;
+ const key = `${name}@${packageMetadata.version || "unknown"}`;
+ if (thirdPartyLicenses.has(key)) return;
+ const licenseName = readdirSync(directory)
+ .filter((entry) => /^(licen[cs]e|copying)(\.|$)/i.test(entry))
+ .sort()[0];
+ if (licenseName) {
+ thirdPartyLicenses.set(key, readFileSync(join(directory, licenseName), "utf8").trim());
+ } else if (packageMetadata.license === "MIT") {
+ const author = typeof packageMetadata.author === "string"
+ ? packageMetadata.author
+ : packageMetadata.author?.name || name;
+ thirdPartyLicenses.set(key, mitFallback(author));
+ } else {
+ throw new Error(`Missing license file for bundled dependency ${key}`);
+ }
+}
+for (const input of Object.keys(result.metafile.inputs)) {
+ if (input.startsWith("<") || input.startsWith("(")) continue;
+ let directory = dirname(realpathSync(resolve("/", input)));
+ while (directory.startsWith(sourceRoot)) {
+ const packagePath = join(directory, "package.json");
+ if (existsSync(packagePath)) {
+ addPackageLicense(directory);
+ break;
+ }
+ const parent = dirname(directory);
+ if (parent === directory) break;
+ directory = parent;
+ }
+}
+const pnpmStore = join(sourceRoot, "node_modules", ".pnpm");
+for (const packageName of bundledPackages) {
+ const hasLicense = [...thirdPartyLicenses.keys()].some((key) => key.startsWith(`${packageName}@`));
+ if (hasLicense) continue;
+ const packageDirectory = readdirSync(pnpmStore)
+ .map((entry) => join(pnpmStore, entry, "node_modules", packageName))
+ .find((candidate) => existsSync(join(candidate, "package.json")));
+ if (!packageDirectory) throw new Error(`Cannot locate bundled dependency ${packageName}`);
+ addPackageLicense(packageDirectory);
+}
+const notice = [
+ "Eraser diagrams browser bundle third-party notices",
+ "===================================================",
+ "",
+ `Generated from eraserlabs/eraser-diagrams commit ${ERASER_COMMIT}.`,
+ "The Eraser Diagrams license is in eraser-diagrams.LICENSE.",
+ "",
+ ...[...thirdPartyLicenses.entries()].sort(([left], [right]) => left.localeCompare(right)).flatMap(([name, license]) => [
+ name,
+ "-".repeat(name.length),
+ license,
+ "",
+ ]),
+].join("\n");
+writeFileSync(join(repositoryRoot, "src/iac_code/web/static/js/vendor/eraser-diagrams.NOTICE"), notice, "utf8");
+console.log(`Built Eraser browser bundle from ${ERASER_COMMIT}: ${bundledPackages.join(", ")}`);
diff --git a/scripts/web/eraser/browser-entry.js b/scripts/web/eraser/browser-entry.js
new file mode 100644
index 00000000..e410c43f
--- /dev/null
+++ b/scripts/web/eraser/browser-entry.js
@@ -0,0 +1,31 @@
+import { stockLibrary, buildRenderPageSetup } from "@eraserlabs/diagrams/library";
+import { stockNormalizers } from "@eraserlabs/diagrams/normalizers";
+import "@eraserlabs/render/browser";
+import { createResolver, prepareLibrary } from "@eraserlabs/resolve";
+
+window.createIacEraserRenderer = async function createIacEraserRenderer(iconMap) {
+ const resolver = await createResolver({
+ library: stockLibrary,
+ normalizers: stockNormalizers,
+ iconLoader: async (name) => {
+ const icon = iconMap[name];
+ if (!icon) throw new Error(`Unknown local icon: ${name}`);
+ return icon;
+ },
+ });
+ window.__eraser.setup(buildRenderPageSetup(prepareLibrary(stockLibrary)));
+ await window.__eraser.registerFonts({
+ css:
+ ':root{--font-clean:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB",sans-serif;--font-rough:var(--font-clean);--font-mono:ui-monospace,SFMono-Regular,Consolas,monospace}',
+ faces: [],
+ });
+ return async (input) => {
+ const resolved = await resolver.resolve(input);
+ if (!resolved.ok) {
+ const reason = resolved.errors?.map((item) => item.message || String(item)).join("; ") || "resolve failed";
+ throw new Error(reason);
+ }
+ const layout = await window.__eraser.run(resolved);
+ return { layout, warnings: resolved.warnings || [] };
+ };
+};
diff --git a/scripts/web/evaluate_eraser_corpus.py b/scripts/web/evaluate_eraser_corpus.py
new file mode 100644
index 00000000..3d479877
--- /dev/null
+++ b/scripts/web/evaluate_eraser_corpus.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""Evaluate the fixed ROS visual corpus through the public preview API and layout worker."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import subprocess
+import tempfile
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_MANIFEST = REPOSITORY_ROOT / "src/iac_code/web/static/diagram-preview-corpus.json"
+DEFAULT_WORKER = REPOSITORY_ROOT / "src/iac_code/web/static/js/eraser_layout_worker.js"
+
+
+def _arguments() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--templates-root", type=Path, required=True)
+ parser.add_argument("--base-url", default="http://127.0.0.1:8766")
+ parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
+ parser.add_argument("--worker", type=Path, default=DEFAULT_WORKER)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument(
+ "--allow-template-revision",
+ action="store_true",
+ help="run against a template repository revision other than the pinned corpus revision",
+ )
+ return parser.parse_args()
+
+
+def _git_revision(root: Path) -> str:
+ completed = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=root,
+ capture_output=True,
+ check=True,
+ encoding="utf-8",
+ text=True,
+ timeout=10,
+ )
+ return completed.stdout.strip()
+
+
+def _request_json(url: str, *, payload: dict[str, Any] | None = None) -> dict[str, Any]:
+ body = None if payload is None else json.dumps(payload).encode("utf-8")
+ request = urllib.request.Request(
+ url,
+ data=body,
+ method="GET" if body is None else "POST",
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return json.load(response)
+ except urllib.error.HTTPError as error:
+ detail = error.read().decode("utf-8", errors="replace")
+ raise RuntimeError("{} returned {}: {}".format(url, error.code, detail)) from error
+
+
+def _assert_preview_page(base_url: str) -> None:
+ with urllib.request.urlopen(base_url.rstrip("/") + "/diagram-preview", timeout=15) as response:
+ html = response.read().decode("utf-8")
+ if 'data-diagram-preview="dropzone"' not in html:
+ raise RuntimeError("/diagram-preview did not return the template preview page")
+
+
+def _run_worker(worker: Path, graphs: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ node = shutil.which("node")
+ if node is None:
+ raise RuntimeError("node is required to execute the Eraser layout worker")
+ harness_source = """
+const fs = require("node:fs");
+const vm = require("node:vm");
+const outputs = [];
+const context = { self: { postMessage: (value) => outputs.push(value) } };
+vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), context);
+for (const [index, graph] of JSON.parse(fs.readFileSync(process.argv[3], "utf8")).entries()) {
+ context.self.onmessage({ data: { requestId: String(index), graph } });
+}
+process.stdout.write(JSON.stringify(outputs));
+""".strip()
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ harness = directory / "worker-harness.cjs"
+ graph_file = directory / "graphs.json"
+ harness.write_text(harness_source, encoding="utf-8")
+ graph_file.write_text(json.dumps(graphs, ensure_ascii=False), encoding="utf-8")
+ completed = subprocess.run(
+ [node, str(harness), str(worker), str(graph_file)],
+ capture_output=True,
+ check=False,
+ encoding="utf-8",
+ text=True,
+ timeout=60,
+ )
+ if completed.returncode != 0:
+ raise RuntimeError("layout worker failed: {}".format(completed.stderr.strip()))
+ outputs = json.loads(completed.stdout)
+ failures = [item.get("error", "unknown worker error") for item in outputs if not item.get("ok")]
+ if failures:
+ raise RuntimeError("layout worker rejected corpus: {}".format("; ".join(failures)))
+ return [item["layout"] for item in outputs]
+
+
+def _overlaps(left: dict[str, Any], right: dict[str, Any], padding: float = 0) -> bool:
+ return (
+ left["x"] + padding < right["x"] + right["width"] - padding
+ and right["x"] + padding < left["x"] + left["width"] - padding
+ and left["y"] + padding < right["y"] + right["height"] - padding
+ and right["y"] + padding < left["y"] + left["height"] - padding
+ )
+
+
+def _segment_crosses_rect(start: list[float], end: list[float], rectangle: dict[str, Any], margin: float = 3) -> bool:
+ left = rectangle["x"] + margin
+ right = rectangle["x"] + rectangle["width"] - margin
+ top = rectangle["y"] + margin
+ bottom = rectangle["y"] + rectangle["height"] - margin
+ if start[0] == end[0]:
+ return left < start[0] < right and max(start[1], end[1]) > top and min(start[1], end[1]) < bottom
+ if start[1] == end[1]:
+ return top < start[1] < bottom and max(start[0], end[0]) > left and min(start[0], end[0]) < right
+ return False
+
+
+def _layout_metrics(graph: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
+ items = {item["id"]: item for item in layout["items"]}
+ values = list(items.values())
+ sibling_overlaps: list[list[str]] = []
+ for index, left in enumerate(values):
+ for right in values[index + 1 :]:
+ if left.get("parentId") == right.get("parentId") and _overlaps(left, right):
+ sibling_overlaps.append([left["id"], right["id"]])
+ containment_failures = []
+ for item in values:
+ parent = items.get(item.get("parentId"))
+ if parent and not (
+ item["x"] >= parent["x"]
+ and item["y"] >= parent["y"]
+ and item["x"] + item["width"] <= parent["x"] + parent["width"]
+ and item["y"] + item["height"] <= parent["y"] + parent["height"]
+ ):
+ containment_failures.append(item["id"])
+ outside = [
+ item["id"]
+ for item in values
+ if item["x"] < 0
+ or item["y"] < 0
+ or item["x"] + item["width"] > layout["width"]
+ or item["y"] + item["height"] > layout["height"]
+ ]
+ edge_node_intersections = []
+ for edge in layout["edges"]:
+ for item in values:
+ if item["kind"] != "node" or item["id"] in {edge["from"], edge["to"]}:
+ continue
+ if any(_segment_crosses_rect(start, end, item) for start, end in zip(edge["points"], edge["points"][1:])):
+ edge_node_intersections.append([edge["id"], item["id"]])
+ return {
+ "layoutVersion": layout["layoutVersion"],
+ "width": layout["width"],
+ "height": layout["height"],
+ "aspectRatio": round(layout["width"] / layout["height"], 3),
+ "nodeCount": len(graph["nodes"]),
+ "containerCount": len(graph["containers"]),
+ "edgeCount": len(graph["edges"]),
+ "siblingOverlaps": sibling_overlaps,
+ "containmentFailures": containment_failures,
+ "outsideCanvas": outside,
+ "edgeNodeIntersections": edge_node_intersections,
+ }
+
+
+def _markdown(report: dict[str, Any]) -> str:
+ summary = report["summary"]
+ lines = [
+ "# Eraser visual corpus evaluation",
+ "",
+ "- Template revision: `{}`".format(report["templateRepositoryCommit"]),
+ "- Layout version: `{}`".format(summary["layoutVersion"]),
+ "- Templates: {}/{} passed".format(summary["passedTemplates"], summary["templateCount"]),
+ "- Views: {}/{} passed".format(summary["passedViews"], summary["viewCount"]),
+ "- Sibling overlaps: {}".format(summary["siblingOverlaps"]),
+ "- Containment failures: {}".format(summary["containmentFailures"]),
+ "- Outside canvas: {}".format(summary["outsideCanvas"]),
+ "- Edge/node intersections: {}".format(summary["edgeNodeIntersections"]),
+ "- Extreme aspect ratios: {}".format(summary["extremeAspectRatios"]),
+ "",
+ "| # | Template | Views | Layout size(s) | Result |",
+ "| ---: | --- | ---: | --- | --- |",
+ ]
+ for index, item in enumerate(report["templates"], 1):
+ sizes = ", ".join("{}×{}".format(view["width"], view["height"]) for view in item["views"])
+ lines.append(
+ "| {} | `{}` | {} | {} | {} |".format(index, item["path"], len(item["views"]), sizes, item["result"])
+ )
+ return "\n".join(lines) + "\n"
+
+
+def main() -> int:
+ arguments = _arguments()
+ manifest = json.loads(arguments.manifest.read_text(encoding="utf-8"))
+ expected_revision = manifest["templateRepositoryCommit"]
+ actual_revision = _git_revision(arguments.templates_root)
+ if actual_revision != expected_revision and not arguments.allow_template_revision:
+ raise RuntimeError(
+ "template repository is at {}; expected {} (use --allow-template-revision to override)".format(
+ actual_revision, expected_revision
+ )
+ )
+ _assert_preview_page(arguments.base_url)
+ templates = []
+ graphs = []
+ graph_owners = []
+ endpoint = arguments.base_url.rstrip("/") + "/api/diagram-preview"
+ for relative_path in manifest["templates"]:
+ path = arguments.templates_root / relative_path
+ payload = _request_json(endpoint, payload={"filename": path.name, "content": path.read_text(encoding="utf-8")})
+ views = payload.get("views") or []
+ if not views:
+ raise RuntimeError("{} returned no architecture views".format(relative_path))
+ template = {"path": relative_path, "filename": payload.get("filename"), "views": [], "result": "pending"}
+ template_index = len(templates)
+ templates.append(template)
+ for view_index, view in enumerate(views):
+ graph = view.get("graph")
+ if not isinstance(graph, dict):
+ raise RuntimeError("{} view {} returned no graph".format(relative_path, view_index))
+ graphs.append(graph)
+ graph_owners.append((template_index, view_index, graph))
+ layouts = _run_worker(arguments.worker, graphs)
+ for layout, (template_index, _view_index, graph) in zip(layouts, graph_owners):
+ templates[template_index]["views"].append(_layout_metrics(graph, layout))
+ for template in templates:
+ failed = any(
+ view[metric]
+ for view in template["views"]
+ for metric in ("siblingOverlaps", "containmentFailures", "outsideCanvas", "edgeNodeIntersections")
+ )
+ template["result"] = "fail" if failed else "pass"
+ all_views = [view for template in templates for view in template["views"]]
+ summary = {
+ "templateCount": len(templates),
+ "passedTemplates": sum(template["result"] == "pass" for template in templates),
+ "viewCount": len(all_views),
+ "passedViews": sum(
+ not any(
+ view[metric]
+ for metric in ("siblingOverlaps", "containmentFailures", "outsideCanvas", "edgeNodeIntersections")
+ )
+ for view in all_views
+ ),
+ "layoutVersion": all_views[0]["layoutVersion"] if all_views else "",
+ "siblingOverlaps": sum(len(view["siblingOverlaps"]) for view in all_views),
+ "containmentFailures": sum(len(view["containmentFailures"]) for view in all_views),
+ "outsideCanvas": sum(len(view["outsideCanvas"]) for view in all_views),
+ "edgeNodeIntersections": sum(len(view["edgeNodeIntersections"]) for view in all_views),
+ "extremeAspectRatios": sum(view["aspectRatio"] < 0.55 or view["aspectRatio"] > 3.8 for view in all_views),
+ }
+ report = {
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
+ "baseUrl": arguments.base_url,
+ "templateRepository": str(arguments.templates_root.resolve()),
+ "templateRepositoryCommit": actual_revision,
+ "manifest": str(arguments.manifest.resolve()),
+ "worker": str(arguments.worker.resolve()),
+ "summary": summary,
+ "templates": templates,
+ }
+ arguments.output.mkdir(parents=True, exist_ok=True)
+ (arguments.output / "evaluation.json").write_text(
+ json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ (arguments.output / "evaluation.md").write_text(_markdown(report), encoding="utf-8")
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
+ return 0 if summary["passedTemplates"] == summary["templateCount"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/setup.py b/setup.py
index fd0435bb..bbe80473 100644
--- a/setup.py
+++ b/setup.py
@@ -88,6 +88,7 @@
"**/*.js",
"**/*.svg",
"**/*.LICENSE",
+ "**/*.NOTICE",
"**/*.mo",
"**/*.po",
],
diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po
index 5f1745cc..4fb366c1 100644
--- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po
+++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po
@@ -148,7 +148,7 @@ msgstr ""
"Pipeline-Handoff."
#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py
-#: src/iac_code/a2a/transports/dispatcher.py
+#: src/iac_code/a2a/task_store.py src/iac_code/a2a/transports/dispatcher.py
msgid "Task canceled."
msgstr "Aufgabe abgebrochen."
@@ -158,6 +158,10 @@ msgstr ""
"Die Berechtigungsentscheidung des normalen Chats ist vor der Sicherung "
"nicht verfügbar."
+#: src/iac_code/a2a/executor.py src/iac_code/a2a/transports/dispatcher.py
+msgid "Task canceled while waiting for input."
+msgstr "Aufgabe beim Warten auf Eingabe abgebrochen."
+
#: src/iac_code/a2a/executor.py src/iac_code/agui/errors.py
msgid "Session backup is still synchronizing. Retry after 3 seconds."
msgstr ""
@@ -522,10 +526,6 @@ msgstr ""
msgid "Task {task_id} is already completed."
msgstr "Aufgabe {task_id} ist bereits abgeschlossen."
-#: src/iac_code/a2a/transports/dispatcher.py
-msgid "Task canceled while waiting for input."
-msgstr "Aufgabe beim Warten auf Eingabe abgebrochen."
-
#: src/iac_code/a2a/transports/dispatcher.py
msgid "Task completed."
msgstr "Aufgabe abgeschlossen."
@@ -13567,6 +13567,42 @@ msgstr "Es läuft bereits ein Update."
msgid "The update command exited with code {}."
msgstr "Der Update-Befehl wurde mit Code {} beendet."
+#: src/iac_code/web/app.py
+msgid "The template must be 2 MB or smaller."
+msgstr "Die Vorlage darf höchstens 2 MB groß sein."
+
+#: src/iac_code/web/app.py
+msgid "Choose a ROS template in YAML, YML, or JSON format."
+msgstr "Wählen Sie eine ROS-Vorlage im YAML-, YML- oder JSON-Format aus."
+
+#: src/iac_code/web/app.py
+msgid "The file does not look like a ROS template."
+msgstr "Die Datei scheint keine ROS-Vorlage zu sein."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be parsed."
+msgstr "Die ROS-Vorlage konnte nicht analysiert werden."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template must contain a mapping at its root."
+msgstr "Die ROS-Vorlage muss auf der obersten Ebene eine Zuordnung enthalten."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template does not contain any resources."
+msgstr "Die ROS-Vorlage enthält keine Ressourcen."
+
+#: src/iac_code/web/app.py
+msgid ""
+"The ROS template does not contain resources supported by the architecture"
+" renderer."
+msgstr ""
+"Die ROS-Vorlage enthält keine vom Architektur-Renderer unterstützten "
+"Ressourcen."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be rendered."
+msgstr "Die ROS-Vorlage konnte nicht gerendert werden."
+
#: src/iac_code/web/app.py
msgid "A project directory is required."
msgstr "Ein Projektverzeichnis ist erforderlich."
@@ -13999,6 +14035,10 @@ msgstr "Sitzung · {}"
msgid "unknown theme"
msgstr "unbekanntes Design"
+#: src/iac_code/web/settings.py
+msgid "unknown architecture diagram renderer"
+msgstr "Unbekannter Renderer für Architekturdiagramme"
+
#: src/iac_code/web/settings.py
msgid "unknown language"
msgstr "unbekannte Sprache"
diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po
index 99648cf7..0d1bbc4a 100644
--- a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po
+++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po
@@ -18,6 +18,70 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Architecture diagram preview"
+msgstr "Vorschau des Architekturdiagramms"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template to render its architecture diagram locally."
+msgstr ""
+"Wählen Sie eine ROS-Vorlage aus oder legen Sie sie ab, um das "
+"Architekturdiagramm lokal zu rendern."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Back to conversations"
+msgstr "Zurück zu den Unterhaltungen"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Template library"
+msgstr "Vorlagenbibliothek"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid ""
+"Select the local ros-templates folder once, then switch between the fixed"
+" corpus or random templates."
+msgstr ""
+"Wählen Sie den lokalen Ordner ros-templates einmal aus und wechseln Sie "
+"dann zwischen dem festen Korpus und zufälligen Vorlagen."
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select ros-templates folder"
+msgstr "Ordner ros-templates auswählen"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Fixed 30-template corpus"
+msgstr "Fester Korpus mit 30 Vorlagen"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select a template"
+msgstr "Vorlage auswählen"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Random other template"
+msgstr "Andere Vorlage zufällig wählen"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Single template"
+msgstr "Einzelne Vorlage"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Supports ROS YAML, YML, and JSON templates up to 2 MB."
+msgstr "Unterstützt ROS-Vorlagen in YAML, YML und JSON bis 2 MB."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template"
+msgstr "ROS-Vorlage auswählen oder ablegen"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Drop a template here, or click to choose"
+msgstr "Vorlage hier ablegen oder zum Auswählen klicken"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "The file is read for this preview and is not saved."
+msgstr "Die Datei wird nur für diese Vorschau gelesen und nicht gespeichert."
+
#: src/iac_code/web/static/index.html
#: src/iac_code/web/static/js/components/workspace.js
msgid "Session"
@@ -977,6 +1041,81 @@ msgstr ""
msgid "Failed to start update: {error}"
msgstr "Update konnte nicht gestartet werden: {error}"
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/components/output_panel.js
+#: src/iac_code/web/static/js/components/pipeline.js
+msgid "Architecture diagram"
+msgstr "Architekturdiagramm"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This historical diagram is shown with Mermaid."
+msgstr "Dieses historische Diagramm wird mit Mermaid angezeigt."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This diagram is temporarily shown with Mermaid."
+msgstr "Dieses Diagramm wird vorübergehend mit Mermaid angezeigt."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/mermaid_render.js
+#, python-brace-format
+msgid "View {n}"
+msgstr "Ansicht {n}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Renderer: {renderer}"
+msgstr "Renderer: {renderer}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The fixed template corpus could not be loaded."
+msgstr "Der feste Vorlagenkorpus konnte nicht geladen werden."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No supported ROS templates were found in that folder."
+msgstr "In diesem Ordner wurden keine unterstützten ROS-Vorlagen gefunden."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} template loaded; {available} of 30 corpus templates available."
+msgstr "Geladene Vorlagen: {count}; im Korpus verfügbar: {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} templates loaded; {available} of 30 corpus templates available."
+msgstr "Geladene Vorlagen: {count}; im Korpus verfügbar: {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template could not be rendered."
+msgstr "Die Vorlage konnte nicht gerendert werden."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template must be 2 MB or smaller."
+msgstr "Die Vorlage darf höchstens 2 MB groß sein."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendering {filename}…"
+msgstr "{filename} wird gerendert…"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture view"
+msgstr "{count} Architekturansicht"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture views"
+msgstr "{count} Architekturansichten"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendered {filename}"
+msgstr "{filename} gerendert"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No templates remain outside the fixed corpus."
+msgstr "Außerhalb des festen Korpus sind keine Vorlagen verfügbar."
+
#: src/iac_code/web/static/js/events.js
msgid "Local shell"
msgstr "Lokale Shell"
@@ -986,11 +1125,6 @@ msgstr "Lokale Shell"
msgid "Unknown error"
msgstr "Unbekannter Fehler"
-#: src/iac_code/web/static/js/mermaid_render.js
-#, python-brace-format
-msgid "View {n}"
-msgstr "Ansicht {n}"
-
#: src/iac_code/web/static/js/mermaid_render.js
msgid "No pricing information"
msgstr "Keine Preisinformationen"
@@ -1386,11 +1520,6 @@ msgstr "Erfolg"
msgid "In progress / failed"
msgstr "In Bearbeitung / fehlgeschlagen"
-#: src/iac_code/web/static/js/components/output_panel.js
-#: src/iac_code/web/static/js/components/pipeline.js
-msgid "Architecture diagram"
-msgstr "Architekturdiagramm"
-
#: src/iac_code/web/static/js/components/output_panel.js
msgid "File no longer exists"
msgstr "Datei existiert nicht mehr"
@@ -3981,6 +4110,22 @@ msgstr ""
"Wählen Sie die Oberflächensprache; die Seite wird nach dem Speichern neu "
"geladen."
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Architecture diagram renderer"
+msgstr "Renderer für Architekturdiagramme"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid ""
+"Choose how architecture diagrams are displayed; the change takes effect "
+"after saving."
+msgstr ""
+"Wählen Sie, wie Architekturdiagramme angezeigt werden; die Änderung wird "
+"nach dem Speichern wirksam."
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Open template preview"
+msgstr "Vorlagenvorschau öffnen"
+
#: src/iac_code/web/static/js/components/workspace.js
msgid "Downloading…"
msgstr "Wird heruntergeladen…"
diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po
index 571e4f2b..b81fff7b 100644
--- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po
+++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po
@@ -149,7 +149,7 @@ msgstr ""
"completada."
#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py
-#: src/iac_code/a2a/transports/dispatcher.py
+#: src/iac_code/a2a/task_store.py src/iac_code/a2a/transports/dispatcher.py
msgid "Task canceled."
msgstr "Tarea cancelada."
@@ -159,6 +159,10 @@ msgstr ""
"La decisión de permiso del chat normal no está disponible antes de la "
"copia de seguridad."
+#: src/iac_code/a2a/executor.py src/iac_code/a2a/transports/dispatcher.py
+msgid "Task canceled while waiting for input."
+msgstr "Tarea cancelada mientras esperaba entrada."
+
#: src/iac_code/a2a/executor.py src/iac_code/agui/errors.py
msgid "Session backup is still synchronizing. Retry after 3 seconds."
msgstr ""
@@ -515,10 +519,6 @@ msgstr ""
msgid "Task {task_id} is already completed."
msgstr "La tarea {task_id} ya está completada."
-#: src/iac_code/a2a/transports/dispatcher.py
-msgid "Task canceled while waiting for input."
-msgstr "Tarea cancelada mientras esperaba entrada."
-
#: src/iac_code/a2a/transports/dispatcher.py
msgid "Task completed."
msgstr "Tarea completada."
@@ -13472,6 +13472,42 @@ msgstr "Ya hay una actualización en curso."
msgid "The update command exited with code {}."
msgstr "El comando de actualización finalizó con el código {}."
+#: src/iac_code/web/app.py
+msgid "The template must be 2 MB or smaller."
+msgstr "La plantilla debe tener un tamaño máximo de 2 MB."
+
+#: src/iac_code/web/app.py
+msgid "Choose a ROS template in YAML, YML, or JSON format."
+msgstr "Selecciona una plantilla ROS en formato YAML, YML o JSON."
+
+#: src/iac_code/web/app.py
+msgid "The file does not look like a ROS template."
+msgstr "El archivo no parece ser una plantilla ROS."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be parsed."
+msgstr "No se pudo analizar la plantilla ROS."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template must contain a mapping at its root."
+msgstr "La raíz de la plantilla ROS debe ser un mapa."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template does not contain any resources."
+msgstr "La plantilla ROS no contiene recursos."
+
+#: src/iac_code/web/app.py
+msgid ""
+"The ROS template does not contain resources supported by the architecture"
+" renderer."
+msgstr ""
+"La plantilla ROS no contiene recursos compatibles con el renderizador de "
+"arquitectura."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be rendered."
+msgstr "No se pudo renderizar la plantilla ROS."
+
#: src/iac_code/web/app.py
msgid "A project directory is required."
msgstr "Se requiere un directorio de proyecto."
@@ -13906,6 +13942,10 @@ msgstr "Sesión · {}"
msgid "unknown theme"
msgstr "tema desconocido"
+#: src/iac_code/web/settings.py
+msgid "unknown architecture diagram renderer"
+msgstr "Renderizador de diagramas de arquitectura desconocido"
+
#: src/iac_code/web/settings.py
msgid "unknown language"
msgstr "idioma desconocido"
diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po
index 71c34ea9..ec0d04f2 100644
--- a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po
+++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po
@@ -18,6 +18,70 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Architecture diagram preview"
+msgstr "Vista previa del diagrama de arquitectura"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template to render its architecture diagram locally."
+msgstr ""
+"Selecciona o arrastra una plantilla ROS para renderizar localmente su "
+"diagrama de arquitectura."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Back to conversations"
+msgstr "Volver a las conversaciones"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Template library"
+msgstr "Biblioteca de plantillas"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid ""
+"Select the local ros-templates folder once, then switch between the fixed"
+" corpus or random templates."
+msgstr ""
+"Selecciona una vez la carpeta local ros-templates y alterna entre el "
+"corpus fijo y plantillas aleatorias."
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select ros-templates folder"
+msgstr "Seleccionar carpeta ros-templates"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Fixed 30-template corpus"
+msgstr "Corpus fijo de 30 plantillas"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select a template"
+msgstr "Seleccionar una plantilla"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Random other template"
+msgstr "Otra plantilla al azar"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Single template"
+msgstr "Plantilla individual"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Supports ROS YAML, YML, and JSON templates up to 2 MB."
+msgstr "Admite plantillas ROS YAML, YML y JSON de hasta 2 MB."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template"
+msgstr "Seleccionar o arrastrar una plantilla ROS"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Drop a template here, or click to choose"
+msgstr "Arrastra una plantilla aquí o haz clic para seleccionarla"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "The file is read for this preview and is not saved."
+msgstr "El archivo solo se lee para esta vista previa y no se guarda."
+
#: src/iac_code/web/static/index.html
#: src/iac_code/web/static/js/components/workspace.js
msgid "Session"
@@ -976,6 +1040,81 @@ msgstr ""
msgid "Failed to start update: {error}"
msgstr "No se pudo iniciar la actualización: {error}"
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/components/output_panel.js
+#: src/iac_code/web/static/js/components/pipeline.js
+msgid "Architecture diagram"
+msgstr "Diagrama de arquitectura"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This historical diagram is shown with Mermaid."
+msgstr "Este diagrama histórico se muestra con Mermaid."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This diagram is temporarily shown with Mermaid."
+msgstr "Este diagrama se muestra temporalmente con Mermaid."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/mermaid_render.js
+#, python-brace-format
+msgid "View {n}"
+msgstr "Vista {n}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Renderer: {renderer}"
+msgstr "Renderizador: {renderer}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The fixed template corpus could not be loaded."
+msgstr "No se pudo cargar el corpus fijo de plantillas."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No supported ROS templates were found in that folder."
+msgstr "No se encontraron plantillas ROS compatibles en esa carpeta."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} template loaded; {available} of 30 corpus templates available."
+msgstr "Plantillas cargadas: {count}; disponibles en el corpus: {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} templates loaded; {available} of 30 corpus templates available."
+msgstr "Plantillas cargadas: {count}; disponibles en el corpus: {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template could not be rendered."
+msgstr "No se pudo renderizar la plantilla."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template must be 2 MB or smaller."
+msgstr "La plantilla debe tener un tamaño máximo de 2 MB."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendering {filename}…"
+msgstr "Renderizando {filename}…"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture view"
+msgstr "{count} vista de arquitectura"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture views"
+msgstr "{count} vistas de arquitectura"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendered {filename}"
+msgstr "Se renderizó {filename}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No templates remain outside the fixed corpus."
+msgstr "No hay plantillas fuera del corpus fijo."
+
#: src/iac_code/web/static/js/events.js
msgid "Local shell"
msgstr "Shell local"
@@ -985,11 +1124,6 @@ msgstr "Shell local"
msgid "Unknown error"
msgstr "Error desconocido"
-#: src/iac_code/web/static/js/mermaid_render.js
-#, python-brace-format
-msgid "View {n}"
-msgstr "Vista {n}"
-
#: src/iac_code/web/static/js/mermaid_render.js
msgid "No pricing information"
msgstr "Sin información de precios"
@@ -1379,11 +1513,6 @@ msgstr "Correcto"
msgid "In progress / failed"
msgstr "En curso / fallido"
-#: src/iac_code/web/static/js/components/output_panel.js
-#: src/iac_code/web/static/js/components/pipeline.js
-msgid "Architecture diagram"
-msgstr "Diagrama de arquitectura"
-
#: src/iac_code/web/static/js/components/output_panel.js
msgid "File no longer exists"
msgstr "El archivo ya no existe"
@@ -3968,6 +4097,22 @@ msgstr "Idioma"
msgid "Choose the interface language; the page reloads after saving."
msgstr "Elige el idioma de la interfaz; la página se recarga al guardar."
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Architecture diagram renderer"
+msgstr "Renderizador de diagramas de arquitectura"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid ""
+"Choose how architecture diagrams are displayed; the change takes effect "
+"after saving."
+msgstr ""
+"Elige cómo se muestran los diagramas de arquitectura; el cambio se aplica"
+" al guardar."
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Open template preview"
+msgstr "Abrir vista previa de plantilla"
+
#: src/iac_code/web/static/js/components/workspace.js
msgid "Downloading…"
msgstr "Descargando…"
diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po
index 52a8c730..16bb8fd8 100644
--- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po
+++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po
@@ -149,7 +149,7 @@ msgstr ""
"terminée."
#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py
-#: src/iac_code/a2a/transports/dispatcher.py
+#: src/iac_code/a2a/task_store.py src/iac_code/a2a/transports/dispatcher.py
msgid "Task canceled."
msgstr "Tâche annulée."
@@ -159,6 +159,10 @@ msgstr ""
"La décision d’autorisation du chat normal est indisponible avant la "
"sauvegarde."
+#: src/iac_code/a2a/executor.py src/iac_code/a2a/transports/dispatcher.py
+msgid "Task canceled while waiting for input."
+msgstr "Tâche annulée pendant l’attente d’une saisie."
+
#: src/iac_code/a2a/executor.py src/iac_code/agui/errors.py
msgid "Session backup is still synchronizing. Retry after 3 seconds."
msgstr ""
@@ -509,10 +513,6 @@ msgstr ""
msgid "Task {task_id} is already completed."
msgstr "La tâche {task_id} est déjà terminée."
-#: src/iac_code/a2a/transports/dispatcher.py
-msgid "Task canceled while waiting for input."
-msgstr "Tâche annulée pendant l’attente d’une saisie."
-
#: src/iac_code/a2a/transports/dispatcher.py
msgid "Task completed."
msgstr "Tâche terminée."
@@ -13528,6 +13528,42 @@ msgstr "Une mise à jour est déjà en cours."
msgid "The update command exited with code {}."
msgstr "La commande de mise à jour s’est terminée avec le code {}."
+#: src/iac_code/web/app.py
+msgid "The template must be 2 MB or smaller."
+msgstr "Le modèle ne doit pas dépasser 2 Mo."
+
+#: src/iac_code/web/app.py
+msgid "Choose a ROS template in YAML, YML, or JSON format."
+msgstr "Sélectionnez un modèle ROS au format YAML, YML ou JSON."
+
+#: src/iac_code/web/app.py
+msgid "The file does not look like a ROS template."
+msgstr "Ce fichier ne semble pas être un modèle ROS."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be parsed."
+msgstr "Le modèle ROS n’a pas pu être analysé."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template must contain a mapping at its root."
+msgstr "La racine du modèle ROS doit être un objet associatif."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template does not contain any resources."
+msgstr "Le modèle ROS ne contient aucune ressource."
+
+#: src/iac_code/web/app.py
+msgid ""
+"The ROS template does not contain resources supported by the architecture"
+" renderer."
+msgstr ""
+"Le modèle ROS ne contient aucune ressource prise en charge par le moteur "
+"de rendu d’architecture."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be rendered."
+msgstr "Le modèle ROS n’a pas pu être rendu."
+
#: src/iac_code/web/app.py
msgid "A project directory is required."
msgstr "Un répertoire de projet est requis."
@@ -13964,6 +14000,10 @@ msgstr "Session · {}"
msgid "unknown theme"
msgstr "thème inconnu"
+#: src/iac_code/web/settings.py
+msgid "unknown architecture diagram renderer"
+msgstr "Moteur de rendu de schéma d’architecture inconnu"
+
#: src/iac_code/web/settings.py
msgid "unknown language"
msgstr "langue inconnue"
diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po
index 54b1ec93..7aa26f85 100644
--- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po
+++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po
@@ -18,6 +18,70 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Architecture diagram preview"
+msgstr "Aperçu du schéma d’architecture"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template to render its architecture diagram locally."
+msgstr ""
+"Sélectionnez ou déposez un modèle ROS pour générer localement son schéma "
+"d’architecture."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Back to conversations"
+msgstr "Retour aux conversations"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Template library"
+msgstr "Bibliothèque de modèles"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid ""
+"Select the local ros-templates folder once, then switch between the fixed"
+" corpus or random templates."
+msgstr ""
+"Sélectionnez une fois le dossier local ros-templates, puis alternez entre"
+" le corpus fixe et des modèles aléatoires."
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select ros-templates folder"
+msgstr "Sélectionner le dossier ros-templates"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Fixed 30-template corpus"
+msgstr "Corpus fixe de 30 modèles"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select a template"
+msgstr "Sélectionner un modèle"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Random other template"
+msgstr "Autre modèle aléatoire"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Single template"
+msgstr "Modèle individuel"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Supports ROS YAML, YML, and JSON templates up to 2 MB."
+msgstr "Prend en charge les modèles ROS YAML, YML et JSON jusqu’à 2 Mo."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template"
+msgstr "Sélectionner ou déposer un modèle ROS"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Drop a template here, or click to choose"
+msgstr "Déposez un modèle ici ou cliquez pour le sélectionner"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "The file is read for this preview and is not saved."
+msgstr "Le fichier est lu pour cet aperçu et n’est pas enregistré."
+
#: src/iac_code/web/static/index.html
#: src/iac_code/web/static/js/components/workspace.js
msgid "Session"
@@ -978,6 +1042,81 @@ msgstr ""
msgid "Failed to start update: {error}"
msgstr "Échec du démarrage de la mise à jour : {error}"
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/components/output_panel.js
+#: src/iac_code/web/static/js/components/pipeline.js
+msgid "Architecture diagram"
+msgstr "Schéma d’architecture"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This historical diagram is shown with Mermaid."
+msgstr "Ce schéma historique est affiché avec Mermaid."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This diagram is temporarily shown with Mermaid."
+msgstr "Ce schéma est temporairement affiché avec Mermaid."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/mermaid_render.js
+#, python-brace-format
+msgid "View {n}"
+msgstr "Vue {n}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Renderer: {renderer}"
+msgstr "Moteur de rendu : {renderer}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The fixed template corpus could not be loaded."
+msgstr "Impossible de charger le corpus fixe de modèles."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No supported ROS templates were found in that folder."
+msgstr "Aucun modèle ROS compatible n’a été trouvé dans ce dossier."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} template loaded; {available} of 30 corpus templates available."
+msgstr "Modèles chargés : {count} ; disponibles dans le corpus : {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} templates loaded; {available} of 30 corpus templates available."
+msgstr "Modèles chargés : {count} ; disponibles dans le corpus : {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template could not be rendered."
+msgstr "Le modèle n’a pas pu être rendu."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template must be 2 MB or smaller."
+msgstr "Le modèle ne doit pas dépasser 2 Mo."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendering {filename}…"
+msgstr "Rendu de {filename}…"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture view"
+msgstr "{count} vue d’architecture"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture views"
+msgstr "{count} vues d’architecture"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendered {filename}"
+msgstr "{filename} rendu"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No templates remain outside the fixed corpus."
+msgstr "Aucun modèle n’est disponible en dehors du corpus fixe."
+
#: src/iac_code/web/static/js/events.js
msgid "Local shell"
msgstr "Shell local"
@@ -987,11 +1126,6 @@ msgstr "Shell local"
msgid "Unknown error"
msgstr "Erreur inconnue"
-#: src/iac_code/web/static/js/mermaid_render.js
-#, python-brace-format
-msgid "View {n}"
-msgstr "Vue {n}"
-
#: src/iac_code/web/static/js/mermaid_render.js
msgid "No pricing information"
msgstr "Aucune information tarifaire"
@@ -1387,11 +1521,6 @@ msgstr "Succès"
msgid "In progress / failed"
msgstr "En cours / échec"
-#: src/iac_code/web/static/js/components/output_panel.js
-#: src/iac_code/web/static/js/components/pipeline.js
-msgid "Architecture diagram"
-msgstr "Schéma d’architecture"
-
#: src/iac_code/web/static/js/components/output_panel.js
msgid "File no longer exists"
msgstr "Le fichier n'existe plus"
@@ -3979,6 +4108,22 @@ msgstr ""
"Choisissez la langue de l’interface ; la page se recharge après "
"l’enregistrement."
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Architecture diagram renderer"
+msgstr "Moteur de rendu des schémas d’architecture"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid ""
+"Choose how architecture diagrams are displayed; the change takes effect "
+"after saving."
+msgstr ""
+"Choisissez comment afficher les schémas d’architecture ; la modification "
+"s’applique après l’enregistrement."
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Open template preview"
+msgstr "Ouvrir l’aperçu du modèle"
+
#: src/iac_code/web/static/js/components/workspace.js
msgid "Downloading…"
msgstr "Téléchargement…"
diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po
index 340a7dbb..2af24ca5 100644
--- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po
+++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po
@@ -122,7 +122,7 @@ msgid "Cleanup-only continuation requires a completed Pipeline handoff."
msgstr "クリーンアップ専用の継続には、完了した Pipeline 引き継ぎが必要です。"
#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py
-#: src/iac_code/a2a/transports/dispatcher.py
+#: src/iac_code/a2a/task_store.py src/iac_code/a2a/transports/dispatcher.py
msgid "Task canceled."
msgstr "タスクがキャンセルされました。"
@@ -130,6 +130,10 @@ msgstr "タスクがキャンセルされました。"
msgid "Normal permission decision is unavailable before backup."
msgstr "バックアップ前に通常チャットの権限判断を取得できません。"
+#: src/iac_code/a2a/executor.py src/iac_code/a2a/transports/dispatcher.py
+msgid "Task canceled while waiting for input."
+msgstr "入力待ち中にタスクがキャンセルされました。"
+
#: src/iac_code/a2a/executor.py src/iac_code/agui/errors.py
msgid "Session backup is still synchronizing. Retry after 3 seconds."
msgstr "セッションのバックアップはまだ同期中です。3秒後に再試行してください。"
@@ -464,10 +468,6 @@ msgstr ""
msgid "Task {task_id} is already completed."
msgstr "タスク {task_id} はすでに完了しています。"
-#: src/iac_code/a2a/transports/dispatcher.py
-msgid "Task canceled while waiting for input."
-msgstr "入力待ち中にタスクがキャンセルされました。"
-
#: src/iac_code/a2a/transports/dispatcher.py
msgid "Task completed."
msgstr "タスクが完了しました。"
@@ -12522,6 +12522,40 @@ msgstr "更新はすでに進行中です。"
msgid "The update command exited with code {}."
msgstr "更新コマンドは終了コード {} で終了しました。"
+#: src/iac_code/web/app.py
+msgid "The template must be 2 MB or smaller."
+msgstr "テンプレートは 2 MB 以下にしてください。"
+
+#: src/iac_code/web/app.py
+msgid "Choose a ROS template in YAML, YML, or JSON format."
+msgstr "YAML、YML、または JSON 形式の ROS テンプレートを選択してください。"
+
+#: src/iac_code/web/app.py
+msgid "The file does not look like a ROS template."
+msgstr "このファイルは ROS テンプレートではないようです。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be parsed."
+msgstr "ROS テンプレートを解析できませんでした。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template must contain a mapping at its root."
+msgstr "ROS テンプレートのルートはマッピングである必要があります。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template does not contain any resources."
+msgstr "ROS テンプレートにリソースが含まれていません。"
+
+#: src/iac_code/web/app.py
+msgid ""
+"The ROS template does not contain resources supported by the architecture"
+" renderer."
+msgstr "ROS テンプレートにアーキテクチャレンダラーが対応するリソースが含まれていません。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be rendered."
+msgstr "ROS テンプレートを描画できませんでした。"
+
#: src/iac_code/web/app.py
msgid "A project directory is required."
msgstr "プロジェクトディレクトリが必要です。"
@@ -12952,6 +12986,10 @@ msgstr "セッション · {}"
msgid "unknown theme"
msgstr "不明なテーマ"
+#: src/iac_code/web/settings.py
+msgid "unknown architecture diagram renderer"
+msgstr "不明なアーキテクチャ図レンダラー"
+
#: src/iac_code/web/settings.py
msgid "unknown language"
msgstr "不明な言語"
diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po
index f85ba8b0..02414c18 100644
--- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po
+++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po
@@ -18,6 +18,66 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Architecture diagram preview"
+msgstr "アーキテクチャ図のプレビュー"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template to render its architecture diagram locally."
+msgstr "ROS テンプレートを選択またはドロップして、アーキテクチャ図をローカルで描画します。"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Back to conversations"
+msgstr "会話に戻る"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Template library"
+msgstr "テンプレートライブラリ"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid ""
+"Select the local ros-templates folder once, then switch between the fixed"
+" corpus or random templates."
+msgstr "ローカルの ros-templates フォルダーを一度選択すると、固定コーパスまたはランダムなテンプレートを切り替えて表示できます。"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select ros-templates folder"
+msgstr "ros-templates フォルダーを選択"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Fixed 30-template corpus"
+msgstr "固定 30 テンプレート"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select a template"
+msgstr "テンプレートを選択"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Random other template"
+msgstr "他のテンプレートをランダム表示"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Single template"
+msgstr "単一テンプレート"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Supports ROS YAML, YML, and JSON templates up to 2 MB."
+msgstr "最大 2 MB の ROS YAML、YML、JSON テンプレートに対応しています。"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template"
+msgstr "ROS テンプレートを選択またはドロップ"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Drop a template here, or click to choose"
+msgstr "ここにテンプレートをドロップするか、クリックして選択"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "The file is read for this preview and is not saved."
+msgstr "ファイルはこのプレビュー用に読み取られ、保存されません。"
+
#: src/iac_code/web/static/index.html
#: src/iac_code/web/static/js/components/workspace.js
msgid "Session"
@@ -960,6 +1020,81 @@ msgstr "更新を開始できませんでした。詳細は診断を開いて確
msgid "Failed to start update: {error}"
msgstr "更新の開始に失敗しました: {error}"
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/components/output_panel.js
+#: src/iac_code/web/static/js/components/pipeline.js
+msgid "Architecture diagram"
+msgstr "アーキテクチャ図"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This historical diagram is shown with Mermaid."
+msgstr "この過去の図は Mermaid で表示されています。"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This diagram is temporarily shown with Mermaid."
+msgstr "この図は一時的に Mermaid で表示されています。"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/mermaid_render.js
+#, python-brace-format
+msgid "View {n}"
+msgstr "ビュー {n}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Renderer: {renderer}"
+msgstr "レンダラー: {renderer}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The fixed template corpus could not be loaded."
+msgstr "固定テンプレートコーパスを読み込めませんでした。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No supported ROS templates were found in that folder."
+msgstr "選択したフォルダーに対応する ROS テンプレートがありません。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} template loaded; {available} of 30 corpus templates available."
+msgstr "{count} 件のテンプレートを読み込みました。固定 30 件のうち {available} 件を利用できます。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} templates loaded; {available} of 30 corpus templates available."
+msgstr "{count} 件のテンプレートを読み込みました。固定 30 件のうち {available} 件を利用できます。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template could not be rendered."
+msgstr "テンプレートを描画できませんでした。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template must be 2 MB or smaller."
+msgstr "テンプレートは 2 MB 以下にしてください。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendering {filename}…"
+msgstr "{filename} を描画しています…"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture view"
+msgstr "アーキテクチャビュー {count} 件"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture views"
+msgstr "アーキテクチャビュー {count} 件"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendered {filename}"
+msgstr "{filename} を描画しました"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No templates remain outside the fixed corpus."
+msgstr "固定コーパス以外のテンプレートはありません。"
+
#: src/iac_code/web/static/js/events.js
msgid "Local shell"
msgstr "ローカルシェル"
@@ -969,11 +1104,6 @@ msgstr "ローカルシェル"
msgid "Unknown error"
msgstr "不明なエラー"
-#: src/iac_code/web/static/js/mermaid_render.js
-#, python-brace-format
-msgid "View {n}"
-msgstr "ビュー {n}"
-
#: src/iac_code/web/static/js/mermaid_render.js
msgid "No pricing information"
msgstr "価格情報はありません"
@@ -1353,11 +1483,6 @@ msgstr "成功"
msgid "In progress / failed"
msgstr "進行中/失敗"
-#: src/iac_code/web/static/js/components/output_panel.js
-#: src/iac_code/web/static/js/components/pipeline.js
-msgid "Architecture diagram"
-msgstr "アーキテクチャ図"
-
#: src/iac_code/web/static/js/components/output_panel.js
msgid "File no longer exists"
msgstr "ファイルは存在しません"
@@ -3857,6 +3982,20 @@ msgstr "言語"
msgid "Choose the interface language; the page reloads after saving."
msgstr "インターフェース言語を選択します。保存後にページが再読み込みされます。"
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Architecture diagram renderer"
+msgstr "アーキテクチャ図レンダラー"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid ""
+"Choose how architecture diagrams are displayed; the change takes effect "
+"after saving."
+msgstr "アーキテクチャ図の表示方法を選択します。保存後すぐに反映されます。"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Open template preview"
+msgstr "テンプレートプレビューを開く"
+
#: src/iac_code/web/static/js/components/workspace.js
msgid "Downloading…"
msgstr "ダウンロードしています…"
diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po
index b074addd..a7bb116c 100644
--- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po
+++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po
@@ -146,7 +146,7 @@ msgstr ""
"concluída."
#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py
-#: src/iac_code/a2a/transports/dispatcher.py
+#: src/iac_code/a2a/task_store.py src/iac_code/a2a/transports/dispatcher.py
msgid "Task canceled."
msgstr "Tarefa cancelada."
@@ -154,6 +154,10 @@ msgstr "Tarefa cancelada."
msgid "Normal permission decision is unavailable before backup."
msgstr "A decisão de permissão do chat normal não está disponível antes do backup."
+#: src/iac_code/a2a/executor.py src/iac_code/a2a/transports/dispatcher.py
+msgid "Task canceled while waiting for input."
+msgstr "Tarefa cancelada enquanto aguardava entrada."
+
#: src/iac_code/a2a/executor.py src/iac_code/agui/errors.py
msgid "Session backup is still synchronizing. Retry after 3 seconds."
msgstr ""
@@ -508,10 +512,6 @@ msgstr ""
msgid "Task {task_id} is already completed."
msgstr "A tarefa {task_id} já foi concluída."
-#: src/iac_code/a2a/transports/dispatcher.py
-msgid "Task canceled while waiting for input."
-msgstr "Tarefa cancelada enquanto aguardava entrada."
-
#: src/iac_code/a2a/transports/dispatcher.py
msgid "Task completed."
msgstr "Tarefa concluída."
@@ -13360,6 +13360,42 @@ msgstr "Já há uma atualização em andamento."
msgid "The update command exited with code {}."
msgstr "O comando de atualização foi encerrado com o código {}."
+#: src/iac_code/web/app.py
+msgid "The template must be 2 MB or smaller."
+msgstr "O modelo deve ter no máximo 2 MB."
+
+#: src/iac_code/web/app.py
+msgid "Choose a ROS template in YAML, YML, or JSON format."
+msgstr "Selecione um modelo ROS no formato YAML, YML ou JSON."
+
+#: src/iac_code/web/app.py
+msgid "The file does not look like a ROS template."
+msgstr "O arquivo não parece ser um modelo ROS."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be parsed."
+msgstr "Não foi possível analisar o modelo ROS."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template must contain a mapping at its root."
+msgstr "A raiz do modelo ROS deve conter um mapeamento."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template does not contain any resources."
+msgstr "O modelo ROS não contém recursos."
+
+#: src/iac_code/web/app.py
+msgid ""
+"The ROS template does not contain resources supported by the architecture"
+" renderer."
+msgstr ""
+"O modelo ROS não contém recursos compatíveis com o renderizador de "
+"arquitetura."
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be rendered."
+msgstr "Não foi possível renderizar o modelo ROS."
+
#: src/iac_code/web/app.py
msgid "A project directory is required."
msgstr "É necessário um diretório de projeto."
@@ -13792,6 +13828,10 @@ msgstr "Sessão · {}"
msgid "unknown theme"
msgstr "tema desconhecido"
+#: src/iac_code/web/settings.py
+msgid "unknown architecture diagram renderer"
+msgstr "Renderizador de diagramas de arquitetura desconhecido"
+
#: src/iac_code/web/settings.py
msgid "unknown language"
msgstr "idioma desconhecido"
diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po
index 60c3da5f..4d886759 100644
--- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po
+++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po
@@ -18,6 +18,70 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Architecture diagram preview"
+msgstr "Pré-visualização do diagrama de arquitetura"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template to render its architecture diagram locally."
+msgstr ""
+"Selecione ou solte um modelo ROS para renderizar localmente o diagrama de"
+" arquitetura."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Back to conversations"
+msgstr "Voltar às conversas"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Template library"
+msgstr "Biblioteca de modelos"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid ""
+"Select the local ros-templates folder once, then switch between the fixed"
+" corpus or random templates."
+msgstr ""
+"Selecione uma vez a pasta local ros-templates e alterne entre o conjunto "
+"fixo e modelos aleatórios."
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select ros-templates folder"
+msgstr "Selecionar pasta ros-templates"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Fixed 30-template corpus"
+msgstr "Conjunto fixo de 30 modelos"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select a template"
+msgstr "Selecionar um modelo"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Random other template"
+msgstr "Outro modelo aleatório"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Single template"
+msgstr "Modelo individual"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Supports ROS YAML, YML, and JSON templates up to 2 MB."
+msgstr "Compatível com modelos ROS YAML, YML e JSON de até 2 MB."
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template"
+msgstr "Selecionar ou soltar um modelo ROS"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Drop a template here, or click to choose"
+msgstr "Solte um modelo aqui ou clique para selecionar"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "The file is read for this preview and is not saved."
+msgstr "O arquivo é lido para esta pré-visualização e não é salvo."
+
#: src/iac_code/web/static/index.html
#: src/iac_code/web/static/js/components/workspace.js
msgid "Session"
@@ -972,6 +1036,81 @@ msgstr ""
msgid "Failed to start update: {error}"
msgstr "Falha ao iniciar a atualização: {error}"
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/components/output_panel.js
+#: src/iac_code/web/static/js/components/pipeline.js
+msgid "Architecture diagram"
+msgstr "Diagrama de arquitetura"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This historical diagram is shown with Mermaid."
+msgstr "Este diagrama histórico é exibido com Mermaid."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This diagram is temporarily shown with Mermaid."
+msgstr "Este diagrama é exibido temporariamente com Mermaid."
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/mermaid_render.js
+#, python-brace-format
+msgid "View {n}"
+msgstr "Visualização {n}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Renderer: {renderer}"
+msgstr "Renderizador: {renderer}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The fixed template corpus could not be loaded."
+msgstr "Não foi possível carregar o conjunto fixo de modelos."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No supported ROS templates were found in that folder."
+msgstr "Nenhum modelo ROS compatível foi encontrado nessa pasta."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} template loaded; {available} of 30 corpus templates available."
+msgstr "Modelos carregados: {count}; disponíveis no conjunto: {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} templates loaded; {available} of 30 corpus templates available."
+msgstr "Modelos carregados: {count}; disponíveis no conjunto: {available}/30."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template could not be rendered."
+msgstr "Não foi possível renderizar o modelo."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template must be 2 MB or smaller."
+msgstr "O modelo deve ter no máximo 2 MB."
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendering {filename}…"
+msgstr "Renderizando {filename}…"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture view"
+msgstr "{count} visualização de arquitetura"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture views"
+msgstr "{count} visualizações de arquitetura"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendered {filename}"
+msgstr "{filename} renderizado"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No templates remain outside the fixed corpus."
+msgstr "Não há modelos fora do conjunto fixo."
+
#: src/iac_code/web/static/js/events.js
msgid "Local shell"
msgstr "Shell local"
@@ -981,11 +1120,6 @@ msgstr "Shell local"
msgid "Unknown error"
msgstr "Erro desconhecido"
-#: src/iac_code/web/static/js/mermaid_render.js
-#, python-brace-format
-msgid "View {n}"
-msgstr "Visualização {n}"
-
#: src/iac_code/web/static/js/mermaid_render.js
msgid "No pricing information"
msgstr "Sem informações de preço"
@@ -1377,11 +1511,6 @@ msgstr "Sucesso"
msgid "In progress / failed"
msgstr "Em andamento / falhou"
-#: src/iac_code/web/static/js/components/output_panel.js
-#: src/iac_code/web/static/js/components/pipeline.js
-msgid "Architecture diagram"
-msgstr "Diagrama de arquitetura"
-
#: src/iac_code/web/static/js/components/output_panel.js
msgid "File no longer exists"
msgstr "O arquivo não existe mais"
@@ -3957,6 +4086,22 @@ msgstr "Idioma"
msgid "Choose the interface language; the page reloads after saving."
msgstr "Escolha o idioma da interface; a página é recarregada após salvar."
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Architecture diagram renderer"
+msgstr "Renderizador de diagramas de arquitetura"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid ""
+"Choose how architecture diagrams are displayed; the change takes effect "
+"after saving."
+msgstr ""
+"Escolha como os diagramas de arquitetura são exibidos; a alteração entra "
+"em vigor após salvar."
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Open template preview"
+msgstr "Abrir pré-visualização do modelo"
+
#: src/iac_code/web/static/js/components/workspace.js
msgid "Downloading…"
msgstr "Baixando…"
diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po
index 8cb9e8d4..540c0e5f 100644
--- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po
+++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po
@@ -122,7 +122,7 @@ msgid "Cleanup-only continuation requires a completed Pipeline handoff."
msgstr "仅清理模式的继续操作需要先完成 Pipeline 交接。"
#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py
-#: src/iac_code/a2a/transports/dispatcher.py
+#: src/iac_code/a2a/task_store.py src/iac_code/a2a/transports/dispatcher.py
msgid "Task canceled."
msgstr "任务已取消。"
@@ -130,6 +130,10 @@ msgstr "任务已取消。"
msgid "Normal permission decision is unavailable before backup."
msgstr "备份前无法获取普通对话权限决定。"
+#: src/iac_code/a2a/executor.py src/iac_code/a2a/transports/dispatcher.py
+msgid "Task canceled while waiting for input."
+msgstr "等待输入时任务已取消。"
+
#: src/iac_code/a2a/executor.py src/iac_code/agui/errors.py
msgid "Session backup is still synchronizing. Retry after 3 seconds."
msgstr "会话备份仍在同步中,请在 3 秒后重试。"
@@ -462,10 +466,6 @@ msgstr "Windows 不支持 Unix 域套接字传输。请使用 --transport http
msgid "Task {task_id} is already completed."
msgstr "任务 {task_id} 已完成。"
-#: src/iac_code/a2a/transports/dispatcher.py
-msgid "Task canceled while waiting for input."
-msgstr "等待输入时任务已取消。"
-
#: src/iac_code/a2a/transports/dispatcher.py
msgid "Task completed."
msgstr "任务已完成。"
@@ -12306,6 +12306,40 @@ msgstr "更新已在进行中。"
msgid "The update command exited with code {}."
msgstr "更新命令以退出码 {} 结束。"
+#: src/iac_code/web/app.py
+msgid "The template must be 2 MB or smaller."
+msgstr "模板大小不能超过 2 MB。"
+
+#: src/iac_code/web/app.py
+msgid "Choose a ROS template in YAML, YML, or JSON format."
+msgstr "请选择 YAML、YML 或 JSON 格式的 ROS 模板。"
+
+#: src/iac_code/web/app.py
+msgid "The file does not look like a ROS template."
+msgstr "该文件看起来不是 ROS 模板。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be parsed."
+msgstr "ROS 模板无法解析。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template must contain a mapping at its root."
+msgstr "ROS 模板的根节点必须是映射。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template does not contain any resources."
+msgstr "ROS 模板中不包含任何资源。"
+
+#: src/iac_code/web/app.py
+msgid ""
+"The ROS template does not contain resources supported by the architecture"
+" renderer."
+msgstr "ROS 模板中没有架构图渲染器支持的资源。"
+
+#: src/iac_code/web/app.py
+msgid "The ROS template could not be rendered."
+msgstr "无法渲染 ROS 模板。"
+
#: src/iac_code/web/app.py
msgid "A project directory is required."
msgstr "需要项目目录。"
@@ -12736,6 +12770,10 @@ msgstr "会话 · {}"
msgid "unknown theme"
msgstr "未知的主题"
+#: src/iac_code/web/settings.py
+msgid "unknown architecture diagram renderer"
+msgstr "未知的架构图渲染器"
+
#: src/iac_code/web/settings.py
msgid "unknown language"
msgstr "未知的语言"
diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po
index cfd1e966..7abf7e04 100644
--- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po
+++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po
@@ -18,6 +18,66 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Architecture diagram preview"
+msgstr "架构图预览"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template to render its architecture diagram locally."
+msgstr "选择或拖入 ROS 模板,在本地渲染架构图。"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Back to conversations"
+msgstr "返回会话"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Template library"
+msgstr "模板库"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid ""
+"Select the local ros-templates folder once, then switch between the fixed"
+" corpus or random templates."
+msgstr "选择一次本地 ros-templates 目录,即可在固定样本和随机模板之间切换。"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select ros-templates folder"
+msgstr "选择 ros-templates 目录"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Fixed 30-template corpus"
+msgstr "固定 30 个模板样本"
+
+#: src/iac_code/web/static/diagram-preview.html
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "Select a template"
+msgstr "选择模板"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Random other template"
+msgstr "随机其他模板"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Single template"
+msgstr "单个模板"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Supports ROS YAML, YML, and JSON templates up to 2 MB."
+msgstr "支持最大 2 MB 的 ROS YAML、YML 和 JSON 模板。"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Choose or drop a ROS template"
+msgstr "选择或拖入 ROS 模板"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "Drop a template here, or click to choose"
+msgstr "将模板拖到这里,或点击选择"
+
+#: src/iac_code/web/static/diagram-preview.html
+msgid "The file is read for this preview and is not saved."
+msgstr "文件仅用于本次预览,不会保存。"
+
#: src/iac_code/web/static/index.html
#: src/iac_code/web/static/js/components/workspace.js
msgid "Session"
@@ -958,6 +1018,81 @@ msgstr "无法开始更新。请打开诊断目录查看详情。"
msgid "Failed to start update: {error}"
msgstr "更新启动失败:{error}"
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/components/output_panel.js
+#: src/iac_code/web/static/js/components/pipeline.js
+msgid "Architecture diagram"
+msgstr "架构图"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This historical diagram is shown with Mermaid."
+msgstr "这张历史架构图已用 Mermaid 显示。"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+msgid "This diagram is temporarily shown with Mermaid."
+msgstr "这张架构图暂时使用 Mermaid 显示。"
+
+#: src/iac_code/web/static/js/architecture_diagram.js
+#: src/iac_code/web/static/js/mermaid_render.js
+#, python-brace-format
+msgid "View {n}"
+msgstr "视图 {n}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Renderer: {renderer}"
+msgstr "渲染器:{renderer}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The fixed template corpus could not be loaded."
+msgstr "无法载入固定模板样本。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No supported ROS templates were found in that folder."
+msgstr "所选目录中没有受支持的 ROS 模板。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} template loaded; {available} of 30 corpus templates available."
+msgstr "已载入 {count} 个模板;固定 30 个样本中有 {available} 个可用。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} templates loaded; {available} of 30 corpus templates available."
+msgstr "已载入 {count} 个模板;固定 30 个样本中有 {available} 个可用。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template could not be rendered."
+msgstr "无法渲染该模板。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "The template must be 2 MB or smaller."
+msgstr "模板大小不能超过 2 MB。"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendering {filename}…"
+msgstr "正在渲染 {filename}…"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture view"
+msgstr "{count} 个架构视图"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "{count} architecture views"
+msgstr "{count} 个架构视图"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+#, python-brace-format
+msgid "Rendered {filename}"
+msgstr "已渲染 {filename}"
+
+#: src/iac_code/web/static/js/diagram_preview.js
+msgid "No templates remain outside the fixed corpus."
+msgstr "固定样本以外没有可用模板。"
+
#: src/iac_code/web/static/js/events.js
msgid "Local shell"
msgstr "本地 Shell"
@@ -967,11 +1102,6 @@ msgstr "本地 Shell"
msgid "Unknown error"
msgstr "未知错误"
-#: src/iac_code/web/static/js/mermaid_render.js
-#, python-brace-format
-msgid "View {n}"
-msgstr "视图 {n}"
-
#: src/iac_code/web/static/js/mermaid_render.js
msgid "No pricing information"
msgstr "暂无询价信息"
@@ -1351,11 +1481,6 @@ msgstr "成功"
msgid "In progress / failed"
msgstr "进行中/失败"
-#: src/iac_code/web/static/js/components/output_panel.js
-#: src/iac_code/web/static/js/components/pipeline.js
-msgid "Architecture diagram"
-msgstr "架构图"
-
#: src/iac_code/web/static/js/components/output_panel.js
msgid "File no longer exists"
msgstr "文件已不存在"
@@ -3849,6 +3974,20 @@ msgstr "界面语言"
msgid "Choose the interface language; the page reloads after saving."
msgstr "选择界面语言,保存后页面自动刷新。"
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Architecture diagram renderer"
+msgstr "架构图渲染器"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid ""
+"Choose how architecture diagrams are displayed; the change takes effect "
+"after saving."
+msgstr "选择架构图的显示方式;保存后立即生效。"
+
+#: src/iac_code/web/static/js/components/workspace.js
+msgid "Open template preview"
+msgstr "打开模板预览"
+
#: src/iac_code/web/static/js/components/workspace.js
msgid "Downloading…"
msgstr "正在下载…"
diff --git a/src/iac_code/pipeline/engine/architecture_graph.py b/src/iac_code/pipeline/engine/architecture_graph.py
index 1464c633..a135508e 100644
--- a/src/iac_code/pipeline/engine/architecture_graph.py
+++ b/src/iac_code/pipeline/engine/architecture_graph.py
@@ -20,6 +20,7 @@
ConceptEdgeRule,
RuleLabel,
)
+from iac_code.pipeline.engine.diagram_graph import build_diagram_graph
COMPACT_MIN_VISIBLE_NODES = 25
COMPACT_MIN_VISIBLE_ELEMENTS = 25
@@ -194,6 +195,7 @@ class ArchitectureViewResult:
purpose: str
mermaid_source: str
architecture_context: dict[str, Any]
+ graph: dict[str, Any]
@dataclass(frozen=True)
@@ -1205,6 +1207,7 @@ def _render_views_from_context(
) -> tuple[ArchitectureViewResult, ...]:
raw_views = semantic_plan.get("views") if isinstance(semantic_plan, dict) else None
if not isinstance(raw_views, list) or not raw_views:
+ graph = _diagram_graph_from_context(architecture_context)
return (
ArchitectureViewResult(
id="overview",
@@ -1217,6 +1220,7 @@ def _render_views_from_context(
"purpose": _("Show the complete architecture diagram."),
"rendered_as": "full_architecture",
},
+ graph=graph,
),
)
@@ -1371,7 +1375,7 @@ def _render_single_view_from_context(
view_node_parent,
rules,
)
- view_context = {
+ view_context: dict[str, Any] = {
"id": view_id,
"title": title,
"purpose": purpose,
@@ -1384,12 +1388,89 @@ def _render_single_view_from_context(
"layout": layout,
"rendered_as": "filtered_architecture_view",
}
+ graph = _diagram_graph(
+ nodes=view_nodes,
+ containers=view_containers,
+ edges=view_edges,
+ layer_parent=view_layer_parent,
+ node_parent=view_node_parent,
+ )
return ArchitectureViewResult(
id=view_id,
title=title,
purpose=purpose,
mermaid_source=mermaid_source,
architecture_context=view_context,
+ graph=graph,
+ )
+
+
+def _diagram_graph_from_context(architecture_context: dict[str, Any]) -> dict[str, Any]:
+ """Build the renderer-neutral graph from the exact full-view projection."""
+ nodes = _context_nodes_by_id(architecture_context)
+ containers = _context_containers_by_id(architecture_context)
+ edges = [
+ GraphEdge(
+ from_id=str(item.get("from") or ""),
+ to_id=str(item.get("to") or ""),
+ style=str(item.get("style") or "solid_arrow"),
+ label=str(item["label"]) if item.get("label") is not None else None,
+ )
+ for item in architecture_context.get("visible_edges", [])
+ if isinstance(item, dict) and item.get("from") and item.get("to")
+ ]
+ return _diagram_graph(
+ nodes=nodes,
+ containers=containers,
+ edges=edges,
+ layer_parent=_context_layer_parent(containers),
+ node_parent=_context_node_parent(architecture_context),
+ )
+
+
+def _diagram_graph(
+ *,
+ nodes: dict[str, dict[str, Any]],
+ containers: dict[str, dict[str, Any]],
+ edges: list[GraphEdge],
+ layer_parent: dict[str, str],
+ node_parent: dict[str, str],
+) -> dict[str, Any]:
+ """Return DiagramGraph v1 from the same projection used to emit Mermaid."""
+ visible_ids = set(nodes) | set(containers)
+ graph_edges: list[dict[str, Any]] = []
+ for index, edge in enumerate(_dedupe_edges(edges), start=1):
+ if edge.from_id not in visible_ids or edge.to_id not in visible_ids:
+ continue
+ graph_edges.append(
+ {
+ "id": f"edge_{index}_{edge.from_id}_{edge.to_id}",
+ "from": edge.from_id,
+ "to": edge.to_id,
+ "label": edge.label or "",
+ "style": edge.style,
+ }
+ )
+ return build_diagram_graph(
+ nodes=[
+ {
+ "id": node_id,
+ "label": str(node.get("label") or node_id).replace("\\n", "\n"),
+ "resourceType": str(node.get("type") or ""),
+ "parentId": node_parent.get(node_id),
+ }
+ for node_id, node in nodes.items()
+ ],
+ containers=[
+ {
+ "id": container_id,
+ "label": str(container.get("label") or container_id).replace("\\n", "\n"),
+ "resourceType": str(container.get("type") or ""),
+ "parentId": layer_parent.get(container_id),
+ }
+ for container_id, container in containers.items()
+ ],
+ edges=graph_edges,
)
diff --git a/src/iac_code/pipeline/engine/diagram_graph.py b/src/iac_code/pipeline/engine/diagram_graph.py
new file mode 100644
index 00000000..c1513039
--- /dev/null
+++ b/src/iac_code/pipeline/engine/diagram_graph.py
@@ -0,0 +1,145 @@
+"""Renderer-neutral DiagramGraph construction helpers."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+
+def build_diagram_graph(
+ *,
+ nodes: Sequence[Mapping[str, Any]],
+ containers: Sequence[Mapping[str, Any]],
+ edges: Sequence[Mapping[str, Any]],
+ direction: str = "LR",
+) -> dict[str, Any]:
+ """Build the shared, versioned graph payload used by Web diagram views."""
+ return {
+ "version": 1,
+ "nodes": [dict(node) for node in nodes],
+ "containers": [dict(container) for container in containers],
+ "edges": [dict(edge) for edge in edges],
+ "layout": {"direction": direction},
+ }
+
+
+def project_plan_diagram_graph(
+ *,
+ nodes: Sequence[Mapping[str, Any]],
+ groups: Sequence[Mapping[str, Any]],
+ edges: Sequence[Mapping[str, Any]],
+ node_labels: Mapping[str, str],
+ group_anchor_labels: Mapping[str, str],
+) -> dict[str, Any]:
+ """Project sanitized planning topology into DiagramGraph v1.
+
+ Planning models often emit a resource node whose raw id is also used as a group name.
+ DiagramGraph can represent that node as the group container, including nested groups, so the
+ graph does not repeat the same resource as a box, a container, and a containment edge.
+ """
+ nodes_by_raw_id = {str(node["raw_id"]).casefold(): node for node in nodes}
+ group_anchors: dict[str, Mapping[str, Any]] = {}
+ for group in groups:
+ group_id = str(group["id"])
+ anchor = nodes_by_raw_id.get(str(group["raw_id"]).casefold())
+ if anchor is not None and anchor.get("group") != group_id:
+ group_anchors[group_id] = anchor
+
+ # Reject malformed mutual containment instead of emitting a cyclic parentId chain.
+ def has_anchor_cycle(group_id: str) -> bool:
+ seen: set[str] = set()
+ current: str | None = group_id
+ while current is not None and current in group_anchors:
+ if current in seen:
+ return True
+ seen.add(current)
+ parent = group_anchors[current].get("group")
+ current = str(parent) if parent is not None else None
+ return False
+
+ group_anchors = {group_id: anchor for group_id, anchor in group_anchors.items() if not has_anchor_cycle(group_id)}
+ folded_groups = {str(anchor["id"]): group_id for group_id, anchor in group_anchors.items()}
+
+ graph_nodes: list[dict[str, Any]] = []
+ for node in nodes:
+ node_id = str(node["id"])
+ if node_id in folded_groups:
+ continue
+ item: dict[str, Any] = {
+ "id": node_id,
+ "label": node_labels.get(node_id, str(node.get("label") or node_id)),
+ "resourceType": "",
+ "parentId": node.get("group"),
+ }
+ if node.get("product"):
+ item["product"] = node["product"]
+ if node.get("role"):
+ item["role"] = node["role"]
+ graph_nodes.append(item)
+
+ container_parents = {
+ str(group["id"]): group_anchors[str(group["id"])].get("group") if str(group["id"]) in group_anchors else None
+ for group in groups
+ }
+
+ def is_descendant(item_parent: object, container_id: str) -> bool:
+ current = str(item_parent) if item_parent is not None else None
+ seen: set[str] = set()
+ while current is not None and current not in seen:
+ if current == container_id:
+ return True
+ seen.add(current)
+ current = container_parents.get(current)
+ return False
+
+ graph_edges: list[tuple[str, str, str]] = []
+ seen_edges: set[tuple[str, str, str]] = set()
+ node_groups = {str(node["id"]): node.get("group") for node in nodes}
+ for edge in edges:
+ raw_source = str(edge["source"])
+ raw_target = str(edge["target"])
+ label = str(edge.get("label") or "")
+ source = folded_groups.get(raw_source, raw_source)
+ target = folded_groups.get(raw_target, raw_target)
+ if source == target:
+ continue
+ if raw_source in folded_groups and is_descendant(node_groups.get(raw_target), source):
+ continue
+ if raw_target in folded_groups and is_descendant(node_groups.get(raw_source), target):
+ continue
+ key = (source, target, label)
+ if key in seen_edges:
+ continue
+ seen_edges.add(key)
+ graph_edges.append(key)
+
+ graph_containers: list[dict[str, Any]] = []
+ for group in groups:
+ group_id = str(group["id"])
+ if not any(node.get("group") == group_id for node in nodes):
+ continue
+ anchor = group_anchors.get(group_id)
+ anchor_id = str(anchor["id"]) if anchor is not None else ""
+ graph_containers.append(
+ {
+ "id": group_id,
+ "label": group_anchor_labels.get(anchor_id, str(group.get("label") or group_id)),
+ "resourceType": "",
+ "parentId": container_parents[group_id],
+ }
+ )
+
+ return build_diagram_graph(
+ nodes=graph_nodes,
+ containers=graph_containers,
+ edges=[
+ {
+ "id": f"edge_{index}_{source}_{target}",
+ "from": source,
+ "to": target,
+ "label": label,
+ "style": "solid_arrow",
+ }
+ for index, (source, target, label) in enumerate(graph_edges, start=1)
+ ],
+ )
diff --git a/src/iac_code/pipeline/engine/display_replay.py b/src/iac_code/pipeline/engine/display_replay.py
index 53446361..792f43b0 100644
--- a/src/iac_code/pipeline/engine/display_replay.py
+++ b/src/iac_code/pipeline/engine/display_replay.py
@@ -46,7 +46,7 @@ class DisplayCandidate:
candidate_index: int | None = None
mermaid_source: str = ""
diagram_stage: str = "optimized"
- diagram_views: list[dict[str, str]] = field(default_factory=list)
+ diagram_views: list[dict[str, Any]] = field(default_factory=list)
summary: str = ""
cost_items: list[dict[str, Any]] = field(default_factory=list)
total_monthly_cost: str = ""
@@ -603,8 +603,8 @@ def _diagram_stage(value: Any) -> str:
return "draft" if value == "draft" else "optimized"
@staticmethod
- def _diagram_views(value: Any, fallback_mermaid_source: str) -> list[dict[str, str]]:
- views: list[dict[str, str]] = []
+ def _diagram_views(value: Any, fallback_mermaid_source: str) -> list[dict[str, Any]]:
+ views: list[dict[str, Any]] = []
if isinstance(value, list):
for index, raw in enumerate(value, start=1):
if not isinstance(raw, dict):
@@ -616,14 +616,16 @@ def _diagram_views(value: Any, fallback_mermaid_source: str) -> list[dict[str, s
view_id = raw_view.get("id")
title = raw_view.get("title")
purpose = raw_view.get("purpose")
- views.append(
- {
- "id": str(view_id or f"view_{index}"),
- "title": str(title or view_id or f"Diagram {index}"),
- "purpose": str(purpose or ""),
- "mermaid_source": mermaid_source,
- }
- )
+ view: dict[str, Any] = {
+ "id": str(view_id or f"view_{index}"),
+ "title": str(title or view_id or f"Diagram {index}"),
+ "purpose": str(purpose or ""),
+ "mermaid_source": mermaid_source,
+ }
+ graph = raw_view.get("graph")
+ if isinstance(graph, dict) and graph.get("version") == 1:
+ view["graph"] = graph
+ views.append(view)
if not views and fallback_mermaid_source:
views.append(
{
diff --git a/src/iac_code/pipeline/engine/show_diagram_tool.py b/src/iac_code/pipeline/engine/show_diagram_tool.py
index a00dba29..8ae3ef5c 100644
--- a/src/iac_code/pipeline/engine/show_diagram_tool.py
+++ b/src/iac_code/pipeline/engine/show_diagram_tool.py
@@ -277,16 +277,17 @@ def _diagram_event_from_render_result(
render_result: ArchitectureMultiViewRenderResult,
diagram_stage: str,
) -> DiagramEvent:
- views = [
+ views: list[dict[str, Any]] = [
{
"id": view.id,
"title": view.title,
"purpose": view.purpose,
"mermaid_source": view.mermaid_source,
+ "graph": view.graph,
}
for view in render_result.views
]
- mermaid_source = views[0]["mermaid_source"] if views else "graph TD"
+ mermaid_source = str(views[0]["mermaid_source"]) if views else "graph TD"
return DiagramEvent(
candidate_name=candidate_name,
template_content=template_content,
diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py
index 2325ac6c..e4468d60 100644
--- a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py
+++ b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py
@@ -1,8 +1,8 @@
"""Step 1 candidate outline tool and the local planned-architecture renderer.
``show_architecture_plan`` now submits one complete, lightweight candidate outline batch. Rich
-topology rendering remains in this module so the pipeline-local ``show_candidate_detail`` tool can
-reuse the existing sanitizing and Mermaid behavior without moving or duplicating it.
+topology validation and Mermaid rendering remain here for the pipeline-local
+``show_candidate_detail`` tool; renderer-neutral graph projection lives in the engine adapter.
"""
from __future__ import annotations
@@ -14,6 +14,7 @@
from loguru import logger
from iac_code.i18n import _
+from iac_code.pipeline.engine.diagram_graph import project_plan_diagram_graph
from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import (
latest_candidate_outline_batch,
normalize_outline_candidates,
@@ -152,9 +153,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) ->
"Displayed {count} candidate outlines; candidateSetId={candidate_set_id}. "
"Do not repeat show_architecture_plan unless the user changes the candidate set; "
"continue with show_candidate_detail."
- ).format(
- count=len(candidates), candidate_set_id=candidate_set_id
- ),
+ ).format(count=len(candidates), candidate_set_id=candidate_set_id),
metadata={"candidate_set_id": candidate_set_id},
)
@@ -169,13 +168,21 @@ def __init__(self) -> None:
self.warnings: list[str] = []
-def render_architecture_graph(topology_graph: Any) -> tuple[str, dict[str, Any], list[str]]:
- """Validate one rich detail graph and return its Mermaid source and UI context."""
+def render_architecture_graph(topology_graph: Any) -> tuple[str, dict[str, Any], dict[str, Any], list[str]]:
+ """Validate a rich detail graph and return Mermaid, context, DiagramGraph, and warnings."""
if not isinstance(topology_graph, dict):
raise ValueError(_("topology_graph must be an object with nodes and edges"))
plan = _build_architecture_plan(topology_graph.get("nodes"), topology_graph.get("edges"))
- return _render_plan_mermaid(plan), _plan_architecture_context(plan), list(plan.warnings)
+ context = _plan_architecture_context(plan)
+ graph = project_plan_diagram_graph(
+ nodes=plan.nodes,
+ groups=plan.groups,
+ edges=plan.edges,
+ node_labels={node["id"]: _node_mermaid_label(node).replace("\\n", "\n") for node in plan.nodes},
+ group_anchor_labels={node["id"]: _group_mermaid_title(node) for node in plan.nodes},
+ )
+ return _render_plan_mermaid(plan), context, graph, list(plan.warnings)
def _normalized_candidate_index(value: Any) -> int | None:
diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py
index 469f436f..84ff8e10 100644
--- a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py
+++ b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py
@@ -177,9 +177,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) ->
batch = latest_candidate_outline_batch(records)
if batch is None:
return ToolResult.error(
- _(
- "show_candidate_detail is not allowed before a successful show_architecture_plan outline batch."
- )
+ _("show_candidate_detail is not allowed before a successful show_architecture_plan outline batch.")
)
expected_index = first_missing_candidate_detail_index(records, batch)
@@ -211,7 +209,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) ->
)
try:
- mermaid_source, architecture_context, warnings = render_architecture_graph(
+ mermaid_source, architecture_context, graph, warnings = render_architecture_graph(
tool_input.get("topology_graph")
)
except ValueError as exc:
@@ -250,6 +248,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) ->
"title": _("Architecture plan"),
"purpose": "",
"mermaid_source": mermaid_source,
+ "graph": graph,
}
],
candidate_set_id=batch.candidate_set_id,
diff --git a/src/iac_code/types/stream_events.py b/src/iac_code/types/stream_events.py
index 7a0b320d..9660a2d4 100644
--- a/src/iac_code/types/stream_events.py
+++ b/src/iac_code/types/stream_events.py
@@ -427,7 +427,7 @@ class DiagramEvent(ToolEmittedEvent):
candidate_index: int | None = None
architecture_context: dict[str, Any] | None = None
diagram_stage: Literal["draft", "optimized"] = "optimized"
- views: list[dict[str, str]] = field(default_factory=list)
+ views: list[dict[str, Any]] = field(default_factory=list)
candidate_set_id: str | None = None
detail_stage: Literal["outline", "detail"] | None = None
type: Literal["diagram"] = "diagram"
diff --git a/src/iac_code/web/app.py b/src/iac_code/web/app.py
index a1cfa754..682924bf 100644
--- a/src/iac_code/web/app.py
+++ b/src/iac_code/web/app.py
@@ -79,6 +79,7 @@ def _pipeline_pending_input_coordinates(snapshot: dict[str, Any] | None) -> tupl
MAX_PROJECT_LIST_LIMIT = 1000
MAX_PROJECT_SESSION_LIMIT = 200
WEB_SHUTDOWN_TASK_TIMEOUT_SECONDS = 5.0
+MAX_DIAGRAM_PREVIEW_BYTES = 2 * 1024 * 1024
class _SuppressAllRedactionMiddleware:
@@ -182,6 +183,7 @@ def create_app(
from iac_code.web import mcp_settings
from iac_code.web.cleanup import cleanup_blocks_normal_chat, session_cleanup_summary
from iac_code.web.commands import WebCommandDispatcher, command_metadata
+ from iac_code.web.diagrams import TemplateDiagramPreviewError, render_template_diagram_views
from iac_code.web.events import encode_sse, make_resync_event, normalize_event_payload, observe_published_events
from iac_code.web.mcp_settings import MCPWebError
from iac_code.web.memory import (
@@ -243,6 +245,7 @@ def create_app(
clear_provider_config,
developer_settings,
get_appearance_theme,
+ get_architecture_diagram_renderer,
get_session_defaults,
get_ui_language,
is_foreign_normal_visible,
@@ -252,6 +255,7 @@ def create_app(
save_active_provider,
save_aliyun_cloud,
save_appearance_theme,
+ save_architecture_diagram_renderer,
save_developer_settings,
save_foreign_sessions_visibility,
save_provider_config,
@@ -1997,18 +2001,87 @@ async def index(_request):
# 把新会话默认(权限/模式)注入
,让首屏创建的草稿即刻采用,避免异步拉取的闪烁。
# 放在 而非 ,以免破坏对主题标签精确形态的既有断言。
defaults = get_session_defaults()
+ architecture_renderer = get_architecture_diagram_renderer()
html = html.replace(
"",
- ''.format(
+ ''.format(
escape(defaults["permissionMode"], quote=True),
escape(defaults["mode"], quote=True),
escape(defaults["pipelineName"], quote=True),
+ escape(architecture_renderer, quote=True),
' data-token-mode="true"' if token_mode else "",
),
1,
)
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
+ async def diagram_preview(_request):
+ html = (STATIC_DIR / "diagram-preview.html").read_text(encoding="utf-8")
+ theme = get_appearance_theme()
+ lang = resolve_ui_language(get_ui_language())
+ catalog = load_webui_catalog(lang)
+ html = html.replace(
+ '',
+ ''.format(lang, theme),
+ 1,
+ )
+ i18n_script = "".format(
+ json.dumps({"lang": lang, "messages": catalog}, ensure_ascii=False).replace("<", "\\u003c")
+ )
+ html = html.replace("", i18n_script + "\n ", 1)
+ html = html.replace(
+ "",
+ ''.format(
+ escape(get_architecture_diagram_renderer(), quote=True),
+ ' data-token-mode="true"' if token_mode else "",
+ ),
+ 1,
+ )
+ return HTMLResponse(html, headers={"Cache-Control": "no-store"})
+
+ async def post_diagram_preview(request):
+ try:
+ data = await json_object_body(request)
+ filename = required_string(data, "filename")
+ content = required_string(data, "content")
+ except ValueError as exc:
+ return json_error(str(exc), 400)
+ if len(content.encode("utf-8")) > MAX_DIAGRAM_PREVIEW_BYTES:
+ return JSONResponse(
+ {"error": {"code": "template_too_large", "message": _("The template must be 2 MB or smaller.")}},
+ status_code=413,
+ )
+ suffix = Path(filename).suffix.lower()
+ try:
+ views = await run_in_threadpool(render_template_diagram_views, content, suffix)
+ except TemplateDiagramPreviewError as exc:
+ error_code = str(exc)
+ messages = {
+ "unsupported_template_format": _("Choose a ROS template in YAML, YML, or JSON format."),
+ "not_ros_template": _("The file does not look like a ROS template."),
+ "template_parse_failed": _("The ROS template could not be parsed."),
+ "template_is_not_mapping": _("The ROS template must contain a mapping at its root."),
+ "template_has_no_resources": _("The ROS template does not contain any resources."),
+ "template_has_no_supported_resources": _(
+ "The ROS template does not contain resources supported by the architecture renderer."
+ ),
+ "template_render_failed": _("The ROS template could not be rendered."),
+ }
+ error_message = messages.get(error_code, messages["template_render_failed"])
+ return JSONResponse(
+ {"error": {"code": error_code, "message": error_message}},
+ status_code=400,
+ )
+ return JSONResponse(
+ {
+ "filename": Path(filename).name,
+ "format": "ros",
+ "mermaidSource": views[0]["mermaidSource"],
+ "views": views,
+ }
+ )
+
async def create_session(request):
try:
data = await json_object_body(request)
@@ -3815,6 +3888,17 @@ async def put_appearance_settings(request):
except ValueError as exc:
return json_error(str(exc), 400)
+ async def get_architecture_diagram_settings(request):
+ return JSONResponse({"renderer": get_architecture_diagram_renderer()})
+
+ async def put_architecture_diagram_settings(request):
+ try:
+ data = await json_object_body(request)
+ renderer = required_string(data, "renderer")
+ return JSONResponse(await state_transaction(save_architecture_diagram_renderer, renderer))
+ except ValueError as exc:
+ return json_error(str(exc), 400)
+
async def get_ui_language_settings(request):
return JSONResponse(ui_language_payload())
@@ -5294,6 +5378,8 @@ async def event_stream():
]
),
Route("/", index, methods=["GET"]),
+ Route("/diagram-preview", diagram_preview, methods=["GET"]),
+ Route("/api/diagram-preview", post_diagram_preview, methods=["POST"]),
Route("/api/sessions", create_session, methods=["POST"]),
]
if desktop_config is not None:
@@ -5352,6 +5438,8 @@ async def event_stream():
),
Route("/api/settings/appearance", get_appearance_settings, methods=["GET"]),
Route("/api/settings/appearance", put_appearance_settings, methods=["PUT"]),
+ Route("/api/settings/architecture-diagram", get_architecture_diagram_settings, methods=["GET"]),
+ Route("/api/settings/architecture-diagram", put_architecture_diagram_settings, methods=["PUT"]),
Route("/api/settings/ui-language", get_ui_language_settings, methods=["GET"]),
Route("/api/settings/ui-language", put_ui_language_settings, methods=["PUT"]),
Route("/api/settings/session-defaults", get_session_defaults_settings, methods=["GET"]),
diff --git a/src/iac_code/web/diagram_cache.py b/src/iac_code/web/diagram_cache.py
index 3b0765ed..3cc20ae6 100644
--- a/src/iac_code/web/diagram_cache.py
+++ b/src/iac_code/web/diagram_cache.py
@@ -15,6 +15,7 @@
from iac_code.utils.file_security import atomic_write_text, ensure_private_dir
DIAGRAM_CACHE_DIR_NAME = "diagram-cache"
+DIAGRAM_CACHE_VERSION = 2
logger = logging.getLogger(__name__)
@@ -49,15 +50,21 @@ def read_cached(context_id: str | None, candidate_index: int | str, template_con
return None
raw_views = data.get("views")
if isinstance(raw_views, list):
- views = [
- {
+ views = []
+ for v in raw_views:
+ if not isinstance(v, dict) or not isinstance(v.get("mermaidSource"), str) or not v.get("mermaidSource"):
+ continue
+ view = {
"id": str(v.get("id") or ""),
"title": str(v.get("title") or ""),
"mermaidSource": v["mermaidSource"],
}
- for v in raw_views
- if isinstance(v, dict) and isinstance(v.get("mermaidSource"), str) and v.get("mermaidSource")
- ]
+ if "purpose" in v:
+ view["purpose"] = str(v.get("purpose") or "")
+ graph = v.get("graph")
+ if isinstance(graph, dict) and graph.get("version") == 1:
+ view["graph"] = graph
+ views.append(view)
return views or None
legacy = data.get("mermaidSource")
if isinstance(legacy, str) and legacy:
@@ -78,6 +85,7 @@ def write_cached(
thash = template_hash(template_content)
path = cache_path(safe, candidate_index, thash)
payload = {
+ "version": DIAGRAM_CACHE_VERSION,
"candidateIndex": candidate_index,
"templateHash": thash,
"views": views,
diff --git a/src/iac_code/web/diagram_optimizer.py b/src/iac_code/web/diagram_optimizer.py
index 0314cee2..dd130231 100644
--- a/src/iac_code/web/diagram_optimizer.py
+++ b/src/iac_code/web/diagram_optimizer.py
@@ -189,12 +189,23 @@ async def _optimize_one(
multi = await asyncio.to_thread(
render_ros_template_architecture_views, target.template_content, semantic_plan=plan
)
- views: list[dict] = []
+ views: list[dict[str, Any]] = []
for v in multi.views:
raw = v.mermaid_source
if not raw or raw.startswith(_ERROR_MERMAID_PREFIX) or raw.strip() == "graph TD":
continue
- views.append({"id": v.id, "title": v.title, "mermaidSource": browser_mermaid_source(raw)})
+ view: dict[str, Any] = {
+ "id": v.id,
+ "title": v.title,
+ "mermaidSource": browser_mermaid_source(raw),
+ }
+ purpose = getattr(v, "purpose", "")
+ if purpose:
+ view["purpose"] = purpose
+ graph = getattr(v, "graph", None)
+ if isinstance(graph, dict) and graph.get("version") == 1:
+ view["graph"] = graph
+ views.append(view)
if not views:
raise RuntimeError("optimized render did not produce a usable diagram")
await asyncio.to_thread(
diff --git a/src/iac_code/web/diagrams.py b/src/iac_code/web/diagrams.py
index 505d5417..a2d2da03 100644
--- a/src/iac_code/web/diagrams.py
+++ b/src/iac_code/web/diagrams.py
@@ -6,38 +6,71 @@
from pathlib import Path
from typing import Any
+from iac_code.pipeline.engine.architecture_graph import render_ros_template_architecture_views
from iac_code.pipeline.engine.architecture_semantic_planning import browser_mermaid_source
-from iac_code.pipeline.engine.show_diagram_tool import ros_template_to_mermaid
from iac_code.web.diagram_cache import read_cached
from iac_code.web.outputs import TEMPLATE_SUFFIXES, is_template_content, pipeline_candidate_costs
_MATERIALIZED_STEP_ID = "materialize_selected_candidate"
_MATERIALIZED_OPTIMIZATION_KEY = "materialized"
_ARCHITECTURE_PLAN_SOURCE = "architecture_plan"
+PREVIEW_TEMPLATE_SUFFIXES = {".json", ".yaml", ".yml"}
-def _mermaid_or_none(content: str, suffix: str) -> str | None:
- """仅对 ROS YAML 模板产出 mermaid;非 YAML/非模板/解析失败一律 None。"""
+class TemplateDiagramPreviewError(ValueError):
+ """Raised when an uploaded template cannot produce an architecture preview."""
+
+
+def render_template_diagram_views(content: str, suffix: str) -> list[dict[str, Any]]:
+ """Render a standalone preview from an in-memory ROS template.
+
+ JSON is accepted because ROS JSON is also valid input for ``ros_yaml_load``. Terraform
+ is intentionally excluded until there is a deterministic Terraform graph parser.
+ """
+ normalized_suffix = suffix.lower()
+ if normalized_suffix not in PREVIEW_TEMPLATE_SUFFIXES:
+ raise TemplateDiagramPreviewError("unsupported_template_format")
+ if not content.strip() or not is_template_content(content, normalized_suffix):
+ raise TemplateDiagramPreviewError("not_ros_template")
+ try:
+ result = render_ros_template_architecture_views(content)
+ except Exception as exc:
+ raise TemplateDiagramPreviewError("template_render_failed") from exc
+ error_reason = result.architecture_context.get("error")
+ if error_reason == "yaml_parse_error":
+ raise TemplateDiagramPreviewError("template_parse_failed")
+ if error_reason:
+ raise TemplateDiagramPreviewError(str(error_reason))
+ views: list[dict[str, Any]] = [
+ {
+ "id": view.id,
+ "title": view.title,
+ "purpose": view.purpose,
+ "mermaidSource": browser_mermaid_source(view.mermaid_source),
+ "graph": view.graph,
+ }
+ for view in result.views
+ if view.mermaid_source
+ ]
+ if not views or not str(views[0]["mermaidSource"]).strip():
+ raise TemplateDiagramPreviewError("template_has_no_supported_resources")
+ return views
+
+
+def _rendered_views_or_none(content: str, suffix: str) -> list[dict[str, Any]] | None:
+ """Derive Mermaid and DiagramGraph views from one exact template projection."""
if suffix not in {".yaml", ".yml"}:
return None
- if not is_template_content(content, suffix):
- return None
try:
- source = ros_template_to_mermaid(content)
- except Exception:
- return None
- if not source:
- return None
- # ros_template_to_mermaid 不抛异常:YAML 解析失败时返回哨兵图(节点 id 恒为 Error,
- # 仅括号内文案随 i18n 变化),据此判定为不可解析并跳过。
- if source.startswith("graph TD\n Error["):
+ return render_template_diagram_views(content, suffix)
+ except TemplateDiagramPreviewError:
return None
- # 非 dict / 无 Resources 时 ros_template_to_mermaid 只返回裸表头(无节点),视为空图跳过。
- if source.strip() == "graph TD":
- return None
- # ros_template_to_mermaid 产出的 subgraph 标题未加引号且可能含括号(如 "VPC (10.0.0.0/16)"),
- # 浏览器端 mermaid.js 会解析失败(炸弹图)。复用与 HTML 预览(write_html)同一转换,给标题加引号。
- return browser_mermaid_source(source)
+
+
+def _mermaid_or_none(content: str, suffix: str) -> str | None:
+ """Compatibility helper returning the first renderer-neutral view's Mermaid source."""
+ views = _rendered_views_or_none(content, suffix)
+ return views[0]["mermaidSource"] if views else None
def _read_content(cwd: Path, raw_path: Any, captured: str | None) -> tuple[str, str] | None:
@@ -187,14 +220,20 @@ def diagram_items(
view_source = raw_view.get("mermaidSource") or raw_view.get("mermaid_source")
if not isinstance(view_source, str) or not view_source.strip():
continue
- views.append(
- {
- "id": str(raw_view.get("id") or "overview"),
- "title": str(raw_view.get("title") or ""),
- "purpose": str(raw_view.get("purpose") or ""),
- "mermaidSource": view_source,
- }
- )
+ view: dict[str, Any] = {
+ "id": str(raw_view.get("id") or "overview"),
+ "title": str(raw_view.get("title") or ""),
+ "purpose": str(raw_view.get("purpose") or ""),
+ "mermaidSource": view_source,
+ }
+ graph = raw_view.get("graph")
+ if isinstance(graph, dict) and graph.get("version") == 1:
+ view["graph"] = graph
+ views.append(view)
+ if views and "graph" not in views[0]:
+ graph = architecture_context.get("graph")
+ if isinstance(graph, dict) and graph.get("version") == 1:
+ views[0]["graph"] = graph
stage = str(data.get("diagramStage") or "optimized")
entry: dict[str, Any] = {
"diagramId": str(data.get("diagramId") or envelope.get("eventId") or f"plan:{index}"),
@@ -225,9 +264,10 @@ def diagram_items(
if read is None:
continue
content, suffix = read
- source = _mermaid_or_none(content, suffix)
- if source is None:
+ draft_views = _rendered_views_or_none(content, suffix)
+ if not draft_views:
continue
+ source = draft_views[0]["mermaidSource"]
candidate = envelope.get("candidate")
candidate = candidate if isinstance(candidate, dict) else {}
index = candidate.get("index")
@@ -267,6 +307,8 @@ def diagram_items(
}
if cached:
entry["views"] = cached
+ else:
+ entry["views"] = draft_views
exact_cost = materialized_costs.get(canonical_key)
if exact_cost is not None:
entry.update(exact_cost)
@@ -290,6 +332,8 @@ def diagram_items(
}
if cached:
entry["views"] = cached
+ else:
+ entry["views"] = draft_views
cost = costs.get(index)
if cost is not None:
entry["costItems"] = cost["costItems"]
diff --git a/src/iac_code/web/events.py b/src/iac_code/web/events.py
index a51f4e13..f2f21223 100644
--- a/src/iac_code/web/events.py
+++ b/src/iac_code/web/events.py
@@ -561,12 +561,16 @@ def translate_stream_event(self, event: StreamEvent, *, turn_id: str) -> dict[st
},
)
if isinstance(event, DiagramEvent):
- data = {
+ data: dict[str, Any] = {
"candidateName": event.candidate_name,
"templateContent": event.template_content,
"mermaidSource": event.mermaid_source,
"candidateIndex": event.candidate_index,
+ "diagramStage": event.diagram_stage,
+ "views": event.views,
}
+ if event.architecture_context is not None:
+ data["architectureContext"] = event.architecture_context
if event.candidate_set_id:
data["candidateSetId"] = event.candidate_set_id
if event.detail_stage:
diff --git a/src/iac_code/web/settings.py b/src/iac_code/web/settings.py
index 13c26420..a699b2a2 100644
--- a/src/iac_code/web/settings.py
+++ b/src/iac_code/web/settings.py
@@ -163,6 +163,8 @@ def save_telemetry_settings(share_content: bool) -> dict[str, bool]:
_APPEARANCE_SETTINGS_KEY = "appearance"
VALID_THEMES = ("graphite", "midnight", "evergreen", "sepia", "ivory")
DEFAULT_THEME = "graphite"
+VALID_ARCHITECTURE_DIAGRAM_RENDERERS = ("eraser", "mermaid")
+DEFAULT_ARCHITECTURE_DIAGRAM_RENDERER = "eraser"
def get_appearance_theme() -> str:
@@ -190,6 +192,33 @@ def save_appearance_theme(theme: str) -> dict[str, str]:
return {"theme": theme}
+def get_architecture_diagram_renderer() -> str:
+ """Return the persisted Web architecture renderer, defaulting to Eraser."""
+ settings = _load_yaml(get_settings_path())
+ section = settings.get(_APPEARANCE_SETTINGS_KEY)
+ if not isinstance(section, dict):
+ return DEFAULT_ARCHITECTURE_DIAGRAM_RENDERER
+ renderer = section.get("architectureDiagramRenderer")
+ if not isinstance(renderer, str) or renderer not in VALID_ARCHITECTURE_DIAGRAM_RENDERERS:
+ return DEFAULT_ARCHITECTURE_DIAGRAM_RENDERER
+ return renderer
+
+
+def save_architecture_diagram_renderer(renderer: str) -> dict[str, str]:
+ """Persist the Web architecture renderer while preserving appearance keys."""
+ if renderer not in VALID_ARCHITECTURE_DIAGRAM_RENDERERS:
+ raise ValueError(_("unknown architecture diagram renderer"))
+ settings_path = get_settings_path()
+ settings = _load_yaml(settings_path)
+ section = settings.get(_APPEARANCE_SETTINGS_KEY)
+ if not isinstance(section, dict):
+ section = {}
+ section["architectureDiagramRenderer"] = renderer
+ settings[_APPEARANCE_SETTINGS_KEY] = section
+ _save_yaml(settings_path, settings)
+ return {"renderer": renderer}
+
+
_UI_SETTINGS_KEY = "ui"
diff --git a/src/iac_code/web/static/diagram-preview-corpus.json b/src/iac_code/web/static/diagram-preview-corpus.json
new file mode 100644
index 00000000..e447f04d
--- /dev/null
+++ b/src/iac_code/web/static/diagram-preview-corpus.json
@@ -0,0 +1,35 @@
+{
+ "templateRepositoryCommit": "b2d8085990d471094a2de7f90b131992f6ba0795",
+ "templates": [
+ "examples/storage/simple-oss-bucket.yml",
+ "examples/free/multi-zone-network.yml",
+ "resources/ecs/ecs-instance-case2.yml",
+ "resources/ecs/nat-gateway.yml",
+ "resources/vpc/router-interface-update.yml",
+ "resources/slb/listener.yml",
+ "resources/alb/load-balancer-case2.yml",
+ "documents/solution/network/ga-nlb-global-accelerate.yml",
+ "resources/cen/cen.yml",
+ "examples/network/cen-open-isolated-networks.yml",
+ "examples/network/security-vpc.yml",
+ "documents/solution/network/enterprise-cloud-network-architecture-planning.yml",
+ "examples/elastic/ess-1-slb-2-rds-2-ecs.yml",
+ "documents/solution/high-availability-architecture/basic-highly-available-architecture-new.yml",
+ "documents/solution/high-availability-architecture/build-high-performance-flash-sale-system.yaml",
+ "documents/solution/micro/build-microservices-on-ack.yml",
+ "documents/solution/micro/elastic-scaling-container-through-hpa.yml",
+ "resources/eci/container-group.yml",
+ "documents/solution/internet-application-development/Run-Dify-on-Serverless.yml",
+ "documents/solution/internet-application-development/serverless-rabbitmq.yml",
+ "documents/solution/high-availability-architecture/serverless-high-availability-architecture.yml",
+ "documents/solution/database/migrate-rds-mysql-to-polardb-mysql.yml",
+ "documents/solution/database/Redis-for-High-Concurrency.yml",
+ "documents/solution/data-analysis/flink-kafka-streaming-lakehouse.yaml",
+ "documents/solution/database/through-RocketMQ-transaction-messages.yml",
+ "documents/solution/account/enterprise-multi-account-identity-authority-centralized-management.yml",
+ "documents/solution/security-and-compliance/protect-web-applications-with-WAF.yml",
+ "documents/solution/cdn-and-video-cloud/cdn-speeds-up-distribution-of-file-on-oss.yml",
+ "documents/solution/ai/pai-ai-painting-solution.yml",
+ "documents/solution/iot/iot-platform-device-data-on-the-cloud.yml"
+ ]
+}
diff --git a/src/iac_code/web/static/diagram-preview.css b/src/iac_code/web/static/diagram-preview.css
new file mode 100644
index 00000000..a129829b
--- /dev/null
+++ b/src/iac_code/web/static/diagram-preview.css
@@ -0,0 +1,332 @@
+.diagram-preview-page {
+ width: min(100%, 1600px);
+ min-height: 100vh;
+ margin: 0 auto;
+ padding: clamp(1rem, 3vw, 2.5rem);
+}
+
+.diagram-preview-header,
+.diagram-preview-result-header,
+.diagram-preview-picker-copy,
+.diagram-preview-library-copy {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.diagram-preview-header {
+ margin-bottom: 1.25rem;
+}
+
+.diagram-preview-header h1 {
+ margin-top: 0.15rem;
+ font-size: clamp(1.45rem, 2.6vw, 2.25rem);
+ line-height: 1.2;
+}
+
+.diagram-preview-eyebrow {
+ color: var(--codex-unread);
+ font-size: 0.72rem;
+ font-weight: 760;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.diagram-preview-intro,
+.diagram-preview-library-copy p,
+.diagram-preview-picker-copy p,
+.diagram-preview-result-header p,
+.diagram-preview-dropzone > span:last-child {
+ color: var(--codex-muted);
+}
+
+.diagram-preview-intro {
+ max-width: 42rem;
+ margin-top: 0.55rem;
+ line-height: 1.55;
+}
+
+.diagram-preview-back {
+ flex: 0 0 auto;
+ padding: 0.52rem 0.75rem;
+ border: 1px solid var(--codex-border);
+ border-radius: 0.55rem;
+ color: var(--codex-text);
+ text-decoration: none;
+}
+
+.diagram-preview-back:hover,
+.diagram-preview-back:focus-visible {
+ border-color: var(--codex-border-strong);
+ background: var(--codex-hover);
+}
+
+.diagram-preview-picker,
+.diagram-preview-library,
+.diagram-preview-result {
+ border: 1px solid var(--codex-border);
+ border-radius: 0.85rem;
+ background: var(--codex-panel-raised);
+ box-shadow: 0 16px 46px color-mix(in srgb, var(--codex-bg) 44%, transparent);
+}
+
+.diagram-preview-library {
+ position: sticky;
+ z-index: 5;
+ top: 0.75rem;
+ display: grid;
+ grid-template-columns: minmax(14rem, 0.8fr) minmax(32rem, 1.8fr);
+ gap: 0.7rem 1rem;
+ align-items: end;
+ padding: clamp(0.85rem, 1.6vw, 1.1rem);
+ background: color-mix(in srgb, var(--codex-panel-raised) 96%, transparent);
+ backdrop-filter: blur(18px);
+}
+
+.diagram-preview-library-copy {
+ display: block;
+}
+
+.diagram-preview-library-copy p {
+ max-width: 32rem;
+ margin-top: 0.35rem;
+ font-size: 0.78rem;
+ line-height: 1.45;
+}
+
+.diagram-preview-library-actions {
+ display: grid;
+ grid-template-columns: max-content minmax(14rem, 1fr) max-content;
+ gap: 0.65rem;
+ align-items: end;
+}
+
+.diagram-preview-button,
+.diagram-preview-field select {
+ min-height: 2.55rem;
+ border: 1px solid var(--codex-border);
+ border-radius: 0.6rem;
+ color: var(--codex-text);
+ background: var(--codex-panel);
+ font: inherit;
+}
+
+.diagram-preview-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.42rem;
+ padding: 0.55rem 0.8rem;
+ font-size: 0.8rem;
+ font-weight: 680;
+ cursor: pointer;
+}
+
+.diagram-preview-button:hover,
+.diagram-preview-button:focus-visible,
+.diagram-preview-field select:hover,
+.diagram-preview-field select:focus-visible {
+ border-color: var(--codex-unread);
+ outline: none;
+}
+
+.diagram-preview-button:disabled,
+.diagram-preview-field select:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+.diagram-preview-field {
+ display: grid;
+ gap: 0.28rem;
+ min-width: 0;
+ color: var(--codex-muted);
+ font-size: 0.7rem;
+ font-weight: 650;
+}
+
+.diagram-preview-field select {
+ width: 100%;
+ min-width: 0;
+ padding: 0.45rem 2rem 0.45rem 0.65rem;
+ font-size: 0.78rem;
+}
+
+.diagram-preview-library-status {
+ grid-column: 1 / -1;
+ min-height: 1rem;
+ color: var(--codex-muted);
+ font-size: 0.74rem;
+}
+
+.diagram-preview-library-status.is-error {
+ color: var(--danger, #db6d65);
+}
+
+.diagram-preview-picker {
+ margin-top: 1rem;
+ padding: clamp(0.9rem, 2vw, 1.3rem);
+}
+
+.diagram-preview-picker-copy {
+ align-items: baseline;
+ margin-bottom: 0.85rem;
+}
+
+.diagram-preview-picker-copy p,
+.diagram-preview-result-header p {
+ font-size: 0.82rem;
+}
+
+.diagram-preview-dropzone {
+ display: grid;
+ justify-items: center;
+ gap: 0.48rem;
+ min-height: 8rem;
+ padding: 1.5rem;
+ border: 1px dashed var(--codex-border-strong);
+ border-radius: 0.7rem;
+ background: color-mix(in srgb, var(--codex-panel-raised) 78%, var(--codex-bg));
+ text-align: center;
+ cursor: pointer;
+ transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
+}
+
+.diagram-preview-dropzone:hover,
+.diagram-preview-dropzone:focus-visible,
+.diagram-preview-dropzone.is-dragging {
+ border-color: var(--codex-unread);
+ background: color-mix(in srgb, var(--codex-unread) 8%, var(--codex-panel-raised));
+ outline: none;
+}
+
+.diagram-preview-dropzone.is-dragging {
+ transform: scale(1.003);
+}
+
+.diagram-preview-drop-icon {
+ display: grid;
+ width: 2.35rem;
+ height: 2.35rem;
+ place-items: center;
+ border-radius: 50%;
+ color: var(--codex-text);
+ background: var(--codex-hover);
+}
+
+.diagram-preview-drop-icon svg {
+ width: 1.25rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.6;
+}
+
+.diagram-preview-dropzone strong {
+ font-size: 0.95rem;
+}
+
+.diagram-preview-dropzone > span:last-child {
+ font-size: 0.78rem;
+}
+
+.diagram-preview-status {
+ display: block;
+ min-height: 1.2rem;
+ margin-top: 0.65rem;
+ color: var(--codex-muted);
+ font-size: 0.82rem;
+}
+
+.diagram-preview-status.is-error {
+ color: var(--danger, #db6d65);
+}
+
+.diagram-preview-result {
+ margin-top: 1rem;
+ overflow: hidden;
+}
+
+.diagram-preview-result-header {
+ align-items: center;
+ padding: 0.9rem 1rem;
+ border-bottom: 1px solid var(--codex-border);
+}
+
+.diagram-preview-result-header h2 {
+ overflow-wrap: anywhere;
+}
+
+.diagram-preview-result-header p {
+ margin-top: 0.25rem;
+}
+
+.diagram-preview-renderer {
+ flex: 0 0 auto;
+ padding: 0.25rem 0.55rem;
+ border: 1px solid var(--codex-border);
+ border-radius: 999px;
+ color: var(--codex-muted);
+ font-size: 0.74rem;
+}
+
+.diagram-preview-canvas {
+ min-height: 32rem;
+ padding: clamp(0.75rem, 2vw, 1.4rem);
+ overflow: auto;
+ background: color-mix(in srgb, var(--codex-bg) 88%, #000000);
+}
+
+:root[data-theme="ivory"] .diagram-preview-canvas {
+ background: color-mix(in srgb, var(--codex-bg) 92%, #d7d7d0);
+}
+
+.diagram-preview-render {
+ min-width: 100%;
+}
+
+.diagram-preview-render .architecture-eraser-frame {
+ min-height: 30rem;
+}
+
+.diagram-preview-render .mermaid-diagram {
+ min-height: 28rem;
+ align-items: center;
+}
+
+@media (max-width: 680px) {
+ .diagram-preview-header,
+ .diagram-preview-library-copy,
+ .diagram-preview-picker-copy,
+ .diagram-preview-result-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .diagram-preview-back,
+ .diagram-preview-renderer {
+ align-self: flex-start;
+ }
+
+ .diagram-preview-canvas {
+ min-height: 25rem;
+ }
+}
+
+@media (max-width: 980px) {
+ .diagram-preview-library {
+ position: static;
+ grid-template-columns: 1fr;
+ }
+
+ .diagram-preview-library-actions {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+
+ .diagram-preview-button {
+ width: 100%;
+ }
+}
diff --git a/src/iac_code/web/static/diagram-preview.html b/src/iac_code/web/static/diagram-preview.html
new file mode 100644
index 00000000..a4680ff4
--- /dev/null
+++ b/src/iac_code/web/static/diagram-preview.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
Template library
+
+ Select the local ros-templates folder once, then switch between the fixed corpus or random templates.
+
+
+
+
+
+ Select ros-templates folder
+
+
+ Fixed 30-template corpus
+
+ Select a template
+
+
+
+ ↝
+ Random other template
+
+
+
+
+
+
+
+
Single template
+
+ Supports ROS YAML, YML, and JSON templates up to 2 MB.
+
+
+
+
+
+
+
+
+
+ Drop a template here, or click to choose
+
+ The file is read for this preview and is not saved.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/iac_code/web/static/eraser-frame.html b/src/iac_code/web/static/eraser-frame.html
new file mode 100644
index 00000000..9b7dd2a6
--- /dev/null
+++ b/src/iac_code/web/static/eraser-frame.html
@@ -0,0 +1,51 @@
+
+
+
+