living-ai-documentation 2.0.0 → 2.3.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.
Files changed (31) hide show
  1. package/dist/src/frontend/agents.js +288 -0
  2. package/dist/src/frontend/config.js +3 -2
  3. package/dist/src/frontend/diagram/color-picker.js +72 -0
  4. package/dist/src/frontend/diagram/defaults-modal.js +302 -0
  5. package/dist/src/frontend/diagram/edge-panel.js +110 -11
  6. package/dist/src/frontend/diagram/main.js +5 -8
  7. package/dist/src/frontend/diagram/network.js +16 -7
  8. package/dist/src/frontend/diagram/node-panel.js +104 -2
  9. package/dist/src/frontend/diagram.html +52 -29
  10. package/dist/src/frontend/i18n/en.json +43 -1
  11. package/dist/src/frontend/i18n/fr.json +43 -1
  12. package/dist/src/frontend/index.html +156 -33
  13. package/dist/src/frontend/workspace/app.js +439 -67
  14. package/dist/src/frontend/workspace/app.js.map +1 -1
  15. package/dist/src/frontend/workspace/app.ts +582 -84
  16. package/dist/src/frontend/workspace/index.html +232 -82
  17. package/dist/src/frontend/workspace/persistence.js +61 -0
  18. package/dist/src/frontend/workspace/persistence.js.map +1 -1
  19. package/dist/src/frontend/workspace/persistence.ts +111 -0
  20. package/dist/src/frontend/workspace/styles.css +293 -34
  21. package/dist/src/lib/config.d.ts +23 -0
  22. package/dist/src/lib/config.d.ts.map +1 -1
  23. package/dist/src/lib/config.js +16 -0
  24. package/dist/src/lib/config.js.map +1 -1
  25. package/dist/src/routes/config.d.ts.map +1 -1
  26. package/dist/src/routes/config.js +13 -0
  27. package/dist/src/routes/config.js.map +1 -1
  28. package/dist/src/routes/workspace.d.ts.map +1 -1
  29. package/dist/src/routes/workspace.js +528 -6
  30. package/dist/src/routes/workspace.js.map +1 -1
  31. package/package.json +2 -2
@@ -7,11 +7,14 @@ exports.workspaceRouter = workspaceRouter;
7
7
  const express_1 = require("express");
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
+ const config_1 = require("../lib/config");
10
11
  const WORKSPACE_VERSION = 1;
11
12
  const MAX_WORKSPACE_ENTITIES = 1000;
12
13
  const MAX_LABEL_LENGTH = 160;
13
14
  const MAX_TEXT_FIELD_LENGTH = 4000;
14
15
  const MAX_WORKSPACE_FILE_BYTES = 1024 * 1024;
16
+ const WORKSPACE_PROVIDER_ROOT = path_1.default.join('AI', 'WORKSPACE');
17
+ const DEFAULT_LLM_TEST_TIMEOUT_MS = 5000;
15
18
  function workspaceFilePath(docsPath) {
16
19
  return path_1.default.join(docsPath, '.workspace');
17
20
  }
@@ -41,7 +44,7 @@ function writeWorkspaceState(docsPath, state) {
41
44
  fs_1.default.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8');
42
45
  fs_1.default.renameSync(tmpPath, filePath);
43
46
  }
44
- function sanitizeWorkspaceState(input) {
47
+ function sanitizeWorkspaceState(input, docsPath) {
45
48
  if (!isRecord(input)) {
46
49
  throw new Error('workspace payload must be an object');
47
50
  }
@@ -55,12 +58,16 @@ function sanitizeWorkspaceState(input) {
55
58
  const updatedAt = typeof input.updatedAt === 'string' && input.updatedAt.trim()
56
59
  ? input.updatedAt
57
60
  : new Date().toISOString();
58
- return {
61
+ const state = {
59
62
  version: WORKSPACE_VERSION,
60
63
  updatedAt,
61
64
  entities: entitiesInput.map(sanitizeEntity),
62
65
  camera: sanitizeCamera(input.camera),
63
66
  };
67
+ if (docsPath) {
68
+ ensureProviderWorkspaceFolders(docsPath, state);
69
+ }
70
+ return state;
64
71
  }
65
72
  function sanitizeEntity(input) {
66
73
  if (!isRecord(input)) {
@@ -85,14 +92,91 @@ function sanitizeEntity(input) {
85
92
  function sanitizeConfig(input) {
86
93
  const config = isRecord(input) ? input : {};
87
94
  return {
88
- environment: safeOptionalString(config.environment, 'local', MAX_LABEL_LENGTH),
89
95
  endpoint: safeOptionalString(config.endpoint, '', MAX_TEXT_FIELD_LENGTH),
90
96
  token: safeOptionalString(config.token, '', MAX_TEXT_FIELD_LENGTH),
91
- timeout: clamp(safeFiniteNumber(config.timeout, 30), 1, 120),
92
- retryPolicy: safeOptionalString(config.retryPolicy, 'linear', MAX_LABEL_LENGTH),
97
+ model: safeOptionalString(config.model, '', MAX_LABEL_LENGTH),
98
+ timeout: clamp(safeFiniteNumber(config.timeout, 180), 1, 600),
99
+ systemPrompt: safeOptionalString(config.systemPrompt, '', MAX_TEXT_FIELD_LENGTH),
100
+ requiresUserInput: config.requiresUserInput === true,
101
+ userInputDescription: safeOptionalString(config.userInputDescription, '', MAX_TEXT_FIELD_LENGTH),
102
+ expectedOutputMarker: safeOptionalString(config.expectedOutputMarker, '', MAX_LABEL_LENGTH),
93
103
  description: safeOptionalString(config.description, '', MAX_TEXT_FIELD_LENGTH),
104
+ workspaceFolder: sanitizeWorkspaceFolder(safeOptionalString(config.workspaceFolder, '', MAX_TEXT_FIELD_LENGTH)),
94
105
  };
95
106
  }
107
+ function ensureProviderWorkspaceFolders(docsPath, state) {
108
+ const workspaceRoot = path_1.default.resolve(docsPath, WORKSPACE_PROVIDER_ROOT);
109
+ fs_1.default.mkdirSync(workspaceRoot, { recursive: true });
110
+ const reservedFolders = new Set();
111
+ for (const entity of state.entities) {
112
+ if (entity.kind !== 'agent') {
113
+ entity.config.workspaceFolder = '';
114
+ continue;
115
+ }
116
+ const existingFolder = sanitizeWorkspaceFolder(entity.config.workspaceFolder);
117
+ const labelFolder = toPosixPath(path_1.default.join(WORKSPACE_PROVIDER_ROOT, slugify(entity.label)));
118
+ // Rename existing folder if label slug changed
119
+ if (existingFolder && existingFolder !== labelFolder) {
120
+ const absoluteExisting = path_1.default.resolve(docsPath, existingFolder);
121
+ if (fs_1.default.existsSync(absoluteExisting)) {
122
+ const uniqueLabel = uniqueWorkspaceFolder(labelFolder, reservedFolders);
123
+ const absoluteNew = path_1.default.resolve(docsPath, uniqueLabel);
124
+ if (!absoluteNew.startsWith(`${workspaceRoot}${path_1.default.sep}`)) {
125
+ throw new Error('provider workspace folder escapes AI/WORKSPACE');
126
+ }
127
+ fs_1.default.renameSync(absoluteExisting, absoluteNew);
128
+ entity.config.workspaceFolder = toPosixPath(uniqueLabel);
129
+ reservedFolders.add(entity.config.workspaceFolder);
130
+ continue;
131
+ }
132
+ }
133
+ const baseFolder = existingFolder || labelFolder;
134
+ const uniqueFolder = uniqueWorkspaceFolder(baseFolder, reservedFolders);
135
+ const absoluteFolder = path_1.default.resolve(docsPath, uniqueFolder);
136
+ if (!absoluteFolder.startsWith(`${workspaceRoot}${path_1.default.sep}`) && absoluteFolder !== workspaceRoot) {
137
+ throw new Error('provider workspace folder escapes AI/WORKSPACE');
138
+ }
139
+ fs_1.default.mkdirSync(absoluteFolder, { recursive: true });
140
+ entity.config.workspaceFolder = toPosixPath(uniqueFolder);
141
+ reservedFolders.add(entity.config.workspaceFolder);
142
+ }
143
+ }
144
+ function sanitizeWorkspaceFolder(folder) {
145
+ if (!folder) {
146
+ return '';
147
+ }
148
+ const normalized = toPosixPath(path_1.default.normalize(folder));
149
+ const prefix = toPosixPath(WORKSPACE_PROVIDER_ROOT);
150
+ if (normalized.startsWith('../') ||
151
+ path_1.default.isAbsolute(normalized) ||
152
+ normalized === prefix ||
153
+ !normalized.startsWith(`${prefix}/`)) {
154
+ return '';
155
+ }
156
+ return normalized;
157
+ }
158
+ function uniqueWorkspaceFolder(folder, reservedFolders) {
159
+ const parsed = path_1.default.posix.parse(toPosixPath(folder));
160
+ let candidate = toPosixPath(folder);
161
+ let index = 2;
162
+ while (reservedFolders.has(candidate)) {
163
+ candidate = path_1.default.posix.join(parsed.dir, `${parsed.name}_${index}`);
164
+ index += 1;
165
+ }
166
+ return candidate;
167
+ }
168
+ function slugify(value) {
169
+ const slug = value
170
+ .normalize('NFKD')
171
+ .replace(/[\u0300-\u036f]/g, '')
172
+ .toLowerCase()
173
+ .replace(/[^a-z0-9]+/g, '_')
174
+ .replace(/^_+|_+$/g, '');
175
+ return slug || 'llm_provider';
176
+ }
177
+ function toPosixPath(value) {
178
+ return value.split(path_1.default.sep).join('/');
179
+ }
96
180
  function sanitizeCamera(input) {
97
181
  const camera = isRecord(input) ? input : {};
98
182
  return {
@@ -125,6 +209,326 @@ function clamp(value, min, max) {
125
209
  function isRecord(input) {
126
210
  return typeof input === 'object' && input !== null && !Array.isArray(input);
127
211
  }
212
+ function llmModelsUrl(endpoint) {
213
+ const url = new URL(endpoint);
214
+ const cleanPath = url.pathname.replace(/\/+$/, '');
215
+ if (cleanPath.endsWith('/models')) {
216
+ url.pathname = cleanPath;
217
+ }
218
+ else if (cleanPath.endsWith('/v1')) {
219
+ url.pathname = `${cleanPath}/models`;
220
+ }
221
+ else {
222
+ url.pathname = `${cleanPath}/v1/models`;
223
+ }
224
+ return url;
225
+ }
226
+ async function testLlmConnection(input) {
227
+ if (typeof input.endpoint !== 'string' || !input.endpoint.trim()) {
228
+ throw new Error('endpoint is required');
229
+ }
230
+ const model = typeof input.model === 'string' ? input.model.trim() : '';
231
+ const timeoutSeconds = clamp(safeFiniteNumber(input.timeout, 5), 1, 30);
232
+ const controller = new AbortController();
233
+ const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000 || DEFAULT_LLM_TEST_TIMEOUT_MS);
234
+ const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
235
+ if (typeof input.token === 'string' && input.token.trim()) {
236
+ headers.Authorization = `Bearer ${input.token.trim()}`;
237
+ }
238
+ if (model) {
239
+ const base = input.endpoint.trim();
240
+ const chatUrl = new URL(base.endsWith('/v1') ? `${base}/chat/completions` : `${base}/v1/chat/completions`);
241
+ try {
242
+ const response = await fetch(chatUrl, {
243
+ method: 'POST',
244
+ headers,
245
+ signal: controller.signal,
246
+ body: JSON.stringify({
247
+ model,
248
+ messages: [{ role: 'user', content: 'ping' }],
249
+ max_tokens: 1,
250
+ }),
251
+ });
252
+ const text = await response.text();
253
+ let replyModel = null;
254
+ try {
255
+ const body = JSON.parse(text);
256
+ if (isRecord(body) && typeof body.model === 'string') {
257
+ replyModel = body.model;
258
+ }
259
+ }
260
+ catch { /* ignore */ }
261
+ return {
262
+ ok: response.ok,
263
+ status: response.status,
264
+ url: chatUrl.toString(),
265
+ detail: response.ok
266
+ ? `Model ${replyModel ?? model} responded`
267
+ : `Chat completion failed (${response.status})`,
268
+ };
269
+ }
270
+ finally {
271
+ clearTimeout(timeout);
272
+ }
273
+ }
274
+ const url = llmModelsUrl(input.endpoint.trim());
275
+ try {
276
+ const response = await fetch(url, {
277
+ method: 'GET',
278
+ headers,
279
+ signal: controller.signal,
280
+ });
281
+ const text = await response.text();
282
+ let modelCount = null;
283
+ try {
284
+ const body = JSON.parse(text);
285
+ if (isRecord(body) && Array.isArray(body.data)) {
286
+ modelCount = body.data.length;
287
+ }
288
+ }
289
+ catch { /* ignore */ }
290
+ return {
291
+ ok: response.ok,
292
+ status: response.status,
293
+ url: url.toString(),
294
+ detail: response.ok
295
+ ? modelCount !== null
296
+ ? `Connection OK (${modelCount} model${modelCount === 1 ? '' : 's'})`
297
+ : `Connection OK (${response.status})`
298
+ : `Connection failed (${response.status})`,
299
+ };
300
+ }
301
+ finally {
302
+ clearTimeout(timeout);
303
+ }
304
+ }
305
+ async function callMcp(endpoint, method, params) {
306
+ const response = await fetch(endpoint, {
307
+ method: 'POST',
308
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
309
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
310
+ });
311
+ const text = await response.text();
312
+ // Handle SSE (data: ...) or plain JSON
313
+ for (const line of text.split('\n')) {
314
+ const trimmed = line.startsWith('data:') ? line.slice(5).trim() : line.trim();
315
+ if (!trimmed || trimmed === '[DONE]')
316
+ continue;
317
+ try {
318
+ const parsed = JSON.parse(trimmed);
319
+ if (isRecord(parsed) && isRecord(parsed.result))
320
+ return parsed.result;
321
+ if (isRecord(parsed) && parsed.error)
322
+ throw new Error(String(parsed.error.message ?? parsed.error));
323
+ }
324
+ catch {
325
+ continue;
326
+ }
327
+ }
328
+ throw new Error('No valid MCP response');
329
+ }
330
+ async function runAgent(config) {
331
+ if (!config.endpoint.trim())
332
+ throw new Error('endpoint is required');
333
+ if (!config.model.trim())
334
+ throw new Error('model is required');
335
+ if (!config.systemPrompt.trim())
336
+ throw new Error('systemPrompt is required');
337
+ const timeoutSeconds = clamp(config.timeout, 1, 600);
338
+ const base = config.endpoint.trim();
339
+ const chatUrl = new URL(base.endsWith('/v1') ? `${base}/chat/completions` : `${base}/v1/chat/completions`);
340
+ const llmHeaders = { Accept: 'application/json', 'Content-Type': 'application/json' };
341
+ if (config.token.trim()) {
342
+ llmHeaders.Authorization = `Bearer ${config.token.trim()}`;
343
+ }
344
+ let mcpTools = [];
345
+ if (config.mcpEndpoint) {
346
+ try {
347
+ const toolsRes = await callMcp(config.mcpEndpoint, 'tools/list', {});
348
+ if (isRecord(toolsRes) && Array.isArray(toolsRes.tools)) {
349
+ mcpTools = toolsRes.tools.map((tool) => {
350
+ if (!isRecord(tool))
351
+ return null;
352
+ return {
353
+ type: 'function',
354
+ function: {
355
+ name: tool.name,
356
+ description: tool.description ?? '',
357
+ parameters: tool.inputSchema ?? { type: 'object', properties: {} },
358
+ },
359
+ };
360
+ }).filter(Boolean);
361
+ }
362
+ }
363
+ catch {
364
+ mcpTools = [];
365
+ }
366
+ }
367
+ const messages = [{ role: 'system', content: config.systemPrompt.trim() }];
368
+ const expectedOutputMarker = config.expectedOutputMarker.trim();
369
+ if (config.userInput.trim()) {
370
+ messages.push({ role: 'user', content: config.userInput.trim() });
371
+ }
372
+ const controller = new AbortController();
373
+ const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
374
+ let toolsSupported = mcpTools.length > 0;
375
+ try {
376
+ for (let turn = 0; turn < 5; turn += 1) {
377
+ const llmBody = { model: config.model.trim(), messages };
378
+ if (toolsSupported && mcpTools.length > 0)
379
+ llmBody.tools = mcpTools;
380
+ const response = await fetch(chatUrl, {
381
+ method: 'POST',
382
+ headers: llmHeaders,
383
+ signal: controller.signal,
384
+ body: JSON.stringify(llmBody),
385
+ });
386
+ // Model doesn't support tool use — retry once without tools
387
+ if (!response.ok && response.status === 400 && toolsSupported) {
388
+ toolsSupported = false;
389
+ // Inject tool descriptions as context in the system message
390
+ const toolDescriptions = mcpTools
391
+ .map((t) => {
392
+ if (!isRecord(t) || !isRecord(t.function))
393
+ return '';
394
+ return `- ${t.function.name}: ${t.function.description ?? ''}`;
395
+ })
396
+ .filter(Boolean)
397
+ .join('\n');
398
+ if (toolDescriptions && messages.length > 0 && isRecord(messages[0]) && messages[0].role === 'system') {
399
+ messages[0].content =
400
+ `${messages[0].content}\n\nAvailable MCP tools (call them by name in your response):\n${toolDescriptions}`;
401
+ }
402
+ continue;
403
+ }
404
+ if (!response.ok) {
405
+ throw new Error(`LLM error (${response.status})`);
406
+ }
407
+ const parsed = JSON.parse(await response.text());
408
+ if (!isRecord(parsed) || !Array.isArray(parsed.choices) || !parsed.choices.length)
409
+ break;
410
+ const choice = parsed.choices[0];
411
+ if (!isRecord(choice) || !isRecord(choice.message))
412
+ break;
413
+ const msg = choice.message;
414
+ messages.push(msg);
415
+ if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0 && config.mcpEndpoint) {
416
+ for (const toolCall of msg.tool_calls) {
417
+ if (!isRecord(toolCall) || !isRecord(toolCall.function))
418
+ continue;
419
+ const fnName = typeof toolCall.function.name === 'string' ? toolCall.function.name : '';
420
+ const fnArgs = typeof toolCall.function.arguments === 'string'
421
+ ? JSON.parse(toolCall.function.arguments)
422
+ : (toolCall.function.arguments ?? {});
423
+ let toolResult = '';
424
+ try {
425
+ const mcpResult = await callMcp(config.mcpEndpoint, 'tools/call', { name: fnName, arguments: fnArgs });
426
+ if (isRecord(mcpResult) && Array.isArray(mcpResult.content)) {
427
+ toolResult = mcpResult.content
428
+ .filter((content) => isRecord(content) && typeof content.text === 'string')
429
+ .map((content) => content.text)
430
+ .join('\n');
431
+ }
432
+ else {
433
+ toolResult = JSON.stringify(mcpResult);
434
+ }
435
+ }
436
+ catch (error) {
437
+ toolResult = `Tool error: ${error instanceof Error ? error.message : String(error)}`;
438
+ }
439
+ messages.push({ role: 'tool', tool_call_id: toolCall.id ?? '', content: toolResult });
440
+ }
441
+ continue;
442
+ }
443
+ if (typeof msg.content === 'string' && msg.content.trim()) {
444
+ const content = msg.content.trim();
445
+ if (!expectedOutputMarker || content.includes(expectedOutputMarker)) {
446
+ return content;
447
+ }
448
+ messages.push({
449
+ role: 'user',
450
+ content: `Your previous answer did not include the required output marker "${expectedOutputMarker}". ` +
451
+ `Return the final answer now, include "${expectedOutputMarker}", and do not explain the correction.`,
452
+ });
453
+ continue;
454
+ }
455
+ break;
456
+ }
457
+ }
458
+ finally {
459
+ clearTimeout(timer);
460
+ }
461
+ throw new Error(expectedOutputMarker
462
+ ? `Agent response did not include required output marker: ${expectedOutputMarker}`
463
+ : 'Agent did not produce a response');
464
+ }
465
+ function agentRunMarkdown(args) {
466
+ const status = args.ok ? 'Success' : 'Failed';
467
+ const userInput = args.userInput.trim() || '_No user input._';
468
+ return `---\n**date:** ${new Date().toISOString()}\n**status:** ${status}\n**description:** Resultat d'execution de l'agent ${args.agent.label} via ${args.provider.label}.\n**tags:** agent, run-agent, workspace, llm, ${slugify(args.agent.label)}\n---\n\n# ${args.title}\n\n## Execution\n\n- Agent: ${args.agent.label}\n- Provider: ${args.provider.label}\n- Model: ${args.provider.config.model}\n- Status: ${status}\n\n## User input\n\n${userInput}\n\n## Response\n\n${args.content}\n`;
469
+ }
470
+ function createAgentRunDocument(docsPath, agent, provider, ok, userInput, content) {
471
+ const folder = sanitizeWorkspaceFolder(agent.config.workspaceFolder);
472
+ if (!folder)
473
+ throw new Error('agent workspace folder is missing');
474
+ const docsRoot = path_1.default.resolve(docsPath);
475
+ const targetDir = path_1.default.resolve(docsPath, folder);
476
+ if (!targetDir.startsWith(`${docsRoot}${path_1.default.sep}`) && targetDir !== docsRoot) {
477
+ throw new Error('agent workspace folder escapes docs folder');
478
+ }
479
+ fs_1.default.mkdirSync(targetDir, { recursive: true });
480
+ const title = `Run - ${agent.label}`;
481
+ const baseFilename = buildWorkspaceFilenameForDocs(docsPath, title, 'AGENT');
482
+ const baseName = baseFilename.replace(/\.md$/, '');
483
+ let filename = baseFilename;
484
+ let filePath = path_1.default.join(targetDir, filename);
485
+ let suffix = 2;
486
+ while (fs_1.default.existsSync(filePath)) {
487
+ filename = `${baseName}_${suffix}.md`;
488
+ filePath = path_1.default.join(targetDir, filename);
489
+ suffix += 1;
490
+ }
491
+ fs_1.default.writeFileSync(filePath, agentRunMarkdown({ title, agent, provider, ok, userInput, content }), 'utf-8');
492
+ const relativePath = path_1.default.relative(docsPath, filePath);
493
+ return {
494
+ id: encodeURIComponent(relativePath.slice(0, -3)),
495
+ filename: relativePath,
496
+ title,
497
+ ok,
498
+ };
499
+ }
500
+ function findWorkspaceAgentRunContext(state, agentId) {
501
+ const agent = state.entities.find((entity) => entity.id === agentId && entity.kind === 'agent');
502
+ if (!agent) {
503
+ throw new Error('agent not found');
504
+ }
505
+ const provider = state.entities.find((entity) => entity.id === agent.parentId && entity.kind === 'llm');
506
+ if (!provider) {
507
+ throw new Error('agent parent LLM provider not found');
508
+ }
509
+ return {
510
+ agent,
511
+ provider,
512
+ mcp: state.entities.find((entity) => entity.kind === 'mcp') ?? null,
513
+ };
514
+ }
515
+ function buildWorkspaceFilenameForDocs(docsPath, title, category) {
516
+ const { filenamePattern } = (0, config_1.readConfig)(docsPath);
517
+ const now = new Date();
518
+ const yyyy = String(now.getFullYear());
519
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
520
+ const dd = String(now.getDate()).padStart(2, '0');
521
+ const hh = String(now.getHours()).padStart(2, '0');
522
+ const min = String(now.getMinutes()).padStart(2, '0');
523
+ return (filenamePattern || 'YYYY_MM_DD_HH_mm_[Category]_title')
524
+ .replace('YYYY', yyyy)
525
+ .replace('MM', mm)
526
+ .replace('DD', dd)
527
+ .replace('HH', hh)
528
+ .replace('mm', min)
529
+ .replace(/\[Category\]/i, `[${category}]`)
530
+ .replace(/(?<![a-z0-9])(?:title_words|title)(?![a-z0-9])/i, slugify(title)) + '.md';
531
+ }
128
532
  function workspaceRouter(docsPath) {
129
533
  const router = (0, express_1.Router)();
130
534
  router.get('/', (_req, res) => {
@@ -140,7 +544,7 @@ function workspaceRouter(docsPath) {
140
544
  const state = sanitizeWorkspaceState({
141
545
  ...req.body,
142
546
  updatedAt: new Date().toISOString(),
143
- });
547
+ }, docsPath);
144
548
  writeWorkspaceState(docsPath, state);
145
549
  res.json(state);
146
550
  }
@@ -149,6 +553,124 @@ function workspaceRouter(docsPath) {
149
553
  res.status(400).json({ error: message });
150
554
  }
151
555
  });
556
+ router.post('/list-models', async (req, res) => {
557
+ try {
558
+ const body = req.body;
559
+ if (typeof body.endpoint !== 'string' || !body.endpoint.trim()) {
560
+ res.status(400).json({ ok: false, error: 'endpoint is required' });
561
+ return;
562
+ }
563
+ const url = llmModelsUrl(body.endpoint.trim());
564
+ const headers = { Accept: 'application/json' };
565
+ if (typeof body.token === 'string' && body.token.trim()) {
566
+ headers.Authorization = `Bearer ${body.token.trim()}`;
567
+ }
568
+ const controller = new AbortController();
569
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_LLM_TEST_TIMEOUT_MS);
570
+ try {
571
+ const response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
572
+ const text = await response.text();
573
+ let models = [];
574
+ try {
575
+ const parsed = JSON.parse(text);
576
+ if (isRecord(parsed) && Array.isArray(parsed.data)) {
577
+ models = parsed.data
578
+ .map((m) => (isRecord(m) && typeof m.id === 'string' ? m.id : null))
579
+ .filter((id) => id !== null);
580
+ }
581
+ }
582
+ catch { /* ignore */ }
583
+ res.json({ ok: response.ok, models });
584
+ }
585
+ finally {
586
+ clearTimeout(timeout);
587
+ }
588
+ }
589
+ catch (error) {
590
+ const message = error instanceof Error ? error.message : 'Failed to list models';
591
+ res.status(400).json({ ok: false, error: message });
592
+ }
593
+ });
594
+ router.post('/run-agent-document', async (req, res) => {
595
+ try {
596
+ const body = req.body;
597
+ if (typeof body.agentId !== 'string' || !body.agentId.trim()) {
598
+ res.status(400).json({ ok: false, error: 'agentId is required' });
599
+ return;
600
+ }
601
+ const state = readWorkspaceState(docsPath);
602
+ ensureProviderWorkspaceFolders(docsPath, state);
603
+ writeWorkspaceState(docsPath, state);
604
+ const { agent, provider, mcp } = findWorkspaceAgentRunContext(state, body.agentId.trim());
605
+ const userInput = typeof body.userInput === 'string' ? body.userInput.trim() : '';
606
+ try {
607
+ const content = await runAgent({
608
+ endpoint: provider.config.endpoint,
609
+ token: provider.config.token,
610
+ model: agent.config.model || provider.config.model,
611
+ systemPrompt: agent.config.systemPrompt,
612
+ userInput,
613
+ timeout: agent.config.timeout || provider.config.timeout,
614
+ mcpEndpoint: mcp?.config.endpoint ?? '',
615
+ expectedOutputMarker: agent.config.expectedOutputMarker,
616
+ });
617
+ const document = createAgentRunDocument(docsPath, agent, provider, true, userInput, content);
618
+ res.json({ ok: true, content, document });
619
+ }
620
+ catch (error) {
621
+ const message = error instanceof Error ? error.message : 'Agent run failed';
622
+ const document = createAgentRunDocument(docsPath, agent, provider, false, userInput, message);
623
+ res.status(502).json({ ok: false, error: message, document });
624
+ }
625
+ }
626
+ catch (error) {
627
+ const message = error instanceof Error ? error.message : 'Agent run failed';
628
+ const status = message === 'agent not found' ? 404 : 400;
629
+ res.status(status).json({ ok: false, error: message });
630
+ }
631
+ });
632
+ router.post('/run-agent', async (req, res) => {
633
+ try {
634
+ const body = req.body;
635
+ if (typeof body.endpoint !== 'string' || !body.endpoint.trim()) {
636
+ res.status(400).json({ ok: false, error: 'endpoint is required' });
637
+ return;
638
+ }
639
+ if (typeof body.model !== 'string' || !body.model.trim()) {
640
+ res.status(400).json({ ok: false, error: 'model is required' });
641
+ return;
642
+ }
643
+ if (typeof body.systemPrompt !== 'string' || !body.systemPrompt.trim()) {
644
+ res.status(400).json({ ok: false, error: 'systemPrompt is required' });
645
+ return;
646
+ }
647
+ const content = await runAgent({
648
+ endpoint: body.endpoint,
649
+ token: typeof body.token === 'string' ? body.token : '',
650
+ model: body.model,
651
+ systemPrompt: body.systemPrompt,
652
+ userInput: typeof body.userInput === 'string' ? body.userInput : '',
653
+ timeout: clamp(safeFiniteNumber(body.timeout, 180), 1, 600),
654
+ mcpEndpoint: typeof body.mcpEndpoint === 'string' ? body.mcpEndpoint : '',
655
+ expectedOutputMarker: typeof body.expectedOutputMarker === 'string' ? body.expectedOutputMarker : '',
656
+ });
657
+ res.json({ ok: true, content });
658
+ }
659
+ catch (error) {
660
+ const message = error instanceof Error ? error.message : 'Agent run failed';
661
+ res.status(400).json({ ok: false, error: message });
662
+ }
663
+ });
664
+ router.post('/test-llm', async (req, res) => {
665
+ try {
666
+ const result = await testLlmConnection(req.body);
667
+ res.status(result.ok ? 200 : 502).json(result);
668
+ }
669
+ catch (error) {
670
+ const message = error instanceof Error ? error.message : 'Failed to test LLM connection';
671
+ res.status(400).json({ ok: false, error: message });
672
+ }
673
+ });
152
674
  return router;
153
675
  }
154
676
  //# sourceMappingURL=workspace.js.map