synthos 0.10.0 → 0.11.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/README.md +5 -5
- package/default-pages/elevenlabs_effects_studio/chat-history.json +1 -0
- package/default-pages/elevenlabs_effects_studio/page.html +1345 -1363
- package/default-pages/elevenlabs_effects_studio/page.json +13 -11
- package/default-pages/elevenlabs_voice_studio/chat-history.json +1 -0
- package/default-pages/elevenlabs_voice_studio/page.html +782 -801
- package/default-pages/elevenlabs_voice_studio/page.json +13 -11
- package/default-pages/json_tools/chat-history.json +1 -0
- package/default-pages/json_tools/page.html +70 -90
- package/default-pages/json_tools/page.json +12 -10
- package/default-pages/my_notes/chat-history.json +1 -0
- package/default-pages/my_notes/page.html +115 -131
- package/default-pages/my_notes/page.json +14 -12
- package/default-pages/neon_asteroids/chat-history.json +1 -0
- package/default-pages/neon_asteroids/page.html +1777 -1803
- package/default-pages/neon_asteroids/page.json +14 -12
- package/default-pages/oregon_trail/chat-history.json +1 -0
- package/default-pages/oregon_trail/page.html +290 -307
- package/default-pages/oregon_trail/page.json +14 -12
- package/default-pages/solar_explorer/chat-history.json +1 -0
- package/default-pages/solar_explorer/page.html +1929 -1951
- package/default-pages/solar_explorer/page.json +14 -12
- package/default-pages/solar_tutorial/chat-history.json +1 -0
- package/default-pages/solar_tutorial/page.html +464 -478
- package/default-pages/solar_tutorial/page.json +12 -10
- package/default-pages/us_map/chat-history.json +1 -0
- package/default-pages/us_map/page.html +170 -193
- package/default-pages/us_map/page.json +14 -12
- package/default-pages/us_map/page.light.png +0 -0
- package/default-pages/us_map_1850/chat-history.json +1 -0
- package/default-pages/us_map_1850/page.html +302 -326
- package/default-pages/us_map_1850/page.json +14 -12
- package/default-pages/western_cities_1850/chat-history.json +1 -0
- package/default-pages/western_cities_1850/page.html +503 -527
- package/default-pages/western_cities_1850/page.json +14 -12
- package/default-themes/aurora-dawn.v3.css +15 -14
- package/default-themes/aurora-dusk.v3.css +26 -26
- package/default-themes/cosmos-dawn.v3.css +15 -14
- package/default-themes/cosmos-dusk.v3.css +26 -26
- package/default-themes/elemental-dawn.v3.css +200 -0
- package/default-themes/nebula-dawn.v3.css +15 -14
- package/default-themes/nebula-dusk.v3.css +24 -24
- package/default-themes/solar-flare-dawn.v3.css +15 -14
- package/default-themes/solar-flare-dusk.v3.css +26 -26
- package/dist/builders/anthropic.d.ts +26 -2
- package/dist/builders/anthropic.d.ts.map +1 -1
- package/dist/builders/anthropic.js +132 -31
- package/dist/builders/anthropic.js.map +1 -1
- package/dist/builders/claudecode.d.ts +13 -0
- package/dist/builders/claudecode.d.ts.map +1 -0
- package/dist/builders/claudecode.js +253 -0
- package/dist/builders/claudecode.js.map +1 -0
- package/dist/builders/index.d.ts +2 -1
- package/dist/builders/index.d.ts.map +1 -1
- package/dist/builders/index.js +8 -1
- package/dist/builders/index.js.map +1 -1
- package/dist/builders/openai.js +2 -1
- package/dist/builders/openai.js.map +1 -1
- package/dist/builders/types.d.ts +31 -7
- package/dist/builders/types.d.ts.map +1 -1
- package/dist/builders/types.js +60 -28
- package/dist/builders/types.js.map +1 -1
- package/dist/connectors/types.d.ts +8 -0
- package/dist/connectors/types.d.ts.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +13 -6
- package/dist/init.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +161 -14
- package/dist/migrations.js.map +1 -1
- package/dist/models/anthropic.d.ts +1 -0
- package/dist/models/anthropic.d.ts.map +1 -1
- package/dist/models/anthropic.js +129 -29
- package/dist/models/anthropic.js.map +1 -1
- package/dist/models/chainOfThought.d.ts.map +1 -1
- package/dist/models/chainOfThought.js +32 -19
- package/dist/models/chainOfThought.js.map +1 -1
- package/dist/models/index.d.ts +2 -2
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +2 -1
- package/dist/models/index.js.map +1 -1
- package/dist/models/providers.d.ts +1 -0
- package/dist/models/providers.d.ts.map +1 -1
- package/dist/models/providers.js +12 -4
- package/dist/models/providers.js.map +1 -1
- package/dist/models/types.d.ts +15 -1
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js.map +1 -1
- package/dist/pages.d.ts +57 -8
- package/dist/pages.d.ts.map +1 -1
- package/dist/pages.js +258 -45
- package/dist/pages.js.map +1 -1
- package/dist/service/createCompletePrompt.d.ts.map +1 -1
- package/dist/service/createCompletePrompt.js +5 -0
- package/dist/service/createCompletePrompt.js.map +1 -1
- package/dist/service/mediaCache.d.ts +36 -0
- package/dist/service/mediaCache.d.ts.map +1 -0
- package/dist/service/mediaCache.js +182 -0
- package/dist/service/mediaCache.js.map +1 -0
- package/dist/service/pageValidator.d.ts +25 -0
- package/dist/service/pageValidator.d.ts.map +1 -0
- package/dist/service/pageValidator.js +315 -0
- package/dist/service/pageValidator.js.map +1 -0
- package/dist/service/server.d.ts.map +1 -1
- package/dist/service/server.js +4 -0
- package/dist/service/server.js.map +1 -1
- package/dist/service/sharedTableSchema.d.ts +73 -0
- package/dist/service/sharedTableSchema.d.ts.map +1 -0
- package/dist/service/sharedTableSchema.js +206 -0
- package/dist/service/sharedTableSchema.js.map +1 -0
- package/dist/service/transformPage.d.ts +49 -11
- package/dist/service/transformPage.d.ts.map +1 -1
- package/dist/service/transformPage.js +354 -241
- package/dist/service/transformPage.js.map +1 -1
- package/dist/service/useApiRoutes.d.ts.map +1 -1
- package/dist/service/useApiRoutes.js +288 -34
- package/dist/service/useApiRoutes.js.map +1 -1
- package/dist/service/useConnectorRoutes.d.ts.map +1 -1
- package/dist/service/useConnectorRoutes.js +170 -32
- package/dist/service/useConnectorRoutes.js.map +1 -1
- package/dist/service/useDataRoutes.d.ts.map +1 -1
- package/dist/service/useDataRoutes.js +59 -2
- package/dist/service/useDataRoutes.js.map +1 -1
- package/dist/service/useExtractRoutes.d.ts +4 -0
- package/dist/service/useExtractRoutes.d.ts.map +1 -0
- package/dist/service/useExtractRoutes.js +304 -0
- package/dist/service/useExtractRoutes.js.map +1 -0
- package/dist/service/usePageRoutes.d.ts +17 -0
- package/dist/service/usePageRoutes.d.ts.map +1 -1
- package/dist/service/usePageRoutes.js +1385 -483
- package/dist/service/usePageRoutes.js.map +1 -1
- package/dist/service/useSharedDataRoutes.d.ts.map +1 -1
- package/dist/service/useSharedDataRoutes.js +54 -2
- package/dist/service/useSharedDataRoutes.js.map +1 -1
- package/dist/settings.d.ts +27 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +40 -1
- package/dist/settings.js.map +1 -1
- package/dist/themes.d.ts +0 -5
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +3 -95
- package/dist/themes.js.map +1 -1
- package/migration-rules/v2-to-v3.md +277 -119
- package/package.json +5 -1
- package/{default-pages/application → required-pages/_shell}/page.html +56 -42
- package/required-pages/_shell/page.json +14 -0
- package/required-pages/_starters/page.html +534 -0
- package/required-pages/_starters/page.json +12 -0
- package/required-pages/builder/page.html +353 -43
- package/required-pages/builder/page.json +12 -10
- package/required-pages/pages/page.html +697 -924
- package/required-pages/pages/page.json +12 -10
- package/required-pages/settings/page.html +1879 -1753
- package/required-pages/settings/page.json +12 -10
- package/required-pages/synthos_apis/page.html +834 -845
- package/required-pages/synthos_apis/page.json +12 -10
- package/required-pages/synthos_scripts/page.html +74 -88
- package/required-pages/synthos_scripts/page.json +12 -10
- package/scripts/append-instructions.py +90 -0
- package/scripts/audit-instructions.py +76 -0
- package/scripts/cleanup-shell-markup.mjs +112 -0
- package/service-connectors/buffer/connector.json +46 -0
- package/service-connectors/canva/connector.json +67 -0
- package/service-connectors/elevenlabs/connector.json +1 -1
- package/src/builders/anthropic.ts +155 -30
- package/src/builders/claudecode.ts +310 -0
- package/src/builders/index.ts +7 -1
- package/src/builders/openai.ts +2 -1
- package/src/builders/types.ts +93 -32
- package/src/connectors/types.ts +8 -0
- package/src/init.ts +13 -7
- package/src/migrations.ts +187 -16
- package/src/models/anthropic.ts +140 -30
- package/src/models/chainOfThought.ts +33 -18
- package/src/models/index.ts +2 -2
- package/src/models/providers.ts +12 -3
- package/src/models/types.ts +21 -1
- package/src/pages.ts +271 -35
- package/src/service/createCompletePrompt.ts +6 -0
- package/src/service/mediaCache.ts +206 -0
- package/src/service/pageValidator.ts +337 -0
- package/src/service/server.ts +4 -0
- package/src/service/sharedTableSchema.ts +236 -0
- package/src/service/transformPage.ts +370 -260
- package/src/service/useApiRoutes.ts +282 -32
- package/src/service/useConnectorRoutes.ts +189 -34
- package/src/service/useDataRoutes.ts +198 -116
- package/src/service/useExtractRoutes.ts +331 -0
- package/src/service/usePageRoutes.ts +1411 -394
- package/src/service/useSharedDataRoutes.ts +184 -109
- package/src/settings.ts +65 -0
- package/src/themes.ts +78 -180
- package/starters/blank_starter/chat-history.json +1 -0
- package/starters/blank_starter/page.dark.png +0 -0
- package/starters/blank_starter/page.html +47 -0
- package/starters/blank_starter/page.json +13 -0
- package/starters/blank_starter/page.light.png +0 -0
- package/starters/calculator_starter/chat-history.json +1 -0
- package/starters/calculator_starter/page.dark.png +0 -0
- package/starters/calculator_starter/page.html +232 -0
- package/starters/calculator_starter/page.json +13 -0
- package/starters/calculator_starter/page.light.png +0 -0
- package/starters/calendar_starter/chat-history.json +1 -0
- package/starters/calendar_starter/page.dark.png +0 -0
- package/starters/calendar_starter/page.html +495 -0
- package/starters/calendar_starter/page.json +13 -0
- package/starters/calendar_starter/page.light.png +0 -0
- package/starters/chat_starter/chat-history.json +1 -0
- package/starters/chat_starter/page.dark.png +0 -0
- package/starters/chat_starter/page.html +351 -0
- package/starters/chat_starter/page.json +13 -0
- package/starters/chat_starter/page.light.png +0 -0
- package/starters/checklist_starter/chat-history.json +1 -0
- package/starters/checklist_starter/page.dark.png +0 -0
- package/starters/checklist_starter/page.html +437 -0
- package/starters/checklist_starter/page.json +13 -0
- package/starters/checklist_starter/page.light.png +0 -0
- package/starters/dashboard_starter/chat-history.json +1 -0
- package/starters/dashboard_starter/page.dark.png +0 -0
- package/starters/dashboard_starter/page.html +195 -0
- package/starters/dashboard_starter/page.json +13 -0
- package/starters/dashboard_starter/page.light.png +0 -0
- package/starters/form_starter/chat-history.json +1 -0
- package/starters/form_starter/page.dark.png +0 -0
- package/starters/form_starter/page.html +313 -0
- package/starters/form_starter/page.json +13 -0
- package/starters/form_starter/page.light.png +0 -0
- package/starters/gallery_starter/chat-history.json +1 -0
- package/starters/gallery_starter/page.dark.png +0 -0
- package/starters/gallery_starter/page.html +418 -0
- package/starters/gallery_starter/page.json +13 -0
- package/starters/gallery_starter/page.light.png +0 -0
- package/starters/generator_starter/chat-history.json +1 -0
- package/starters/generator_starter/page.dark.png +0 -0
- package/starters/generator_starter/page.html +261 -0
- package/starters/generator_starter/page.json +13 -0
- package/starters/generator_starter/page.light.png +0 -0
- package/starters/index.html +538 -0
- package/starters/kanban_starter/chat-history.json +1 -0
- package/starters/kanban_starter/page.dark.png +0 -0
- package/starters/kanban_starter/page.html +432 -0
- package/starters/kanban_starter/page.json +13 -0
- package/starters/kanban_starter/page.light.png +0 -0
- package/starters/presentation_builder/chat-history.json +1 -0
- package/starters/presentation_builder/page.dark.png +0 -0
- package/starters/presentation_builder/page.html +970 -0
- package/starters/presentation_builder/page.json +15 -0
- package/starters/presentation_builder/page.light.png +0 -0
- package/starters/presentation_builder/presentation_voice/voice_config.json +9 -0
- package/starters/pulse_starter/chat-history.json +1 -0
- package/starters/pulse_starter/page.dark.png +0 -0
- package/starters/pulse_starter/page.html +698 -0
- package/starters/pulse_starter/page.json +13 -0
- package/starters/pulse_starter/page.light.png +0 -0
- package/starters/quiz_starter/chat-history.json +1 -0
- package/starters/quiz_starter/page.dark.png +0 -0
- package/starters/quiz_starter/page.html +292 -0
- package/starters/quiz_starter/page.json +13 -0
- package/starters/quiz_starter/page.light.png +0 -0
- package/starters/reference_starter/chat-history.json +1 -0
- package/starters/reference_starter/page.dark.png +0 -0
- package/starters/reference_starter/page.html +250 -0
- package/starters/reference_starter/page.json +13 -0
- package/starters/reference_starter/page.light.png +0 -0
- package/starters/retro_game_starter/chat-history.json +1 -0
- package/starters/retro_game_starter/page.dark.png +0 -0
- package/{default-pages → starters}/retro_game_starter/page.html +1281 -1308
- package/starters/retro_game_starter/page.json +15 -0
- package/starters/retro_game_starter/page.light.png +0 -0
- package/starters/roster_starter/chat-history.json +1 -0
- package/starters/roster_starter/page.dark.png +0 -0
- package/starters/roster_starter/page.html +600 -0
- package/starters/roster_starter/page.json +13 -0
- package/starters/roster_starter/page.light.png +0 -0
- package/starters/server.js +182 -0
- package/starters/start.cmd +1 -0
- package/starters/timeline_starter/chat-history.json +1 -0
- package/starters/timeline_starter/page.dark.png +0 -0
- package/starters/timeline_starter/page.html +446 -0
- package/starters/timeline_starter/page.json +13 -0
- package/starters/timeline_starter/page.light.png +0 -0
- package/starters/tutorial_starter/chat-history.json +1 -0
- package/starters/tutorial_starter/page.dark.png +0 -0
- package/starters/tutorial_starter/page.html +283 -0
- package/starters/tutorial_starter/page.json +13 -0
- package/starters/tutorial_starter/page.light.png +0 -0
- package/static-files/agent.v3.js +122 -0
- package/static-files/connector.v3.js +48 -0
- package/static-files/extract.v3.js +188 -0
- package/static-files/helpers.v3.js +50 -6
- package/static-files/page-bridge.js +114 -0
- package/static-files/page.v3.js +1292 -1290
- package/static-files/script.v3.js +32 -0
- package/static-files/server.v3.js +89 -0
- package/static-files/shell-bridge.v3.js +174 -0
- package/static-files/shell-modals.v3.js +521 -0
- package/static-files/{shell.css → shell.v3.css} +271 -22
- package/static-files/shell.v3.js +1865 -0
- package/static-files/storage.v3.js +176 -0
- package/tests/anthropic.spec.ts +42 -7
- package/tests/builders.spec.ts +72 -4
- package/tests/pageValidator.spec.ts +548 -0
- package/tests/profiles.spec.ts +122 -0
- package/tests/providers.spec.ts +1 -1
- package/tests/sharedTableSchema.spec.ts +242 -0
- package/tests/transformPage.spec.ts +62 -81
- package/default-pages/application/page.json +0 -10
- package/default-pages/retro_game_starter/page.json +0 -12
- package/default-pages/sidebar_page/page.html +0 -51
- package/default-pages/sidebar_page/page.json +0 -10
- package/default-pages/two-panel_page/page.html +0 -68
- package/default-pages/two-panel_page/page.json +0 -10
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { loadPageMetadata, loadPageState, normalizePageName, PAGE_VERSION, savePageMetadata, savePageState,
|
|
2
|
-
import { getModelEntry, hasConfiguredSettings, loadSettings } from "../settings";
|
|
1
|
+
import { loadPageMetadata, loadPageState, normalizePageName, PAGE_VERSION, savePageMetadata, savePageState, saveChangeHtml, loadChangeHtml, getLatestChangeHtmlNumber, getLatestChangeNumber, clearChanges, saveChangeMessages, loadAllChangeMessages, deleteChange, migrateLegacyChangeFiles, ChatMessage } from "../pages";
|
|
2
|
+
import { getModelEntry, hasConfiguredSettings, loadSettings, renderUserProfile } from "../settings";
|
|
3
3
|
import { Application } from 'express';
|
|
4
4
|
import { transformPage, buildRouteHints, serverAPIs, AGENT_API_REFERENCE } from "./transformPage";
|
|
5
5
|
import { SynthOSConfig } from "../init";
|
|
@@ -8,13 +8,17 @@ import { completePrompt } from "../models";
|
|
|
8
8
|
import { green, red, dim, estimateTokens } from "./debugLog";
|
|
9
9
|
import { loadThemeInfo, loadThemeVersion, ThemeInfo } from "../themes";
|
|
10
10
|
import { Customizer } from "../customizer";
|
|
11
|
-
import { createBuilder, ContextSection, Attachment } from "../builders";
|
|
11
|
+
import { createBuilder, ContextSection, SectionMode, Attachment } from "../builders";
|
|
12
|
+
import { classifyRequest, ClassifierSection, ClassifyResult } from "../builders/anthropic";
|
|
12
13
|
import { getConnectorRegistry, ConnectorsConfig, ConnectorOAuthConfig } from "../connectors";
|
|
13
14
|
import { AgentConfig } from "../agents";
|
|
14
15
|
import { listScripts } from "../scripts";
|
|
15
16
|
import path from 'path';
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as crypto from 'crypto';
|
|
16
19
|
import { checkIfExists, findFileInFolders, loadFile } from "../files";
|
|
17
20
|
import * as cheerio from 'cheerio';
|
|
21
|
+
import { listTables as listTableInfo, loadSchema } from "./sharedTableSchema";
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Required CDN imports that must be present on every v2 page.
|
|
@@ -25,24 +29,125 @@ const REQUIRED_IMPORTS: { selector: string; src: string }[] = [
|
|
|
25
29
|
{ selector: 'script[src*="html2canvas"]', src: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js' },
|
|
26
30
|
];
|
|
27
31
|
|
|
32
|
+
// ensureRequiredImports is now ensureRequiredImports$() — operates on shared $ instance
|
|
33
|
+
|
|
34
|
+
const HOME_PAGE_ROUTE = '/builder';
|
|
35
|
+
const PAGE_NOT_FOUND = 'Page not found';
|
|
36
|
+
const SHELL_PAGE_NAME = '_shell';
|
|
37
|
+
const NO_PERSIST_CATEGORIES = ['_Starters', 'System'];
|
|
38
|
+
|
|
39
|
+
// Pages that are served as top-level documents (peers to _shell) — never wrapped
|
|
40
|
+
// in the shell iframe chrome. The page itself owns the full viewport.
|
|
41
|
+
const STANDALONE_PAGE_NAMES = ['_starters'];
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Cache-busting for /static assets
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Versions are computed lazily from file mtime + size, so any edit/deploy
|
|
47
|
+
// produces a new ?v=<hash> suffix and clients fetch fresh content.
|
|
48
|
+
let _staticFolders: readonly string[] = [];
|
|
49
|
+
const _assetVersions = new Map<string, string>();
|
|
50
|
+
|
|
51
|
+
function initStaticAssetVersioning(folders: readonly string[]): void {
|
|
52
|
+
_staticFolders = folders;
|
|
53
|
+
_assetVersions.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getAssetVersion(filename: string): string {
|
|
57
|
+
const cached = _assetVersions.get(filename);
|
|
58
|
+
if (cached) return cached;
|
|
59
|
+
for (const folder of _staticFolders) {
|
|
60
|
+
try {
|
|
61
|
+
const stat = fs.statSync(path.join(folder, filename));
|
|
62
|
+
const version = crypto.createHash('sha1')
|
|
63
|
+
.update(`${stat.mtimeMs}:${stat.size}`)
|
|
64
|
+
.digest('hex').slice(0, 8);
|
|
65
|
+
_assetVersions.set(filename, version);
|
|
66
|
+
return version;
|
|
67
|
+
} catch { /* try next folder */ }
|
|
68
|
+
}
|
|
69
|
+
_assetVersions.set(filename, 'dev');
|
|
70
|
+
return 'dev';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assetUrl(filename: string): string {
|
|
74
|
+
return `/static/${filename}?v=${getAssetVersion(filename)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
28
77
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
78
|
+
* Rewrite /static/<file> URLs in HTML to append cache-busting ?v=<hash>.
|
|
79
|
+
* Matches href= and src= attributes only; leaves URLs with existing query strings alone.
|
|
31
80
|
*/
|
|
32
|
-
function
|
|
33
|
-
|
|
81
|
+
function applyAssetVersions(html: string): string {
|
|
82
|
+
return html.replace(
|
|
83
|
+
/(href|src)="\/static\/([^"?]+)"/g,
|
|
84
|
+
(_m, attr, file) => `${attr}="${assetUrl(file)}"`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Per-page concurrency lock
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Serializes concurrent transform/patch requests for the same page to prevent
|
|
92
|
+
// version clobbering (two requests reading the same currentVersion, both
|
|
93
|
+
// writing to page.v{N+1}.html). Different pages run in parallel.
|
|
94
|
+
const pageLocks = new Map<string, Promise<void>>();
|
|
95
|
+
|
|
96
|
+
function withPageLock<T>(page: string, fn: () => Promise<T>): Promise<T> {
|
|
97
|
+
const prev = pageLocks.get(page) ?? Promise.resolve();
|
|
98
|
+
let releaseLock: () => void;
|
|
99
|
+
const next = new Promise<void>(resolve => { releaseLock = resolve; });
|
|
100
|
+
pageLocks.set(page, next);
|
|
101
|
+
return prev.then(fn).finally(() => {
|
|
102
|
+
releaseLock!();
|
|
103
|
+
// Clean up if no new work was queued behind us
|
|
104
|
+
if (pageLocks.get(page) === next) pageLocks.delete(page);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Attributes safe to perform product-name replacement in (user-visible text). */
|
|
109
|
+
const SAFE_REPLACE_ATTRS = ['alt', 'placeholder', 'aria-label', 'title', 'content'];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Replace "SynthOS" (case-insensitive) with the custom product name in
|
|
113
|
+
* user-visible text only. Skips <script>, <style>, and code-bearing
|
|
114
|
+
* attributes (class, id, src, href, onclick, data-*, etc.) to avoid
|
|
115
|
+
* breaking `window.synthos.*` API calls, CSS classes, and JS identifiers.
|
|
116
|
+
*/
|
|
117
|
+
function replaceProductName(html: string, productName: string): string {
|
|
118
|
+
if (productName === 'SynthOS') return html;
|
|
34
119
|
const $ = cheerio.load(html);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
120
|
+
const regex = /synthos/gi;
|
|
121
|
+
|
|
122
|
+
// Replace in safe attributes on all elements
|
|
123
|
+
$('*').each((_i, el) => {
|
|
124
|
+
if (el.type !== 'tag') return;
|
|
125
|
+
const elem = $(el);
|
|
126
|
+
for (const attr of SAFE_REPLACE_ATTRS) {
|
|
127
|
+
const val = elem.attr(attr);
|
|
128
|
+
if (val && regex.test(val)) {
|
|
129
|
+
elem.attr(attr, val.replace(regex, productName));
|
|
130
|
+
}
|
|
38
131
|
}
|
|
39
|
-
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Replace in text nodes, but skip <script> and <style> contents.
|
|
135
|
+
// Use a simple selector approach: find all text-bearing elements that
|
|
136
|
+
// are NOT script or style, and replace text in their direct text nodes.
|
|
137
|
+
$('*').not('script, style').contents().each((_i: number, node: any) => {
|
|
138
|
+
if (node.type === 'text' && node.data) {
|
|
139
|
+
// Skip if parent is script or style (contents() can include nested)
|
|
140
|
+
const parentTag = node.parentNode?.tagName?.toLowerCase?.() ?? node.parentNode?.name?.toLowerCase?.();
|
|
141
|
+
if (parentTag === 'script' || parentTag === 'style') return;
|
|
142
|
+
if (regex.test(node.data)) {
|
|
143
|
+
node.data = node.data.replace(regex, productName);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
40
148
|
return $.html();
|
|
41
149
|
}
|
|
42
150
|
|
|
43
|
-
const HOME_PAGE_ROUTE = '/builder';
|
|
44
|
-
const PAGE_NOT_FOUND = 'Page not found';
|
|
45
|
-
|
|
46
151
|
function injectPageInfoScript(html: string, pageName: string): string {
|
|
47
152
|
const tag = `<script id="page-info" src="/api/page-info.js?page=${encodeURIComponent(pageName)}"></script>`;
|
|
48
153
|
|
|
@@ -59,44 +164,7 @@ function injectPageInfoScript(html: string, pageName: string): string {
|
|
|
59
164
|
return tag + '\n' + html;
|
|
60
165
|
}
|
|
61
166
|
|
|
62
|
-
|
|
63
|
-
if (pageVersion < 2) return html;
|
|
64
|
-
const tag = `<script id="page-helpers" src="/api/page-helpers.js?v=${pageVersion}"></script>`;
|
|
65
|
-
|
|
66
|
-
// Remove any existing page-helpers script (may be at wrong position from prior LLM output)
|
|
67
|
-
// so it gets re-injected at the correct position below.
|
|
68
|
-
const existing = html.match(/<script\s+id="page-helpers"[^>]*><\/script>/);
|
|
69
|
-
if (existing) {
|
|
70
|
-
html = html.replace(existing[0], '');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Inject into <head> after page-info so helpers are available before inline body scripts
|
|
74
|
-
const pageInfo = html.indexOf('id="page-info"');
|
|
75
|
-
if (pageInfo !== -1) {
|
|
76
|
-
const closeTag = html.indexOf('</script>', pageInfo);
|
|
77
|
-
if (closeTag !== -1) {
|
|
78
|
-
const insertAt = closeTag + '</script>'.length;
|
|
79
|
-
return html.slice(0, insertAt) + '\n' + tag + html.slice(insertAt);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const idx = html.indexOf('</head>');
|
|
84
|
-
if (idx !== -1) {
|
|
85
|
-
return html.slice(0, idx) + tag + '\n' + html.slice(idx);
|
|
86
|
-
}
|
|
87
|
-
return tag + '\n' + html;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function injectPageScript(html: string, pageVersion: number): string {
|
|
91
|
-
if (pageVersion < 2) return html;
|
|
92
|
-
if (html.includes('id="page-script"')) return html;
|
|
93
|
-
const tag = `<script id="page-script" src="/api/page-script.js?v=${pageVersion}"></script>`;
|
|
94
|
-
const idx = html.indexOf('</body>');
|
|
95
|
-
if (idx !== -1) {
|
|
96
|
-
return html.slice(0, idx) + tag + '\n' + html.slice(idx);
|
|
97
|
-
}
|
|
98
|
-
return html + '\n' + tag;
|
|
99
|
-
}
|
|
167
|
+
// injectPageHelpers is now injectPageHelpers$() — operates on shared $ instance
|
|
100
168
|
|
|
101
169
|
/**
|
|
102
170
|
* Wrap each inline <script> body in an IIFE so that top-level const/let
|
|
@@ -119,83 +187,17 @@ function wrapInlineScriptsInIIFE(html: string): string {
|
|
|
119
187
|
});
|
|
120
188
|
}
|
|
121
189
|
|
|
122
|
-
|
|
123
|
-
* Move any external <script src="..."> tags found in <body> up to the end
|
|
124
|
-
* of <head>, preserving their relative order. Library scripts (d3, Chart.js,
|
|
125
|
-
* marked, etc.) only define globals and don't touch the DOM, so executing
|
|
126
|
-
* them in <head> is safe and guarantees they are available before any inline
|
|
127
|
-
* <body> scripts run. This also eliminates browser "parser-blocking cross
|
|
128
|
-
* site script invoked via document.write" warnings.
|
|
129
|
-
*/
|
|
130
|
-
function hoistExternalScriptsToHead(html: string): string {
|
|
131
|
-
const $ = cheerio.load(html, { decodeEntities: false });
|
|
132
|
-
const hoisted: string[] = [];
|
|
133
|
-
$('body script[src]').each((_, el) => {
|
|
134
|
-
const script = $(el);
|
|
135
|
-
// Don't move data-locked system scripts (page-script, page-helpers, etc.)
|
|
136
|
-
if (script.attr('data-locked') !== undefined) return;
|
|
137
|
-
hoisted.push($.html(script));
|
|
138
|
-
script.remove();
|
|
139
|
-
});
|
|
140
|
-
if (hoisted.length > 0) {
|
|
141
|
-
$('head').append(hoisted.join('\n') + '\n');
|
|
142
|
-
}
|
|
143
|
-
return $.html();
|
|
144
|
-
}
|
|
190
|
+
// hoistExternalScriptsToHead is now hoistExternalScriptsToHead$() — operates on shared $ instance
|
|
145
191
|
|
|
146
192
|
/**
|
|
147
193
|
* Inline error-capture script injected as the first child of <head> so it
|
|
148
194
|
* registers window.onerror / unhandledrejection *before* any page scripts run.
|
|
149
195
|
* Stripped before page transformation so the LLM never sees it.
|
|
150
196
|
*/
|
|
151
|
-
const ERROR_CAPTURE_ID = 'synthos-error-capture';
|
|
152
|
-
|
|
153
|
-
const ERROR_CAPTURE_SCRIPT = `<script id="${ERROR_CAPTURE_ID}">
|
|
154
|
-
(function(){
|
|
155
|
-
var E=window.__synthOSErrors=[];
|
|
156
|
-
window.onerror=function(m,s,l,c,e){
|
|
157
|
-
var entry=m+' at '+(s||'?')+':'+(l||'?')+':'+(c||'?');
|
|
158
|
-
if(e&&e.stack)entry+='\\n'+e.stack;
|
|
159
|
-
E.push(entry);showErr();return false;
|
|
160
|
-
};
|
|
161
|
-
window.addEventListener('unhandledrejection',function(ev){
|
|
162
|
-
var r=ev.reason;
|
|
163
|
-
E.push('Unhandled rejection: '+(r&&r.stack?r.stack:String(r)));showErr();
|
|
164
|
-
});
|
|
165
|
-
function showErr(){
|
|
166
|
-
var cm=document.getElementById('chatMessages');if(!cm)return;
|
|
167
|
-
if(showErr._p)return;showErr._p=true;
|
|
168
|
-
setTimeout(function(){
|
|
169
|
-
showErr._p=false;
|
|
170
|
-
var d=document.createElement('div');d.className='chat-message';
|
|
171
|
-
var p=document.createElement('p');
|
|
172
|
-
var pn=(window.pageInfo&&window.pageInfo.productName)||'SynthOS';
|
|
173
|
-
p.innerHTML='<strong>'+pn+':</strong> I noticed a JavaScript error on this page. '+
|
|
174
|
-
'<a href="#" style="color:var(--accent-primary,#a78bfa);text-decoration:underline;cursor:pointer" '+
|
|
175
|
-
'onclick="(function(e){e.preventDefault();var ci=document.getElementById(\\'chatInput\\');'+
|
|
176
|
-
'var f=document.getElementById(\\'chatForm\\');if(!ci||!f)return;'+
|
|
177
|
-
'ci.value=\\'Fix the following JavaScript errors on this page:\\\\n\\\\nCONSOLE_ERRORS:\\\\n\\'+window.__synthOSErrors.join(\\'\\\\n---\\\\n\\');'+
|
|
178
|
-
'window.__synthOSErrors=[];f.requestSubmit?f.requestSubmit():f.submit();})(event)">'+
|
|
179
|
-
'Let me try to fix it</a>';
|
|
180
|
-
d.appendChild(p);cm.appendChild(d);
|
|
181
|
-
cm.scrollTo({top:cm.scrollHeight,behavior:'smooth'});
|
|
182
|
-
},500);
|
|
183
|
-
}
|
|
184
|
-
})();
|
|
185
|
-
</script>`;
|
|
186
|
-
|
|
187
|
-
function injectErrorCapture(html: string, pageVersion: number): string {
|
|
188
|
-
if (pageVersion < 2) return html;
|
|
189
|
-
if (html.includes(`id="${ERROR_CAPTURE_ID}"`)) return html;
|
|
190
|
-
const $ = cheerio.load(html, { decodeEntities: false });
|
|
191
|
-
$('head').prepend(ERROR_CAPTURE_SCRIPT + '\n');
|
|
192
|
-
return $.html();
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
197
|
/**
|
|
197
|
-
* Inject shell.css
|
|
198
|
-
*
|
|
198
|
+
* Inject shell.v3.css and FluentLM base CSS/JS (always — the shell always emits
|
|
199
|
+
* FluentLM-keyed modals). The theme-name class on <html> stays gated to v3 so
|
|
200
|
+
* pre-v3 themes don't accidentally activate scoped rules.
|
|
199
201
|
*/
|
|
200
202
|
function injectShellAssets(html: string, themeName: string, themeVersion: number, toolbarPosition?: string): string {
|
|
201
203
|
const $ = cheerio.load(html, { decodeEntities: false });
|
|
@@ -203,12 +205,12 @@ function injectShellAssets(html: string, themeName: string, themeVersion: number
|
|
|
203
205
|
|
|
204
206
|
// Favicon
|
|
205
207
|
if ($('link#synthos-favicon').length === 0) {
|
|
206
|
-
$('head').prepend(
|
|
208
|
+
$('head').prepend(`<link id="synthos-favicon" rel="icon" type="image/svg+xml" href="${assetUrl('favicon.svg')}">\n`);
|
|
207
209
|
}
|
|
208
210
|
|
|
209
|
-
// shell.css — always injected (provides toolbar + layout chrome)
|
|
211
|
+
// shell.v3.css — always injected (provides toolbar + layout chrome)
|
|
210
212
|
if ($('link#shell-css').length === 0) {
|
|
211
|
-
const shellLink =
|
|
213
|
+
const shellLink = `<link id="shell-css" rel="stylesheet" href="${assetUrl('shell.v3.css')}">`;
|
|
212
214
|
if (themeLink.length > 0) {
|
|
213
215
|
themeLink.before(shellLink + '\n');
|
|
214
216
|
} else {
|
|
@@ -216,29 +218,28 @@ function injectShellAssets(html: string, themeName: string, themeVersion: number
|
|
|
216
218
|
}
|
|
217
219
|
}
|
|
218
220
|
|
|
219
|
-
//
|
|
221
|
+
// Theme-name class on <html> — only for v3 themes that ship scoped rules
|
|
220
222
|
if (themeVersion >= 3) {
|
|
221
|
-
// Add theme name class to <html> (e.g. "nebula-dusk")
|
|
222
223
|
$('html').addClass(themeName);
|
|
224
|
+
}
|
|
223
225
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
226
|
+
// FluentLM CSS — always injected; shell modals depend on .flm-dialog-overlay
|
|
227
|
+
if ($('link#fluentlm-css').length === 0) {
|
|
228
|
+
const fluentLink = `<link id="fluentlm-css" rel="stylesheet" href="${assetUrl('fluentlm.min.css')}">`;
|
|
229
|
+
const shellCss = $('link#shell-css');
|
|
230
|
+
if (shellCss.length > 0) {
|
|
231
|
+
shellCss.before(fluentLink + '\n');
|
|
232
|
+
} else if (themeLink.length > 0) {
|
|
233
|
+
themeLink.before(fluentLink + '\n');
|
|
234
|
+
} else {
|
|
235
|
+
$('head').append(fluentLink + '\n');
|
|
235
236
|
}
|
|
237
|
+
}
|
|
236
238
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
239
|
+
// FluentLM JS — always injected
|
|
240
|
+
if ($('script#fluentlm-js').length === 0) {
|
|
241
|
+
const fluentScript = `<script id="fluentlm-js" src="${assetUrl('fluentlm.min.js')}"></script>`;
|
|
242
|
+
$('body').append(fluentScript + '\n');
|
|
242
243
|
}
|
|
243
244
|
|
|
244
245
|
$('html').attr('data-toolbar', toolbarPosition || 'left');
|
|
@@ -246,10 +247,306 @@ function injectShellAssets(html: string, themeName: string, themeVersion: number
|
|
|
246
247
|
return $.html();
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// iframe architecture helpers
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Cheerio-based transform steps (operate on shared $ instance)
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Strip shell markup (toolbar, chat panel) from the in-memory DOM.
|
|
260
|
+
*/
|
|
261
|
+
function stripShellMarkup$($: cheerio.Root): void {
|
|
262
|
+
$('.shell-toolbar').remove();
|
|
263
|
+
$('.chat-panel').remove();
|
|
264
|
+
$('#loadingOverlay').remove();
|
|
265
|
+
$('script#page-script').remove();
|
|
266
|
+
$('script#synthos-error-capture').remove();
|
|
267
|
+
|
|
268
|
+
// v2-legacy idle animation — the inline <script id="idle-animation"> block
|
|
269
|
+
// declares top-level hideIdleAnimation/showIdleAnimation functions that
|
|
270
|
+
// get wrapped in an IIFE by wrapInlineScriptsInIIFE, so cross-script
|
|
271
|
+
// callers hit ReferenceError. Strip the block and provide no-op globals
|
|
272
|
+
// via the stub script below.
|
|
273
|
+
$('script#idle-animation').remove();
|
|
274
|
+
|
|
275
|
+
// Inject hidden stub elements for shell IDs that legacy page scripts may
|
|
276
|
+
// reference (chatForm, chatInput, chatMessages, loadingOverlay) and no-op
|
|
277
|
+
// globals for v2-era helper functions (hideIdleAnimation,
|
|
278
|
+
// showIdleAnimation) that used to live on window via top-level function
|
|
279
|
+
// declarations. Still needed: 17+ pages reference these IDs. Remove once
|
|
280
|
+
// all pages are migrated to use postMessage bridge instead of direct DOM
|
|
281
|
+
// access.
|
|
282
|
+
const stubScript = `<script data-shell-compat="true">(function(){` +
|
|
283
|
+
`var s={chatForm:'form',chatInput:'textarea',chatMessages:'div',loadingOverlay:'div'};` +
|
|
284
|
+
`for(var id in s){if(!document.getElementById(id)){` +
|
|
285
|
+
`var el=document.createElement(s[id]);el.id=id;el.style.display='none';` +
|
|
286
|
+
`document.body.appendChild(el);}}` +
|
|
287
|
+
`if(typeof window.hideIdleAnimation!=='function')window.hideIdleAnimation=function(){};` +
|
|
288
|
+
`if(typeof window.showIdleAnimation!=='function')window.showIdleAnimation=function(){};` +
|
|
289
|
+
`})();</script>`;
|
|
290
|
+
$('body').prepend(stubScript);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Ensure every required CDN import is present in the page's <head>.
|
|
295
|
+
*/
|
|
296
|
+
function ensureRequiredImports$($: cheerio.Root, pageVersion: number): void {
|
|
297
|
+
if (pageVersion < 2) return;
|
|
298
|
+
for (const imp of REQUIRED_IMPORTS) {
|
|
299
|
+
if ($(imp.selector).length === 0) {
|
|
300
|
+
$('head').append(`<script src="${imp.src}"></script>\n`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Inject FluentLM CSS/JS and theme class into page content for iframe rendering.
|
|
307
|
+
*/
|
|
308
|
+
function injectContentAssets$($: cheerio.Root, themeName: string, themeVersion: number): void {
|
|
309
|
+
// Inject iframe viewport styles
|
|
310
|
+
if ($('style#iframe-viewport').length === 0) {
|
|
311
|
+
$('head').prepend(
|
|
312
|
+
'<style id="iframe-viewport">html,body,.viewer-panel{height:100%;margin:0}</style>\n'
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Move non-locked <script> tags from <head> to end of <body>.
|
|
317
|
+
const headScripts = $('head script').not('[data-locked]');
|
|
318
|
+
headScripts.each((_, el) => {
|
|
319
|
+
const $el = $(el);
|
|
320
|
+
$el.remove();
|
|
321
|
+
$('body').append($el);
|
|
322
|
+
$('body').append('\n');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Theme-name class on <html> — only for v3 themes that ship scoped rules
|
|
326
|
+
if (themeVersion >= 3) {
|
|
327
|
+
$('html').addClass(themeName);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// FluentLM CSS + JS — always injected; page-level FluentLM usage should work
|
|
331
|
+
// regardless of theme version.
|
|
332
|
+
if ($('link#fluentlm-css').length === 0) {
|
|
333
|
+
const fluentLink = `<link id="fluentlm-css" rel="stylesheet" href="${assetUrl('fluentlm.min.css')}">`;
|
|
334
|
+
const themeLink = $('link#theme-css');
|
|
335
|
+
if (themeLink.length > 0) {
|
|
336
|
+
themeLink.before(fluentLink + '\n');
|
|
337
|
+
} else {
|
|
338
|
+
$('head').append(fluentLink + '\n');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if ($('script#fluentlm-js').length === 0) {
|
|
343
|
+
$('body').append(`<script id="fluentlm-js" src="${assetUrl('fluentlm.min.js')}"></script>\n`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Inject page-info script tag into <head>.
|
|
349
|
+
*/
|
|
350
|
+
function injectPageInfoScript$($: cheerio.Root, pageName: string): void {
|
|
351
|
+
const tag = `<script id="page-info" src="/api/page-info.js?page=${encodeURIComponent(pageName)}"></script>`;
|
|
352
|
+
const existing = $('script#page-info');
|
|
353
|
+
if (existing.length > 0) {
|
|
354
|
+
existing.replaceWith(tag);
|
|
355
|
+
} else {
|
|
356
|
+
$('head').append(tag + '\n');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* V3 helper script IDs and their static file names.
|
|
362
|
+
* shell-bridge.v3.js loads first (creates window.synthos); the rest attach namespaces.
|
|
363
|
+
*/
|
|
364
|
+
const V3_HELPER_SCRIPTS = [
|
|
365
|
+
{ id: 'shell-bridge-v3', file: 'shell-bridge.v3.js' },
|
|
366
|
+
{ id: 'server-v3', file: 'server.v3.js' },
|
|
367
|
+
{ id: 'storage-v3', file: 'storage.v3.js' },
|
|
368
|
+
{ id: 'script-v3', file: 'script.v3.js' },
|
|
369
|
+
{ id: 'connector-v3', file: 'connector.v3.js' },
|
|
370
|
+
{ id: 'agent-v3', file: 'agent.v3.js' },
|
|
371
|
+
{ id: 'extract-v3', file: 'extract.v3.js' },
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Inject v3 helper scripts into the page.
|
|
376
|
+
* shell-bridge.v3.js goes at end of <body> (bridge must be last to initialize after DOM).
|
|
377
|
+
* All other v3 scripts go into <head> after page-info.
|
|
378
|
+
*/
|
|
379
|
+
function injectPageHelpers$($: cheerio.Root, pageVersion: number): void {
|
|
380
|
+
if (pageVersion < 2) return;
|
|
381
|
+
|
|
382
|
+
// Remove legacy monolithic helpers if present
|
|
383
|
+
$('script#page-helpers').remove();
|
|
384
|
+
$('script#page-bridge').remove();
|
|
385
|
+
// Remove old bridge id in case pages were rendered with an older version
|
|
386
|
+
$('script#shell-v3').remove();
|
|
387
|
+
|
|
388
|
+
// Inject v3 scripts
|
|
389
|
+
for (const script of V3_HELPER_SCRIPTS) {
|
|
390
|
+
if ($(`script#${script.id}`).length > 0) continue;
|
|
391
|
+
const tag = `<script id="${script.id}" src="${assetUrl(script.file)}"></script>`;
|
|
392
|
+
if (script.id === 'shell-bridge-v3') {
|
|
393
|
+
// Bridge goes at end of body (needs to run after page DOM)
|
|
394
|
+
$('body').append(tag + '\n');
|
|
395
|
+
} else {
|
|
396
|
+
// API scripts go into head after page-info
|
|
397
|
+
const pageInfo = $('script#page-info');
|
|
398
|
+
if (pageInfo.length > 0) {
|
|
399
|
+
pageInfo.after('\n' + tag);
|
|
400
|
+
} else {
|
|
401
|
+
$('head').append(tag + '\n');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @deprecated No longer needed — shell-bridge.v3.js replaces page-bridge.js.
|
|
409
|
+
* Kept as a no-op for any call sites not yet updated.
|
|
410
|
+
*/
|
|
411
|
+
function injectPageBridge$($: cheerio.Root, pageVersion: number): void {
|
|
412
|
+
// No-op: shell-bridge.v3.js is now injected by injectPageHelpers$
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Move external <script src="..."> from <body> to end of <head>.
|
|
417
|
+
*/
|
|
418
|
+
function hoistExternalScriptsToHead$($: cheerio.Root): void {
|
|
419
|
+
const hoisted: string[] = [];
|
|
420
|
+
$('body script[src]').each((_, el) => {
|
|
421
|
+
const script = $(el);
|
|
422
|
+
if (script.attr('data-locked') !== undefined) return;
|
|
423
|
+
hoisted.push($.html(script));
|
|
424
|
+
script.remove();
|
|
425
|
+
});
|
|
426
|
+
if (hoisted.length > 0) {
|
|
427
|
+
$('head').append(hoisted.join('\n') + '\n');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Full iframe content preparation pipeline. Parses HTML once, applies all
|
|
433
|
+
* cheerio-based transforms on the in-memory DOM, serializes once, then runs
|
|
434
|
+
* string-based transforms (IIFE wrapping, product-name replacement).
|
|
435
|
+
*
|
|
436
|
+
* Centralised here so every endpoint that serves page HTML into the iframe
|
|
437
|
+
* goes through the same steps.
|
|
438
|
+
*/
|
|
439
|
+
function prepareForIframe(
|
|
440
|
+
html: string,
|
|
441
|
+
page: string,
|
|
442
|
+
pageVersion: number,
|
|
443
|
+
themeName: string,
|
|
444
|
+
themeVersion: number,
|
|
445
|
+
productName: string,
|
|
446
|
+
): string {
|
|
447
|
+
// Single cheerio parse
|
|
448
|
+
const $ = cheerio.load(html, { decodeEntities: false });
|
|
449
|
+
|
|
450
|
+
// All DOM transforms on the shared $ instance
|
|
451
|
+
stripShellMarkup$($);
|
|
452
|
+
ensureRequiredImports$($, pageVersion);
|
|
453
|
+
injectContentAssets$($, themeName, themeVersion);
|
|
454
|
+
injectPageInfoScript$($, page);
|
|
455
|
+
injectPageHelpers$($, pageVersion);
|
|
456
|
+
injectPageBridge$($, pageVersion);
|
|
457
|
+
hoistExternalScriptsToHead$($);
|
|
458
|
+
|
|
459
|
+
// Single serialize, then string-based transforms
|
|
460
|
+
let out = $.html();
|
|
461
|
+
out = wrapInlineScriptsInIIFE(out);
|
|
462
|
+
out = replaceProductName(out, productName);
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Inject __shellInit script into the shell page with page-specific data.
|
|
468
|
+
*/
|
|
469
|
+
function injectShellInit(
|
|
470
|
+
shellHtml: string,
|
|
471
|
+
page: string,
|
|
472
|
+
messages: ChatMessage[],
|
|
473
|
+
metadata: { title: string; categories: string[]; mode: string; isRequiredPage: boolean; greeting: string; firstRunGreeting: string },
|
|
474
|
+
version: number,
|
|
475
|
+
productName: string,
|
|
476
|
+
builderStarter?: string,
|
|
477
|
+
): string {
|
|
478
|
+
const isBuilder = metadata.categories.some(c =>
|
|
479
|
+
c === 'Builders' || c === 'System' || c === '_Starters'
|
|
480
|
+
);
|
|
481
|
+
const noPersistHistory = metadata.categories.some(c =>
|
|
482
|
+
NO_PERSIST_CATEGORIES.includes(c)
|
|
483
|
+
);
|
|
484
|
+
const isStarter = metadata.categories.includes('_Starters');
|
|
485
|
+
const initData = JSON.stringify({
|
|
486
|
+
page,
|
|
487
|
+
messages,
|
|
488
|
+
version,
|
|
489
|
+
productName,
|
|
490
|
+
pageTitle: metadata.title,
|
|
491
|
+
pageCategories: metadata.categories,
|
|
492
|
+
isLocked: metadata.mode === 'locked',
|
|
493
|
+
isRequiredPage: metadata.isRequiredPage,
|
|
494
|
+
isBuilder,
|
|
495
|
+
noPersistHistory,
|
|
496
|
+
isStarter,
|
|
497
|
+
greeting: metadata.greeting,
|
|
498
|
+
firstRunGreeting: metadata.firstRunGreeting,
|
|
499
|
+
builderStarter: builderStarter ?? '',
|
|
500
|
+
});
|
|
501
|
+
const script = `<script id="shell-init">window.__shellInit=${initData};</script>`;
|
|
502
|
+
// Inject before shell-modals.v3.js — it's the earliest consumer of window.__shellInit
|
|
503
|
+
// and captures values at module-load time, so __shellInit MUST be set before it loads.
|
|
504
|
+
// Match href with or without cache-bust query.
|
|
505
|
+
const shellModalsIdx = shellHtml.search(/<script src="\/static\/shell-modals\.v3\.js(\?[^"]*)?">/);
|
|
506
|
+
if (shellModalsIdx !== -1) {
|
|
507
|
+
return shellHtml.slice(0, shellModalsIdx) + script + '\n' + shellHtml.slice(shellModalsIdx);
|
|
508
|
+
}
|
|
509
|
+
// Fallback: before shell.v3.js
|
|
510
|
+
const shellJsIdx = shellHtml.search(/<script src="\/static\/shell\.v3\.js(\?[^"]*)?">/);
|
|
511
|
+
if (shellJsIdx !== -1) {
|
|
512
|
+
return shellHtml.slice(0, shellJsIdx) + script + '\n' + shellHtml.slice(shellJsIdx);
|
|
513
|
+
}
|
|
514
|
+
// Fallback: inject before </body>
|
|
515
|
+
const bodyIdx = shellHtml.indexOf('</body>');
|
|
516
|
+
if (bodyIdx !== -1) {
|
|
517
|
+
return shellHtml.slice(0, bodyIdx) + script + '\n' + shellHtml.slice(bodyIdx);
|
|
518
|
+
}
|
|
519
|
+
return shellHtml + '\n' + script;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Set the iframe src attribute in the shell page HTML.
|
|
524
|
+
*/
|
|
525
|
+
function setIframeSrc(shellHtml: string, page: string, starter?: string): string {
|
|
526
|
+
const starterQuery = starter ? `&starter=${encodeURIComponent(starter)}` : '';
|
|
527
|
+
return shellHtml.replace(
|
|
528
|
+
'id="viewerFrame"',
|
|
529
|
+
`id="viewerFrame" src="/${page}?frame=1${starterQuery}"`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
249
533
|
// ---------------------------------------------------------------------------
|
|
250
534
|
// Context section builders — assemble ContextSections from enabled features
|
|
251
535
|
// ---------------------------------------------------------------------------
|
|
252
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Extract the first non-empty line of each blank-separated block. Used to
|
|
539
|
+
* generate a sketch for sections whose full content is a list of method
|
|
540
|
+
* signatures + descriptions (e.g. SERVER_APIS, SERVER_SCRIPTS).
|
|
541
|
+
*/
|
|
542
|
+
function firstLineOfEachBlock(s: string): string {
|
|
543
|
+
return s
|
|
544
|
+
.split(/\n\s*\n/)
|
|
545
|
+
.map(b => b.trimStart().split('\n')[0].trim())
|
|
546
|
+
.filter(Boolean)
|
|
547
|
+
.join('\n');
|
|
548
|
+
}
|
|
549
|
+
|
|
253
550
|
function buildContextSection(): ContextSection {
|
|
254
551
|
const now = new Date();
|
|
255
552
|
const dateTime = now.toLocaleString('en-US', {
|
|
@@ -259,34 +556,51 @@ function buildContextSection(): ContextSection {
|
|
|
259
556
|
return {
|
|
260
557
|
title: '<CONTEXT>',
|
|
261
558
|
content: `Current date and time: ${dateTime}`,
|
|
559
|
+
sketch: null,
|
|
560
|
+
mode: 'always-full',
|
|
262
561
|
instructions: '',
|
|
263
562
|
};
|
|
264
563
|
}
|
|
265
564
|
|
|
266
565
|
function buildServerApisSection(customizer?: Customizer): ContextSection {
|
|
267
|
-
const
|
|
566
|
+
const full = (customizer ? buildRouteHints(customizer) : serverAPIs)
|
|
567
|
+
.replace(/^<SERVER_APIS>\n?/, '');
|
|
268
568
|
return {
|
|
269
569
|
title: '<SERVER_APIS>',
|
|
270
|
-
content:
|
|
570
|
+
content: full,
|
|
571
|
+
sketch: firstLineOfEachBlock(full),
|
|
572
|
+
mode: 'classifier-decides',
|
|
573
|
+
forceFullOnInitial: true,
|
|
271
574
|
instructions: 'provides a list of available server APIs and helper functions you can call from injected scripts. Use synthos.* helpers instead of raw fetch().',
|
|
272
575
|
};
|
|
273
576
|
}
|
|
274
577
|
|
|
275
578
|
async function buildServerScriptsSection(pagesFolder: string): Promise<ContextSection> {
|
|
276
|
-
const scripts = await listScripts(pagesFolder);
|
|
579
|
+
const scripts = (await listScripts(pagesFolder)) || '';
|
|
277
580
|
return {
|
|
278
581
|
title: '<SERVER_SCRIPTS>',
|
|
279
|
-
content: scripts
|
|
280
|
-
|
|
582
|
+
content: scripts,
|
|
583
|
+
sketch: scripts ? firstLineOfEachBlock(scripts) : null,
|
|
584
|
+
mode: 'always-omit-when-empty',
|
|
585
|
+
forceFullOnInitial: true,
|
|
586
|
+
instructions: 'provides a list of available scripts callable via synthos.script.run(id, variables).',
|
|
281
587
|
};
|
|
282
588
|
}
|
|
283
589
|
|
|
284
|
-
function buildConnectorsSection(configuredConnectors?: ConnectorsConfig): ContextSection
|
|
285
|
-
|
|
590
|
+
function buildConnectorsSection(configuredConnectors?: ConnectorsConfig): ContextSection {
|
|
591
|
+
const entries = configuredConnectors
|
|
592
|
+
? Object.entries(configuredConnectors).filter(([, cfg]) => cfg.enabled && cfg.apiKey)
|
|
593
|
+
: [];
|
|
286
594
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
595
|
+
if (entries.length === 0) {
|
|
596
|
+
return {
|
|
597
|
+
title: '<CONFIGURED_CONNECTORS>',
|
|
598
|
+
content: '',
|
|
599
|
+
sketch: null,
|
|
600
|
+
mode: 'always-omit-when-empty',
|
|
601
|
+
instructions: '',
|
|
602
|
+
};
|
|
603
|
+
}
|
|
290
604
|
|
|
291
605
|
const blocks = entries.map(([id, cfg]) => {
|
|
292
606
|
const def = getConnectorRegistry().find(d => d.id === id);
|
|
@@ -308,18 +622,34 @@ function buildConnectorsSection(configuredConnectors?: ConnectorsConfig): Contex
|
|
|
308
622
|
return block;
|
|
309
623
|
});
|
|
310
624
|
|
|
311
|
-
const content = `The user has configured and enabled these connectors:\n${blocks.join('\n\n')}\n\nYou may use synthos.
|
|
625
|
+
const content = `The user has configured and enabled these connectors:\n${blocks.join('\n\n')}\n\nYou may use synthos.connector.call(connector, method, path, opts) to call them.\nIMPORTANT: Before making any connector call, ALWAYS check that the connector is configured first using synthos.connector.list(). If the connector is not configured, show the user a friendly message with a link to the Settings > Connectors page (/settings?tab=connectors) so they can set it up.\nDo NOT hardcode API keys. The connector proxy attaches authentication automatically.`;
|
|
626
|
+
|
|
627
|
+
const sketchNames = entries.map(([id]) => {
|
|
628
|
+
const def = getConnectorRegistry().find(d => d.id === id);
|
|
629
|
+
return def?.name ?? id;
|
|
630
|
+
});
|
|
312
631
|
|
|
313
632
|
return {
|
|
314
633
|
title: '<CONFIGURED_CONNECTORS>',
|
|
315
634
|
content,
|
|
635
|
+
sketch: `Configured connectors: ${sketchNames.join(', ')}`,
|
|
636
|
+
mode: 'always-omit-when-empty',
|
|
316
637
|
instructions: '',
|
|
317
638
|
};
|
|
318
639
|
}
|
|
319
640
|
|
|
320
|
-
function buildAgentsSection(configuredAgents?: AgentConfig[]): ContextSection
|
|
641
|
+
function buildAgentsSection(configuredAgents?: AgentConfig[]): ContextSection {
|
|
321
642
|
const enabledAgents = (configuredAgents ?? []).filter(a => a.enabled);
|
|
322
|
-
|
|
643
|
+
|
|
644
|
+
if (enabledAgents.length === 0) {
|
|
645
|
+
return {
|
|
646
|
+
title: '<CONFIGURED_AGENTS>',
|
|
647
|
+
content: '',
|
|
648
|
+
sketch: null,
|
|
649
|
+
mode: 'always-omit-when-empty',
|
|
650
|
+
instructions: '',
|
|
651
|
+
};
|
|
652
|
+
}
|
|
323
653
|
|
|
324
654
|
const agentBlocks = enabledAgents.map(a => {
|
|
325
655
|
let block = `- ${a.name} (id: "${a.id}", provider: ${a.provider})`;
|
|
@@ -334,9 +664,13 @@ function buildAgentsSection(configuredAgents?: AgentConfig[]): ContextSection |
|
|
|
334
664
|
return block;
|
|
335
665
|
});
|
|
336
666
|
|
|
667
|
+
const sketchLines = enabledAgents.map(a => `- ${a.name}: ${a.description}`);
|
|
668
|
+
|
|
337
669
|
return {
|
|
338
670
|
title: '<CONFIGURED_AGENTS>',
|
|
339
671
|
content: `The user has configured these agents:\n\n${agentBlocks.join('\n\n')}\n\n${AGENT_API_REFERENCE}`,
|
|
672
|
+
sketch: `Configured agents:\n${sketchLines.join('\n')}`,
|
|
673
|
+
mode: 'always-omit-when-empty',
|
|
340
674
|
instructions: '',
|
|
341
675
|
};
|
|
342
676
|
}
|
|
@@ -348,13 +682,43 @@ function buildThemeSection(themeInfo?: ThemeInfo): ContextSection {
|
|
|
348
682
|
const colorList = Object.entries(colors)
|
|
349
683
|
.map(([name, value]) => ` --${name}: ${value}`)
|
|
350
684
|
.join('\n');
|
|
351
|
-
content = `Mode: ${mode}
|
|
685
|
+
content = `Mode: ${mode}
|
|
686
|
+
CSS custom properties (use instead of hardcoded values):
|
|
687
|
+
${colorList}
|
|
688
|
+
|
|
689
|
+
Page rendering context: Your page runs inside an iframe. The shell (toolbar, chat panel, modals) is in the parent frame and is NOT part of your page. You own the full <body> — there is no .shell-toolbar, .chat-panel, #chatForm, or #loadingOverlay in your page.
|
|
690
|
+
|
|
691
|
+
Modals and popups: ALWAYS use FluentLM dialog components for any modal or popup. Do NOT create custom overlay classes with position:fixed and z-index. Structure:
|
|
692
|
+
<div class="flm-dialog-overlay" id="myModal" data-light-dismiss>
|
|
693
|
+
<div class="flm-dialog">
|
|
694
|
+
<div class="flm-dialog-header">
|
|
695
|
+
<h2 class="flm-dialog-title">Title</h2>
|
|
696
|
+
<button class="flm-dialog-close" data-icon="Cancel" aria-label="Close"></button>
|
|
697
|
+
</div>
|
|
698
|
+
<div class="flm-dialog-body">Content</div>
|
|
699
|
+
<div class="flm-dialog-footer">
|
|
700
|
+
<button class="flm-button" data-dialog-close>Cancel</button>
|
|
701
|
+
<button class="flm-button flm-button--primary" data-dialog-close>OK</button>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
Show/hide via FluentLM JS: FluentLM auto-wires open/close on elements with data-dialog-open="#id" and data-dialog-close attributes. For programmatic control use: FluentLMDialogComponent.open('overlayId') and FluentLMDialogComponent.close('overlayId'). NEVER use classList.add('is-open') — the correct class is 'flm-dialog-overlay--open' but always prefer the JS API.
|
|
706
|
+
|
|
707
|
+
Navigation: To navigate to another page from within the iframe, use window.__synthOSNavigateTo(url). This routes through the parent shell which handles unsaved-changes checks.
|
|
708
|
+
|
|
709
|
+
Do NOT:
|
|
710
|
+
- Create shell chrome elements (.shell-toolbar, .chat-panel, #chatForm, #loadingOverlay, .chat-messages) — these belong to the parent frame
|
|
711
|
+
- INSERT new <script> blocks that duplicate existing ones — when fixing JavaScript, UPDATE or REPLACE the existing script's nodeId instead. Always give inline scripts a unique id attribute.
|
|
712
|
+
|
|
713
|
+
The <html> element has class "${mode}-mode". Always add .light-mode CSS overrides for any page-specific styles so the page works in both light and dark themes, unless the user has explicitly requested a very specific color scheme.`;
|
|
352
714
|
}
|
|
353
715
|
|
|
354
716
|
return {
|
|
355
717
|
title: '<THEME>',
|
|
356
718
|
content,
|
|
357
|
-
|
|
719
|
+
sketch: null,
|
|
720
|
+
mode: 'always-full',
|
|
721
|
+
instructions: 'provides details on the current theme\'s color scheme to help you generate theme-aware pages.',
|
|
358
722
|
};
|
|
359
723
|
}
|
|
360
724
|
|
|
@@ -402,59 +766,365 @@ function buildLlmdReadingGuideSection(): ContextSection {
|
|
|
402
766
|
return {
|
|
403
767
|
title: '<LLMD_READING_GUIDE>',
|
|
404
768
|
content: LLMD_READING_GUIDE,
|
|
769
|
+
sketch: 'Reading guide for LLMD v0.2 — a token-compressed format used by some context sections (defines @scope, :k=v attrs, -lists, →relations, ::lang literal blocks, and meta keys).',
|
|
770
|
+
mode: 'classifier-decides',
|
|
771
|
+
forceFullOnInitial: true,
|
|
405
772
|
instructions: '',
|
|
406
773
|
};
|
|
407
774
|
}
|
|
408
775
|
|
|
409
|
-
|
|
776
|
+
const FLUENTLM_INSTRUCTIONS = `<FLUENTLM_COMPONENTS> is the component library available on every page. You MUST use FluentLM components instead of writing custom HTML/CSS for standard UI elements.
|
|
777
|
+
REQUIRED: Use flm-button for buttons, flm-textfield for inputs, flm-dropdown for selects, flm-dialog/flm-panel/flm-modal for overlays, flm-pivot for tabs, flm-nav for navigation, flm-toggle for switches, flm-card for cards, flm-callout for tooltips, flm-messagebar for alerts, and all other components listed in <FLUENTLM_COMPONENTS>.
|
|
778
|
+
FORBIDDEN: Do NOT create custom CSS classes for buttons (e.g. .my-btn, .okr-btn), inputs, modals, cards, tabs, dropdowns, or any UI element that has a FluentLM equivalent. Do NOT use raw <button>, <input>, or <select> elements without FluentLM classes.
|
|
779
|
+
Apply FluentLM utility classes (flm-text--secondary, flm-stack, etc.) for layout and typography instead of custom CSS where possible.`;
|
|
780
|
+
|
|
781
|
+
async function buildFluentLMSection(config: SynthOSConfig): Promise<ContextSection> {
|
|
410
782
|
const filePath = await findFileInFolders(config.staticFilesFolders, 'fluentlm-instructions.md');
|
|
411
|
-
if (!filePath)
|
|
783
|
+
if (!filePath) {
|
|
784
|
+
return {
|
|
785
|
+
title: '<FLUENTLM_COMPONENTS>',
|
|
786
|
+
content: '',
|
|
787
|
+
sketch: null,
|
|
788
|
+
mode: 'always-omit-when-empty',
|
|
789
|
+
instructions: '',
|
|
790
|
+
};
|
|
791
|
+
}
|
|
412
792
|
try {
|
|
413
793
|
const content = await loadFile(filePath);
|
|
794
|
+
const componentNames = (content.match(/^## (.+)$/gm) ?? [])
|
|
795
|
+
.map(h => h.replace(/^## /, '').trim())
|
|
796
|
+
.filter(name => !/^(Global Classes|Global Implementation Notes|CSS Custom Properties)$/i.test(name));
|
|
797
|
+
const sketch = componentNames.length > 0
|
|
798
|
+
? `Available FluentLM components (call full reference for usage): ${componentNames.join(', ')}`
|
|
799
|
+
: 'FluentLM UI component library (full reference available on expand).';
|
|
414
800
|
return {
|
|
415
801
|
title: '<FLUENTLM_COMPONENTS>',
|
|
416
802
|
content,
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
Apply FluentLM utility classes (flm-text--secondary, flm-stack, etc.) for layout and typography instead of custom CSS where possible.`,
|
|
803
|
+
sketch,
|
|
804
|
+
mode: 'always-omit-when-empty',
|
|
805
|
+
instructions: FLUENTLM_INSTRUCTIONS,
|
|
421
806
|
};
|
|
422
807
|
} catch {
|
|
423
|
-
return
|
|
808
|
+
return {
|
|
809
|
+
title: '<FLUENTLM_COMPONENTS>',
|
|
810
|
+
content: '',
|
|
811
|
+
sketch: null,
|
|
812
|
+
mode: 'always-omit-when-empty',
|
|
813
|
+
instructions: '',
|
|
814
|
+
};
|
|
424
815
|
}
|
|
425
816
|
}
|
|
426
817
|
|
|
427
|
-
async function buildRecommendedFrameworksSection(config: SynthOSConfig): Promise<ContextSection
|
|
818
|
+
async function buildRecommendedFrameworksSection(config: SynthOSConfig): Promise<ContextSection> {
|
|
428
819
|
const filePath = await findFileInFolders(config.staticFilesFolders, 'recommended-frameworks.llmd');
|
|
429
|
-
if (!filePath)
|
|
820
|
+
if (!filePath) {
|
|
821
|
+
return {
|
|
822
|
+
title: '<RECOMMENDED_FRAMEWORKS>',
|
|
823
|
+
content: '',
|
|
824
|
+
sketch: null,
|
|
825
|
+
mode: 'always-omit-when-empty',
|
|
826
|
+
instructions: '',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
430
829
|
try {
|
|
431
830
|
const content = await loadFile(filePath);
|
|
831
|
+
const names = (content.match(/^@(\w+)/gm) ?? [])
|
|
832
|
+
.map(m => m.slice(1))
|
|
833
|
+
.filter(n => n !== 'recommended_frameworks');
|
|
834
|
+
const sketch = names.length > 0
|
|
835
|
+
? `Recommended frameworks (with CDN URLs in full content): ${names.join(', ')}`
|
|
836
|
+
: 'Recommended third-party frameworks with CDN URLs (expand for details).';
|
|
432
837
|
return {
|
|
433
838
|
title: '<RECOMMENDED_FRAMEWORKS>',
|
|
434
839
|
content,
|
|
435
|
-
|
|
840
|
+
sketch,
|
|
841
|
+
mode: 'always-omit-when-empty',
|
|
842
|
+
instructions: 'lists recommended third-party frameworks with CDN URLs. When a page needs a framework from this list, load it via <link> for CSS in <head>, and <script> tags at the end of <body> (before your page scripts). Always use the version shown in the CDN URL (it is the latest approved version).',
|
|
436
843
|
};
|
|
437
844
|
} catch {
|
|
438
|
-
return
|
|
845
|
+
return {
|
|
846
|
+
title: '<RECOMMENDED_FRAMEWORKS>',
|
|
847
|
+
content: '',
|
|
848
|
+
sketch: null,
|
|
849
|
+
mode: 'always-omit-when-empty',
|
|
850
|
+
instructions: '',
|
|
851
|
+
};
|
|
439
852
|
}
|
|
440
853
|
}
|
|
441
854
|
|
|
442
|
-
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// Shared / per-page table sections
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
// Surfaces table inventory + schemas + one example record per table so the
|
|
859
|
+
// builder generates consumer code against real field names. Both sections
|
|
860
|
+
// ship as `always-omit-when-empty` — workspaces with zero tables pay zero
|
|
861
|
+
// tokens. Spec: docs/specs/shared-tables-schema-sidecar.md.
|
|
862
|
+
|
|
863
|
+
const PAGE_TABLES_RESERVED: ReadonlySet<string> = new Set(['files']);
|
|
864
|
+
|
|
865
|
+
interface TableInfoForPrompt {
|
|
866
|
+
name: string;
|
|
867
|
+
recordCount: number;
|
|
868
|
+
hasSchema: boolean;
|
|
869
|
+
schema?: Record<string, unknown>;
|
|
870
|
+
example?: Record<string, unknown>;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Load one example record for a table — the first id alphabetically, picked
|
|
875
|
+
* deterministically so prompt content is stable across builds.
|
|
876
|
+
*/
|
|
877
|
+
async function loadFirstRecord(
|
|
878
|
+
config: SynthOSConfig,
|
|
879
|
+
parent: string,
|
|
880
|
+
table: string,
|
|
881
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
882
|
+
const sp = config.storageProvider;
|
|
883
|
+
const folder = path.join(parent, table);
|
|
884
|
+
if (!await sp.checkIfExists(folder)) return undefined;
|
|
885
|
+
const files = (await sp.listFiles(folder)).filter(f => f.endsWith('.json')).sort();
|
|
886
|
+
if (files.length === 0) return undefined;
|
|
887
|
+
try {
|
|
888
|
+
const raw = await sp.loadFile(path.join(folder, files[0]));
|
|
889
|
+
const parsed = JSON.parse(raw);
|
|
890
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
891
|
+
return parsed as Record<string, unknown>;
|
|
892
|
+
}
|
|
893
|
+
} catch { /* skip unparsable */ }
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function gatherTablesForPrompt(
|
|
898
|
+
config: SynthOSConfig,
|
|
899
|
+
parent: string,
|
|
900
|
+
reserved: ReadonlySet<string>,
|
|
901
|
+
): Promise<TableInfoForPrompt[]> {
|
|
902
|
+
const tables = await listTableInfo(config, parent, reserved);
|
|
903
|
+
const out: TableInfoForPrompt[] = [];
|
|
904
|
+
for (const t of tables) {
|
|
905
|
+
const wrapper = t.hasSchema ? await loadSchema(config, parent, t.name) : undefined;
|
|
906
|
+
const example = t.recordCount > 0 ? await loadFirstRecord(config, parent, t.name) : undefined;
|
|
907
|
+
out.push({
|
|
908
|
+
name: t.name,
|
|
909
|
+
recordCount: t.recordCount,
|
|
910
|
+
hasSchema: !!wrapper,
|
|
911
|
+
schema: wrapper?.schema,
|
|
912
|
+
example,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function escapeXmlAttr(s: string): string {
|
|
919
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function renderTablesSketch(rootTag: string, tables: TableInfoForPrompt[]): string {
|
|
923
|
+
const lines = tables.map(t =>
|
|
924
|
+
` <table name="${escapeXmlAttr(t.name)}" recordCount="${t.recordCount}" hasSchema="${t.hasSchema}" />`
|
|
925
|
+
);
|
|
926
|
+
return `<${rootTag}>\n${lines.join('\n')}\n</${rootTag}>`;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function renderTablesFull(rootTag: string, tables: TableInfoForPrompt[]): string {
|
|
930
|
+
const blocks = tables.map(t => {
|
|
931
|
+
const parts: string[] = [];
|
|
932
|
+
parts.push(` <table name="${escapeXmlAttr(t.name)}" recordCount="${t.recordCount}">`);
|
|
933
|
+
if (t.schema) {
|
|
934
|
+
parts.push(` <schema>${JSON.stringify(t.schema)}</schema>`);
|
|
935
|
+
}
|
|
936
|
+
if (t.example) {
|
|
937
|
+
parts.push(` <example>${JSON.stringify(t.example)}</example>`);
|
|
938
|
+
}
|
|
939
|
+
parts.push(` </table>`);
|
|
940
|
+
return parts.join('\n');
|
|
941
|
+
});
|
|
942
|
+
return `<${rootTag}>\n${blocks.join('\n')}\n</${rootTag}>`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const SHARED_TABLES_INSTRUCTIONS = 'lists shared tables visible across pages, with their schemas (when defined) and one example record. Use these to write code against the user\'s ACTUAL field names — do not invent names. Read/write via synthos.shared.data.* and synthos.shared.data.defineSchema(table, schema) when extending.';
|
|
946
|
+
const PAGE_TABLES_INSTRUCTIONS = 'lists per-page tables scoped to this page, with their schemas (when defined) and one example record. Use these to write code against the user\'s ACTUAL field names. Read/write via synthos.data.* and synthos.data.defineSchema(table, schema) when extending.';
|
|
947
|
+
|
|
948
|
+
async function buildSharedTablesSection(config: SynthOSConfig): Promise<ContextSection> {
|
|
949
|
+
const parent = path.join(config.pagesFolder, 'shared');
|
|
950
|
+
const tables = await gatherTablesForPrompt(config, parent, new Set());
|
|
951
|
+
if (tables.length === 0) {
|
|
952
|
+
return {
|
|
953
|
+
title: '<SHARED_TABLES>',
|
|
954
|
+
content: '',
|
|
955
|
+
sketch: null,
|
|
956
|
+
mode: 'always-omit-when-empty',
|
|
957
|
+
instructions: '',
|
|
958
|
+
};
|
|
959
|
+
}
|
|
443
960
|
return {
|
|
444
|
-
title: '<
|
|
445
|
-
content:
|
|
446
|
-
|
|
961
|
+
title: '<SHARED_TABLES>',
|
|
962
|
+
content: renderTablesFull('SHARED_TABLES', tables),
|
|
963
|
+
sketch: renderTablesSketch('SHARED_TABLES', tables),
|
|
964
|
+
mode: 'always-omit-when-empty',
|
|
965
|
+
instructions: SHARED_TABLES_INSTRUCTIONS,
|
|
447
966
|
};
|
|
448
967
|
}
|
|
449
968
|
|
|
969
|
+
async function buildPageTablesSection(config: SynthOSConfig, pageName: string): Promise<ContextSection> {
|
|
970
|
+
const parent = path.join(config.pagesFolder, 'pages', pageName);
|
|
971
|
+
const tables = await gatherTablesForPrompt(config, parent, PAGE_TABLES_RESERVED);
|
|
972
|
+
if (tables.length === 0) {
|
|
973
|
+
return {
|
|
974
|
+
title: '<PAGE_TABLES>',
|
|
975
|
+
content: '',
|
|
976
|
+
sketch: null,
|
|
977
|
+
mode: 'always-omit-when-empty',
|
|
978
|
+
instructions: '',
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
title: '<PAGE_TABLES>',
|
|
983
|
+
content: renderTablesFull('PAGE_TABLES', tables),
|
|
984
|
+
sketch: renderTablesSketch('PAGE_TABLES', tables),
|
|
985
|
+
mode: 'always-omit-when-empty',
|
|
986
|
+
instructions: PAGE_TABLES_INSTRUCTIONS,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
// Section assembly — picks full vs sketch per section per the section's mode
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Resolve each section to either its full content or its sketch (or skip it
|
|
996
|
+
* entirely) based on the section's `mode`, the classifier's `expand` set, and
|
|
997
|
+
* whether this is the initial build (`newBuild`).
|
|
998
|
+
*
|
|
999
|
+
* Per `docs/specs/page-section-sketch-full.md`:
|
|
1000
|
+
* - 'always-full' → always render full content.
|
|
1001
|
+
* - 'classifier-decides' → render full when title is in `expand`, else sketch.
|
|
1002
|
+
* - 'always-omit-when-empty' → skip entirely when sketch is null; otherwise
|
|
1003
|
+
* render full when in `expand`, else sketch.
|
|
1004
|
+
* - `forceFullOnInitial` on a section forces full content on the first build
|
|
1005
|
+
* regardless of `expand`.
|
|
1006
|
+
*/
|
|
1007
|
+
export function assembleSections(
|
|
1008
|
+
sections: ContextSection[],
|
|
1009
|
+
expand: ReadonlySet<string>,
|
|
1010
|
+
newBuild: boolean,
|
|
1011
|
+
): ContextSection[] {
|
|
1012
|
+
const out: ContextSection[] = [];
|
|
1013
|
+
for (const s of sections) {
|
|
1014
|
+
const expanded = expand.has(s.title) || (newBuild && s.forceFullOnInitial === true);
|
|
1015
|
+
switch (s.mode) {
|
|
1016
|
+
case 'always-full':
|
|
1017
|
+
out.push(s);
|
|
1018
|
+
break;
|
|
1019
|
+
case 'classifier-decides':
|
|
1020
|
+
if (expanded || s.sketch === null) {
|
|
1021
|
+
out.push(s);
|
|
1022
|
+
} else {
|
|
1023
|
+
out.push({ ...s, content: s.sketch });
|
|
1024
|
+
}
|
|
1025
|
+
break;
|
|
1026
|
+
case 'always-omit-when-empty':
|
|
1027
|
+
if (s.sketch === null) {
|
|
1028
|
+
// Empty section — skip entirely.
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
if (expanded) {
|
|
1032
|
+
out.push(s);
|
|
1033
|
+
} else {
|
|
1034
|
+
out.push({ ...s, content: s.sketch });
|
|
1035
|
+
}
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** Apply per-page section-mode overrides from page metadata. */
|
|
1043
|
+
export function applySectionModeOverrides(
|
|
1044
|
+
sections: ContextSection[],
|
|
1045
|
+
overrides?: Record<string, SectionMode>,
|
|
1046
|
+
): ContextSection[] {
|
|
1047
|
+
if (!overrides || Object.keys(overrides).length === 0) return sections;
|
|
1048
|
+
return sections.map(s => {
|
|
1049
|
+
const override = overrides[s.title];
|
|
1050
|
+
return override ? { ...s, mode: override } : s;
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
|
|
450
1056
|
export function usePageRoutes(config: SynthOSConfig, app: Application, customizer?: Customizer): void {
|
|
1057
|
+
// Initialize cache-busting for /static/* URLs
|
|
1058
|
+
initStaticAssetVersioning(config.staticFilesFolders);
|
|
1059
|
+
|
|
451
1060
|
// Redirect / to /home page
|
|
452
1061
|
app.get('/', (req, res) => res.redirect(HOME_PAGE_ROUTE));
|
|
453
1062
|
|
|
454
|
-
//
|
|
1063
|
+
// Builder with starter scaffold. Renders the same shell as /builder, but
|
|
1064
|
+
// embeds the starter name in shellInit (so chat POSTs include it) and the
|
|
1065
|
+
// iframe src (so iframe content == starter HTML, not the carousel).
|
|
1066
|
+
// Must be registered BEFORE /:page — Express matches in declaration order.
|
|
1067
|
+
app.get('/builder/:starter', async (req, res) => {
|
|
1068
|
+
const { starter } = req.params;
|
|
1069
|
+
|
|
1070
|
+
const isConfigured = await hasConfiguredSettings(config);
|
|
1071
|
+
if (!isConfigured) { res.redirect('/settings?firstRun=1'); return; }
|
|
1072
|
+
|
|
1073
|
+
// Validate starter — must exist with category "_Starters". 404 otherwise.
|
|
1074
|
+
const starterMeta = await loadPageMetadata(config, starter, config.requiredPagesFolders);
|
|
1075
|
+
const isValidStarter = !!starterMeta?.categories?.includes('_Starters');
|
|
1076
|
+
if (!isValidStarter) {
|
|
1077
|
+
res.status(404).send(PAGE_NOT_FOUND);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Reuse builder's metadata for the shell wrapping (page name == 'builder').
|
|
1082
|
+
const builderMeta = await loadPageMetadata(config, 'builder', config.requiredPagesFolders);
|
|
1083
|
+
const settings = await loadSettings(config);
|
|
1084
|
+
const themeName = settings.theme ?? 'nebula-dusk';
|
|
1085
|
+
const themeVersion = await loadThemeVersion(themeName, config);
|
|
1086
|
+
const productName = customizer?.productName ?? 'SynthOS';
|
|
1087
|
+
|
|
1088
|
+
const shellHtml = await loadPageWithFallback(SHELL_PAGE_NAME, config, true);
|
|
1089
|
+
if (!shellHtml) { res.status(500).send('Shell page not found'); return; }
|
|
1090
|
+
|
|
1091
|
+
// Builder is noPersistHistory — start fresh: greeting only.
|
|
1092
|
+
const greetingText = builderMeta?.greeting ?? '';
|
|
1093
|
+
const messages: ChatMessage[] = greetingText
|
|
1094
|
+
? [{ role: 'assistant', content: greetingText }]
|
|
1095
|
+
: [];
|
|
1096
|
+
|
|
1097
|
+
let html = injectShellAssets(shellHtml, themeName, themeVersion, settings.toolbarPosition);
|
|
1098
|
+
html = injectShellInit(html, 'builder', messages, {
|
|
1099
|
+
title: builderMeta?.title ?? '',
|
|
1100
|
+
categories: builderMeta?.categories ?? [],
|
|
1101
|
+
mode: builderMeta?.mode ?? 'unlocked',
|
|
1102
|
+
isRequiredPage: true,
|
|
1103
|
+
greeting: builderMeta?.greeting ?? '',
|
|
1104
|
+
firstRunGreeting: builderMeta?.firstRunGreeting ?? '',
|
|
1105
|
+
}, 0, productName, starter);
|
|
1106
|
+
html = setIframeSrc(html, 'builder', starter);
|
|
1107
|
+
html = injectPageInfoScript(html, 'builder');
|
|
1108
|
+
html = replaceProductName(html, productName);
|
|
1109
|
+
html = applyAssetVersions(html);
|
|
1110
|
+
res.send(html);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// Page retrieval — serves either the shell (parent frame) or page content (iframe)
|
|
455
1114
|
app.get('/:page', async (req, res) => {
|
|
456
|
-
// Redirect if settings not configured
|
|
457
1115
|
const { page } = req.params;
|
|
1116
|
+
const isFrameRequest = req.query.frame === '1';
|
|
1117
|
+
const requestedStarter = isFrameRequest && page === 'builder' && typeof req.query.starter === 'string'
|
|
1118
|
+
? String(req.query.starter)
|
|
1119
|
+
: '';
|
|
1120
|
+
|
|
1121
|
+
// _shell is an internal page — never served directly
|
|
1122
|
+
if (page === SHELL_PAGE_NAME) {
|
|
1123
|
+
res.redirect(HOME_PAGE_ROUTE);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Redirect if settings not configured
|
|
458
1128
|
const isConfigured = await hasConfiguredSettings(config);
|
|
459
1129
|
if (!isConfigured && page !== 'settings') {
|
|
460
1130
|
res.redirect('/settings?firstRun=1');
|
|
@@ -463,7 +1133,7 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
463
1133
|
|
|
464
1134
|
// Ensure page exists — force fresh disk read for required pages
|
|
465
1135
|
const isRequiredPage = config.requiredPages.includes(page);
|
|
466
|
-
|
|
1136
|
+
let pageState = await loadPageWithFallback(page, config, isRequiredPage);
|
|
467
1137
|
if (!pageState) {
|
|
468
1138
|
res.status(404).send(PAGE_NOT_FOUND);
|
|
469
1139
|
return;
|
|
@@ -479,35 +1149,83 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
479
1149
|
return;
|
|
480
1150
|
}
|
|
481
1151
|
|
|
482
|
-
// Load settings
|
|
1152
|
+
// Load settings
|
|
483
1153
|
const settings = await loadSettings(config);
|
|
484
1154
|
const themeName = settings.theme ?? 'nebula-dusk';
|
|
485
1155
|
const themeVersion = await loadThemeVersion(themeName, config);
|
|
1156
|
+
const productName = customizer?.productName ?? 'SynthOS';
|
|
1157
|
+
|
|
1158
|
+
const isStandalone = STANDALONE_PAGE_NAMES.includes(page);
|
|
1159
|
+
|
|
1160
|
+
if (isFrameRequest || isStandalone) {
|
|
1161
|
+
// Builder + ?starter=<name>: swap iframe content to the starter's HTML.
|
|
1162
|
+
// page name stays 'builder' (chat POSTs target /builder); the starter HTML
|
|
1163
|
+
// becomes the working scaffold the chat agent edits.
|
|
1164
|
+
if (requestedStarter) {
|
|
1165
|
+
const starterMeta = await loadPageMetadata(config, requestedStarter, config.requiredPagesFolders);
|
|
1166
|
+
if (!starterMeta?.categories?.includes('_Starters')) {
|
|
1167
|
+
res.status(404).send(PAGE_NOT_FOUND);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const starterState = await loadPageWithFallback(requestedStarter, config, false);
|
|
1171
|
+
if (!starterState) {
|
|
1172
|
+
res.status(404).send(PAGE_NOT_FOUND);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
pageState = starterState;
|
|
1176
|
+
}
|
|
486
1177
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1178
|
+
// --- iframe content OR standalone top-level page: serve page HTML with
|
|
1179
|
+
// theme + helpers + bridge, no shell wrapping. Standalone pages (peers to
|
|
1180
|
+
// _shell) own the full viewport themselves.
|
|
1181
|
+
const html = prepareForIframe(pageState, page, pageVersion, themeName, themeVersion, productName);
|
|
1182
|
+
res.send(html);
|
|
1183
|
+
} else {
|
|
1184
|
+
// --- Shell request: load _shell page, inject init data, set iframe src ---
|
|
1185
|
+
const shellHtml = await loadPageWithFallback(SHELL_PAGE_NAME, config, true);
|
|
1186
|
+
if (!shellHtml) {
|
|
1187
|
+
res.status(500).send('Shell page not found');
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
493
1190
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1191
|
+
// Ensure any legacy v-files / chat-history.json have been migrated
|
|
1192
|
+
// to the c-scheme so we read a single consistent view.
|
|
1193
|
+
await migrateLegacyChangeFiles(config, page);
|
|
1194
|
+
|
|
1195
|
+
// Build the greeting message from metadata (authoritative source),
|
|
1196
|
+
// followed by the flat concat of all per-change message logs.
|
|
1197
|
+
const greetingText = metadata?.greeting ?? '';
|
|
1198
|
+
const changeMessages = await loadAllChangeMessages(config, page);
|
|
1199
|
+
const messages: ChatMessage[] = [];
|
|
1200
|
+
if (greetingText) {
|
|
1201
|
+
messages.push({ role: 'assistant', content: greetingText });
|
|
499
1202
|
}
|
|
500
|
-
|
|
1203
|
+
messages.push(...changeMessages);
|
|
501
1204
|
|
|
502
|
-
|
|
1205
|
+
const latestVersion = await getLatestChangeHtmlNumber(config, page);
|
|
503
1206
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1207
|
+
// Build the shell HTML
|
|
1208
|
+
let html = injectShellAssets(shellHtml, themeName, themeVersion, settings.toolbarPosition);
|
|
1209
|
+
html = injectShellInit(html, page, messages, {
|
|
1210
|
+
title: metadata?.title ?? '',
|
|
1211
|
+
categories: metadata?.categories ?? [],
|
|
1212
|
+
mode: metadata?.mode ?? 'unlocked',
|
|
1213
|
+
isRequiredPage,
|
|
1214
|
+
greeting: metadata?.greeting ?? '',
|
|
1215
|
+
firstRunGreeting: metadata?.firstRunGreeting ?? '',
|
|
1216
|
+
}, latestVersion, productName);
|
|
1217
|
+
html = setIframeSrc(html, page);
|
|
509
1218
|
|
|
510
|
-
|
|
1219
|
+
// Inject page-info script into shell too (for pageInfo access in shell.v3.js)
|
|
1220
|
+
html = injectPageInfoScript(html, page);
|
|
1221
|
+
|
|
1222
|
+
html = replaceProductName(html, productName);
|
|
1223
|
+
|
|
1224
|
+
// Append cache-busting ?v=<hash> to all /static/* refs in shell HTML
|
|
1225
|
+
html = applyAssetVersions(html);
|
|
1226
|
+
|
|
1227
|
+
res.send(html);
|
|
1228
|
+
}
|
|
511
1229
|
});
|
|
512
1230
|
|
|
513
1231
|
// Page save
|
|
@@ -545,46 +1263,37 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
545
1263
|
return;
|
|
546
1264
|
}
|
|
547
1265
|
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
if
|
|
1266
|
+
// Save sequence (per the c-scheme spec):
|
|
1267
|
+
// 1. Determine <last> = max N where page.c<N>.html exists on source.
|
|
1268
|
+
// 2. The final HTML is page.c<last>.html (or page.html if none).
|
|
1269
|
+
// 3. Write that HTML to <saveAs>/page.html.
|
|
1270
|
+
// 4. Delete every page.c*.html on source.
|
|
1271
|
+
// 5. Delete every page.c*.json on source.
|
|
1272
|
+
// 6. Update metadata.
|
|
1273
|
+
// 7. Return redirect.
|
|
1274
|
+
|
|
1275
|
+
// Resolve legacy files before reading latest state
|
|
1276
|
+
await migrateLegacyChangeFiles(config, page);
|
|
1277
|
+
|
|
1278
|
+
const lastN = await getLatestChangeHtmlNumber(config, page);
|
|
1279
|
+
let finalHtml: string | undefined;
|
|
1280
|
+
if (lastN > 0) {
|
|
1281
|
+
finalHtml = await loadChangeHtml(config, page, lastN);
|
|
1282
|
+
}
|
|
1283
|
+
if (!finalHtml) {
|
|
1284
|
+
// No pending changes — fall back to the current baseline
|
|
1285
|
+
finalHtml = await loadPageWithFallback(page, config, false);
|
|
1286
|
+
}
|
|
1287
|
+
if (!finalHtml) {
|
|
551
1288
|
res.status(404).json({ error: PAGE_NOT_FOUND });
|
|
552
1289
|
return;
|
|
553
1290
|
}
|
|
554
1291
|
|
|
555
|
-
//
|
|
556
|
-
{
|
|
557
|
-
const $ = cheerio.load(pageState);
|
|
558
|
-
const messages = $('#chatMessages .chat-message');
|
|
559
|
-
messages.slice(1).remove();
|
|
560
|
-
// Remove any undo links
|
|
561
|
-
$('#chatMessages .synthos-undo-link').remove();
|
|
562
|
-
// Update greeting text if provided
|
|
563
|
-
if (greeting && typeof greeting === 'string' && greeting.trim().length > 0) {
|
|
564
|
-
const firstP = messages.first().find('p');
|
|
565
|
-
const strong = firstP.find('strong');
|
|
566
|
-
if (strong.length) {
|
|
567
|
-
firstP.html('<strong>Synthos:</strong> ' + greeting.trim());
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
pageState = $.html();
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Inject save-line marker at the end of chat messages (skip for locked pages)
|
|
1292
|
+
// Load source page metadata (needed for category checks and greeting fallback)
|
|
574
1293
|
const sourceMetadata = await loadPageMetadata(config, page, config.requiredPagesFolders);
|
|
575
|
-
if (sourceMetadata?.mode !== 'locked') {
|
|
576
|
-
const $ = cheerio.load(pageState);
|
|
577
|
-
// Remove any existing save-line first
|
|
578
|
-
$('#chatMessages .save-line').remove();
|
|
579
|
-
// Append new save-line
|
|
580
|
-
$('#chatMessages').append(
|
|
581
|
-
'<div class="save-line" data-locked="true"><span class="save-line-label">Saved</span></div>'
|
|
582
|
-
);
|
|
583
|
-
pageState = $.html();
|
|
584
|
-
}
|
|
585
1294
|
|
|
586
|
-
//
|
|
587
|
-
await savePageState(config, saveAs,
|
|
1295
|
+
// Write the final HTML as the new baseline for <saveAs>
|
|
1296
|
+
await savePageState(config, saveAs, finalHtml, title, categories);
|
|
588
1297
|
|
|
589
1298
|
// Copy files (sound effects, etc.) from source page when saving as a different name
|
|
590
1299
|
if (page !== saveAs) {
|
|
@@ -608,12 +1317,35 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
608
1317
|
}
|
|
609
1318
|
}
|
|
610
1319
|
|
|
611
|
-
//
|
|
612
|
-
|
|
1320
|
+
// Delete all per-change files (html + json) on source AND destination —
|
|
1321
|
+
// save collapses the journal back to a single baseline.
|
|
1322
|
+
await clearChanges(config, page);
|
|
1323
|
+
if (page !== saveAs) {
|
|
1324
|
+
await clearChanges(config, saveAs);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Delete data tables from the source starter page (ephemeral data shouldn't persist)
|
|
1328
|
+
if (sourceMetadata?.categories?.some(c => NO_PERSIST_CATEGORIES.includes(c))) {
|
|
1329
|
+
const sourcePageDir = path.join(config.pagesFolder, 'pages', page);
|
|
1330
|
+
const sp = config.storageProvider;
|
|
1331
|
+
if (await sp.checkIfExists(sourcePageDir)) {
|
|
1332
|
+
const RESERVED_SUBDIRS = new Set(['files']);
|
|
1333
|
+
const entries = await sp.listFolders(sourcePageDir);
|
|
1334
|
+
for (const entry of entries) {
|
|
1335
|
+
if (!RESERVED_SUBDIRS.has(entry)) {
|
|
1336
|
+
await sp.deleteFolder(path.join(sourcePageDir, entry));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
613
1341
|
|
|
614
|
-
// Also update metadata with categories (in case page.json already existed)
|
|
1342
|
+
// Also update metadata with categories and greeting (in case page.json already existed)
|
|
1343
|
+
const savedGreeting = (greeting && typeof greeting === 'string' && greeting.trim().length > 0)
|
|
1344
|
+
? greeting.trim()
|
|
1345
|
+
: (sourceMetadata?.greeting ?? '');
|
|
615
1346
|
await savePageMetadata(config, saveAs, {
|
|
616
1347
|
title,
|
|
1348
|
+
description: sourceMetadata?.description ?? '',
|
|
617
1349
|
categories,
|
|
618
1350
|
pinned: false,
|
|
619
1351
|
showInAll: true,
|
|
@@ -621,6 +1353,8 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
621
1353
|
lastModified: new Date().toISOString(),
|
|
622
1354
|
pageVersion: PAGE_VERSION,
|
|
623
1355
|
mode: 'unlocked',
|
|
1356
|
+
greeting: savedGreeting,
|
|
1357
|
+
firstRunGreeting: '',
|
|
624
1358
|
});
|
|
625
1359
|
|
|
626
1360
|
res.json({ redirect: `/${saveAs}` });
|
|
@@ -630,31 +1364,125 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
630
1364
|
}
|
|
631
1365
|
});
|
|
632
1366
|
|
|
633
|
-
// Page
|
|
1367
|
+
// Page patch — lightweight DOM patching via cheerio (no LLM)
|
|
1368
|
+
app.post('/:page/patch', async (req, res) => {
|
|
1369
|
+
const { page } = req.params;
|
|
1370
|
+
await withPageLock(page, async () => {
|
|
1371
|
+
try {
|
|
1372
|
+
const { operations, message } = req.body;
|
|
1373
|
+
|
|
1374
|
+
// Validate operations array
|
|
1375
|
+
if (!operations || !Array.isArray(operations) || operations.length === 0) {
|
|
1376
|
+
res.status(400).json({ error: 'operations is required (non-empty array)' });
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Load current page state
|
|
1381
|
+
const pageState = await loadPageWithFallback(page, config, false);
|
|
1382
|
+
if (!pageState) {
|
|
1383
|
+
res.status(404).json({ error: PAGE_NOT_FOUND });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Parse with cheerio
|
|
1388
|
+
const $ = cheerio.load(pageState, { decodeEntities: false });
|
|
1389
|
+
|
|
1390
|
+
// Apply each operation
|
|
1391
|
+
for (const op of operations) {
|
|
1392
|
+
if (op.op !== 'set-attr') {
|
|
1393
|
+
res.status(400).json({ error: `Unsupported operation: '${op.op}'` });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
if (!op.selector || typeof op.selector !== 'string') {
|
|
1397
|
+
res.status(400).json({ error: 'Each operation requires a selector string' });
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (!op.attr || typeof op.attr !== 'string') {
|
|
1401
|
+
res.status(400).json({ error: 'Each set-attr operation requires an attr string' });
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (typeof op.value !== 'string') {
|
|
1405
|
+
res.status(400).json({ error: 'Each set-attr operation requires a value string' });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const matches = $(op.selector);
|
|
1410
|
+
if (matches.length !== 1) {
|
|
1411
|
+
res.status(400).json({
|
|
1412
|
+
error: `Selector '${op.selector}' matched ${matches.length} elements (expected exactly 1)`
|
|
1413
|
+
});
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
matches.attr(op.attr, op.value);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const productName = customizer?.productName ?? 'SynthOS';
|
|
1421
|
+
const html = $.html();
|
|
1422
|
+
|
|
1423
|
+
// Save as next change (c-scheme). Number advances from max(html, json).
|
|
1424
|
+
await migrateLegacyChangeFiles(config, page);
|
|
1425
|
+
const currentVersion = await getLatestChangeNumber(config, page);
|
|
1426
|
+
const nextVersion = currentVersion + 1;
|
|
1427
|
+
await saveChangeHtml(config, page, nextVersion, html);
|
|
1428
|
+
|
|
1429
|
+
// Update lastModified
|
|
1430
|
+
const metadata = await loadPageMetadata(config, page, config.requiredPagesFolders);
|
|
1431
|
+
if (metadata) {
|
|
1432
|
+
metadata.lastModified = new Date().toISOString();
|
|
1433
|
+
await savePageMetadata(config, page, metadata);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Persist patch as a self-contained change entry (skip for Starters/System).
|
|
1437
|
+
// Patches are user-less server-side actions, so the journal captures just
|
|
1438
|
+
// the assistant summary.
|
|
1439
|
+
const patchCategories = metadata?.categories ?? [];
|
|
1440
|
+
if (!patchCategories.some(c => NO_PERSIST_CATEGORIES.includes(c))) {
|
|
1441
|
+
const displayMessage = (message && typeof message === 'string') ? message : 'Page patched';
|
|
1442
|
+
await saveChangeMessages(config, page, nextVersion, [
|
|
1443
|
+
{ role: 'assistant', content: displayMessage },
|
|
1444
|
+
]);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Inject content assets for iframe rendering (no shell chrome)
|
|
1448
|
+
const settings = await loadSettings(config);
|
|
1449
|
+
const pv = metadata?.pageVersion ?? 0;
|
|
1450
|
+
const themeName = settings.theme ?? 'nebula-dusk';
|
|
1451
|
+
const themeVersion = await loadThemeVersion(themeName, config);
|
|
1452
|
+
const out = prepareForIframe(html, page, pv, themeName, themeVersion, productName);
|
|
1453
|
+
|
|
1454
|
+
res.json({ html: out, changeCount: operations.length, version: nextVersion });
|
|
1455
|
+
} catch (err: unknown) {
|
|
1456
|
+
console.error(err);
|
|
1457
|
+
res.status(500).json({ error: (err as Error).message });
|
|
1458
|
+
}
|
|
1459
|
+
}); // withPageLock
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
// Page undo — roll back to the previous change
|
|
634
1463
|
app.post('/:page/undo', async (req, res) => {
|
|
635
1464
|
try {
|
|
636
1465
|
const { page } = req.params;
|
|
637
|
-
|
|
638
|
-
const cv = await
|
|
1466
|
+
await migrateLegacyChangeFiles(config, page);
|
|
1467
|
+
const cv = await getLatestChangeNumber(config, page);
|
|
639
1468
|
if (cv <= 0) {
|
|
640
1469
|
res.status(400).send('Nothing to undo');
|
|
641
1470
|
return;
|
|
642
1471
|
}
|
|
643
1472
|
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if (await sp.checkIfExists(versionFile)) {
|
|
648
|
-
await sp.deleteFile(versionFile);
|
|
649
|
-
}
|
|
1473
|
+
// Drop the entire change pair (both html + json) for the highest N.
|
|
1474
|
+
// Each user turn owns one pair — rolling it back = removing both.
|
|
1475
|
+
await deleteChange(config, page, cv);
|
|
650
1476
|
|
|
651
|
-
//
|
|
652
|
-
|
|
1477
|
+
// Find the next-lower change with HTML to restore; if none, fall
|
|
1478
|
+
// back to the saved baseline.
|
|
653
1479
|
let previousHtml: string | undefined;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1480
|
+
let prevN = cv - 1;
|
|
1481
|
+
while (prevN > 0 && !previousHtml) {
|
|
1482
|
+
previousHtml = await loadChangeHtml(config, page, prevN);
|
|
1483
|
+
if (!previousHtml) prevN--;
|
|
1484
|
+
}
|
|
1485
|
+
if (!previousHtml) {
|
|
658
1486
|
previousHtml = await loadPageWithFallback(page, config, true);
|
|
659
1487
|
}
|
|
660
1488
|
|
|
@@ -663,46 +1491,107 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
663
1491
|
return;
|
|
664
1492
|
}
|
|
665
1493
|
|
|
666
|
-
// Inject
|
|
1494
|
+
// Inject content assets for iframe rendering (no shell chrome)
|
|
667
1495
|
const settings = await loadSettings(config);
|
|
668
1496
|
const metadata = await loadPageMetadata(config, page, config.requiredPagesFolders);
|
|
669
1497
|
const pv = metadata?.pageVersion ?? 0;
|
|
670
1498
|
const themeName = settings.theme ?? 'nebula-dusk';
|
|
671
1499
|
const themeVersion = await loadThemeVersion(themeName, config);
|
|
672
|
-
|
|
673
|
-
out =
|
|
674
|
-
out = injectShellAssets(out, themeName, themeVersion, settings.toolbarPosition);
|
|
675
|
-
out = injectPageInfoScript(out, page);
|
|
676
|
-
out = injectPageHelpers(out, pv);
|
|
677
|
-
out = injectPageScript(out, pv);
|
|
1500
|
+
const productName = customizer?.productName ?? 'SynthOS';
|
|
1501
|
+
const out = prepareForIframe(previousHtml, page, pv, themeName, themeVersion, productName);
|
|
678
1502
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1503
|
+
res.send(out);
|
|
1504
|
+
} catch (err: unknown) {
|
|
1505
|
+
console.error(err);
|
|
1506
|
+
res.status(500).send((err as Error).message);
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
683
1509
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1510
|
+
// Page reset — clear change journal, return saved baseline
|
|
1511
|
+
app.post('/:page/reset', async (req, res) => {
|
|
1512
|
+
try {
|
|
1513
|
+
const { page } = req.params;
|
|
1514
|
+
|
|
1515
|
+
// Clear all change html + json files
|
|
1516
|
+
await migrateLegacyChangeFiles(config, page);
|
|
1517
|
+
await clearChanges(config, page);
|
|
1518
|
+
|
|
1519
|
+
// Load the fresh baseline (page.html from user folder or required folders)
|
|
1520
|
+
const freshHtml = await loadPageWithFallback(page, config, true);
|
|
1521
|
+
if (!freshHtml) {
|
|
1522
|
+
res.status(404).json({ error: PAGE_NOT_FOUND });
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
687
1525
|
|
|
688
|
-
//
|
|
1526
|
+
// Build greeting from metadata (authoritative source)
|
|
1527
|
+
const settings = await loadSettings(config);
|
|
1528
|
+
const metadata = await loadPageMetadata(config, page, config.requiredPagesFolders);
|
|
1529
|
+
const greetingText = metadata?.greeting ?? '';
|
|
1530
|
+
const greeting: ChatMessage[] = greetingText
|
|
1531
|
+
? [{ role: 'assistant', content: greetingText }]
|
|
1532
|
+
: [];
|
|
1533
|
+
const pv = metadata?.pageVersion ?? 0;
|
|
1534
|
+
const themeName = settings.theme ?? 'nebula-dusk';
|
|
1535
|
+
const themeVersion = await loadThemeVersion(themeName, config);
|
|
689
1536
|
const productName = customizer?.productName ?? 'SynthOS';
|
|
690
|
-
|
|
691
|
-
|
|
1537
|
+
const out = prepareForIframe(freshHtml, page, pv, themeName, themeVersion, productName);
|
|
1538
|
+
|
|
1539
|
+
res.json({ html: out, greeting: greeting });
|
|
1540
|
+
} catch (err: unknown) {
|
|
1541
|
+
console.error(err);
|
|
1542
|
+
res.status(500).json({ error: (err as Error).message });
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// Update page greeting (inline edit from chat panel)
|
|
1547
|
+
app.post('/:page/greeting', async (req, res) => {
|
|
1548
|
+
try {
|
|
1549
|
+
const { page } = req.params;
|
|
1550
|
+
const { greeting } = req.body;
|
|
1551
|
+
|
|
1552
|
+
if (typeof greeting !== 'string') {
|
|
1553
|
+
res.status(400).json({ error: 'greeting is required (string)' });
|
|
1554
|
+
return;
|
|
692
1555
|
}
|
|
693
1556
|
|
|
694
|
-
|
|
1557
|
+
// Reject editing on locked pages and the builder page
|
|
1558
|
+
const metadata = await loadPageMetadata(config, page, config.requiredPagesFolders);
|
|
1559
|
+
if (!metadata) {
|
|
1560
|
+
res.status(404).json({ error: PAGE_NOT_FOUND });
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
if (metadata.mode === 'locked') {
|
|
1564
|
+
res.status(403).json({ error: 'This page is locked' });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
if (page === 'builder') {
|
|
1568
|
+
res.status(403).json({ error: 'Builder greeting is not editable' });
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Update greeting in metadata
|
|
1573
|
+
metadata.greeting = greeting.trim();
|
|
1574
|
+
metadata.lastModified = new Date().toISOString();
|
|
1575
|
+
await savePageMetadata(config, page, metadata);
|
|
1576
|
+
|
|
1577
|
+
res.json({ ok: true });
|
|
695
1578
|
} catch (err: unknown) {
|
|
696
1579
|
console.error(err);
|
|
697
|
-
res.status(500).
|
|
1580
|
+
res.status(500).json({ error: (err as Error).message });
|
|
698
1581
|
}
|
|
699
1582
|
});
|
|
700
1583
|
|
|
701
1584
|
// Page transformation
|
|
702
1585
|
app.post('/:page', async (req, res) => {
|
|
1586
|
+
const { page } = req.params;
|
|
1587
|
+
await withPageLock(page, async () => {
|
|
1588
|
+
// Hoisted so the catch block can append errors to the pre-committed
|
|
1589
|
+
// page.c<N>.json turn file.
|
|
1590
|
+
let pendingVersion: number | undefined;
|
|
1591
|
+
let shouldPersistTurn = false;
|
|
1592
|
+
let pendingRawMessage: string | undefined;
|
|
703
1593
|
try {
|
|
704
1594
|
// Ensure settings configured
|
|
705
|
-
const { page } = req.params;
|
|
706
1595
|
const isConfigured = await hasConfiguredSettings(config);
|
|
707
1596
|
if (!isConfigured) {
|
|
708
1597
|
res.status(400).send('Settings not configured');
|
|
@@ -724,12 +1613,19 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
724
1613
|
}
|
|
725
1614
|
|
|
726
1615
|
// Get required and optional parameters
|
|
727
|
-
const { message } = req.body;
|
|
728
|
-
if (typeof
|
|
1616
|
+
const { message: rawMessage } = req.body;
|
|
1617
|
+
if (typeof rawMessage !== 'string') {
|
|
729
1618
|
res.status(400).send('Invalid or missing message parameter');
|
|
730
1619
|
return;
|
|
731
1620
|
}
|
|
732
1621
|
|
|
1622
|
+
// If the shell sent captured JS errors, append them as context for the LLM
|
|
1623
|
+
// but keep the raw message clean for chat history persistence.
|
|
1624
|
+
const clientErrors: string[] = Array.isArray(req.body.errors) ? req.body.errors : [];
|
|
1625
|
+
const message = clientErrors.length > 0
|
|
1626
|
+
? rawMessage + '\n\nCONSOLE_ERRORS:\n' + clientErrors.join('\n---\n')
|
|
1627
|
+
: rawMessage;
|
|
1628
|
+
|
|
733
1629
|
// Extract and validate optional attachments
|
|
734
1630
|
let attachments: Attachment[] | undefined;
|
|
735
1631
|
if (Array.isArray(req.body.attachments)) {
|
|
@@ -780,108 +1676,208 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
780
1676
|
const { instructions } = entry;
|
|
781
1677
|
const productName = customizer?.productName ?? 'SynthOS';
|
|
782
1678
|
|
|
783
|
-
// Build
|
|
784
|
-
|
|
1679
|
+
// Build incoming conversation history from the server-side journal.
|
|
1680
|
+
// Shape: [greeting?, ...flat concat of page.c<n>.json messages]
|
|
1681
|
+
await migrateLegacyChangeFiles(config, page);
|
|
1682
|
+
const pageMeta = await loadPageMetadata(config, page, config.requiredPagesFolders);
|
|
1683
|
+
const incomingHistory: ChatMessage[] = [];
|
|
1684
|
+
if (pageMeta?.greeting) {
|
|
1685
|
+
incomingHistory.push({ role: 'assistant', content: pageMeta.greeting });
|
|
1686
|
+
}
|
|
1687
|
+
incomingHistory.push(...await loadAllChangeMessages(config, page));
|
|
785
1688
|
|
|
786
|
-
//
|
|
787
|
-
|
|
1689
|
+
// Detect first edit (c0 → c1) for saved pages
|
|
1690
|
+
const isRequiredPage = config.requiredPages.includes(page);
|
|
1691
|
+
const pageFolder = path.join(pagesFolder, 'pages', page);
|
|
1692
|
+
const pageFileExists = await config.storageProvider.checkIfExists(path.join(pageFolder, 'page.html'));
|
|
1693
|
+
let currentVersion = await getLatestChangeNumber(config, page);
|
|
1694
|
+
const isFirstEdit = !isRequiredPage && pageFileExists && currentVersion === 0;
|
|
788
1695
|
|
|
789
|
-
//
|
|
790
|
-
|
|
1696
|
+
// Try again — drop the last change before re-running the transform
|
|
1697
|
+
const tryAgain = req.body.tryAgain === true;
|
|
1698
|
+
let transformInput = pageState;
|
|
791
1699
|
|
|
792
|
-
//
|
|
793
|
-
|
|
1700
|
+
// New-build reset: when /builder receives its first message in a
|
|
1701
|
+
// fresh conversation (history is just the greeting, or empty), wipe
|
|
1702
|
+
// the change journal and start from a starter scaffold. The AI
|
|
1703
|
+
// reasons about the scaffold's "standard" node layout and emits ops
|
|
1704
|
+
// using those data-nid values; if the live page still holds stale
|
|
1705
|
+
// state from a previous session, those ops silently skip against
|
|
1706
|
+
// the wrong elements and the resulting page looks built but is
|
|
1707
|
+
// missing critical JS.
|
|
1708
|
+
//
|
|
1709
|
+
// Scaffold selection (in order):
|
|
1710
|
+
// 1. req.body.starter if it's a valid `_Starters` page
|
|
1711
|
+
// 2. blank_starter (the silent fallback for /builder with no pick)
|
|
1712
|
+
// 3. builder's own page.html (last-resort)
|
|
1713
|
+
const isNewBuildForBuilder = page === 'builder' && incomingHistory.length <= 1 && !tryAgain;
|
|
1714
|
+
if (isNewBuildForBuilder) {
|
|
1715
|
+
await clearChanges(config, page);
|
|
1716
|
+
let scaffoldName = '';
|
|
1717
|
+
const requestedStarter = typeof req.body.starter === 'string' ? req.body.starter : '';
|
|
1718
|
+
if (requestedStarter) {
|
|
1719
|
+
const sm = await loadPageMetadata(config, requestedStarter, config.requiredPagesFolders);
|
|
1720
|
+
if (sm?.categories?.includes('_Starters')) {
|
|
1721
|
+
scaffoldName = requestedStarter;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
if (!scaffoldName) scaffoldName = 'blank_starter';
|
|
1725
|
+
let scaffold = await loadPageWithFallback(scaffoldName, config, false);
|
|
1726
|
+
if (!scaffold && scaffoldName !== 'blank_starter') {
|
|
1727
|
+
scaffold = await loadPageWithFallback('blank_starter', config, false);
|
|
1728
|
+
}
|
|
1729
|
+
if (!scaffold) {
|
|
1730
|
+
scaffold = await loadPageWithFallback(page, config, false);
|
|
1731
|
+
}
|
|
1732
|
+
if (scaffold) {
|
|
1733
|
+
transformInput = scaffold;
|
|
1734
|
+
currentVersion = 0;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
794
1737
|
|
|
795
|
-
|
|
796
|
-
|
|
1738
|
+
if (tryAgain && currentVersion > 0) {
|
|
1739
|
+
// Drop the latest change (html + json) and reload prior state
|
|
1740
|
+
await deleteChange(config, page, currentVersion);
|
|
1741
|
+
currentVersion = await getLatestChangeNumber(config, page);
|
|
1742
|
+
const latestHtml = await getLatestChangeHtmlNumber(config, page);
|
|
1743
|
+
if (latestHtml > 0) {
|
|
1744
|
+
transformInput = await loadChangeHtml(config, page, latestHtml) ?? pageState;
|
|
1745
|
+
} else {
|
|
1746
|
+
transformInput = await loadPageWithFallback(page, config, false) ?? pageState;
|
|
1747
|
+
}
|
|
1748
|
+
// incomingHistory is stale now (it read before deletion); the
|
|
1749
|
+
// builder forces Opus+no-ranged for tryAgain regardless.
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// --- Opt-8: NDJSON streaming response ---
|
|
1753
|
+
// Set headers for newline-delimited JSON streaming.
|
|
1754
|
+
// Progress events are emitted before the LLM call; the final result is the last line.
|
|
1755
|
+
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
1756
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
1757
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1758
|
+
const emitProgress = (status: string) => {
|
|
1759
|
+
res.write(JSON.stringify({ event: 'progress', status }) + '\n');
|
|
1760
|
+
};
|
|
797
1761
|
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
|
|
1762
|
+
// ---- Section assembly: build all sections, classifier picks expand ----
|
|
1763
|
+
// Each section ships with both `full` content and a `sketch`; the
|
|
1764
|
+
// classifier sees sketches and returns `expand: string[]` listing
|
|
1765
|
+
// which sections to render in full. Anything else renders as its
|
|
1766
|
+
// sketch (or skips entirely for empty omit-when-empty sections).
|
|
1767
|
+
// See docs/specs/page-section-sketch-full.md.
|
|
801
1768
|
|
|
802
|
-
|
|
803
|
-
const agentsSection = buildAgentsSection(settings.agents);
|
|
804
|
-
if (agentsSection) featureSections.push(agentsSection);
|
|
1769
|
+
const isNewBuildRequest = incomingHistory.length <= 1;
|
|
805
1770
|
|
|
806
|
-
//
|
|
1771
|
+
// Build all candidate sections in deterministic order.
|
|
807
1772
|
const theme = settings.theme;
|
|
808
1773
|
const themeInfo = await loadThemeInfo(theme ?? 'nebula-dusk', config);
|
|
809
|
-
featureSections.push(buildThemeSection(themeInfo));
|
|
810
1774
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1775
|
+
const allSections: ContextSection[] = [];
|
|
1776
|
+
allSections.push(buildContextSection());
|
|
1777
|
+
allSections.push(buildLlmdReadingGuideSection());
|
|
1778
|
+
allSections.push(buildServerApisSection(customizer));
|
|
1779
|
+
allSections.push(await buildServerScriptsSection(pagesFolder));
|
|
1780
|
+
allSections.push(buildThemeSection(themeInfo));
|
|
1781
|
+
allSections.push(buildConnectorsSection(settings.connectors));
|
|
1782
|
+
allSections.push(buildAgentsSection(settings.agents));
|
|
1783
|
+
allSections.push(await buildFluentLMSection(config));
|
|
1784
|
+
allSections.push(await buildRecommendedFrameworksSection(config));
|
|
1785
|
+
allSections.push(await buildSharedTablesSection(config));
|
|
1786
|
+
allSections.push(await buildPageTablesSection(config, page));
|
|
1787
|
+
|
|
1788
|
+
// Custom transform instructions (always-full when present)
|
|
823
1789
|
const customTransformInstructions = customizer ? customizer.getTransformInstructions() : undefined;
|
|
824
1790
|
if (customTransformInstructions && customTransformInstructions.length > 0) {
|
|
825
|
-
|
|
1791
|
+
allSections.push({
|
|
826
1792
|
title: '<CUSTOM_INSTRUCTIONS>',
|
|
827
1793
|
content: customTransformInstructions.join('\n'),
|
|
1794
|
+
sketch: null,
|
|
1795
|
+
mode: 'always-full',
|
|
828
1796
|
instructions: '',
|
|
829
1797
|
});
|
|
830
1798
|
}
|
|
831
1799
|
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
|
|
1800
|
+
// User profile (always-full when present — small, identity context).
|
|
1801
|
+
const profileMarkdown = renderUserProfile(settings.profile);
|
|
1802
|
+
if (profileMarkdown) {
|
|
1803
|
+
allSections.push({
|
|
1804
|
+
title: '<USER_PROFILE>',
|
|
1805
|
+
content: profileMarkdown,
|
|
1806
|
+
sketch: null,
|
|
1807
|
+
mode: 'always-full',
|
|
1808
|
+
forceFullOnInitial: true,
|
|
1809
|
+
instructions: 'Details about the current user. Personalize generated pages accordingly when relevant — use the name, address, locations, hours, and other details verbatim when they fit the page being built.',
|
|
1810
|
+
});
|
|
1811
|
+
console.log('user_profile: injected');
|
|
835
1812
|
}
|
|
836
1813
|
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
let currentVersion = await getLatestVersion(config, page);
|
|
842
|
-
const isFirstEdit = !isRequiredPage && pageFileExists && currentVersion === 0;
|
|
1814
|
+
// Custom context sections from Customizer (appended last).
|
|
1815
|
+
if (customizer) {
|
|
1816
|
+
allSections.push(...customizer.getContextSections());
|
|
1817
|
+
}
|
|
843
1818
|
|
|
844
|
-
//
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1819
|
+
// Apply per-page section-mode overrides from page metadata.
|
|
1820
|
+
const sectionsAfterOverride = applySectionModeOverrides(allSections, pageMeta?.sectionModes);
|
|
1821
|
+
|
|
1822
|
+
// Run the classifier (when an Anthropic API key is available). The
|
|
1823
|
+
// classifier sees sketches for every section that's not always-full
|
|
1824
|
+
// and not empty, and returns `expand: string[]` of titles to render
|
|
1825
|
+
// in full. Without an API key, fall back to expanding everything
|
|
1826
|
+
// expandable (mirrors the previous bypass behavior).
|
|
1827
|
+
let preClassified: ClassifyResult | undefined;
|
|
1828
|
+
let expand = new Set<string>();
|
|
1829
|
+
const classifierSections: ClassifierSection[] = sectionsAfterOverride
|
|
1830
|
+
.filter(s => s.mode !== 'always-full' && s.sketch !== null)
|
|
1831
|
+
.map(s => ({ title: s.title, sketch: s.sketch! }));
|
|
1832
|
+
|
|
1833
|
+
if (entry.configuration.apiKey) {
|
|
1834
|
+
emitProgress('classifying');
|
|
1835
|
+
preClassified = await classifyRequest(
|
|
1836
|
+
entry.configuration.apiKey,
|
|
1837
|
+
transformInput,
|
|
1838
|
+
rawMessage,
|
|
1839
|
+
classifierSections,
|
|
1840
|
+
);
|
|
1841
|
+
console.log(`classifyRequest: "${preClassified.classification}" (expand: ${JSON.stringify(preClassified.expand)})`);
|
|
1842
|
+
expand = new Set(preClassified.expand);
|
|
1843
|
+
} else {
|
|
1844
|
+
// No classifier — expand every non-always-full section that has a sketch.
|
|
1845
|
+
for (const s of classifierSections) expand.add(s.title);
|
|
861
1846
|
}
|
|
862
1847
|
|
|
863
|
-
//
|
|
1848
|
+
// Resolve each section to either its full content or its sketch
|
|
1849
|
+
// (or skip it entirely for empty omit-when-empty sections).
|
|
1850
|
+
const featureSections = assembleSections(sectionsAfterOverride, expand, isNewBuildRequest);
|
|
1851
|
+
|
|
1852
|
+
// Create builder. Tool-handler-based section fetching is retired
|
|
1853
|
+
// in v1 — the classifier prefetches everything the model needs.
|
|
864
1854
|
const builder = createBuilder(entry.provider, wrappedCompletePrompt, instructions, productName, {
|
|
865
1855
|
apiKey: entry.configuration.apiKey,
|
|
866
1856
|
model: entry.configuration.model,
|
|
867
1857
|
wrapModel,
|
|
868
1858
|
isFirstEdit,
|
|
869
1859
|
tryAgain,
|
|
1860
|
+
historyLength: incomingHistory.length,
|
|
1861
|
+
preClassified,
|
|
870
1862
|
});
|
|
871
1863
|
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1864
|
+
// Pre-commit the user message to page.c<N>.json BEFORE the LLM runs
|
|
1865
|
+
// so any thrown error has a concrete file to append to. Skipped for
|
|
1866
|
+
// Starters/System (ephemeral). The final write in the success path
|
|
1867
|
+
// overwrites this with [user, assistant]; the catch block appends
|
|
1868
|
+
// `Error: ...` to this same file if the LLM call throws.
|
|
1869
|
+
const precommitCategories = pageMeta?.categories ?? [];
|
|
1870
|
+
shouldPersistTurn = !precommitCategories.some(c => NO_PERSIST_CATEGORIES.includes(c));
|
|
1871
|
+
pendingVersion = currentVersion + 1;
|
|
1872
|
+
pendingRawMessage = rawMessage;
|
|
1873
|
+
if (shouldPersistTurn) {
|
|
1874
|
+
await saveChangeMessages(config, page, pendingVersion, [
|
|
1875
|
+
{ role: 'user', content: rawMessage },
|
|
1876
|
+
]);
|
|
882
1877
|
}
|
|
883
1878
|
|
|
884
1879
|
// Transform page
|
|
1880
|
+
emitProgress('transforming');
|
|
885
1881
|
const result = await transformPage({
|
|
886
1882
|
pageState: transformInput,
|
|
887
1883
|
message,
|
|
@@ -891,83 +1887,85 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
891
1887
|
isBuilder: page === 'builder',
|
|
892
1888
|
productName,
|
|
893
1889
|
attachments,
|
|
1890
|
+
history: incomingHistory,
|
|
894
1891
|
});
|
|
895
1892
|
|
|
896
1893
|
if (result.completed) {
|
|
897
|
-
let { html, changeCount } = result.value!;
|
|
1894
|
+
let { html, changeCount, replyText, errorText, skippedOps, skipReasons } = result.value!;
|
|
898
1895
|
if (config.debug) {
|
|
899
1896
|
const inTokens = estimateTokens(inputChars).toLocaleString();
|
|
900
1897
|
const outTokens = estimateTokens(outputChars).toLocaleString();
|
|
901
1898
|
console.log(` page: ${page} | message: ${message.length} chars | changes: ${changeCount} ops | ~${inTokens} in / ~${outTokens} out tokens`);
|
|
902
1899
|
}
|
|
903
1900
|
|
|
904
|
-
// Handle 0-ops
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
const $ = cheerio.load(pageState, { decodeEntities: false });
|
|
910
|
-
const chatMessages = $('#chatMessages');
|
|
911
|
-
if (chatMessages.length > 0) {
|
|
912
|
-
const escapedMsg = message.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
913
|
-
chatMessages.append(
|
|
914
|
-
`<div class="chat-message"><p><strong>User:</strong> ${escapedMsg}</p></div>`
|
|
915
|
-
);
|
|
916
|
-
|
|
917
|
-
const tryAgainLink = `<a href="#" style="color:var(--themePrimary);text-decoration:underline;cursor:pointer" `
|
|
918
|
-
+ `onclick="(function(e){e.preventDefault();var ci=document.getElementById('chatInput');if(ci){ci.focus();ci.value='';}})(event)">try again</a>`;
|
|
919
|
-
const undoLink = `<a href="#" style="color:var(--themePrimary);text-decoration:underline;cursor:pointer" `
|
|
920
|
-
+ `onclick="(function(e){e.preventDefault();var o=document.getElementById('loadingOverlay');if(o)o.style.display='flex';`
|
|
921
|
-
+ `fetch(window.location.pathname+'/undo',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})`
|
|
922
|
-
+ `.then(function(r){return r.text()}).then(function(h){document.open();document.write(h);document.close()})`
|
|
923
|
-
+ `.catch(function(){if(o)o.style.display='none'});})(event)">undo</a>`;
|
|
924
|
-
|
|
925
|
-
chatMessages.append(
|
|
926
|
-
`<div class="chat-message"><p><strong>${productName}:</strong> Sorry, I wasn\u2019t able to make changes for that request. Please ${tryAgainLink} or ${undoLink}.</p></div>`
|
|
927
|
-
);
|
|
928
|
-
}
|
|
929
|
-
html = $.html();
|
|
1901
|
+
// Handle 0-ops: model returned no page changes.
|
|
1902
|
+
// Skip HTML snapshot entirely — identical content would pollute undo history.
|
|
1903
|
+
const madeChanges = changeCount > 0;
|
|
1904
|
+
if (!madeChanges) {
|
|
1905
|
+
html = pageState;
|
|
930
1906
|
}
|
|
931
1907
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1908
|
+
const nextVersion = pendingVersion!;
|
|
1909
|
+
if (madeChanges) {
|
|
1910
|
+
await saveChangeHtml(config, page, nextVersion, html);
|
|
1911
|
+
}
|
|
935
1912
|
|
|
936
|
-
// Update lastModified timestamp
|
|
1913
|
+
// Update lastModified timestamp only when actual changes were made
|
|
937
1914
|
const metadata = await loadPageMetadata(config, page, config.requiredPagesFolders);
|
|
938
|
-
if (metadata) {
|
|
1915
|
+
if (madeChanges && metadata) {
|
|
939
1916
|
metadata.lastModified = new Date().toISOString();
|
|
940
1917
|
await savePageMetadata(config, page, metadata);
|
|
941
1918
|
}
|
|
942
1919
|
|
|
943
|
-
//
|
|
1920
|
+
// Finalize the turn's page.c<N>.json — overwrite the user-only
|
|
1921
|
+
// pre-commit with the full [user, assistant] pair.
|
|
1922
|
+
const turnMessages: ChatMessage[] = [{ role: 'user', content: rawMessage }];
|
|
1923
|
+
if (replyText) {
|
|
1924
|
+
turnMessages.push({ role: 'assistant', content: replyText });
|
|
1925
|
+
} else if (errorText) {
|
|
1926
|
+
turnMessages.push({ role: 'assistant', content: `Error: ${errorText}` });
|
|
1927
|
+
} else {
|
|
1928
|
+
turnMessages.push({ role: 'assistant', content: `Made ${changeCount} change(s) to the page.` });
|
|
1929
|
+
}
|
|
1930
|
+
if (shouldPersistTurn) {
|
|
1931
|
+
await saveChangeMessages(config, page, nextVersion, turnMessages);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// Inject content assets for iframe rendering (no shell chrome)
|
|
944
1935
|
const pv = metadata?.pageVersion ?? 0;
|
|
945
1936
|
const themeName = settings.theme ?? 'nebula-dusk';
|
|
946
1937
|
const themeVersion = await loadThemeVersion(themeName, config);
|
|
947
|
-
|
|
948
|
-
out = injectErrorCapture(out, pv);
|
|
949
|
-
out = injectShellAssets(out, themeName, themeVersion, settings.toolbarPosition);
|
|
950
|
-
out = injectPageInfoScript(out, page);
|
|
951
|
-
out = injectPageHelpers(out, pv);
|
|
952
|
-
out = injectPageScript(out, pv);
|
|
953
|
-
|
|
954
|
-
// Inject version meta tag for client-side undo support
|
|
955
|
-
if (nextVersion > 0) {
|
|
956
|
-
out = out.replace('</head>', `<meta name="synthos-version" content="${nextVersion}">\n</head>`);
|
|
957
|
-
}
|
|
1938
|
+
const out = prepareForIframe(html, page, pv, themeName, themeVersion, productName);
|
|
958
1939
|
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1940
|
+
// Surface builder/transform errors to the server log so they're
|
|
1941
|
+
// not lost when only the client sees the NDJSON result event.
|
|
1942
|
+
if (errorText) {
|
|
1943
|
+
console.error(red(` BUILDER ERROR: ${errorText}`));
|
|
1944
|
+
}
|
|
964
1945
|
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
1946
|
+
// Surface skipped ops to the server log so partial builds are
|
|
1947
|
+
// visible in operator logs, not just the client-side warning.
|
|
1948
|
+
if (skippedOps && skippedOps > 0) {
|
|
1949
|
+
console.warn(red(` SKIPPED ${skippedOps} op(s) on ${page}: ${skipReasons?.join('; ')}`));
|
|
968
1950
|
}
|
|
969
1951
|
|
|
970
|
-
|
|
1952
|
+
// `version` reflects the latest HTML snapshot, not the journal —
|
|
1953
|
+
// the shell uses it to decide whether the save button lights up.
|
|
1954
|
+
const htmlVersion = madeChanges
|
|
1955
|
+
? nextVersion
|
|
1956
|
+
: await getLatestChangeHtmlNumber(config, page);
|
|
1957
|
+
|
|
1958
|
+
res.write(JSON.stringify({
|
|
1959
|
+
event: 'result',
|
|
1960
|
+
html: out,
|
|
1961
|
+
replyText: replyText ?? null,
|
|
1962
|
+
errorText: errorText ?? null,
|
|
1963
|
+
changeCount,
|
|
1964
|
+
skippedOps: skippedOps ?? 0,
|
|
1965
|
+
skipReasons: skipReasons ?? [],
|
|
1966
|
+
version: htmlVersion,
|
|
1967
|
+
}) + '\n');
|
|
1968
|
+
res.end();
|
|
971
1969
|
} else {
|
|
972
1970
|
throw result.error;
|
|
973
1971
|
}
|
|
@@ -977,15 +1975,34 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize
|
|
|
977
1975
|
} else {
|
|
978
1976
|
console.error(err);
|
|
979
1977
|
}
|
|
980
|
-
|
|
1978
|
+
|
|
1979
|
+
// Append the error to the pre-committed turn file so the user can
|
|
1980
|
+
// see what went wrong on the next page load.
|
|
1981
|
+
if (pendingVersion != null && shouldPersistTurn && pendingRawMessage != null) {
|
|
1982
|
+
try {
|
|
1983
|
+
await saveChangeMessages(config, page, pendingVersion, [
|
|
1984
|
+
{ role: 'user', content: pendingRawMessage },
|
|
1985
|
+
{ role: 'assistant', content: `Error: ${(err as Error).message}` },
|
|
1986
|
+
]);
|
|
1987
|
+
} catch { /* journal write must never mask the real error */ }
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Emit error as NDJSON if headers already sent, otherwise plain error
|
|
1991
|
+
if (res.headersSent) {
|
|
1992
|
+
res.write(JSON.stringify({ event: 'error', error: (err as Error).message }) + '\n');
|
|
1993
|
+
res.end();
|
|
1994
|
+
} else {
|
|
1995
|
+
res.status(500).send((err as Error).message);
|
|
1996
|
+
}
|
|
981
1997
|
}
|
|
1998
|
+
}); // withPageLock
|
|
982
1999
|
});
|
|
983
2000
|
}
|
|
984
2001
|
|
|
985
2002
|
export async function loadPageWithFallback(page: string, config: SynthOSConfig, reset: boolean): Promise<string|undefined> {
|
|
986
2003
|
if (reset) {
|
|
987
|
-
// Clear working-state
|
|
988
|
-
await
|
|
2004
|
+
// Clear working-state changes so we get the fresh template
|
|
2005
|
+
await clearChanges(config, page);
|
|
989
2006
|
}
|
|
990
2007
|
|
|
991
2008
|
// Try primary pages folder first
|