@documentation-robotics/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PYTHON_CLI_PARITY.md +265 -0
- package/PYTHON_CLI_STRUCTURE_UPDATE.md +246 -0
- package/README.md +926 -0
- package/biome.json +42 -0
- package/bunfig.toml +3 -0
- package/dist/ai/claude-client.js +71 -0
- package/dist/ai/claude-client.js.map +7 -0
- package/dist/ai/context-provider.js +129 -0
- package/dist/ai/context-provider.js.map +7 -0
- package/dist/ai/tools.js +231 -0
- package/dist/ai/tools.js.map +7 -0
- package/dist/cli.js +224 -0
- package/dist/cli.js.map +7 -0
- package/dist/commands/add.js +51 -0
- package/dist/commands/add.js.map +7 -0
- package/dist/commands/changeset.js +174 -0
- package/dist/commands/changeset.js.map +7 -0
- package/dist/commands/chat.js +111 -0
- package/dist/commands/chat.js.map +7 -0
- package/dist/commands/conformance.js +132 -0
- package/dist/commands/conformance.js.map +7 -0
- package/dist/commands/delete.js +31 -0
- package/dist/commands/delete.js.map +7 -0
- package/dist/commands/element.js +28 -0
- package/dist/commands/element.js.map +7 -0
- package/dist/commands/export.js +65 -0
- package/dist/commands/export.js.map +7 -0
- package/dist/commands/info.js +40 -0
- package/dist/commands/info.js.map +7 -0
- package/dist/commands/init.js +45 -0
- package/dist/commands/init.js.map +7 -0
- package/dist/commands/list.js +32 -0
- package/dist/commands/list.js.map +7 -0
- package/dist/commands/migrate.js +36 -0
- package/dist/commands/migrate.js.map +7 -0
- package/dist/commands/project.js +74 -0
- package/dist/commands/project.js.map +7 -0
- package/dist/commands/relationship.js +118 -0
- package/dist/commands/relationship.js.map +7 -0
- package/dist/commands/search.js +48 -0
- package/dist/commands/search.js.map +7 -0
- package/dist/commands/show.js +38 -0
- package/dist/commands/show.js.map +7 -0
- package/dist/commands/trace.js +60 -0
- package/dist/commands/trace.js.map +7 -0
- package/dist/commands/update.js +37 -0
- package/dist/commands/update.js.map +7 -0
- package/dist/commands/upgrade.js +152 -0
- package/dist/commands/upgrade.js.map +7 -0
- package/dist/commands/validate.js +71 -0
- package/dist/commands/validate.js.map +7 -0
- package/dist/commands/version.js +20 -0
- package/dist/commands/version.js.map +7 -0
- package/dist/commands/visualize.js +51 -0
- package/dist/commands/visualize.js.map +7 -0
- package/dist/core/active-changeset.js +52 -0
- package/dist/core/active-changeset.js.map +7 -0
- package/dist/core/changeset.js +244 -0
- package/dist/core/changeset.js.map +7 -0
- package/dist/core/dependency-tracker.js +322 -0
- package/dist/core/dependency-tracker.js.map +7 -0
- package/dist/core/element.js +65 -0
- package/dist/core/element.js.map +7 -0
- package/dist/core/layer.js +75 -0
- package/dist/core/layer.js.map +7 -0
- package/dist/core/manifest.js +57 -0
- package/dist/core/manifest.js.map +7 -0
- package/dist/core/migration-registry.js +131 -0
- package/dist/core/migration-registry.js.map +7 -0
- package/dist/core/model.js +455 -0
- package/dist/core/model.js.map +7 -0
- package/dist/core/projection-engine.js +288 -0
- package/dist/core/projection-engine.js.map +7 -0
- package/dist/core/reference-registry.js +188 -0
- package/dist/core/reference-registry.js.map +7 -0
- package/dist/core/relationship-registry.js +114 -0
- package/dist/core/relationship-registry.js.map +7 -0
- package/dist/core/relationships.js +78 -0
- package/dist/core/relationships.js.map +7 -0
- package/dist/export/archimate-exporter.js +111 -0
- package/dist/export/archimate-exporter.js.map +7 -0
- package/dist/export/export-manager.js +63 -0
- package/dist/export/export-manager.js.map +7 -0
- package/dist/export/graphml-exporter.js +39 -0
- package/dist/export/graphml-exporter.js.map +7 -0
- package/dist/export/index.js +17 -0
- package/dist/export/index.js.map +7 -0
- package/dist/export/json-schema-exporter.js +60 -0
- package/dist/export/json-schema-exporter.js.map +7 -0
- package/dist/export/markdown-exporter.js +99 -0
- package/dist/export/markdown-exporter.js.map +7 -0
- package/dist/export/openapi-exporter.js +85 -0
- package/dist/export/openapi-exporter.js.map +7 -0
- package/dist/export/plantuml-exporter.js +60 -0
- package/dist/export/plantuml-exporter.js.map +7 -0
- package/dist/export/types.js +36 -0
- package/dist/export/types.js.map +7 -0
- package/dist/schemas/bundled/01-motivation-layer.schema.json +942 -0
- package/dist/schemas/bundled/02-business-layer.schema.json +1293 -0
- package/dist/schemas/bundled/03-security-layer.schema.json +2177 -0
- package/dist/schemas/bundled/04-application-layer.schema.json +1328 -0
- package/dist/schemas/bundled/05-technology-layer.schema.json +1465 -0
- package/dist/schemas/bundled/06-api-layer.schema.json +1743 -0
- package/dist/schemas/bundled/07-data-model-layer.schema.json +1253 -0
- package/dist/schemas/bundled/08-datastore-layer.schema.json +892 -0
- package/dist/schemas/bundled/09-ux-layer.schema.json +1506 -0
- package/dist/schemas/bundled/10-navigation-layer.schema.json +843 -0
- package/dist/schemas/bundled/11-apm-observability-layer.schema.json +1430 -0
- package/dist/schemas/bundled/12-testing-layer.schema.json +1091 -0
- package/dist/schemas/bundled/common/layer-extensions.schema.json +266 -0
- package/dist/schemas/bundled/common/predicates.schema.json +493 -0
- package/dist/schemas/bundled/common/relationships.schema.json +791 -0
- package/dist/schemas/bundled/common/source-references.schema.json +71 -0
- package/dist/schemas/bundled/link-registry.json +1839 -0
- package/dist/schemas/bundled/link-registry.schema.json +339 -0
- package/dist/schemas/bundled/relationship-catalog.json +1302 -0
- package/dist/schemas/bundled/relationship-type.schema.json +221 -0
- package/dist/server/server.js +1207 -0
- package/dist/server/server.js.map +7 -0
- package/dist/server/telemetry-middleware.js +24 -0
- package/dist/server/telemetry-middleware.js.map +7 -0
- package/dist/telemetry/console-interceptor.js +34 -0
- package/dist/telemetry/console-interceptor.js.map +7 -0
- package/dist/telemetry/index.js +54 -0
- package/dist/telemetry/index.js.map +7 -0
- package/dist/telemetry/resilient-exporter.js +41 -0
- package/dist/telemetry/resilient-exporter.js.map +7 -0
- package/dist/telemetry/resilient-log-exporter.js +41 -0
- package/dist/telemetry/resilient-log-exporter.js.map +7 -0
- package/dist/telemetry/test-instrumentation.js +32 -0
- package/dist/telemetry/test-instrumentation.js.map +7 -0
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +7 -0
- package/dist/utils/element-utils.js +11 -0
- package/dist/utils/element-utils.js.map +7 -0
- package/dist/utils/errors.js +105 -0
- package/dist/utils/errors.js.map +7 -0
- package/dist/utils/file-io.js +69 -0
- package/dist/utils/file-io.js.map +7 -0
- package/dist/utils/globals.js +28 -0
- package/dist/utils/globals.js.map +7 -0
- package/dist/utils/model-path.js +22 -0
- package/dist/utils/model-path.js.map +7 -0
- package/dist/utils/project-paths.js +45 -0
- package/dist/utils/project-paths.js.map +7 -0
- package/dist/utils/spec-installer.js +85 -0
- package/dist/utils/spec-installer.js.map +7 -0
- package/dist/utils/spec-version.js +33 -0
- package/dist/utils/spec-version.js.map +7 -0
- package/dist/validators/index.js +15 -0
- package/dist/validators/index.js.map +7 -0
- package/dist/validators/naming-validator.js +82 -0
- package/dist/validators/naming-validator.js.map +7 -0
- package/dist/validators/reference-validator.js +106 -0
- package/dist/validators/reference-validator.js.map +7 -0
- package/dist/validators/schema-validator.js +148 -0
- package/dist/validators/schema-validator.js.map +7 -0
- package/dist/validators/semantic-validator.js +80 -0
- package/dist/validators/semantic-validator.js.map +7 -0
- package/dist/validators/types.js +48 -0
- package/dist/validators/types.js.map +7 -0
- package/dist/validators/validator.js +67 -0
- package/dist/validators/validator.js.map +7 -0
- package/docs/TEST-INSTRUMENTATION.md +405 -0
- package/docs/telemetry.md +427 -0
- package/package.json +70 -0
- package/test-debug.js +33 -0
- package/test-special.mjs +46 -0
- package/test_runner.ts +7 -0
|
@@ -0,0 +1,1207 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { upgradeWebSocket, websocket } from "hono/bun";
|
|
3
|
+
import { cors } from "hono/cors";
|
|
4
|
+
import { serve } from "bun";
|
|
5
|
+
import { Model } from "../core/model.js";
|
|
6
|
+
import { telemetryMiddleware } from "./telemetry-middleware.js";
|
|
7
|
+
class VisualizationServer {
|
|
8
|
+
app;
|
|
9
|
+
model;
|
|
10
|
+
_server;
|
|
11
|
+
clients = /* @__PURE__ */ new Set();
|
|
12
|
+
watcher;
|
|
13
|
+
annotations = /* @__PURE__ */ new Map();
|
|
14
|
+
// annotationId -> annotation
|
|
15
|
+
annotationsByElement = /* @__PURE__ */ new Map();
|
|
16
|
+
// elementId -> Set<annotationId>
|
|
17
|
+
changesets = /* @__PURE__ */ new Map();
|
|
18
|
+
// changesetId -> changeset
|
|
19
|
+
authToken;
|
|
20
|
+
authEnabled = !0;
|
|
21
|
+
// Enabled by default for security
|
|
22
|
+
activeChatProcesses = /* @__PURE__ */ new Map();
|
|
23
|
+
// conversationId -> Bun.spawn process
|
|
24
|
+
chatConversationCounter = 0;
|
|
25
|
+
constructor(model, options) {
|
|
26
|
+
this.app = new Hono(), this.model = model, this.authEnabled = options?.authEnabled ?? process.env.DR_AUTH_ENABLED !== "false", this.authToken = options?.authToken || process.env.DR_AUTH_TOKEN || this.generateAuthToken(), this.app.use("/*", cors()), this.app.use("/*", telemetryMiddleware), this.app.use("/api/*", async (c, next) => {
|
|
27
|
+
if (!this.authEnabled)
|
|
28
|
+
return next();
|
|
29
|
+
const authHeader = c.req.header("Authorization"), queryToken = c.req.query("token");
|
|
30
|
+
let providedToken = null;
|
|
31
|
+
return authHeader && authHeader.startsWith("Bearer ") ? providedToken = authHeader.substring(7) : queryToken && (providedToken = queryToken), providedToken ? providedToken !== this.authToken ? c.json({ error: "Invalid authentication token" }, 403) : next() : c.json({ error: "Authentication required. Please provide a valid token." }, 401);
|
|
32
|
+
}), this.setupRoutes(), this.authEnabled && process.env.VERBOSE && console.log(`[Auth] Authentication enabled. Token: ${this.authToken}`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Generate a random auth token
|
|
36
|
+
*/
|
|
37
|
+
generateAuthToken() {
|
|
38
|
+
return `dr-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Setup HTTP routes and WebSocket handler
|
|
42
|
+
*/
|
|
43
|
+
setupRoutes() {
|
|
44
|
+
this.app.get("/", (c) => c.html(this.getViewerHTML())), this.app.get("/health", (c) => c.json({
|
|
45
|
+
status: "ok",
|
|
46
|
+
version: "0.1.0"
|
|
47
|
+
})), this.app.get("/api/model", async (c) => {
|
|
48
|
+
try {
|
|
49
|
+
const modelData = await this.serializeModel();
|
|
50
|
+
return c.json(modelData);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
+
return c.json({ error: message }, 500);
|
|
54
|
+
}
|
|
55
|
+
}), this.app.get("/api/layers/:name", async (c) => {
|
|
56
|
+
try {
|
|
57
|
+
const layerName = c.req.param("name"), layer = await this.model.getLayer(layerName);
|
|
58
|
+
if (!layer)
|
|
59
|
+
return c.json({ error: "Layer not found" }, 404);
|
|
60
|
+
const elements = layer.listElements().map((e) => e.toJSON());
|
|
61
|
+
return c.json({
|
|
62
|
+
name: layerName,
|
|
63
|
+
elements,
|
|
64
|
+
elementCount: elements.length
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
return c.json({ error: message }, 500);
|
|
69
|
+
}
|
|
70
|
+
}), this.app.get("/api/elements/:id", async (c) => {
|
|
71
|
+
try {
|
|
72
|
+
const elementId = c.req.param("id"), element = await this.findElement(elementId);
|
|
73
|
+
return element ? c.json({
|
|
74
|
+
...element.toJSON(),
|
|
75
|
+
annotations: this.annotations.get(elementId) || []
|
|
76
|
+
}) : c.json({ error: "Element not found" }, 404);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
return c.json({ error: message }, 500);
|
|
80
|
+
}
|
|
81
|
+
}), this.app.get("/api/spec", async (c) => {
|
|
82
|
+
try {
|
|
83
|
+
const schemas = await this.loadSchemas();
|
|
84
|
+
return c.json({
|
|
85
|
+
version: "0.1.0",
|
|
86
|
+
type: "schema-collection",
|
|
87
|
+
description: "JSON Schema definitions from dr CLI",
|
|
88
|
+
source: "dr-cli",
|
|
89
|
+
schemas,
|
|
90
|
+
schemaCount: Object.keys(schemas).length
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
return c.json({ error: message }, 500);
|
|
95
|
+
}
|
|
96
|
+
}), this.app.get("/api/link-registry", async (c) => {
|
|
97
|
+
try {
|
|
98
|
+
const linkRegistry = await this.loadLinkRegistry();
|
|
99
|
+
return c.json(linkRegistry);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
return c.json({ error: message }, 404);
|
|
103
|
+
}
|
|
104
|
+
}), this.app.get("/api/annotations", (c) => {
|
|
105
|
+
const elementId = c.req.query("elementId");
|
|
106
|
+
if (elementId) {
|
|
107
|
+
const annotationIds = this.annotationsByElement.get(elementId) || /* @__PURE__ */ new Set(), annotations = Array.from(annotationIds).map((id) => this.annotations.get(id)).filter((a) => a !== void 0);
|
|
108
|
+
return c.json({ annotations });
|
|
109
|
+
} else {
|
|
110
|
+
const annotations = Array.from(this.annotations.values());
|
|
111
|
+
return c.json({ annotations });
|
|
112
|
+
}
|
|
113
|
+
}), this.app.post("/api/annotations", async (c) => {
|
|
114
|
+
try {
|
|
115
|
+
const body = await c.req.json();
|
|
116
|
+
if (!body.elementId || !body.content || !body.author)
|
|
117
|
+
return c.json({
|
|
118
|
+
error: "Missing required fields: elementId, content, author"
|
|
119
|
+
}, 400);
|
|
120
|
+
if (!await this.findElement(body.elementId))
|
|
121
|
+
return c.json({ error: "Element not found" }, 404);
|
|
122
|
+
const annotation = {
|
|
123
|
+
id: this.generateAnnotationId(),
|
|
124
|
+
elementId: String(body.elementId),
|
|
125
|
+
author: String(body.author),
|
|
126
|
+
content: String(body.content),
|
|
127
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
128
|
+
tags: body.tags || []
|
|
129
|
+
};
|
|
130
|
+
return this.annotations.set(annotation.id, annotation), this.annotationsByElement.has(annotation.elementId) || this.annotationsByElement.set(annotation.elementId, /* @__PURE__ */ new Set()), this.annotationsByElement.get(annotation.elementId).add(annotation.id), await this.broadcastMessage({
|
|
131
|
+
type: "annotation.added",
|
|
132
|
+
annotationId: annotation.id,
|
|
133
|
+
elementId: annotation.elementId,
|
|
134
|
+
timestamp: annotation.createdAt
|
|
135
|
+
}), c.json(annotation, 201);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
138
|
+
return c.json({ error: message }, 500);
|
|
139
|
+
}
|
|
140
|
+
}), this.app.put("/api/annotations/:annotationId", async (c) => {
|
|
141
|
+
try {
|
|
142
|
+
const annotationId = c.req.param("annotationId"), annotation = this.annotations.get(annotationId);
|
|
143
|
+
if (!annotation)
|
|
144
|
+
return c.json({ error: "Annotation not found" }, 404);
|
|
145
|
+
const body = await c.req.json();
|
|
146
|
+
return body.content !== void 0 && (annotation.content = String(body.content)), body.tags !== void 0 && (annotation.tags = body.tags), annotation.updatedAt = (/* @__PURE__ */ new Date()).toISOString(), await this.broadcastMessage({
|
|
147
|
+
type: "annotation.updated",
|
|
148
|
+
annotationId: annotation.id,
|
|
149
|
+
timestamp: annotation.updatedAt
|
|
150
|
+
}), c.json(annotation);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
153
|
+
return c.json({ error: message }, 500);
|
|
154
|
+
}
|
|
155
|
+
}), this.app.delete("/api/annotations/:annotationId", async (c) => {
|
|
156
|
+
try {
|
|
157
|
+
const annotationId = c.req.param("annotationId"), annotation = this.annotations.get(annotationId);
|
|
158
|
+
if (!annotation)
|
|
159
|
+
return c.json({ error: "Annotation not found" }, 404);
|
|
160
|
+
this.annotations.delete(annotationId);
|
|
161
|
+
const elementAnnotations = this.annotationsByElement.get(annotation.elementId);
|
|
162
|
+
return elementAnnotations && elementAnnotations.delete(annotationId), await this.broadcastMessage({
|
|
163
|
+
type: "annotation.deleted",
|
|
164
|
+
annotationId,
|
|
165
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
166
|
+
}), c.body(null, 204);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
return c.json({ error: message }, 500);
|
|
170
|
+
}
|
|
171
|
+
}), this.app.get("/api/changesets", (c) => {
|
|
172
|
+
const changesets = {};
|
|
173
|
+
for (const [id, changeset] of this.changesets)
|
|
174
|
+
changesets[id] = {
|
|
175
|
+
name: changeset.metadata.name,
|
|
176
|
+
status: changeset.metadata.status,
|
|
177
|
+
type: changeset.metadata.type,
|
|
178
|
+
created_at: changeset.metadata.created_at,
|
|
179
|
+
elements_count: changeset.changes.changes.length
|
|
180
|
+
};
|
|
181
|
+
return c.json({
|
|
182
|
+
version: "1.0.0",
|
|
183
|
+
changesets
|
|
184
|
+
});
|
|
185
|
+
}), this.app.get("/api/changesets/:changesetId", (c) => {
|
|
186
|
+
const changesetId = c.req.param("changesetId"), changeset = this.changesets.get(changesetId);
|
|
187
|
+
return changeset ? c.json(changeset) : c.json({ error: "Changeset not found" }, 404);
|
|
188
|
+
}), this.app.get("/api/elements/:id/annotations", (c) => {
|
|
189
|
+
const elementId = c.req.param("id"), annotationIds = this.annotationsByElement.get(elementId) || /* @__PURE__ */ new Set(), annotations = Array.from(annotationIds).map((id) => this.annotations.get(id)).filter((a) => a !== void 0);
|
|
190
|
+
return c.json({ elementId, annotations });
|
|
191
|
+
}), this.app.post("/api/elements/:id/annotations", async (c) => {
|
|
192
|
+
try {
|
|
193
|
+
const elementId = c.req.param("id");
|
|
194
|
+
if (!await this.findElement(elementId))
|
|
195
|
+
return c.json({ error: "Element not found" }, 404);
|
|
196
|
+
const body = await c.req.json(), content = (body.content || body.text) && String(body.content || body.text).trim();
|
|
197
|
+
if (!content)
|
|
198
|
+
return c.json({ error: "Annotation content cannot be empty" }, 400);
|
|
199
|
+
const author = body.author ? String(body.author).trim() : "Anonymous", annotation = {
|
|
200
|
+
id: this.generateAnnotationId(),
|
|
201
|
+
elementId,
|
|
202
|
+
author,
|
|
203
|
+
content,
|
|
204
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
205
|
+
tags: body.tags || []
|
|
206
|
+
};
|
|
207
|
+
return this.annotations.set(annotation.id, annotation), this.annotationsByElement.has(elementId) || this.annotationsByElement.set(elementId, /* @__PURE__ */ new Set()), this.annotationsByElement.get(elementId).add(annotation.id), await this.broadcastMessage({
|
|
208
|
+
type: "annotation.added",
|
|
209
|
+
annotationId: annotation.id,
|
|
210
|
+
elementId: annotation.elementId,
|
|
211
|
+
timestamp: annotation.createdAt
|
|
212
|
+
}), c.json({ success: !0, annotation });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
|
+
return c.json({ error: message }, 500);
|
|
216
|
+
}
|
|
217
|
+
}), this.app.get("/ws", async (c, next) => {
|
|
218
|
+
if (this.authEnabled) {
|
|
219
|
+
const queryToken = c.req.query("token");
|
|
220
|
+
if (!queryToken)
|
|
221
|
+
return c.json({ error: "Authentication required. Provide token as query parameter: ?token=YOUR_TOKEN" }, 401);
|
|
222
|
+
if (queryToken !== this.authToken)
|
|
223
|
+
return c.json({ error: "Invalid authentication token" }, 403);
|
|
224
|
+
}
|
|
225
|
+
return next();
|
|
226
|
+
}, upgradeWebSocket(() => ({
|
|
227
|
+
onOpen: (_evt, ws) => {
|
|
228
|
+
this.clients.add(ws), ws.send(JSON.stringify({
|
|
229
|
+
type: "connected",
|
|
230
|
+
version: "0.1.0",
|
|
231
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
232
|
+
})), process.env.VERBOSE && console.log(`[WebSocket] Client connected (total: ${this.clients.size})`);
|
|
233
|
+
},
|
|
234
|
+
onClose: (_evt, ws) => {
|
|
235
|
+
this.clients.delete(ws), process.env.VERBOSE && console.log(`[WebSocket] Client disconnected (total: ${this.clients.size})`);
|
|
236
|
+
},
|
|
237
|
+
onMessage: async (message, ws) => {
|
|
238
|
+
try {
|
|
239
|
+
const rawData = message.data ?? message;
|
|
240
|
+
let msgStr;
|
|
241
|
+
typeof rawData == "string" ? msgStr = rawData : rawData instanceof Buffer || rawData instanceof Uint8Array ? msgStr = new TextDecoder().decode(rawData) : msgStr = String(rawData);
|
|
242
|
+
const data = JSON.parse(msgStr);
|
|
243
|
+
data.jsonrpc === "2.0" ? await this.handleJSONRPCMessage(ws, data) : await this.handleWSMessage(ws, data);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
246
|
+
process.env.DEBUG && console.error(`[WebSocket] Error handling message: ${errorMsg}`), ws.send(JSON.stringify({
|
|
247
|
+
type: "error",
|
|
248
|
+
message: errorMsg
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
onError: (error) => {
|
|
253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
+
console.error(`[WebSocket] Error: ${message}`);
|
|
255
|
+
}
|
|
256
|
+
})));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Serialize model to JSON for client consumption
|
|
260
|
+
*/
|
|
261
|
+
async serializeModel() {
|
|
262
|
+
const layers = {};
|
|
263
|
+
for (const [name, layer] of this.model.layers) {
|
|
264
|
+
const elements = layer.listElements().map((e) => {
|
|
265
|
+
const annotationIds = this.annotationsByElement.get(e.id) || /* @__PURE__ */ new Set(), annotations = Array.from(annotationIds).map((id) => this.annotations.get(id)).filter((a) => a !== void 0);
|
|
266
|
+
return {
|
|
267
|
+
...e.toJSON(),
|
|
268
|
+
annotations
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
layers[name] = {
|
|
272
|
+
name,
|
|
273
|
+
elements,
|
|
274
|
+
elementCount: elements.length
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
manifest: this.model.manifest.toJSON(),
|
|
279
|
+
layers,
|
|
280
|
+
totalElements: Array.from(this.model.layers.values()).reduce((sum, layer) => sum + layer.listElements().length, 0)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Generate unique annotation ID
|
|
285
|
+
*/
|
|
286
|
+
generateAnnotationId() {
|
|
287
|
+
return `ann-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Broadcast message to all connected WebSocket clients
|
|
291
|
+
*/
|
|
292
|
+
async broadcastMessage(message) {
|
|
293
|
+
const messageStr = JSON.stringify(message);
|
|
294
|
+
let failureCount = 0;
|
|
295
|
+
for (const client of this.clients)
|
|
296
|
+
try {
|
|
297
|
+
client.send(messageStr);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
failureCount++;
|
|
300
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
301
|
+
process.env.VERBOSE && console.warn(`[WebSocket] Failed to send message to client: ${msg}`);
|
|
302
|
+
}
|
|
303
|
+
failureCount > 0 && process.env.VERBOSE && console.warn(`[WebSocket] Failed to send message to ${failureCount}/${this.clients.size} clients`);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Find an element across all layers
|
|
307
|
+
*/
|
|
308
|
+
async findElement(id) {
|
|
309
|
+
for (const layer of this.model.layers.values()) {
|
|
310
|
+
const element = layer.getElement(id);
|
|
311
|
+
if (element)
|
|
312
|
+
return element;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Handle WebSocket messages
|
|
318
|
+
*/
|
|
319
|
+
async handleWSMessage(ws, data) {
|
|
320
|
+
switch (data.type) {
|
|
321
|
+
case "subscribe":
|
|
322
|
+
const topics = data.topics || ["model", "annotations"];
|
|
323
|
+
ws.send(JSON.stringify({
|
|
324
|
+
type: "subscribed",
|
|
325
|
+
topics,
|
|
326
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
327
|
+
}));
|
|
328
|
+
const modelData = await this.serializeModel();
|
|
329
|
+
ws.send(JSON.stringify({
|
|
330
|
+
type: "model",
|
|
331
|
+
data: modelData,
|
|
332
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
333
|
+
}));
|
|
334
|
+
break;
|
|
335
|
+
case "ping":
|
|
336
|
+
ws.send(JSON.stringify({
|
|
337
|
+
type: "pong",
|
|
338
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
339
|
+
}));
|
|
340
|
+
break;
|
|
341
|
+
case "annotate":
|
|
342
|
+
data.annotation && await this.broadcastMessage({
|
|
343
|
+
type: "annotation",
|
|
344
|
+
data: data.annotation
|
|
345
|
+
});
|
|
346
|
+
break;
|
|
347
|
+
default:
|
|
348
|
+
ws.send(JSON.stringify({
|
|
349
|
+
type: "error",
|
|
350
|
+
message: `Unknown message type: ${data.type}`
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Handle JSON-RPC 2.0 messages for chat functionality
|
|
356
|
+
*/
|
|
357
|
+
async handleJSONRPCMessage(ws, data) {
|
|
358
|
+
const { method, params, id } = data, sendResponse = (result) => {
|
|
359
|
+
ws.send(JSON.stringify({
|
|
360
|
+
jsonrpc: "2.0",
|
|
361
|
+
result,
|
|
362
|
+
id
|
|
363
|
+
}));
|
|
364
|
+
}, sendError = (code, message, data2) => {
|
|
365
|
+
ws.send(JSON.stringify({
|
|
366
|
+
jsonrpc: "2.0",
|
|
367
|
+
error: { code, message, data: data2 },
|
|
368
|
+
id
|
|
369
|
+
}));
|
|
370
|
+
};
|
|
371
|
+
try {
|
|
372
|
+
switch (method) {
|
|
373
|
+
case "chat.status":
|
|
374
|
+
const claudeAvailable = await this.checkClaudeCodeCLI();
|
|
375
|
+
sendResponse({
|
|
376
|
+
sdk_available: claudeAvailable,
|
|
377
|
+
sdk_version: claudeAvailable ? "claude-code-cli" : null,
|
|
378
|
+
error_message: claudeAvailable ? null : "Claude Code CLI not found in PATH"
|
|
379
|
+
});
|
|
380
|
+
break;
|
|
381
|
+
case "chat.send":
|
|
382
|
+
if (!params || !params.message) {
|
|
383
|
+
sendError(-32602, "Message cannot be empty");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (!await this.checkClaudeCodeCLI()) {
|
|
387
|
+
sendError(-32001, "Claude Code CLI not available. Install Claude Code to enable chat.");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const conversationId = `conv-${++this.chatConversationCounter}-${Date.now()}`;
|
|
391
|
+
await this.launchClaudeCodeChat(ws, conversationId, params.message, id);
|
|
392
|
+
break;
|
|
393
|
+
case "chat.cancel":
|
|
394
|
+
let cancelled = !1, cancelledConvId = null;
|
|
395
|
+
for (const [convId, process2] of this.activeChatProcesses)
|
|
396
|
+
try {
|
|
397
|
+
process2.kill(), this.activeChatProcesses.delete(convId), cancelled = !0, cancelledConvId = convId;
|
|
398
|
+
break;
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
sendResponse({
|
|
402
|
+
cancelled,
|
|
403
|
+
conversation_id: cancelledConvId
|
|
404
|
+
});
|
|
405
|
+
break;
|
|
406
|
+
default:
|
|
407
|
+
sendError(-32601, `Method not found: ${method}`);
|
|
408
|
+
}
|
|
409
|
+
} catch (error) {
|
|
410
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
411
|
+
sendError(-32603, "Internal error", errorMsg);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Check if Claude Code CLI is available
|
|
416
|
+
*/
|
|
417
|
+
async checkClaudeCodeCLI() {
|
|
418
|
+
try {
|
|
419
|
+
return Bun.spawnSync({
|
|
420
|
+
cmd: ["which", "claude"],
|
|
421
|
+
stdout: "pipe",
|
|
422
|
+
stderr: "pipe"
|
|
423
|
+
}).exitCode === 0;
|
|
424
|
+
} catch {
|
|
425
|
+
return !1;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Launch Claude Code CLI with dr-architect agent and stream responses
|
|
430
|
+
*/
|
|
431
|
+
async launchClaudeCodeChat(ws, conversationId, message, requestId) {
|
|
432
|
+
try {
|
|
433
|
+
const proc = Bun.spawn({
|
|
434
|
+
cmd: [
|
|
435
|
+
"claude",
|
|
436
|
+
"--agent",
|
|
437
|
+
"dr-architect",
|
|
438
|
+
"--print",
|
|
439
|
+
"--dangerously-skip-permissions",
|
|
440
|
+
"--verbose",
|
|
441
|
+
"--output-format",
|
|
442
|
+
"stream-json"
|
|
443
|
+
],
|
|
444
|
+
cwd: this.model.rootPath,
|
|
445
|
+
stdin: "pipe",
|
|
446
|
+
stdout: "pipe",
|
|
447
|
+
stderr: "pipe"
|
|
448
|
+
});
|
|
449
|
+
this.activeChatProcesses.set(conversationId, proc), proc.stdin?.write(new TextEncoder().encode(message)), proc.stdin?.end();
|
|
450
|
+
const stdoutReader = proc.stdout.getReader(), decoder = new TextDecoder();
|
|
451
|
+
(async () => {
|
|
452
|
+
let accumulatedText = "", buffer = "";
|
|
453
|
+
try {
|
|
454
|
+
for (; ; ) {
|
|
455
|
+
const { done, value } = await stdoutReader.read();
|
|
456
|
+
if (done)
|
|
457
|
+
break;
|
|
458
|
+
const chunk = decoder.decode(value, { stream: !0 });
|
|
459
|
+
buffer += chunk;
|
|
460
|
+
const lines = buffer.split(`
|
|
461
|
+
`);
|
|
462
|
+
buffer = lines.pop() || "";
|
|
463
|
+
for (const line of lines)
|
|
464
|
+
if (line.trim())
|
|
465
|
+
try {
|
|
466
|
+
const event = JSON.parse(line);
|
|
467
|
+
if (event.type === "assistant") {
|
|
468
|
+
const content = event.message?.content || [];
|
|
469
|
+
for (const block of content)
|
|
470
|
+
block.type === "text" ? (accumulatedText += block.text, ws.send(JSON.stringify({
|
|
471
|
+
jsonrpc: "2.0",
|
|
472
|
+
method: "chat.response.chunk",
|
|
473
|
+
params: {
|
|
474
|
+
conversation_id: conversationId,
|
|
475
|
+
content: block.text,
|
|
476
|
+
is_final: !1,
|
|
477
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
478
|
+
}
|
|
479
|
+
}))) : block.type === "tool_use" && ws.send(JSON.stringify({
|
|
480
|
+
jsonrpc: "2.0",
|
|
481
|
+
method: "chat.tool.invoke",
|
|
482
|
+
params: {
|
|
483
|
+
conversation_id: conversationId,
|
|
484
|
+
tool_name: block.name,
|
|
485
|
+
tool_input: block.input,
|
|
486
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
487
|
+
}
|
|
488
|
+
}));
|
|
489
|
+
} else event.type === "result" && ws.send(JSON.stringify({
|
|
490
|
+
jsonrpc: "2.0",
|
|
491
|
+
method: "chat.tool.result",
|
|
492
|
+
params: {
|
|
493
|
+
conversation_id: conversationId,
|
|
494
|
+
result: event.result,
|
|
495
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
496
|
+
}
|
|
497
|
+
}));
|
|
498
|
+
} catch {
|
|
499
|
+
ws.send(JSON.stringify({
|
|
500
|
+
jsonrpc: "2.0",
|
|
501
|
+
method: "chat.response.chunk",
|
|
502
|
+
params: {
|
|
503
|
+
conversation_id: conversationId,
|
|
504
|
+
content: line + `
|
|
505
|
+
`,
|
|
506
|
+
is_final: !1,
|
|
507
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
508
|
+
}
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
buffer.trim() && ws.send(JSON.stringify({
|
|
513
|
+
jsonrpc: "2.0",
|
|
514
|
+
method: "chat.response.chunk",
|
|
515
|
+
params: {
|
|
516
|
+
conversation_id: conversationId,
|
|
517
|
+
content: buffer,
|
|
518
|
+
is_final: !0,
|
|
519
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
520
|
+
}
|
|
521
|
+
}));
|
|
522
|
+
const exitCode = await proc.exited;
|
|
523
|
+
this.activeChatProcesses.delete(conversationId), ws.send(JSON.stringify({
|
|
524
|
+
jsonrpc: "2.0",
|
|
525
|
+
result: {
|
|
526
|
+
conversation_id: conversationId,
|
|
527
|
+
status: exitCode === 0 ? "complete" : "error",
|
|
528
|
+
exit_code: exitCode,
|
|
529
|
+
full_response: accumulatedText,
|
|
530
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
531
|
+
},
|
|
532
|
+
id: requestId
|
|
533
|
+
}));
|
|
534
|
+
} catch (error) {
|
|
535
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
536
|
+
ws.send(JSON.stringify({
|
|
537
|
+
jsonrpc: "2.0",
|
|
538
|
+
error: {
|
|
539
|
+
code: -32603,
|
|
540
|
+
message: `Chat failed: ${errorMsg}`
|
|
541
|
+
},
|
|
542
|
+
id: requestId
|
|
543
|
+
})), this.activeChatProcesses.delete(conversationId);
|
|
544
|
+
try {
|
|
545
|
+
proc.kill();
|
|
546
|
+
} catch {
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
})();
|
|
550
|
+
} catch (error) {
|
|
551
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
552
|
+
ws.send(JSON.stringify({
|
|
553
|
+
jsonrpc: "2.0",
|
|
554
|
+
error: {
|
|
555
|
+
code: -32603,
|
|
556
|
+
message: `Failed to launch Claude Code: ${errorMsg}`
|
|
557
|
+
},
|
|
558
|
+
id: requestId
|
|
559
|
+
}));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Setup file watcher for model changes
|
|
564
|
+
*/
|
|
565
|
+
setupFileWatcher() {
|
|
566
|
+
const drPath = `${this.model.rootPath}/.dr`, bunWatch = globalThis.Bun?.watch;
|
|
567
|
+
if (!bunWatch) {
|
|
568
|
+
console.warn("[Watcher] Bun.watch not available, file watching disabled");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
this.watcher = bunWatch(drPath, {
|
|
572
|
+
recursive: !0,
|
|
573
|
+
onChange: async (_event, path) => {
|
|
574
|
+
process.env.VERBOSE && console.log(`[Watcher] File changed: ${path}`);
|
|
575
|
+
try {
|
|
576
|
+
this.model = await Model.load(this.model.rootPath, { lazyLoad: !1 });
|
|
577
|
+
const modelData = await this.serializeModel(), message = JSON.stringify({
|
|
578
|
+
type: "model.updated",
|
|
579
|
+
data: modelData,
|
|
580
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
581
|
+
});
|
|
582
|
+
for (const client of this.clients)
|
|
583
|
+
try {
|
|
584
|
+
client.send(message);
|
|
585
|
+
} catch (error) {
|
|
586
|
+
if (process.env.DEBUG) {
|
|
587
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
588
|
+
console.debug(`[Watcher] Failed to send update: ${msg}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
} catch (error) {
|
|
592
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
593
|
+
console.error(`[Watcher] Failed to reload model: ${message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Load all JSON schemas from bundled directory
|
|
600
|
+
*/
|
|
601
|
+
async loadSchemas() {
|
|
602
|
+
const schemasPath = new URL("../schemas/bundled/", import.meta.url).pathname, schemas = {};
|
|
603
|
+
try {
|
|
604
|
+
const fs = await import("fs/promises"), path = await import("path"), fileList = await fs.readdir(schemasPath);
|
|
605
|
+
for (const file of fileList)
|
|
606
|
+
if (file.endsWith(".schema.json") || file.endsWith(".json")) {
|
|
607
|
+
const filePath = path.join(schemasPath, file), content = await fs.readFile(filePath, "utf-8");
|
|
608
|
+
schemas[file] = JSON.parse(content);
|
|
609
|
+
}
|
|
610
|
+
return schemas;
|
|
611
|
+
} catch (error) {
|
|
612
|
+
throw console.error("Failed to load schemas:", error), new Error("Failed to load schema files");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Load link registry from bundled directory
|
|
617
|
+
*/
|
|
618
|
+
async loadLinkRegistry() {
|
|
619
|
+
const linkRegistryPath = new URL("../schemas/bundled/link-registry.json", import.meta.url).pathname;
|
|
620
|
+
try {
|
|
621
|
+
const content = await (await import("fs/promises")).readFile(linkRegistryPath, "utf-8");
|
|
622
|
+
return JSON.parse(content);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
throw console.error("Failed to load link registry:", error), new Error("Link registry not found");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Get viewer HTML
|
|
629
|
+
*/
|
|
630
|
+
getViewerHTML() {
|
|
631
|
+
return `<!DOCTYPE html>
|
|
632
|
+
<html>
|
|
633
|
+
<head>
|
|
634
|
+
<meta charset="UTF-8">
|
|
635
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
636
|
+
<title>Documentation Robotics Viewer</title>
|
|
637
|
+
<style>
|
|
638
|
+
* {
|
|
639
|
+
margin: 0;
|
|
640
|
+
padding: 0;
|
|
641
|
+
box-sizing: border-box;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
body {
|
|
645
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
646
|
+
background: #f5f5f5;
|
|
647
|
+
color: #333;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
header {
|
|
651
|
+
background: white;
|
|
652
|
+
border-bottom: 1px solid #e0e0e0;
|
|
653
|
+
padding: 20px;
|
|
654
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.header-content {
|
|
658
|
+
max-width: 1400px;
|
|
659
|
+
margin: 0 auto;
|
|
660
|
+
display: flex;
|
|
661
|
+
justify-content: space-between;
|
|
662
|
+
align-items: center;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
h1 {
|
|
666
|
+
font-size: 24px;
|
|
667
|
+
font-weight: 600;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.status-badge {
|
|
671
|
+
display: inline-block;
|
|
672
|
+
padding: 6px 12px;
|
|
673
|
+
border-radius: 4px;
|
|
674
|
+
font-size: 12px;
|
|
675
|
+
font-weight: 500;
|
|
676
|
+
text-transform: uppercase;
|
|
677
|
+
letter-spacing: 0.5px;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.status-badge.connected {
|
|
681
|
+
background: #4caf50;
|
|
682
|
+
color: white;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.status-badge.disconnected {
|
|
686
|
+
background: #f44336;
|
|
687
|
+
color: white;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.container {
|
|
691
|
+
max-width: 1400px;
|
|
692
|
+
margin: 0 auto;
|
|
693
|
+
padding: 20px;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.content {
|
|
697
|
+
display: grid;
|
|
698
|
+
grid-template-columns: 300px 1fr;
|
|
699
|
+
gap: 20px;
|
|
700
|
+
height: calc(100vh - 100px);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.sidebar {
|
|
704
|
+
background: white;
|
|
705
|
+
border-radius: 4px;
|
|
706
|
+
overflow-y: auto;
|
|
707
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
708
|
+
padding: 16px;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.main-panel {
|
|
712
|
+
background: white;
|
|
713
|
+
border-radius: 4px;
|
|
714
|
+
overflow-y: auto;
|
|
715
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
716
|
+
padding: 20px;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.layer-group {
|
|
720
|
+
margin-bottom: 16px;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.layer-header {
|
|
724
|
+
font-weight: 600;
|
|
725
|
+
padding: 8px 0;
|
|
726
|
+
border-bottom: 2px solid #e0e0e0;
|
|
727
|
+
margin-bottom: 8px;
|
|
728
|
+
cursor: pointer;
|
|
729
|
+
user-select: none;
|
|
730
|
+
display: flex;
|
|
731
|
+
align-items: center;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.layer-header:hover {
|
|
735
|
+
color: #2196F3;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
.layer-header::before {
|
|
739
|
+
content: '\u25B6';
|
|
740
|
+
margin-right: 8px;
|
|
741
|
+
display: inline-block;
|
|
742
|
+
transition: transform 0.2s;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.layer-header.expanded::before {
|
|
746
|
+
transform: rotate(90deg);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.layer-elements {
|
|
750
|
+
margin-left: 16px;
|
|
751
|
+
display: none;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.layer-elements.expanded {
|
|
755
|
+
display: block;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
.element-item {
|
|
759
|
+
padding: 8px;
|
|
760
|
+
margin-bottom: 4px;
|
|
761
|
+
background: #f9f9f9;
|
|
762
|
+
border-left: 3px solid #e0e0e0;
|
|
763
|
+
cursor: pointer;
|
|
764
|
+
border-radius: 2px;
|
|
765
|
+
transition: all 0.2s;
|
|
766
|
+
font-size: 13px;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.element-item:hover {
|
|
770
|
+
background: #f0f0f0;
|
|
771
|
+
border-left-color: #2196F3;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.element-item.selected {
|
|
775
|
+
background: #e3f2fd;
|
|
776
|
+
border-left-color: #2196F3;
|
|
777
|
+
font-weight: 500;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.element-details {
|
|
781
|
+
min-height: 300px;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
.detail-header {
|
|
785
|
+
border-bottom: 2px solid #e0e0e0;
|
|
786
|
+
padding-bottom: 12px;
|
|
787
|
+
margin-bottom: 16px;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
.detail-header h2 {
|
|
791
|
+
font-size: 18px;
|
|
792
|
+
margin-bottom: 4px;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
.detail-header .element-type {
|
|
796
|
+
font-size: 12px;
|
|
797
|
+
color: #999;
|
|
798
|
+
text-transform: uppercase;
|
|
799
|
+
letter-spacing: 0.5px;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.detail-section {
|
|
803
|
+
margin-bottom: 16px;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.detail-section h3 {
|
|
807
|
+
font-size: 13px;
|
|
808
|
+
font-weight: 600;
|
|
809
|
+
text-transform: uppercase;
|
|
810
|
+
color: #999;
|
|
811
|
+
margin-bottom: 8px;
|
|
812
|
+
letter-spacing: 0.5px;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.detail-value {
|
|
816
|
+
font-size: 14px;
|
|
817
|
+
line-height: 1.6;
|
|
818
|
+
color: #333;
|
|
819
|
+
word-break: break-word;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
.annotations-list {
|
|
823
|
+
max-height: 200px;
|
|
824
|
+
overflow-y: auto;
|
|
825
|
+
border: 1px solid #e0e0e0;
|
|
826
|
+
border-radius: 4px;
|
|
827
|
+
padding: 8px;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.annotation-item {
|
|
831
|
+
padding: 8px;
|
|
832
|
+
margin-bottom: 8px;
|
|
833
|
+
background: #fffde7;
|
|
834
|
+
border-left: 3px solid #fbc02d;
|
|
835
|
+
border-radius: 2px;
|
|
836
|
+
font-size: 12px;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.annotation-item .author {
|
|
840
|
+
font-weight: 600;
|
|
841
|
+
color: #666;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
.annotation-item .text {
|
|
845
|
+
margin: 4px 0 0 0;
|
|
846
|
+
color: #333;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.annotation-item .timestamp {
|
|
850
|
+
font-size: 11px;
|
|
851
|
+
color: #999;
|
|
852
|
+
margin-top: 4px;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
.annotation-form {
|
|
856
|
+
display: grid;
|
|
857
|
+
gap: 8px;
|
|
858
|
+
margin-top: 12px;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
.annotation-form input,
|
|
862
|
+
.annotation-form textarea {
|
|
863
|
+
padding: 8px;
|
|
864
|
+
border: 1px solid #e0e0e0;
|
|
865
|
+
border-radius: 4px;
|
|
866
|
+
font-family: inherit;
|
|
867
|
+
font-size: 12px;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.annotation-form textarea {
|
|
871
|
+
resize: vertical;
|
|
872
|
+
min-height: 60px;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.annotation-form button {
|
|
876
|
+
padding: 8px 12px;
|
|
877
|
+
background: #2196F3;
|
|
878
|
+
color: white;
|
|
879
|
+
border: none;
|
|
880
|
+
border-radius: 4px;
|
|
881
|
+
font-weight: 500;
|
|
882
|
+
cursor: pointer;
|
|
883
|
+
transition: background 0.2s;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
.annotation-form button:hover {
|
|
887
|
+
background: #1976D2;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.loading {
|
|
891
|
+
text-align: center;
|
|
892
|
+
padding: 40px;
|
|
893
|
+
color: #999;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.loading::after {
|
|
897
|
+
content: '';
|
|
898
|
+
display: inline-block;
|
|
899
|
+
width: 16px;
|
|
900
|
+
height: 16px;
|
|
901
|
+
border: 2px solid #f3f3f3;
|
|
902
|
+
border-top: 2px solid #2196F3;
|
|
903
|
+
border-radius: 50%;
|
|
904
|
+
animation: spin 1s linear infinite;
|
|
905
|
+
margin-left: 8px;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
@keyframes spin {
|
|
909
|
+
0% { transform: rotate(0deg); }
|
|
910
|
+
100% { transform: rotate(360deg); }
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
.stats {
|
|
914
|
+
font-size: 12px;
|
|
915
|
+
color: #999;
|
|
916
|
+
margin-bottom: 16px;
|
|
917
|
+
padding-bottom: 12px;
|
|
918
|
+
border-bottom: 1px solid #e0e0e0;
|
|
919
|
+
}
|
|
920
|
+
</style>
|
|
921
|
+
</head>
|
|
922
|
+
<body>
|
|
923
|
+
<header>
|
|
924
|
+
<div class="header-content">
|
|
925
|
+
<div>
|
|
926
|
+
<h1>\u{1F4DA} Documentation Robotics Viewer</h1>
|
|
927
|
+
</div>
|
|
928
|
+
<div class="status-badge connected" id="status">\u25CF Connected</div>
|
|
929
|
+
</div>
|
|
930
|
+
</header>
|
|
931
|
+
|
|
932
|
+
<div class="container">
|
|
933
|
+
<div class="content">
|
|
934
|
+
<div class="sidebar">
|
|
935
|
+
<div class="stats" id="stats">Loading...</div>
|
|
936
|
+
<div id="model-tree">
|
|
937
|
+
<div class="loading">Loading model...</div>
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
|
|
941
|
+
<div class="main-panel">
|
|
942
|
+
<div id="element-details">
|
|
943
|
+
<div class="loading">Select an element to view details</div>
|
|
944
|
+
</div>
|
|
945
|
+
</div>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
|
|
949
|
+
<script>
|
|
950
|
+
let currentModel = null;
|
|
951
|
+
let selectedElementId = null;
|
|
952
|
+
|
|
953
|
+
const statusEl = document.getElementById('status');
|
|
954
|
+
const modelTreeEl = document.getElementById('model-tree');
|
|
955
|
+
const detailsEl = document.getElementById('element-details');
|
|
956
|
+
const statsEl = document.getElementById('stats');
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Escape HTML special characters to prevent XSS
|
|
960
|
+
* @param {string} text - Text to escape
|
|
961
|
+
* @returns {string} HTML-escaped text
|
|
962
|
+
*/
|
|
963
|
+
function escapeHtml(text) {
|
|
964
|
+
if (!text) return '';
|
|
965
|
+
const div = document.createElement('div');
|
|
966
|
+
div.textContent = text;
|
|
967
|
+
return div.innerHTML;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const ws = new WebSocket(\`ws://\${window.location.host}/ws\`);
|
|
971
|
+
|
|
972
|
+
ws.addEventListener('open', () => {
|
|
973
|
+
console.log('WebSocket connected');
|
|
974
|
+
updateStatus(true);
|
|
975
|
+
ws.send(JSON.stringify({ type: 'subscribe' }));
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
ws.addEventListener('message', (event) => {
|
|
979
|
+
try {
|
|
980
|
+
const message = JSON.parse(event.data);
|
|
981
|
+
|
|
982
|
+
if (message.type === 'model' || message.type === 'model-update') {
|
|
983
|
+
currentModel = message.data;
|
|
984
|
+
renderModel();
|
|
985
|
+
} else if (message.type === 'annotation') {
|
|
986
|
+
// Update annotations for the element
|
|
987
|
+
renderSelectedElement();
|
|
988
|
+
} else if (message.type === 'error') {
|
|
989
|
+
console.error('WebSocket error:', message.message);
|
|
990
|
+
}
|
|
991
|
+
} catch (error) {
|
|
992
|
+
console.error('Failed to parse message:', error);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
ws.addEventListener('close', () => {
|
|
997
|
+
console.log('WebSocket disconnected');
|
|
998
|
+
updateStatus(false);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
ws.addEventListener('error', (error) => {
|
|
1002
|
+
console.error('WebSocket error:', error);
|
|
1003
|
+
updateStatus(false);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
function updateStatus(connected) {
|
|
1007
|
+
if (connected) {
|
|
1008
|
+
statusEl.className = 'status-badge connected';
|
|
1009
|
+
statusEl.textContent = '\u25CF Connected';
|
|
1010
|
+
} else {
|
|
1011
|
+
statusEl.className = 'status-badge disconnected';
|
|
1012
|
+
statusEl.textContent = '\u25CF Disconnected';
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function renderModel() {
|
|
1017
|
+
if (!currentModel) return;
|
|
1018
|
+
|
|
1019
|
+
// Update stats
|
|
1020
|
+
const layerCount = Object.keys(currentModel.layers).length;
|
|
1021
|
+
const elementCount = currentModel.totalElements || 0;
|
|
1022
|
+
statsEl.textContent = \`\${layerCount} layers \xB7 \${elementCount} elements\`;
|
|
1023
|
+
|
|
1024
|
+
// Render layers and elements
|
|
1025
|
+
modelTreeEl.innerHTML = '';
|
|
1026
|
+
const layers = Object.entries(currentModel.layers)
|
|
1027
|
+
.sort((a, b) => a[0].localeCompare(b[0]));
|
|
1028
|
+
|
|
1029
|
+
for (const [layerName, layerData] of layers) {
|
|
1030
|
+
const layerDiv = document.createElement('div');
|
|
1031
|
+
layerDiv.className = 'layer-group';
|
|
1032
|
+
|
|
1033
|
+
const headerDiv = document.createElement('div');
|
|
1034
|
+
headerDiv.className = 'layer-header';
|
|
1035
|
+
headerDiv.textContent = layerName;
|
|
1036
|
+
headerDiv.addEventListener('click', () => {
|
|
1037
|
+
headerDiv.classList.toggle('expanded');
|
|
1038
|
+
elementsDiv.classList.toggle('expanded');
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
const elementsDiv = document.createElement('div');
|
|
1042
|
+
elementsDiv.className = 'layer-elements';
|
|
1043
|
+
|
|
1044
|
+
for (const element of layerData.elements) {
|
|
1045
|
+
const elemDiv = document.createElement('div');
|
|
1046
|
+
elemDiv.className = 'element-item';
|
|
1047
|
+
if (element.id === selectedElementId) {
|
|
1048
|
+
elemDiv.classList.add('selected');
|
|
1049
|
+
}
|
|
1050
|
+
elemDiv.innerHTML = \`\${escapeHtml(element.id)}<br><span style="font-size: 11px; color: #999;">\${escapeHtml(element.name)}</span>\`;
|
|
1051
|
+
elemDiv.addEventListener('click', () => {
|
|
1052
|
+
selectedElementId = element.id;
|
|
1053
|
+
document.querySelectorAll('.element-item').forEach(el => el.classList.remove('selected'));
|
|
1054
|
+
elemDiv.classList.add('selected');
|
|
1055
|
+
renderSelectedElement();
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
elementsDiv.appendChild(elemDiv);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
layerDiv.appendChild(headerDiv);
|
|
1062
|
+
layerDiv.appendChild(elementsDiv);
|
|
1063
|
+
modelTreeEl.appendChild(layerDiv);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function renderSelectedElement() {
|
|
1068
|
+
if (!selectedElementId || !currentModel) {
|
|
1069
|
+
detailsEl.innerHTML = '<div class="loading">Select an element to view details</div>';
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Find element in model
|
|
1074
|
+
let element = null;
|
|
1075
|
+
for (const layer of Object.values(currentModel.layers)) {
|
|
1076
|
+
element = layer.elements.find(e => e.id === selectedElementId);
|
|
1077
|
+
if (element) break;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (!element) {
|
|
1081
|
+
detailsEl.innerHTML = '<div class="loading">Element not found</div>';
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let html = \`
|
|
1086
|
+
<div class="detail-header">
|
|
1087
|
+
<div class="element-type">\${escapeHtml(element.type)}</div>
|
|
1088
|
+
<h2>\${escapeHtml(element.name)}</h2>
|
|
1089
|
+
<div style="font-size: 12px; color: #666; margin-top: 4px;"><code>\${escapeHtml(element.id)}</code></div>
|
|
1090
|
+
</div>
|
|
1091
|
+
\`;
|
|
1092
|
+
|
|
1093
|
+
if (element.description) {
|
|
1094
|
+
html += \`
|
|
1095
|
+
<div class="detail-section">
|
|
1096
|
+
<h3>Description</h3>
|
|
1097
|
+
<div class="detail-value">\${escapeHtml(element.description)}</div>
|
|
1098
|
+
</div>
|
|
1099
|
+
\`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (element.properties && Object.keys(element.properties).length > 0) {
|
|
1103
|
+
html += \`
|
|
1104
|
+
<div class="detail-section">
|
|
1105
|
+
<h3>Properties</h3>
|
|
1106
|
+
<div class="detail-value"><pre>\${escapeHtml(JSON.stringify(element.properties, null, 2))}</pre></div>
|
|
1107
|
+
</div>
|
|
1108
|
+
\`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (element.annotations && element.annotations.length > 0) {
|
|
1112
|
+
html += \`
|
|
1113
|
+
<div class="detail-section">
|
|
1114
|
+
<h3>Annotations</h3>
|
|
1115
|
+
<div class="annotations-list">
|
|
1116
|
+
\${element.annotations.map(ann => \`
|
|
1117
|
+
<div class="annotation-item">
|
|
1118
|
+
<div class="author">\${escapeHtml(ann.author)}</div>
|
|
1119
|
+
<div class="text">\${escapeHtml(ann.text)}</div>
|
|
1120
|
+
<div class="timestamp">\${new Date(ann.timestamp).toLocaleString()}</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
\`).join('')}
|
|
1123
|
+
</div>
|
|
1124
|
+
</div>
|
|
1125
|
+
\`;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
html += \`
|
|
1129
|
+
<div class="detail-section">
|
|
1130
|
+
<h3>Add Annotation</h3>
|
|
1131
|
+
<form class="annotation-form" onsubmit="addAnnotation(event, '\${escapeHtml(element.id)}')">
|
|
1132
|
+
<input type="text" placeholder="Author name" id="ann-author" required>
|
|
1133
|
+
<textarea placeholder="Annotation text" id="ann-text" required></textarea>
|
|
1134
|
+
<button type="submit">Add Annotation</button>
|
|
1135
|
+
</form>
|
|
1136
|
+
</div>
|
|
1137
|
+
\`;
|
|
1138
|
+
|
|
1139
|
+
detailsEl.innerHTML = html;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function addAnnotation(event, elementId) {
|
|
1143
|
+
event.preventDefault();
|
|
1144
|
+
|
|
1145
|
+
const author = document.getElementById('ann-author').value;
|
|
1146
|
+
const text = document.getElementById('ann-text').value;
|
|
1147
|
+
|
|
1148
|
+
if (!author || !text) return;
|
|
1149
|
+
|
|
1150
|
+
// Send to server
|
|
1151
|
+
fetch(\`/api/elements/\${elementId}/annotations\`, {
|
|
1152
|
+
method: 'POST',
|
|
1153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1154
|
+
body: JSON.stringify({ author, text })
|
|
1155
|
+
})
|
|
1156
|
+
.then(r => r.json())
|
|
1157
|
+
.then(data => {
|
|
1158
|
+
if (data.success) {
|
|
1159
|
+
document.getElementById('ann-author').value = '';
|
|
1160
|
+
document.getElementById('ann-text').value = '';
|
|
1161
|
+
}
|
|
1162
|
+
})
|
|
1163
|
+
.catch(err => console.error('Failed to add annotation:', err));
|
|
1164
|
+
}
|
|
1165
|
+
</script>
|
|
1166
|
+
</body>
|
|
1167
|
+
</html>`;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Start the server
|
|
1171
|
+
*/
|
|
1172
|
+
async start(port = 8080) {
|
|
1173
|
+
this.setupFileWatcher(), this._server = serve({
|
|
1174
|
+
port,
|
|
1175
|
+
fetch: this.app.fetch,
|
|
1176
|
+
websocket
|
|
1177
|
+
// Add WebSocket handler from hono/bun
|
|
1178
|
+
}), console.log(`\u2713 Visualization server running at http://localhost:${port}`);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Get the current auth token
|
|
1182
|
+
*/
|
|
1183
|
+
getAuthToken() {
|
|
1184
|
+
return this.authToken;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Check if authentication is enabled
|
|
1188
|
+
*/
|
|
1189
|
+
isAuthEnabled() {
|
|
1190
|
+
return this.authEnabled;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Stop the server
|
|
1194
|
+
*/
|
|
1195
|
+
stop() {
|
|
1196
|
+
for (const client of this.clients)
|
|
1197
|
+
try {
|
|
1198
|
+
client.close();
|
|
1199
|
+
} catch {
|
|
1200
|
+
}
|
|
1201
|
+
this.clients.clear(), this.watcher && this.watcher.close?.(), this._server && this._server.stop();
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
export {
|
|
1205
|
+
VisualizationServer
|
|
1206
|
+
};
|
|
1207
|
+
//# sourceMappingURL=server.js.map
|