@vierratale/ai 0.1.0-beta.8 → 0.1.0-beta.9
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 +120 -16
- package/bin/vierrataleai.js +74 -1
- package/package.json +7 -1
- package/src/catalog.js +78 -49
- package/src/cli.js +864 -103
- package/src/cmd/agent.js +1695 -0
- package/src/cmd/executor.js +257 -0
- package/src/cmd/tools.js +131 -0
- package/src/config.js +24 -10
- package/src/index.js +1 -0
- package/src/installer.js +130 -2
- package/src/prompts/system.json +2 -2
- package/src/prompts/system.md +42 -0
- package/src/providers/anthropic.js +65 -34
- package/src/providers/base.js +10 -0
- package/src/providers/cortex.js +223 -40
- package/src/providers/gemini.js +62 -31
- package/src/providers/openai.js +64 -33
- package/src/session.js +195 -15
- package/src/ui/branding.js +1 -1
- package/src/ui/chatbox.js +271 -0
- package/src/utils/filewriter.js +97 -0
- package/src/utils/intents.js +61 -1
- package/src/utils/logger.js +67 -0
- package/src/utils/math.js +142 -0
- package/build.sh +0 -17
package/src/cmd/agent.js
ADDED
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { Config } from '../config.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
import { makeExecutor, dispatchTool, TOOL_EXECUTORS } from './tools.js';
|
|
7
|
+
|
|
8
|
+
const MAX_PLANNING_ROUNDS = 3;
|
|
9
|
+
const MAX_STEPS = 8;
|
|
10
|
+
|
|
11
|
+
const TOOL_PLAN_PROMPT = `You can run commands and touch files on the user's machine inside the current workspace to satisfy their request.
|
|
12
|
+
|
|
13
|
+
Reply with EXACTLY ONE JSON array of the tool calls needed to do the job, in order. Each element is one JSON object:
|
|
14
|
+
|
|
15
|
+
{"name":"run_command","command":"mkdir -p test"}
|
|
16
|
+
{"name":"create_file","path":"test/index.js","content":"console.log('hi')"}
|
|
17
|
+
{"name":"create_directory","path":"src"}
|
|
18
|
+
{"name":"read_file","path":"package.json"}
|
|
19
|
+
{"name":"list_directory","path":"."}
|
|
20
|
+
{"name":"delete_file","path":"old.txt"}
|
|
21
|
+
|
|
22
|
+
Rules:
|
|
23
|
+
- run_command runs WITHOUT a shell. Allowed commands: mkdir touch ls pwd cat cp mv rm npm npx node python python3 git echo printf.
|
|
24
|
+
- run_command supports arguments with spaces (quote them). No shell operators (&& | > ; etc.), no wildcards for rm, no inline -e/-c code for node/python, no paths outside the workspace.
|
|
25
|
+
- To create a file containing code or text, ALWAYS use create_file (never echo/cat > …). Pass the full content in "content".
|
|
26
|
+
- Prefer create_file over redirecting output.
|
|
27
|
+
- If you need to create a folder first, plan create_directory before create_file inside it.
|
|
28
|
+
- Code you create must be runnable. For Python servers bind to 127.0.0.1, read PORT from the environment (default 8000), and fall back to a free port (port 0) when the default is taken. HTML/CSS/JS must be self-contained and reference each other by relative filename.
|
|
29
|
+
- If NO tools are needed, reply exactly: NONE
|
|
30
|
+
- Output the JSON array only — no prose, no markdown fences unless the array is inside them.`;
|
|
31
|
+
|
|
32
|
+
const TOOL_RESULTS_PROMPT = (resultsJson) => `The tools below were ALREADY executed in the workspace. Their results:
|
|
33
|
+
|
|
34
|
+
${resultsJson}
|
|
35
|
+
|
|
36
|
+
Give the user a short, natural reply (in the user's own language) telling them what happened. Keep the whole reply under 40 words. Do NOT tell them to run the commands yourself. If they asked for a file containing code and files were created with content, you may also emit FILE: blocks if more files are still required.`;
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Request detection
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const OP_PATTERNS = [
|
|
43
|
+
/\b(buat|create|make|new|touch)\b[^.!?\n]{0,40}\b(folder|dir|directory)\b/i,
|
|
44
|
+
/\b(buat|create|make|new|touch|tulis|write)\b[^.!?\n]{0,40}\b(file|berkas)\b/i,
|
|
45
|
+
/\b(hapus|delete|delete!|remove|rm|buang)\b/i,
|
|
46
|
+
/\b(lihat|tampilkan|show|list|ls|buka|isi)\b[^.!?\n]{0,30}\b(folder|dir|directory|isi)\b/i,
|
|
47
|
+
/\b(pwd|where am i|direktori|folder sekarang|folder kerja)\b/i,
|
|
48
|
+
/\b(ganti nama|rename|ren|pindah|move|mv)\b/i,
|
|
49
|
+
/\b(copy|duplicate|salin|cp)\b/i,
|
|
50
|
+
/\b(npm|yarn|pnpm)\s+(install|i|add|run|start|dev|test|build|init|create)\b/i,
|
|
51
|
+
/\b(jalankan|run|execute|exec)\b[^.!?\n]{0,20}\b(npm|node|python|git)\b/i,
|
|
52
|
+
/\bgit\s+(status|add|commit|push|pull|clone|init|log|diff|remote)\b/i,
|
|
53
|
+
/\b(buat|create|make|generate)\b[^.!?\n]{0,40}\b(project|proyek|app|aplikasi|web|website|site|situs|webpage|halaman)\b/i,
|
|
54
|
+
/\b(pack|kemas|satukan|taruh|simpan)\b[^.!?\n]{0,50}\b(folder|dir|directory)\b/i,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export function looksLikeOperationRequest(text) {
|
|
58
|
+
return OP_PATTERNS.some((re) => re.test(String(text || '')));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Tool-plan parsing (tolerant of fences/prose around the JSON)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export function normalizeToolCall(raw) {
|
|
66
|
+
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string') return null;
|
|
67
|
+
const name = raw.name;
|
|
68
|
+
if (!TOOL_EXECUTORS[name]) return null;
|
|
69
|
+
let params;
|
|
70
|
+
if (name === 'run_command') {
|
|
71
|
+
if (typeof raw.command !== 'string') return null;
|
|
72
|
+
params = { command: raw.command };
|
|
73
|
+
} else if (name === 'create_file') {
|
|
74
|
+
if (typeof raw.path !== 'string' || raw.path.trim() === '') return null;
|
|
75
|
+
params = { path: raw.path, content: typeof raw.content === 'string' ? raw.content : String(raw.content ?? '') };
|
|
76
|
+
} else if (name === 'create_directory' || name === 'read_file' || name === 'delete_file' || name === 'list_directory') {
|
|
77
|
+
if (typeof raw.path !== 'string') return null;
|
|
78
|
+
params = { path: raw.path };
|
|
79
|
+
} else {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return { name, params };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseToolPlan(text) {
|
|
86
|
+
const t = String(text || '').trim();
|
|
87
|
+
if (!t) return [];
|
|
88
|
+
if (/^\s*NONE\b/i.test(t)) return [];
|
|
89
|
+
|
|
90
|
+
const fenced = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
91
|
+
const candidate = fenced ? fenced[1] : t;
|
|
92
|
+
|
|
93
|
+
const arrM = candidate.match(/\[[\s\S]*\]/);
|
|
94
|
+
const objM = candidate.match(/\{[\s\S]*\}/);
|
|
95
|
+
let parsed = null;
|
|
96
|
+
if (arrM) {
|
|
97
|
+
try { parsed = JSON.parse(arrM[0]); } catch {}
|
|
98
|
+
}
|
|
99
|
+
if (!parsed && objM) {
|
|
100
|
+
try { parsed = [JSON.parse(objM[0])]; } catch {}
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(parsed)) return [];
|
|
103
|
+
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const item of parsed) {
|
|
106
|
+
const norm = normalizeToolCall(item);
|
|
107
|
+
if (norm) out.push(norm);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Heuristic fallback plan (used when the model emits no usable tool plan).
|
|
114
|
+
// Handles the common Indonesian/English requests without the model.
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const TOKEN = '[A-Za-z0-9_.\\-]+';
|
|
118
|
+
const FILLERS = 'lalu|dan|didalamnya|di dalamnya|dengan|untuk|baru|dulu|ya|please|then|and|inside|new';
|
|
119
|
+
|
|
120
|
+
function namedToken(text, regex) {
|
|
121
|
+
const m = text.match(regex);
|
|
122
|
+
if (!m) return null;
|
|
123
|
+
const t = m[m.length - 1].trim();
|
|
124
|
+
if (!t || new RegExp(`^(${FILLERS})$`, 'i').test(t)) return null;
|
|
125
|
+
return t;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function codeNamedFile(text) {
|
|
129
|
+
const m = String(text || '').match(/([\w.-]+\.(?:html?|css|js|jsx|ts|tsx|json|md|txt|py|php|rb|go|yaml|yml|sh|csv|sql))/i);
|
|
130
|
+
return m ? m[1] : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function heuristicPlan(requestText) {
|
|
134
|
+
const text = String(requestText || '');
|
|
135
|
+
const lower = text.toLowerCase();
|
|
136
|
+
|
|
137
|
+
const cp = /\b(copy|duplicate|salin|duplikat)\b[^.!?\n]*?([A-Za-z0-9_.\/-]+)\.([A-Za-z0-9]+)[^.!?\n]*?\b(ke|menjadi|jadi|as|to)\b\s+([A-Za-z0-9_.\/-]+)\.([A-Za-z0-9]+)/i;
|
|
138
|
+
let m = cp.exec(text);
|
|
139
|
+
if (m) {
|
|
140
|
+
return [{ name: 'run_command', params: { command: `cp ${m[2]}.${m[3]} ${m[4]}.${m[5]}` } }];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const mv = /\b(ganti nama|rename|ren|pindah|move|mv)\b[^.!?\n]*?([A-Za-z0-9_.\/-]+)\s*(ke|menjadi|jadi|as|to)\s*([A-Za-z0-9_.\/-]+)/i;
|
|
144
|
+
m = mv.exec(text);
|
|
145
|
+
if (m && /\.[A-Za-z0-9]+$/.test(m[2]) && /\.[A-Za-z0-9]+$/.test(m[4])) {
|
|
146
|
+
return [{ name: 'run_command', params: { command: `mv ${m[2]} ${m[4]}` } }];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
m = /\b(hapus|delete|remove|rm|buang)\b\s+(?:the\s+|file\s+)?([A-Za-z0-9_.\/-]+)/i.exec(text);
|
|
150
|
+
if (m && !new RegExp(`^(?:${FILLERS})$`).test(m[2])) {
|
|
151
|
+
return [{ name: 'delete_file', params: { path: m[2] } }];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
m = /\b(git\s+(?:status|log|diff|remote\s+-v|status\s+-\w*))\b/i.exec(text);
|
|
155
|
+
if (m) {
|
|
156
|
+
return [{ name: 'run_command', params: { command: m[1] } }];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
m = /\b(npm|yarn|pnpm)(\s+\S+)*/i.exec(text);
|
|
160
|
+
if (/\b(jalankan|run|execute|install|pasang)\b[^.!?\n]{0,25}\bnpm\b|\bnpm\s+(install|i|add|run|start|dev|test|build|init|create)\b/i.test(text) && m) {
|
|
161
|
+
const tail = m[0].trim();
|
|
162
|
+
return [{ name: 'run_command', params: { command: tail } }];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
m = /\b(jalankan|run|execute|exec|kerjakan)\b\s+(node|python3?)\b\s*([A-Za-z0-9_.\/-]+(?:\.[A-Za-z0-9]+)?)?/i.exec(text);
|
|
166
|
+
if (m) {
|
|
167
|
+
const cmd = `${m[2]} ${m[3] || ''}`.trim();
|
|
168
|
+
return [{ name: 'run_command', params: { command: cmd } }];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
m = /\b(jalankan|run|execute|exec)\b\s+(git)\b\s+([a-z]+(?:\s+\S+)*)/i.exec(text);
|
|
172
|
+
if (m) {
|
|
173
|
+
return [{ name: 'run_command', params: { command: `${m[2]} ${m[3]}` } }];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (/\b(project|proyek|aplikasi|app)\b/i.test(text) && /\b(buat|create|make|generate)\b/i.test(text)) {
|
|
177
|
+
const files = scaffoldFiles(text.toLowerCase());
|
|
178
|
+
if (files.length) return files;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const webScaffold = webFolderPlan(text, folderPlanName(text));
|
|
182
|
+
if (webScaffold.length) return webScaffold;
|
|
183
|
+
|
|
184
|
+
if (/\b(pwd|where am i)\b/i.test(text) || /\b(direktori|folder sekarang|folder kerja)\b/i.test(text)) {
|
|
185
|
+
return [{ name: 'run_command', params: { command: 'pwd' } }];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const stripQualifier = (s) =>
|
|
189
|
+
String(s || '').replace(/^(named|called|bernama|berjudul|dengan nama|menjadi|as|to)\s*/i, '').trim() || null;
|
|
190
|
+
const folderName = stripQualifier(namedToken(text, /\b(buat|create|make|new)\b[^.!?\n]{0,30}(?<![A-Za-z0-9_-])\b(folder|dir|directory)\b\s+(?:(?:named|called|bernama|berjudul|dengan nama|as|to)\s+)?([A-Za-z0-9_.\-]+)/i)) ||
|
|
191
|
+
stripQualifier(namedToken(text, /\b(folder|dir|directory)\b\s+(?:(?:named|called|bernama|berjudul|dengan nama|as|to)\s+)?([A-Za-z0-9_.\-]+)[\s\S]{0,10}$/i));
|
|
192
|
+
const fileName = codeNamedFile(text) || stripQualifier(namedToken(text, /\b(buat|create|make|new|touch)\b[^.!?\n]{0,30}(?<![A-Za-z0-9_-])\b(file|berkas)\b\s+(\d?[A-Za-z0-9_.\-]+)/i));
|
|
193
|
+
|
|
194
|
+
if (fileName || folderName) {
|
|
195
|
+
const plan = [];
|
|
196
|
+
if (folderName) plan.push({ name: 'create_directory', params: { path: folderName } });
|
|
197
|
+
if (fileName) {
|
|
198
|
+
const inside = /\b(di\s+dalamnya|didalamnya|di dalam|inside|dalam)\b/i.test(text);
|
|
199
|
+
let content = '';
|
|
200
|
+
if (/\.(txt|md|markdown|csv|json)$/i.test(fileName)) {
|
|
201
|
+
const says = text.match(/\b(says?|said|berisi|isinya|bertuliskan)\s+["']?([^"'.!?\n]{1,120})["']?/i);
|
|
202
|
+
if (says) content = says[2].trim() + '\n';
|
|
203
|
+
}
|
|
204
|
+
plan.push({ name: 'create_file', params: { path: folderName && inside ? `${folderName}/${fileName}` : fileName, content } });
|
|
205
|
+
}
|
|
206
|
+
return plan;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (/\b(lihat|tampilkan|show|list|ls|buka|isi)\b/i.test(text)) {
|
|
210
|
+
let pathArg = null;
|
|
211
|
+
const pathMatch = text.match(/\b(src|test|public|dist|node_modules)\b/i);
|
|
212
|
+
if (pathMatch) pathArg = pathMatch[1];
|
|
213
|
+
return [{ name: 'list_directory', params: { path: pathArg || '.' } }];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function scaffoldFiles(lower) {
|
|
220
|
+
const type = lower.match(/\b(react|node|nodejs|python|py|next|vue|vite)\b/);
|
|
221
|
+
const kind = type ? type[1] : null;
|
|
222
|
+
const named = namedToken(lower, new RegExp(`\\b(project|proyek|app)\\b\\s+(?:sederhana|simple|baru|dengan|menggunakan)?\\s*(${TOKEN})`, 'i'));
|
|
223
|
+
const base = (named || '').replace(/[^a-z0-9._-]/gi, '').replace(/[\/\\]/g, '-' );
|
|
224
|
+
const files = [];
|
|
225
|
+
const file = (path, content) => ({ name: 'create_file', params: { path, content } });
|
|
226
|
+
|
|
227
|
+
if (kind === 'react' || kind === 'vue' || kind === 'vite') {
|
|
228
|
+
const appName = base || 'my-app';
|
|
229
|
+
if (kind === 'vue') {
|
|
230
|
+
files.push(file(`${appName}/package.json`, '{\n "name": "' + appName + '",\n "version": "1.0.0",\n "private": true,\n "type": "module",\n "scripts": {\n "dev": "vite",\n "build": "vite build"\n },\n "dependencies": {\n "vue": "^3.5.0"\n },\n "devDependencies": {\n "@vitejs/plugin-vue": "^5.1.0",\n "vite": "^5.4.8"\n }\n}\n'));
|
|
231
|
+
files.push(file(`${appName}/index.html`, '<!doctype html>\n<html lang="en">\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>' + titleCase(appName.replace(/[-_]+/g, ' ')) + '</title>\n</head>\n<body>\n <div id="app"></div>\n <script type="module" src="/src/main.js"></script>\n</body>\n</html>\n'));
|
|
232
|
+
files.push(file(`${appName}/src/main.js`, "import { createApp } from 'vue'\nimport App from './App.vue'\ncreateApp(App).mount('#app')\n"));
|
|
233
|
+
files.push(file(`${appName}/src/App.vue`, '<template>\n <div class="app">\n <h1>Welcome to Vue</h1>\n <p>Built with Vue 3 + Vite.</p>\n </div>\n</template>\n'));
|
|
234
|
+
} else {
|
|
235
|
+
files.push(...tsxUrl(appName, titleCase(appName.replace(/[-_]+/g, ' ')), ''));
|
|
236
|
+
}
|
|
237
|
+
return files;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (kind === 'python' || kind === 'py') {
|
|
241
|
+
return [file((base ? base + '/' : '') + 'app.py', 'print("hello")\n')];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (kind === 'node' || kind === 'nodejs') {
|
|
245
|
+
files.push(file((base ? base + '/' : '') + 'package.json', '{\n "name": "' + (base || 'my-app') + '",\n "version": "1.0.0",\n "type": "module",\n "main": "index.js",\n "scripts": {\n "start": "node index.js"\n }\n}\n'));
|
|
246
|
+
files.push(file((base ? base + '/' : '') + 'index.js', "console.log('Hello from " + (base || 'Node') + "')\n"));
|
|
247
|
+
return files;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (named) {
|
|
251
|
+
return [{ name: 'create_directory', params: { path: named } }];
|
|
252
|
+
}
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function folderPlanName(text) {
|
|
257
|
+
const m = /\b(?:folder|dir|directory|into|inside|bernama|named)\b\s+(?:a\s+|the\s+|an\s+|new\s+|baru\s+)?([A-Za-z0-9_.-]+)/i.exec(text);
|
|
258
|
+
const name = m ? m[1] : '';
|
|
259
|
+
if (/^(a|an|the|new|baru|folder|dir|directory|into|inside|named|bernama)$/i.test(name)) return '';
|
|
260
|
+
return name;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const escapeHtml = (s) =>
|
|
264
|
+
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
265
|
+
|
|
266
|
+
const sanitize = (name) =>
|
|
267
|
+
String(name || '').replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'webapp';
|
|
268
|
+
|
|
269
|
+
function titleCase(s) {
|
|
270
|
+
const stop = new Set(['a', 'an', 'the', 'of', 'for', 'in', 'on', 'to', 'from', 'with', 'and', 'or', 'at', 'into', 'about']);
|
|
271
|
+
return String(s || '')
|
|
272
|
+
.split(/[_-\s]+/)
|
|
273
|
+
.filter(Boolean)
|
|
274
|
+
.map((w, i) => {
|
|
275
|
+
const lw = w.toLowerCase();
|
|
276
|
+
if (i > 0 && stop.has(lw)) return lw;
|
|
277
|
+
return lw.charAt(0).toUpperCase() + lw.slice(1);
|
|
278
|
+
})
|
|
279
|
+
.join(' ');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const TOPIC_STOP = new Set(['a', 'an', 'the', 'my', 'our', 'your', 'his', 'her', 'its', 'new', 'sebuah']);
|
|
283
|
+
|
|
284
|
+
function webTopicName(text) {
|
|
285
|
+
const raw = String(text || '');
|
|
286
|
+
const m =
|
|
287
|
+
raw.match(/\b(?:tentang|mengenai|about)\s+([A-Za-z0-9][A-Za-z0-9 _-]{0,23})/i) ||
|
|
288
|
+
raw.match(/\b(?:bernama|berjudul|dengan nama|named|called)\s+([A-Za-z0-9][A-Za-z0-9 _-]{0,23})/i) ||
|
|
289
|
+
raw.match(/\b(?:buat|create|make|generate|bikin)\b[^.!?\n]{0,24}(?:(?:me|saya|aku)\s+)?(?:a|an|sebuah|satu)\s+([A-Za-z0-9][A-Za-z0-9 _-]{0,23})(?=\s+(?:web\s*(?:app|page|site)?|website|situs|webpage|halaman)\b)/i);
|
|
290
|
+
if (!m) return '';
|
|
291
|
+
const first = m[1].trim().split(/\s+/)[0].toLowerCase();
|
|
292
|
+
if (TOPIC_STOP.has(first)) return '';
|
|
293
|
+
return m[1].trim().slice(0, 24);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function webStack(text) {
|
|
297
|
+
const t = String(text || '');
|
|
298
|
+
const node = /\bnode(js)?\b|express/i.test(t);
|
|
299
|
+
const tsx = /\breact\b|\btsx\b|\btypescript\b|\bts\b/i.test(t);
|
|
300
|
+
return { node, python: !node, js: true, tsx };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const WEB_SECTIONS = ['about', 'features', 'gallery', 'stats', 'contact'];
|
|
304
|
+
|
|
305
|
+
const WEB_SECTION_LABELS = {
|
|
306
|
+
about: 'About',
|
|
307
|
+
features: 'Features',
|
|
308
|
+
gallery: 'Gallery',
|
|
309
|
+
stats: 'Stats',
|
|
310
|
+
contact: 'Contact',
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const PALETTES = {
|
|
314
|
+
purple: { accent: '#7c3aed', accent2: '#06b6d4' },
|
|
315
|
+
blue: { accent: '#2563eb', accent2: '#06b6d4' },
|
|
316
|
+
cyan: { accent: '#0891b2', accent2: '#38bdf8' },
|
|
317
|
+
green: { accent: '#059669', accent2: '#34d399' },
|
|
318
|
+
amber: { accent: '#d97706', accent2: '#f59e0b' },
|
|
319
|
+
rose: { accent: '#e11d48', accent2: '#fb7185' },
|
|
320
|
+
pink: { accent: '#db2777', accent2: '#a855f7' },
|
|
321
|
+
orange: { accent: '#ea580c', accent2: '#fbbf24' },
|
|
322
|
+
slate: { accent: '#64748b', accent2: '#94a3b8' },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
function webStyleName(text) {
|
|
326
|
+
const t = String(text || '');
|
|
327
|
+
if (/\b(minimal|simpl(?:e|istic|ify))\b/i.test(t) && !/\b(dark|gelap)\b/i.test(t)) return 'minimal';
|
|
328
|
+
if (/\b(light|terang|bright|clean|pastel|white)\b/i.test(t) && !/\b(dark|gelap)\b/i.test(t)) return 'light';
|
|
329
|
+
return 'dark';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function webPalette(text) {
|
|
333
|
+
const t = String(text || '').toLowerCase();
|
|
334
|
+
const order = [
|
|
335
|
+
[/purple|violet|ungu/, 'purple'],
|
|
336
|
+
[/blue|biru/, 'blue'],
|
|
337
|
+
[/cyan|teal/, 'cyan'],
|
|
338
|
+
[/green|emerald|hijau/, 'green'],
|
|
339
|
+
[/gold|amber|yellow|kuning/, 'amber'],
|
|
340
|
+
[/red|rose|merah/, 'rose'],
|
|
341
|
+
[/pink/, 'pink'],
|
|
342
|
+
[/orange|oranye/, 'orange'],
|
|
343
|
+
[/gray|grey|slate|abu/, 'slate'],
|
|
344
|
+
];
|
|
345
|
+
for (const [re, key] of order) if (re.test(t)) return PALETTES[key];
|
|
346
|
+
const hex = t.match(/#([0-9a-f]{6})\b/i);
|
|
347
|
+
if (hex) return { accent: '#' + hex[1].toLowerCase(), accent2: '#06b6d4' };
|
|
348
|
+
return PALETTES.purple;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function hexToRgb(hex) {
|
|
352
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
|
|
353
|
+
if (!m) return null;
|
|
354
|
+
const n = parseInt(m[1], 16);
|
|
355
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Renders the stylesheet from a design config while keeping the default output
|
|
359
|
+
// byte-identical when the defaults are used.
|
|
360
|
+
function webCss(cfg = {}) {
|
|
361
|
+
const { accent = '#7c3aed', accent2 = '#06b6d4', style = 'dark' } = cfg;
|
|
362
|
+
const a = (hexToRgb(accent) || [124, 58, 237]).join(', ');
|
|
363
|
+
const b = (hexToRgb(accent2) || [6, 182, 212]).join(', ');
|
|
364
|
+
const lightA = style === 'minimal' ? 0.06 : 0.12;
|
|
365
|
+
const lightB = style === 'minimal' ? 0.05 : 0.1;
|
|
366
|
+
return WEB_CSS
|
|
367
|
+
.replace('--accent: #7c3aed;', `--accent: ${accent};`)
|
|
368
|
+
.replace('--accent-2: #06b6d4;', `--accent-2: ${accent2};`)
|
|
369
|
+
.replaceAll('rgba(124, 58, 237, 0.35)', `rgba(${a}, 0.35)`)
|
|
370
|
+
.replaceAll('rgba(6, 182, 212, 0.25)', `rgba(${b}, 0.25)`)
|
|
371
|
+
.replaceAll('rgba(6, 182, 212, 0.08)', `rgba(${b}, 0.08)`)
|
|
372
|
+
.replaceAll('rgba(124, 58, 237, 0.12)', `rgba(${a}, ${lightA})`)
|
|
373
|
+
.replaceAll('rgba(6, 182, 212, 0.1)', `rgba(${b}, ${lightB})`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Cheap, reliable design config derived directly from the request text.
|
|
377
|
+
// Used when the AI model is unavailable or fails to return a JSON config.
|
|
378
|
+
function probeWebConfig(text, folderName) {
|
|
379
|
+
const topic = webTopicName(text);
|
|
380
|
+
const folder = folderName || folderPlanName(text) || sanitize(topic || 'webapp');
|
|
381
|
+
const nameBase = topic || (folder === 'webapp' ? 'Webapp' : folder.replace(/[_]+/g, ' '));
|
|
382
|
+
const palette = webPalette(text);
|
|
383
|
+
return {
|
|
384
|
+
name: titleCase(nameBase),
|
|
385
|
+
folder,
|
|
386
|
+
topic,
|
|
387
|
+
description: '',
|
|
388
|
+
style: webStyleName(text),
|
|
389
|
+
accent: palette.accent,
|
|
390
|
+
accent2: palette.accent2,
|
|
391
|
+
sections: [...WEB_SECTIONS],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const WEB_CONFIG_PROMPT = `You are a web designer. The user asked to build a website. Reply with ONLY a compact JSON object (no markdown fences, no commentary) in exactly this shape:
|
|
396
|
+
|
|
397
|
+
{"name":"SiteTitle","description":"one upbeat sentence for the hero","accent":"#7c3aed","accent2":"#06b6d4"}
|
|
398
|
+
|
|
399
|
+
Rules:
|
|
400
|
+
- name: short site title, max 40 chars, no quote or angle-bracket characters.
|
|
401
|
+
- description: one friendly sentence describing the site for the hero, max 160 chars, and it must not contain any double-quote, angle-bracket, ampersand or backslash characters.
|
|
402
|
+
- accent and accent2: 6-digit hex colors that work together. Use the colors the user names when they name any.
|
|
403
|
+
Honour any name, style and colors the user gives in their request. Keep it short - the JSON only, under 120 words in total.`;
|
|
404
|
+
|
|
405
|
+
function normalizeWebConfig(raw, fallback) {
|
|
406
|
+
let obj = null;
|
|
407
|
+
const cleaned = String(raw || '').replace(/```[a-z]*\n?/gi, '').replace(/```/g, '').trim();
|
|
408
|
+
const start = cleaned.indexOf('{');
|
|
409
|
+
const end = cleaned.lastIndexOf('}');
|
|
410
|
+
if (start >= 0 && end > start) {
|
|
411
|
+
try { obj = JSON.parse(cleaned.slice(start, end + 1)); } catch { obj = null; }
|
|
412
|
+
}
|
|
413
|
+
const str = (v, len) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, len) : '');
|
|
414
|
+
const rawSections = Array.isArray(obj && obj.sections)
|
|
415
|
+
? [...new Set(obj.sections.map((s) => String(s)).filter((s) => WEB_SECTIONS.includes(s)))]
|
|
416
|
+
: [];
|
|
417
|
+
return {
|
|
418
|
+
name: str(obj && obj.name, 40) || fallback.name,
|
|
419
|
+
folder: sanitize(str(obj && obj.folder, 40) || fallback.folder),
|
|
420
|
+
topic: str(obj && obj.topic, 40) || fallback.topic,
|
|
421
|
+
description: str(obj && obj.description, 160) || '',
|
|
422
|
+
style: ['dark', 'light', 'minimal'].includes(obj && obj.style) ? obj.style : fallback.style,
|
|
423
|
+
accent: hexToRgb(obj && obj.accent) ? String(obj.accent).toLowerCase() : fallback.accent,
|
|
424
|
+
accent2: hexToRgb(obj && obj.accent2) ? String(obj.accent2).toLowerCase() : fallback.accent2,
|
|
425
|
+
sections: rawSections.length ? rawSections : fallback.sections,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Runs a short, bounded request to the design model. Resolves with the raw
|
|
430
|
+
// reply, or rejects when the provider is missing, the request errors or the
|
|
431
|
+
// 15s budget is exceeded. Callers fall back to heuristics when it rejects.
|
|
432
|
+
function completeWebConfig(provider, messages, requestText, systemPrompt) {
|
|
433
|
+
const call = provider && typeof provider.complete === 'function' ? provider.complete.bind(provider) : null;
|
|
434
|
+
if (!call) return Promise.reject(new Error('no design provider'));
|
|
435
|
+
const history = messages && messages.length ? messages.slice(-6) : [{ role: 'user', content: requestText }];
|
|
436
|
+
const planTimeoutMs = Number(Config.get('planTimeoutMs')) || 120000;
|
|
437
|
+
const timeoutMs = Math.min(planTimeoutMs, 15000);
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
let timer;
|
|
440
|
+
const request = call([...history, { role: 'user', content: requestText }], {
|
|
441
|
+
model: Config.getEffectiveModel(provider.name),
|
|
442
|
+
system_prompt: systemPrompt,
|
|
443
|
+
maxTokens: 150,
|
|
444
|
+
signal: controller.signal,
|
|
445
|
+
});
|
|
446
|
+
return Promise.race([
|
|
447
|
+
request,
|
|
448
|
+
new Promise((_resolve, reject) => {
|
|
449
|
+
timer = setTimeout(() => {
|
|
450
|
+
if (!controller.signal.aborted) controller.abort();
|
|
451
|
+
reject(Object.assign(new Error('web AI design config timed out'), { name: 'DeadlineError' }));
|
|
452
|
+
}, timeoutMs);
|
|
453
|
+
}),
|
|
454
|
+
]).then(
|
|
455
|
+
(raw) => { clearTimeout(timer); return raw; },
|
|
456
|
+
(err) => { clearTimeout(timer); return Promise.reject(err); }
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Asks the model for the site's design config (name, subject, palette, style,
|
|
461
|
+
// description, sections). Returns null when the provider is missing or the
|
|
462
|
+
// answer is unusable, so callers can fall back to probeWebConfig().
|
|
463
|
+
async function webDesignConfig(provider, messages, requestText) {
|
|
464
|
+
if (!provider || typeof provider.complete !== 'function') return null;
|
|
465
|
+
try {
|
|
466
|
+
const raw = await completeWebConfig(provider, messages, requestText, WEB_CONFIG_PROMPT);
|
|
467
|
+
return normalizeWebConfig(raw, probeWebConfig(requestText));
|
|
468
|
+
} catch (err) {
|
|
469
|
+
logger.log('PLAN', `web AI design config unavailable (${err && err.message ? err.message : 'error'}); using heuristic design`);
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Refine-after-build: the user tweaks the site they already got
|
|
476
|
+
// (colours, theme, title, sections...) and we re-render it in place.
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
function webRefinePrompt(base) {
|
|
480
|
+
return `You are a web designer editing an existing website. Current site: name = "${base.name}", theme = ${base.style}, accent color = ${base.accent}, accent2 = ${base.accent2}. The user asked to change this site. Reply with ONLY a JSON object (no markdown fences, no commentary) that contains ONLY the keys that should change, from this set:
|
|
481
|
+
{"name":"SiteTitle","description":"one upbeat sentence for the hero","accent":"#7c3aed","accent2":"#06b6d4"}
|
|
482
|
+
Rules:
|
|
483
|
+
- name: short site title, max 40 chars, no quote or angle-bracket characters.
|
|
484
|
+
- description: one friendly sentence for the hero, max 160 chars, and it must not contain any double-quote, angle-bracket, ampersand or backslash characters.
|
|
485
|
+
- accent and accent2: 6-digit hex colors that work together.
|
|
486
|
+
Honour only what the user asks to change; leave everything else exactly as-is. Keep it short - the JSON only, under 120 words in total.`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Finds the most recent website folder referenced by any earlier message in
|
|
490
|
+
// the session (e.g. the TOOL_RESULTS_PROMPT rows written after a web build).
|
|
491
|
+
function lastBuiltWebFolder(messages) {
|
|
492
|
+
const re = /([A-Za-z0-9_.-]+\/index\.html)/g;
|
|
493
|
+
let last = null;
|
|
494
|
+
for (const m of messages || []) {
|
|
495
|
+
let content = '';
|
|
496
|
+
if (typeof m === 'string') content = m;
|
|
497
|
+
else if (m && typeof m.content === 'string') content = m.content;
|
|
498
|
+
else if (m && m.content && typeof m.content === 'object') content = JSON.stringify(m.content);
|
|
499
|
+
const matches = [...String(content).matchAll(re)];
|
|
500
|
+
if (matches.length) last = matches[matches.length - 1][1];
|
|
501
|
+
}
|
|
502
|
+
return last ? String(last).replace(/\/index\.html$/i, '').replace(/\\/g, '/') : null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const WEB_REFINE_CHANGE_VERB = /\b(make|set|switch|change|turn|convert|update|re-?color|re-?style|re-?design|add|remove|delete|drop|hide|use|apply|adjust|repaint|restyle|recolor|redesign)\b/i;
|
|
506
|
+
const WEB_REFINE_TARGET = /\b(it|this|the site|the website|the web\s*site|the web\s*page|the page|the design|the layout|the title|the name|the brand|the background|the theme|the colou?rs?|the accen?t|the text|the home(?:page)?|my site|my website|our site|our website)\b|\b(section|page|theme|mode|colou?r|palette|accent|style|title|name|background|font|dark|light|minimal|blue|red|green|purple|orange|yellow|pink|cyan|teal|indigo|violet|silver|gold|slate|gray|grey|black|white|neon)\b/i;
|
|
507
|
+
const WEB_REFINE_QUESTION = /^\s*(what|which|whose|does|did|is|are|was|were|how\s+(many|much|do|fast|long)|why|when|where|can\s+you\s+(tell|describe|show|explain))\b/i;
|
|
508
|
+
// Fresh website / folder / file / project builds belong to the normal plan path.
|
|
509
|
+
const WEB_REFINE_FRESH_BUILD = /\b(buat|create|make|generate|new|touch)\b[^.!?\n]{0,40}\b(folder|dir|directory|file|berkas|project|proyek|app|aplikasi|web\s*(app|page|site)?|website|site|situs|webpage|halaman)\b/i;
|
|
510
|
+
|
|
511
|
+
// Returns the folder of the last built website when `text` reads like a
|
|
512
|
+
// change request aimed at it, otherwise null.
|
|
513
|
+
function webRefineRequest(text, messages) {
|
|
514
|
+
const t = String(text || '').trim();
|
|
515
|
+
if (!t) return null;
|
|
516
|
+
if (WEB_REFINE_QUESTION.test(t)) return null;
|
|
517
|
+
if (WEB_REFINE_FRESH_BUILD.test(t)) return null;
|
|
518
|
+
if (!WEB_REFINE_CHANGE_VERB.test(t)) return null;
|
|
519
|
+
if (!WEB_REFINE_TARGET.test(t)) return null;
|
|
520
|
+
const folder = lastBuiltWebFolder(messages);
|
|
521
|
+
return folder || null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Re-derives the design config of an already-built site straight off disk.
|
|
525
|
+
function parseWebConfigFromFolder(cwd, folder) {
|
|
526
|
+
const dir = join(cwd || '.', folder);
|
|
527
|
+
const read = (file) => {
|
|
528
|
+
try { return readFileSync(join(dir, file), 'utf8'); } catch { return ''; }
|
|
529
|
+
};
|
|
530
|
+
const html = read('index.html');
|
|
531
|
+
if (!html) return null;
|
|
532
|
+
const css = read('style.css');
|
|
533
|
+
const dec = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
534
|
+
const titleM = /<title>([^<]*)<\/title>/i.exec(html);
|
|
535
|
+
const name = dec(titleM ? titleM[1] : '').trim().slice(0, 40) || titleCase(sanitize(folder));
|
|
536
|
+
const heroM = /<p class="hero-sub">(.*?)<\/p>/s.exec(html);
|
|
537
|
+
let description = '';
|
|
538
|
+
if (heroM) {
|
|
539
|
+
const d = dec(heroM[1]);
|
|
540
|
+
if (!/^Everything you need to know about /i.test(d) && !/^A beautifully designed page/i.test(d)) description = d.slice(0, 200);
|
|
541
|
+
}
|
|
542
|
+
const style = html.includes('data-theme="light"') ? 'light' : 'dark';
|
|
543
|
+
const sections = WEB_SECTIONS.filter((id) => html.includes(`id="${id}"`));
|
|
544
|
+
const accentM = /--accent:\s*#([0-9a-fA-F]{6})\b/.exec(css);
|
|
545
|
+
const accent2M = /--accent-2:\s*#([0-9a-fA-F]{6})\b/.exec(css);
|
|
546
|
+
const backend = read('src/App.tsx') || read('tsconfig.json') ? 'tsx'
|
|
547
|
+
: read('server.js') || read('package.json') ? 'node'
|
|
548
|
+
: 'python';
|
|
549
|
+
return {
|
|
550
|
+
name, folder, topic: '', description, style,
|
|
551
|
+
accent: accentM ? '#' + accentM[1].toLowerCase() : '#7c3aed',
|
|
552
|
+
accent2: accent2M ? '#' + accent2M[1].toLowerCase() : '#06b6d4',
|
|
553
|
+
sections: sections.length ? sections : [...WEB_SECTIONS],
|
|
554
|
+
backend,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const WEB_COLOR_HINTS = [
|
|
559
|
+
[/purple|violet|ungu/, 'purple'],
|
|
560
|
+
[/blue|biru|azure/, 'blue'],
|
|
561
|
+
[/cyan|teal/, 'cyan'],
|
|
562
|
+
[/green|emerald|hijau|lime/, 'green'],
|
|
563
|
+
[/gold|amber|yellow|kuning/, 'amber'],
|
|
564
|
+
[/red|rose|merah|maroon|crimson/, 'rose'],
|
|
565
|
+
[/pink/, 'pink'],
|
|
566
|
+
[/orange|oranye/, 'orange'],
|
|
567
|
+
[/gray|grey|slate|abu|silver|black|white/, 'slate'],
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
function paletteFromText(text, fallbackAccent2) {
|
|
571
|
+
const t = String(text || '').toLowerCase();
|
|
572
|
+
for (const [re, key] of WEB_COLOR_HINTS) if (re.test(t)) return { accent: PALETTES[key].accent, accent2: PALETTES[key].accent2 };
|
|
573
|
+
const hex = /#([0-9a-f]{6})\b/i.exec(t);
|
|
574
|
+
if (hex) return { accent: '#' + hex[1].toLowerCase(), accent2: fallbackAccent2 };
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const WEB_SECTION_ALIASES = {
|
|
579
|
+
about: ['about', 'about us', 'overview', 'intro', 'pengenalan', 'kenalan', 'tentang'],
|
|
580
|
+
features: ['features', 'services', 'feature', 'fitur', 'keunggulan'],
|
|
581
|
+
gallery: ['gallery', 'portfolio', 'portofolio', 'galeri', 'photos', 'images'],
|
|
582
|
+
stats: ['stats', 'statistics', 'statistik', 'angka', 'numbers', 'metrics'],
|
|
583
|
+
contact: ['contact', 'kontak', 'hubungi', 'reach'],
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// Applies the deterministic, prompt-derived edit hints on top of the current
|
|
587
|
+
// site config (baseline from disk).
|
|
588
|
+
function applyWebRefineHints(text, base) {
|
|
589
|
+
const t = String(text || '');
|
|
590
|
+
const cfg = { ...base, topic: base.topic || '' };
|
|
591
|
+
|
|
592
|
+
const pal = paletteFromText(t, cfg.accent2);
|
|
593
|
+
if (pal) { cfg.accent = pal.accent; cfg.accent2 = pal.accent2; }
|
|
594
|
+
|
|
595
|
+
const hasStyle = /\b(light|dark|minimal|bright|clean|pastel|white|terang|gelap|simpl(e|istic|ify)|night|midnight)\b/i.test(t);
|
|
596
|
+
if (hasStyle) cfg.style = webStyleName(t);
|
|
597
|
+
|
|
598
|
+
const qm = /(?:change|rename|set|update|make)\b[^.!?\n]{0,30}\b(?:the\s+)?(?:title|name|brand)\b[^.!?\n]{0,24}\b(?:to|as|:|be|into)\b[^.!?\n]{0,6}"([^"]{1,40})"/i.exec(t);
|
|
599
|
+
const um = /(?:change|rename|set|update|make)\b[^.!?\n]{0,30}\b(?:the\s+)?(?:title|name|brand)\b[^.!?\n]{0,24}\b(?:to|as|:|be|into)\b[^.!?\n]{0,2}\b([A-Za-z0-9][\w &!'-]{1,40})\b/i.exec(t);
|
|
600
|
+
const titleMatch = qm || um;
|
|
601
|
+
if (titleMatch) {
|
|
602
|
+
let nm = titleMatch[1].trim().replace(/[.!?\n]+$/g, '').trim();
|
|
603
|
+
if (nm) cfg.name = nm.slice(0, 40);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
607
|
+
const adds = [];
|
|
608
|
+
const rems = [];
|
|
609
|
+
const addSw = /\b(add|include|insert|put|bring|with|plus|and)\b/i;
|
|
610
|
+
const remSw = /\b(remove|delete|drop|hide|cut|without|lose|minus)\b/i;
|
|
611
|
+
for (const [id, names] of Object.entries(WEB_SECTION_ALIASES)) {
|
|
612
|
+
for (const name of names) {
|
|
613
|
+
const re = new RegExp(`\\b${esc(name)}\\b`, 'gi');
|
|
614
|
+
for (const m of String(t).matchAll(re)) {
|
|
615
|
+
const before = t.slice(Math.max(0, m.index - 26), m.index);
|
|
616
|
+
const after = t.slice(m.index + m[0].length, m.index + m[0].length + 12);
|
|
617
|
+
if (remSw.test(before) || remSw.test(after)) { if (!rems.includes(id)) rems.push(id); }
|
|
618
|
+
else if (addSw.test(before) || addSw.test(after)) { if (!adds.includes(id)) adds.push(id); }
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const id of adds) if (!cfg.sections.includes(id)) cfg.sections = [...cfg.sections, id];
|
|
623
|
+
for (const id of rems) cfg.sections = cfg.sections.filter((s) => s !== id);
|
|
624
|
+
return cfg;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Re-renders an already-built site in place from the refined config. Returns
|
|
628
|
+
// null when there is no previous site to refine.
|
|
629
|
+
async function webRefine(provider, messages, requestText, cwd) {
|
|
630
|
+
const folder = webRefineRequest(requestText, messages);
|
|
631
|
+
if (!folder) return null;
|
|
632
|
+
const base = parseWebConfigFromFolder(cwd, folder);
|
|
633
|
+
if (!base) return null;
|
|
634
|
+
let cfg = applyWebRefineHints(requestText, base);
|
|
635
|
+
let raw = '';
|
|
636
|
+
try {
|
|
637
|
+
raw = await completeWebConfig(provider, messages, requestText, webRefinePrompt(base));
|
|
638
|
+
} catch { raw = ''; }
|
|
639
|
+
if (raw) {
|
|
640
|
+
const got = normalizeWebConfig(raw, cfg);
|
|
641
|
+
cfg = {
|
|
642
|
+
...cfg,
|
|
643
|
+
name: got.name && got.name !== cfg.name ? got.name : cfg.name,
|
|
644
|
+
description: got.description || cfg.description,
|
|
645
|
+
accent: got.accent && got.accent !== cfg.accent ? got.accent : cfg.accent,
|
|
646
|
+
accent2: got.accent2 && got.accent2 !== cfg.accent2 ? got.accent2 : cfg.accent2,
|
|
647
|
+
};
|
|
648
|
+
logger.log('PLAN', 'web refine (AI design config)');
|
|
649
|
+
} else {
|
|
650
|
+
logger.log('PLAN', 'web refine (heuristic design)');
|
|
651
|
+
}
|
|
652
|
+
const hint = webStack(requestText);
|
|
653
|
+
const tsx = hint.tsx;
|
|
654
|
+
const stack = { tsx, js: true, node: tsx ? false : hint.node || base.backend === 'node', python: false };
|
|
655
|
+
stack.python = !tsx && !stack.node;
|
|
656
|
+
logger.log('PLAN', `plan: web refine ${folder}`);
|
|
657
|
+
return renderWebPlan(cfg, requestText, stack);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// True when a plan looks like an actual generated website (an index.html inside
|
|
661
|
+
// a folder plus at least one web file: stylesheet, backend or TSX config).
|
|
662
|
+
function isWebSitePlan(plan) {
|
|
663
|
+
if (!Array.isArray(plan) || !plan.length) return false;
|
|
664
|
+
const files = plan
|
|
665
|
+
.filter((c) => c && c.name === 'create_file' && c.params && typeof c.params.path === 'string')
|
|
666
|
+
.map((c) => String(c.params.path).replace(/\\/g, '/'));
|
|
667
|
+
if (!files.some((p) => /\/index\.html$/i.test(p))) return false;
|
|
668
|
+
return files.some((p) => /(?:^|\/)(?:style\.css|app\.py|tsconfig\.json|server\.js|src\/styles\.css)$/i.test(p));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const WEB_CSS = `:root {
|
|
672
|
+
--bg: #0b1020;
|
|
673
|
+
--bg-soft: #121a33;
|
|
674
|
+
--card: #151d3a;
|
|
675
|
+
--text: #e7ecf8;
|
|
676
|
+
--muted: #94a3c8;
|
|
677
|
+
--accent: #7c3aed;
|
|
678
|
+
--accent-2: #06b6d4;
|
|
679
|
+
--border: rgba(255, 255, 255, 0.08);
|
|
680
|
+
--radius: 16px;
|
|
681
|
+
--shadow: 0 18px 45px rgba(0, 0, 0, 0.35);
|
|
682
|
+
--nav-h: 3.8rem;
|
|
683
|
+
}
|
|
684
|
+
html[data-theme="light"] {
|
|
685
|
+
--bg: #f4f6fb;
|
|
686
|
+
--bg-soft: #e7ecf6;
|
|
687
|
+
--card: #ffffff;
|
|
688
|
+
--text: #16213a;
|
|
689
|
+
--muted: #5a6a8f;
|
|
690
|
+
--border: rgba(22, 33, 58, 0.12);
|
|
691
|
+
--shadow: 0 18px 45px rgba(22, 33, 58, 0.12);
|
|
692
|
+
}
|
|
693
|
+
* { box-sizing: border-box; }
|
|
694
|
+
html { scroll-behavior: smooth; }
|
|
695
|
+
body {
|
|
696
|
+
margin: 0;
|
|
697
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
698
|
+
background:
|
|
699
|
+
radial-gradient(1200px 600px at 80% -10%, rgba(124, 58, 237, 0.35), transparent 60%),
|
|
700
|
+
radial-gradient(900px 500px at 10% 0%, rgba(6, 182, 212, 0.25), transparent 55%),
|
|
701
|
+
var(--bg);
|
|
702
|
+
color: var(--text);
|
|
703
|
+
line-height: 1.6;
|
|
704
|
+
}
|
|
705
|
+
html[data-theme="light"] body {
|
|
706
|
+
background:
|
|
707
|
+
radial-gradient(1200px 600px at 80% -10%, rgba(124, 58, 237, 0.12), transparent 60%),
|
|
708
|
+
radial-gradient(900px 500px at 10% 0%, rgba(6, 182, 212, 0.1), transparent 55%),
|
|
709
|
+
var(--bg);
|
|
710
|
+
}
|
|
711
|
+
::selection { background: var(--accent); color: #fff; }
|
|
712
|
+
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible {
|
|
713
|
+
outline: 3px solid var(--accent-2); outline-offset: 2px;
|
|
714
|
+
}
|
|
715
|
+
.container { width: min(1080px, 92%); margin: 0 auto; }
|
|
716
|
+
h1, h2, h3 { line-height: 1.15; margin: 0 0 0.5em; }
|
|
717
|
+
h2 { font-size: clamp(1.6rem, 3vw, 2.2rem); }
|
|
718
|
+
section { scroll-margin-top: var(--nav-h); }
|
|
719
|
+
.site-header {
|
|
720
|
+
position: sticky; top: 0; z-index: 50;
|
|
721
|
+
background: rgba(11, 16, 32, 0.75);
|
|
722
|
+
backdrop-filter: blur(12px);
|
|
723
|
+
border-bottom: 1px solid var(--border);
|
|
724
|
+
transition: box-shadow 0.25s ease;
|
|
725
|
+
}
|
|
726
|
+
html[data-theme="light"] .site-header { background: rgba(244, 246, 251, 0.8); }
|
|
727
|
+
.site-header.scrolled { box-shadow: 0 6px 22px rgba(0, 0, 0, 0.3); }
|
|
728
|
+
html[data-theme="light"] .site-header.scrolled { box-shadow: 0 6px 22px rgba(22, 33, 58, 0.12); }
|
|
729
|
+
.nav { display: flex; align-items: center; justify-content: space-between; padding: 0.8rem 0; }
|
|
730
|
+
.brand {
|
|
731
|
+
font-size: 1.15rem; font-weight: 800; color: var(--text);
|
|
732
|
+
text-decoration: none; letter-spacing: -0.02em;
|
|
733
|
+
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
|
734
|
+
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
|
735
|
+
}
|
|
736
|
+
.nav-links { display: flex; align-items: center; gap: 1.3rem; list-style: none; margin: 0; padding: 0; }
|
|
737
|
+
.nav-links li { display: flex; }
|
|
738
|
+
.nav-links a { color: var(--muted); text-decoration: none; font-weight: 600; transition: color 0.2s; }
|
|
739
|
+
.nav-links a:hover { color: var(--text); }
|
|
740
|
+
.nav-toggle { display: none; background: none; border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
|
|
741
|
+
.theme-toggle {
|
|
742
|
+
background: rgba(255, 255, 255, 0.06); color: var(--text);
|
|
743
|
+
border: 1px solid var(--border); border-radius: 999px;
|
|
744
|
+
padding: 0.35rem 0.8rem; font: inherit; font-size: 0.8rem; font-weight: 700;
|
|
745
|
+
cursor: pointer; transition: background 0.2s, box-shadow 0.2s;
|
|
746
|
+
}
|
|
747
|
+
.theme-toggle:hover { background: rgba(255, 255, 255, 0.14); }
|
|
748
|
+
html[data-theme="light"] .theme-toggle { background: rgba(22, 33, 58, 0.06); }
|
|
749
|
+
html[data-theme="light"] .theme-toggle:hover { background: rgba(22, 33, 58, 0.12); }
|
|
750
|
+
.hero { padding: clamp(3.5rem, 9vw, 7rem) 0 3.5rem; text-align: center; }
|
|
751
|
+
.hero-eyebrow {
|
|
752
|
+
display: inline-flex; gap: 0.5rem; font-size: 0.8rem; font-weight: 600;
|
|
753
|
+
color: var(--accent-2); letter-spacing: 0.08em; text-transform: uppercase;
|
|
754
|
+
border: 1px solid var(--border); padding: 0.35rem 0.8rem; border-radius: 999px;
|
|
755
|
+
background: rgba(6, 182, 212, 0.08);
|
|
756
|
+
}
|
|
757
|
+
.hero h1 {
|
|
758
|
+
font-size: clamp(2.4rem, 6vw, 4.2rem); letter-spacing: -0.03em; margin: 0.8rem 0;
|
|
759
|
+
background: linear-gradient(100deg, #fff 20%, #c4b5fd 50%, #67e8f9 80%);
|
|
760
|
+
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
|
761
|
+
}
|
|
762
|
+
html[data-theme="light"] .hero h1 {
|
|
763
|
+
background: linear-gradient(100deg, #16213a 20%, #7c3aed 50%, #06b6d4 80%);
|
|
764
|
+
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
|
765
|
+
}
|
|
766
|
+
.hero-sub { color: var(--muted); font-size: clamp(1rem, 2vw, 1.2rem); max-width: 620px; margin: 0 auto 1.8rem; }
|
|
767
|
+
.hero-actions { display: flex; gap: 0.9rem; justify-content: center; flex-wrap: wrap; }
|
|
768
|
+
.btn {
|
|
769
|
+
display: inline-block; padding: 0.75rem 1.4rem; border-radius: 12px;
|
|
770
|
+
font-weight: 700; text-decoration: none; transition: transform 0.15s, box-shadow 0.15s;
|
|
771
|
+
}
|
|
772
|
+
.btn:hover { transform: translateY(-2px); }
|
|
773
|
+
.btn-primary {
|
|
774
|
+
color: #fff; background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
|
775
|
+
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.35);
|
|
776
|
+
}
|
|
777
|
+
.btn-ghost { color: var(--text); border: 1px solid var(--border); background: rgba(255, 255, 255, 0.04); }
|
|
778
|
+
html[data-theme="light"] .btn-ghost { background: rgba(22, 33, 58, 0.04); }
|
|
779
|
+
.section { padding: clamp(2.5rem, 6vw, 4.5rem) 0; }
|
|
780
|
+
.section.alt { background: var(--bg-soft); }
|
|
781
|
+
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 2.5rem; align-items: center; }
|
|
782
|
+
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.2rem; margin-top: 1.5rem; }
|
|
783
|
+
.card, .stat-card {
|
|
784
|
+
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
785
|
+
padding: 1.4rem; box-shadow: var(--shadow);
|
|
786
|
+
}
|
|
787
|
+
.card h3 { margin-top: 0.2rem; }
|
|
788
|
+
.card .num, .stat-card strong {
|
|
789
|
+
color: transparent; background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
|
790
|
+
-webkit-background-clip: text; background-clip: text;
|
|
791
|
+
}
|
|
792
|
+
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.2rem; margin-top: 1.5rem; }
|
|
793
|
+
.stat-card strong { font-size: 2rem; display: block; }
|
|
794
|
+
.stat-card span { color: var(--muted); font-size: 0.9rem; }
|
|
795
|
+
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
|
|
796
|
+
.tile {
|
|
797
|
+
aspect-ratio: 16 / 10; border-radius: var(--radius); border: 1px solid var(--border);
|
|
798
|
+
display: flex; align-items: flex-end; padding: 1rem;
|
|
799
|
+
color: #fff; font-weight: 800; letter-spacing: 0.02em;
|
|
800
|
+
background:
|
|
801
|
+
linear-gradient(135deg, rgba(124, 58, 237, 0.9), rgba(6, 182, 212, 0.75)),
|
|
802
|
+
radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.4), transparent 45%);
|
|
803
|
+
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
|
804
|
+
}
|
|
805
|
+
.tile.t2 { background: linear-gradient(135deg, rgba(6, 182, 212, 0.9), rgba(34, 211, 238, 0.7)); }
|
|
806
|
+
.tile.t3 { background: linear-gradient(135deg, rgba(217, 70, 239, 0.9), rgba(124, 58, 237, 0.7)); }
|
|
807
|
+
.tile.t4 { background: linear-gradient(135deg, rgba(16, 185, 129, 0.9), rgba(6, 182, 212, 0.7)); }
|
|
808
|
+
.tile:hover { transform: translateY(-6px); box-shadow: var(--shadow); }
|
|
809
|
+
.muted { color: var(--muted); }
|
|
810
|
+
code {
|
|
811
|
+
background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border);
|
|
812
|
+
padding: 0.1rem 0.45rem; border-radius: 6px; font-size: 0.9em;
|
|
813
|
+
}
|
|
814
|
+
html[data-theme="light"] code { background: rgba(22, 33, 58, 0.06); }
|
|
815
|
+
.form-wrap { max-width: 560px; }
|
|
816
|
+
label { display: block; font-weight: 600; margin: 0.9rem 0 0.35rem; }
|
|
817
|
+
input, textarea {
|
|
818
|
+
width: 100%; padding: 0.7rem 0.9rem; border-radius: 10px;
|
|
819
|
+
border: 1px solid var(--border); background: rgba(255, 255, 255, 0.05);
|
|
820
|
+
color: var(--text); font: inherit;
|
|
821
|
+
}
|
|
822
|
+
input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
|
823
|
+
.form-status { margin-top: 0.8rem; font-weight: 600; }
|
|
824
|
+
.form-status.ok { color: #34d399; }
|
|
825
|
+
.form-status.err { color: #f87171; }
|
|
826
|
+
.site-footer {
|
|
827
|
+
border-top: 1px solid var(--border); padding: 1.6rem 0; text-align: center;
|
|
828
|
+
color: var(--muted); font-size: 0.9rem;
|
|
829
|
+
}
|
|
830
|
+
#back-to-top {
|
|
831
|
+
position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 60;
|
|
832
|
+
border: 1px solid var(--border); background: var(--card); color: var(--text);
|
|
833
|
+
border-radius: 999px; padding: 0.7rem 1rem; font: inherit; font-weight: 800; cursor: pointer;
|
|
834
|
+
opacity: 0; pointer-events: none; transform: translateY(8px);
|
|
835
|
+
transition: opacity 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease;
|
|
836
|
+
}
|
|
837
|
+
#back-to-top.show { opacity: 1; pointer-events: auto; transform: none; }
|
|
838
|
+
#back-to-top:hover { box-shadow: var(--shadow); }
|
|
839
|
+
.reveal { opacity: 0; transform: translateY(18px); transition: opacity 0.6s ease, transform 0.6s ease; }
|
|
840
|
+
.reveal.visible { opacity: 1; transform: none; }
|
|
841
|
+
@media (max-width: 760px) {
|
|
842
|
+
.grid-2 { grid-template-columns: 1fr; }
|
|
843
|
+
.nav-links {
|
|
844
|
+
display: none; flex-direction: column; gap: 0.8rem;
|
|
845
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
|
846
|
+
padding: 1rem; position: absolute; right: 4%; top: 3.6rem; min-width: 200px;
|
|
847
|
+
}
|
|
848
|
+
.nav-links.open { display: flex; }
|
|
849
|
+
.nav-links li { width: 100%; }
|
|
850
|
+
.nav-toggle { display: block; }
|
|
851
|
+
}
|
|
852
|
+
@media (prefers-reduced-motion: reduce) {
|
|
853
|
+
html { scroll-behavior: auto; }
|
|
854
|
+
.reveal, .tile, #back-to-top, .site-header { transition: none; }
|
|
855
|
+
.reveal { opacity: 1; transform: none; }
|
|
856
|
+
}
|
|
857
|
+
`;
|
|
858
|
+
|
|
859
|
+
const WEB_JS = `(function () {
|
|
860
|
+
'use strict';
|
|
861
|
+
var root = document.documentElement;
|
|
862
|
+
var year = document.getElementById('year');
|
|
863
|
+
if (year) year.textContent = new Date().getFullYear();
|
|
864
|
+
|
|
865
|
+
var themeBtn = document.getElementById('theme-toggle');
|
|
866
|
+
var savedTheme = null;
|
|
867
|
+
try { savedTheme = localStorage.getItem('theme'); } catch (err) {}
|
|
868
|
+
var prefersLight = false;
|
|
869
|
+
if (typeof window.matchMedia === 'function') {
|
|
870
|
+
prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
|
|
871
|
+
}
|
|
872
|
+
var theme = savedTheme || (prefersLight ? 'light' : 'dark');
|
|
873
|
+
root.setAttribute('data-theme', theme);
|
|
874
|
+
if (themeBtn) {
|
|
875
|
+
themeBtn.textContent = theme === 'light' ? 'Dark' : 'Light';
|
|
876
|
+
themeBtn.addEventListener('click', function () {
|
|
877
|
+
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
|
|
878
|
+
root.setAttribute('data-theme', next);
|
|
879
|
+
themeBtn.textContent = next === 'light' ? 'Dark' : 'Light';
|
|
880
|
+
try { localStorage.setItem('theme', next); } catch (err) {}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
var toggle = document.querySelector('.nav-toggle');
|
|
885
|
+
var links = document.querySelector('.nav-links');
|
|
886
|
+
if (toggle && links) {
|
|
887
|
+
toggle.addEventListener('click', function () {
|
|
888
|
+
var open = links.classList.toggle('open');
|
|
889
|
+
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
var revealEls = document.querySelectorAll('.reveal');
|
|
894
|
+
if ('IntersectionObserver' in window && revealEls.length) {
|
|
895
|
+
var io = new IntersectionObserver(function (entries) {
|
|
896
|
+
entries.forEach(function (entry) {
|
|
897
|
+
if (entry.isIntersecting) {
|
|
898
|
+
entry.target.classList.add('visible');
|
|
899
|
+
io.unobserve(entry.target);
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
}, { threshold: 0.12 });
|
|
903
|
+
revealEls.forEach(function (el) { io.observe(el); });
|
|
904
|
+
} else {
|
|
905
|
+
revealEls.forEach(function (el) { el.classList.add('visible'); });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
var headerEl = document.querySelector('.site-header');
|
|
909
|
+
var topBtn = document.getElementById('back-to-top');
|
|
910
|
+
function onScroll() {
|
|
911
|
+
var y = window.scrollY || document.documentElement.scrollTop || 0;
|
|
912
|
+
if (headerEl) headerEl.classList.toggle('scrolled', y > 10);
|
|
913
|
+
if (topBtn) topBtn.classList.toggle('show', y > 400);
|
|
914
|
+
}
|
|
915
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
916
|
+
onScroll();
|
|
917
|
+
if (topBtn) {
|
|
918
|
+
topBtn.addEventListener('click', function () {
|
|
919
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
var visit = document.getElementById('stat-visits');
|
|
924
|
+
var nameEl = document.getElementById('stat-name');
|
|
925
|
+
if (visit && nameEl && typeof fetch === 'function') {
|
|
926
|
+
fetch('/api/info')
|
|
927
|
+
.then(function (res) { if (!res.ok) throw new Error(res.status); return res.json(); })
|
|
928
|
+
.then(function (data) {
|
|
929
|
+
visit.textContent = data.visits;
|
|
930
|
+
nameEl.textContent = data.name;
|
|
931
|
+
})
|
|
932
|
+
.catch(function () { visit.textContent = 'offline'; });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
var form = document.getElementById('contact-form');
|
|
936
|
+
if (form) {
|
|
937
|
+
form.addEventListener('submit', function (event) {
|
|
938
|
+
event.preventDefault();
|
|
939
|
+
var status = document.getElementById('form-status');
|
|
940
|
+
var body = {
|
|
941
|
+
name: form.elements.name ? form.elements.name.value : '',
|
|
942
|
+
message: form.elements.message ? form.elements.message.value : ''
|
|
943
|
+
};
|
|
944
|
+
var finish = function (ok, text) {
|
|
945
|
+
status.textContent = text;
|
|
946
|
+
status.className = 'form-status ' + (ok ? 'ok' : 'err');
|
|
947
|
+
if (ok) form.reset();
|
|
948
|
+
};
|
|
949
|
+
if (typeof fetch === 'function') {
|
|
950
|
+
fetch('/api/contact', {
|
|
951
|
+
method: 'POST',
|
|
952
|
+
headers: { 'Content-Type': 'application/json' },
|
|
953
|
+
body: JSON.stringify(body)
|
|
954
|
+
})
|
|
955
|
+
.then(function (res) { return res.json(); })
|
|
956
|
+
.then(function (data) { finish(Boolean(data.ok), data.ok ? 'Message sent, thank you!' : 'Could not send.'); })
|
|
957
|
+
.catch(function () { finish(false, 'Backend unreachable - message not sent.'); });
|
|
958
|
+
} else {
|
|
959
|
+
finish(true, 'Thanks for your message! (static demo)');
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
})();
|
|
964
|
+
`;
|
|
965
|
+
|
|
966
|
+
const WEB_APP_PY = `import errno
|
|
967
|
+
import json
|
|
968
|
+
import os
|
|
969
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
970
|
+
|
|
971
|
+
PORT = int(os.environ.get("PORT", "8000"))
|
|
972
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
973
|
+
|
|
974
|
+
VISITS_FILE = os.path.join(BASE_DIR, "visits.json")
|
|
975
|
+
MESSAGES_FILE = os.path.join(BASE_DIR, "messages.json")
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def load_json(path: str, default):
|
|
979
|
+
try:
|
|
980
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
981
|
+
return json.load(fh)
|
|
982
|
+
except Exception:
|
|
983
|
+
return default
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def save_json(path: str, value) -> None:
|
|
987
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
988
|
+
json.dump(value, fh, indent=2)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def site_name() -> str:
|
|
992
|
+
return os.path.basename(BASE_DIR)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
996
|
+
def end_headers(self):
|
|
997
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
998
|
+
super().end_headers()
|
|
999
|
+
|
|
1000
|
+
def _send_json(self, payload: dict, status: int = 200) -> None:
|
|
1001
|
+
body = json.dumps(payload).encode("utf-8")
|
|
1002
|
+
self.send_response(status)
|
|
1003
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1004
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1005
|
+
self.end_headers()
|
|
1006
|
+
self.wfile.write(body)
|
|
1007
|
+
|
|
1008
|
+
def do_GET(self):
|
|
1009
|
+
if self.path == "/api/info":
|
|
1010
|
+
visits = int(load_json(VISITS_FILE, 0)) + 1
|
|
1011
|
+
save_json(VISITS_FILE, visits)
|
|
1012
|
+
self._send_json({
|
|
1013
|
+
"name": site_name(),
|
|
1014
|
+
"visits": visits,
|
|
1015
|
+
"features": ["About", "Live data", "Responsive design"],
|
|
1016
|
+
})
|
|
1017
|
+
return
|
|
1018
|
+
if self.path == "/api/visit":
|
|
1019
|
+
visits = int(load_json(VISITS_FILE, 0)) + 1
|
|
1020
|
+
save_json(VISITS_FILE, visits)
|
|
1021
|
+
self._send_json({"visits": visits})
|
|
1022
|
+
return
|
|
1023
|
+
return super().do_GET()
|
|
1024
|
+
|
|
1025
|
+
def do_POST(self):
|
|
1026
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
1027
|
+
try:
|
|
1028
|
+
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
1029
|
+
except Exception:
|
|
1030
|
+
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
|
1031
|
+
return
|
|
1032
|
+
if self.path == "/api/contact":
|
|
1033
|
+
messages = load_json(MESSAGES_FILE, [])
|
|
1034
|
+
messages.append({
|
|
1035
|
+
"name": payload.get("name", ""),
|
|
1036
|
+
"message": payload.get("message", ""),
|
|
1037
|
+
})
|
|
1038
|
+
save_json(MESSAGES_FILE, messages)
|
|
1039
|
+
self._send_json({"ok": True, "saved": len(messages)})
|
|
1040
|
+
return
|
|
1041
|
+
self._send_json({"ok": False, "error": "unknown endpoint"}, 404)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
if __name__ == "__main__":
|
|
1045
|
+
try:
|
|
1046
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
|
1047
|
+
except OSError as err:
|
|
1048
|
+
if err.errno != errno.EADDRINUSE:
|
|
1049
|
+
raise
|
|
1050
|
+
print(f"Port {PORT} is already in use - picking a free port instead.")
|
|
1051
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
1052
|
+
print(f"Serving {site_name()} on http://127.0.0.1:{httpd.server_address[1]}")
|
|
1053
|
+
httpd.serve_forever()
|
|
1054
|
+
`;
|
|
1055
|
+
|
|
1056
|
+
const WEB_SERVER_JS = `import http from 'node:http';
|
|
1057
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
1058
|
+
import { join, extname, sep } from 'node:path';
|
|
1059
|
+
import os from 'node:os';
|
|
1060
|
+
import { fileURLToPath } from 'node:url';
|
|
1061
|
+
|
|
1062
|
+
const __dirname = os.path.dirname(fileURLToPath(import.meta.url));
|
|
1063
|
+
const PORT = parseInt(process.env.PORT || '8080', 10);
|
|
1064
|
+
|
|
1065
|
+
const MIME = {
|
|
1066
|
+
'.html': 'text/html; charset=utf-8',
|
|
1067
|
+
'.css': 'text/css; charset=utf-8',
|
|
1068
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
1069
|
+
'.json': 'application/json; charset=utf-8',
|
|
1070
|
+
'.svg': 'image/svg+xml',
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
let visits = 0;
|
|
1074
|
+
|
|
1075
|
+
function safePath(p) {
|
|
1076
|
+
const full = join(__dirname, decodeURIComponent(p).replace(/^[\\\\/]+/, ''));
|
|
1077
|
+
if (full !== __dirname && !full.startsWith(__dirname + sep)) return null;
|
|
1078
|
+
return full;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function send(res, status, type, body) {
|
|
1082
|
+
res.writeHead(status, { 'Content-Type': type, 'Access-Control-Allow-Origin': '*' });
|
|
1083
|
+
res.end(body);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function sendJson(res, payload, status) {
|
|
1087
|
+
send(res, status || 200, 'application/json; charset=utf-8', JSON.stringify(payload));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function readMessages() {
|
|
1091
|
+
try { return JSON.parse(readFileSync(join(__dirname, 'messages.json'), 'utf-8')); } catch { return []; }
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function siteName() {
|
|
1095
|
+
return os.path.basename(__dirname);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const server = http.createServer((req, res) => {
|
|
1099
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
1100
|
+
const path = url.pathname;
|
|
1101
|
+
|
|
1102
|
+
if (path === '/api/info') {
|
|
1103
|
+
visits += 1;
|
|
1104
|
+
sendJson(res, { name: siteName(), visits, features: ['About', 'Live data', 'Responsive design'] });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (path === '/api/visit') {
|
|
1108
|
+
visits += 1;
|
|
1109
|
+
sendJson(res, { visits });
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (req.method === 'POST' && path === '/api/contact') {
|
|
1113
|
+
let body = '';
|
|
1114
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
1115
|
+
req.on('end', () => {
|
|
1116
|
+
try {
|
|
1117
|
+
const messages = readMessages();
|
|
1118
|
+
messages.push(JSON.parse(body));
|
|
1119
|
+
writeFileSync(join(__dirname, 'messages.json'), JSON.stringify(messages, null, 2));
|
|
1120
|
+
sendJson(res, { ok: true });
|
|
1121
|
+
} catch {
|
|
1122
|
+
sendJson(res, { ok: false }, 400);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const full = safePath(path === '/' || path === '' ? '/index.html' : path);
|
|
1129
|
+
if (!full) { send(res, 404, 'text/plain; charset=utf-8', 'Not found'); return; }
|
|
1130
|
+
try {
|
|
1131
|
+
const buf = readFileSync(full);
|
|
1132
|
+
send(res, 200, MIME[extname(full)] || 'application/octet-stream', buf);
|
|
1133
|
+
} catch {
|
|
1134
|
+
send(res, 404, 'text/plain; charset=utf-8', 'Not found');
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
server.on('error', (err) => {
|
|
1139
|
+
if (err.code === 'EADDRINUSE') {
|
|
1140
|
+
console.log('Port ' + PORT + ' is already in use - picking a free port instead.');
|
|
1141
|
+
server.listen(0);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
throw err;
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
1148
|
+
console.log('Serving on http://127.0.0.1:' + server.address().port);
|
|
1149
|
+
});
|
|
1150
|
+
`;
|
|
1151
|
+
|
|
1152
|
+
const TSX_TSCONFIG = `{
|
|
1153
|
+
"compilerOptions": {
|
|
1154
|
+
"target": "ES2020",
|
|
1155
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
1156
|
+
"module": "ESNext",
|
|
1157
|
+
"moduleResolution": "bundler",
|
|
1158
|
+
"jsx": "react-jsx",
|
|
1159
|
+
"strict": true,
|
|
1160
|
+
"noUnusedLocals": true,
|
|
1161
|
+
"noUnusedParameters": true,
|
|
1162
|
+
"isolatedModules": true,
|
|
1163
|
+
"skipLibCheck": true,
|
|
1164
|
+
"noEmit": true,
|
|
1165
|
+
"resolveJsonModule": true
|
|
1166
|
+
},
|
|
1167
|
+
"include": ["src"]
|
|
1168
|
+
}
|
|
1169
|
+
`;
|
|
1170
|
+
|
|
1171
|
+
const TSX_VITE_CONFIG = `import { defineConfig } from 'vite';
|
|
1172
|
+
import react from '@vitejs/plugin-react';
|
|
1173
|
+
|
|
1174
|
+
export default defineConfig({
|
|
1175
|
+
plugins: [react()],
|
|
1176
|
+
server: { host: true, port: 5173 },
|
|
1177
|
+
});
|
|
1178
|
+
`;
|
|
1179
|
+
|
|
1180
|
+
const TSX_INDEX_HTML = `<!doctype html>
|
|
1181
|
+
<html lang="en">
|
|
1182
|
+
<head>
|
|
1183
|
+
<meta charset="UTF-8" />
|
|
1184
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1185
|
+
<meta name="description" content="__TITLE__ - a React + TypeScript website generated automatically." />
|
|
1186
|
+
<title>__TITLE__</title>
|
|
1187
|
+
</head>
|
|
1188
|
+
<body>
|
|
1189
|
+
<div id="root"></div>
|
|
1190
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
1191
|
+
</body>
|
|
1192
|
+
</html>
|
|
1193
|
+
`;
|
|
1194
|
+
|
|
1195
|
+
const TSX_MAIN_TSX = `import { StrictMode } from 'react';
|
|
1196
|
+
import { createRoot } from 'react-dom/client';
|
|
1197
|
+
import App from './App.tsx';
|
|
1198
|
+
import './styles.css';
|
|
1199
|
+
|
|
1200
|
+
const root = document.getElementById('root');
|
|
1201
|
+
if (root) {
|
|
1202
|
+
createRoot(root).render(
|
|
1203
|
+
<StrictMode>
|
|
1204
|
+
<App />
|
|
1205
|
+
</StrictMode>,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
`;
|
|
1209
|
+
|
|
1210
|
+
const TSX_APP_TSX = `import { useEffect, useState, type FormEvent } from 'react';
|
|
1211
|
+
|
|
1212
|
+
type SiteInfo = { name: string; visits: number };
|
|
1213
|
+
|
|
1214
|
+
const APP_NAME = '__TITLE__';
|
|
1215
|
+
|
|
1216
|
+
const features = [
|
|
1217
|
+
{ title: 'About', body: 'A clear, friendly introduction to __TOPIC__ and everything it covers, in a fast single-page app built with React and TypeScript.' },
|
|
1218
|
+
{ title: 'Live', body: 'The UI is fully component-driven TSX, so any part of the page can update instantly without a full reload.' },
|
|
1219
|
+
{ title: 'Responsive', body: 'Fluid grids, accessible markup and a strictly typed codebase make __TOPIC__ easy to extend and maintain.' },
|
|
1220
|
+
];
|
|
1221
|
+
|
|
1222
|
+
export default function App() {
|
|
1223
|
+
const [info, setInfo] = useState<SiteInfo | null>(null);
|
|
1224
|
+
const [sync, setSync] = useState('');
|
|
1225
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
1226
|
+
|
|
1227
|
+
useEffect(() => {
|
|
1228
|
+
fetch('/api/info')
|
|
1229
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
1230
|
+
.then((data: SiteInfo | null) => {
|
|
1231
|
+
setInfo(data);
|
|
1232
|
+
setSync(data ? 'Live API request succeeded.' : 'Backend offline - showing defaults.');
|
|
1233
|
+
})
|
|
1234
|
+
.catch(() => setSync('Backend offline - showing defaults.'));
|
|
1235
|
+
}, []);
|
|
1236
|
+
|
|
1237
|
+
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
1238
|
+
event.preventDefault();
|
|
1239
|
+
const data = new FormData(event.currentTarget);
|
|
1240
|
+
const payload = {
|
|
1241
|
+
name: String(data.get('name') || ''),
|
|
1242
|
+
message: String(data.get('message') || ''),
|
|
1243
|
+
};
|
|
1244
|
+
if (typeof fetch === 'function') {
|
|
1245
|
+
fetch('/api/contact', {
|
|
1246
|
+
method: 'POST',
|
|
1247
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1248
|
+
body: JSON.stringify(payload),
|
|
1249
|
+
})
|
|
1250
|
+
.then((res) => res.json())
|
|
1251
|
+
.then((res) => setSync(res.ok ? 'Message sent, thank you!' : 'Could not send the message.'))
|
|
1252
|
+
.catch(() => setSync('Backend unreachable - message not sent.'));
|
|
1253
|
+
} else {
|
|
1254
|
+
setSync('Thanks for your message! (static demo)');
|
|
1255
|
+
}
|
|
1256
|
+
event.currentTarget.reset();
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
return (
|
|
1260
|
+
<div className="shell">
|
|
1261
|
+
<header className="site-header">
|
|
1262
|
+
<nav className="nav container">
|
|
1263
|
+
<a className="brand" href="#top">{APP_NAME}</a>
|
|
1264
|
+
<button
|
|
1265
|
+
className="nav-toggle"
|
|
1266
|
+
type="button"
|
|
1267
|
+
aria-expanded={menuOpen}
|
|
1268
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
1269
|
+
>
|
|
1270
|
+
Menu
|
|
1271
|
+
</button>
|
|
1272
|
+
<ul className={'nav-links' + (menuOpen ? ' open' : '')}>
|
|
1273
|
+
<li><a href="#about">About</a></li>
|
|
1274
|
+
<li><a href="#features">Features</a></li>
|
|
1275
|
+
<li><a href="#stats">Stats</a></li>
|
|
1276
|
+
<li><a href="#contact">Contact</a></li>
|
|
1277
|
+
</ul>
|
|
1278
|
+
</nav>
|
|
1279
|
+
</header>
|
|
1280
|
+
|
|
1281
|
+
<main id="top">
|
|
1282
|
+
<section className="hero">
|
|
1283
|
+
<div className="container">
|
|
1284
|
+
<p className="hero-eyebrow">React 18 · TypeScript · Vite · CSS</p>
|
|
1285
|
+
<h1>Welcome to {APP_NAME}</h1>
|
|
1286
|
+
<p className="hero-sub">A modern, typed single-page app about __TOPIC__, generated automatically and designed as clean, reusable TSX components.</p>
|
|
1287
|
+
<div className="hero-actions">
|
|
1288
|
+
<a className="btn btn-primary" href="#about">Explore {APP_NAME}</a>
|
|
1289
|
+
<a className="btn btn-ghost" href="#contact">Get in touch</a>
|
|
1290
|
+
</div>
|
|
1291
|
+
</div>
|
|
1292
|
+
</section>
|
|
1293
|
+
|
|
1294
|
+
<section id="about" className="section">
|
|
1295
|
+
<div className="container">
|
|
1296
|
+
<h2>About __TOPIC__</h2>
|
|
1297
|
+
<p>This React + TypeScript (TSX) site was generated automatically from a single request. The UI lives in typed components inside src/, the design system lives in src/styles.css, and you can start editing right away.</p>
|
|
1298
|
+
<p>Run <code>npm install</code> then <code>npm run dev</code> to launch the Vite dev server. Type errors are checked on every save.</p>
|
|
1299
|
+
</div>
|
|
1300
|
+
</section>
|
|
1301
|
+
|
|
1302
|
+
<section id="features" className="section alt">
|
|
1303
|
+
<div className="container">
|
|
1304
|
+
<h2>Features</h2>
|
|
1305
|
+
<div className="card-grid">
|
|
1306
|
+
{features.map((feature, index) => (
|
|
1307
|
+
<article className="card reveal" key={feature.title}>
|
|
1308
|
+
<span className="num">0{index + 1}</span>
|
|
1309
|
+
<h3>{feature.title}</h3>
|
|
1310
|
+
<p>{feature.body}</p>
|
|
1311
|
+
</article>
|
|
1312
|
+
))}
|
|
1313
|
+
</div>
|
|
1314
|
+
</div>
|
|
1315
|
+
</section>
|
|
1316
|
+
|
|
1317
|
+
<section id="stats" className="section">
|
|
1318
|
+
<div className="container">
|
|
1319
|
+
<h2>Live stats</h2>
|
|
1320
|
+
<div className="stat-grid">
|
|
1321
|
+
<div className="stat-card"><strong>{info ? info.visits : '...'}</strong><span>site visitors</span></div>
|
|
1322
|
+
<div className="stat-card"><strong>{info ? info.name : APP_NAME}</strong><span>current topic</span></div>
|
|
1323
|
+
<div className="stat-card"><strong>7</strong><span>generated files</span></div>
|
|
1324
|
+
</div>
|
|
1325
|
+
<p className="muted">{sync || 'Connecting to the backend API...'}</p>
|
|
1326
|
+
</div>
|
|
1327
|
+
</section>
|
|
1328
|
+
|
|
1329
|
+
<section id="contact" className="section alt">
|
|
1330
|
+
<div className="container form-wrap">
|
|
1331
|
+
<h2>Contact</h2>
|
|
1332
|
+
<p className="muted">The form below posts to /api/contact when a backend is connected.</p>
|
|
1333
|
+
<form onSubmit={submit}>
|
|
1334
|
+
<label htmlFor="name">Your name</label>
|
|
1335
|
+
<input id="name" name="name" type="text" required />
|
|
1336
|
+
<label htmlFor="message">Message</label>
|
|
1337
|
+
<textarea id="message" name="message" rows={4} required></textarea>
|
|
1338
|
+
<button className="btn btn-primary" type="submit">Send message</button>
|
|
1339
|
+
{sync ? <p className="form-status" role="status">{sync}</p> : null}
|
|
1340
|
+
</form>
|
|
1341
|
+
</div>
|
|
1342
|
+
</section>
|
|
1343
|
+
</main>
|
|
1344
|
+
|
|
1345
|
+
<footer className="site-footer">
|
|
1346
|
+
<div className="container">
|
|
1347
|
+
<p>{new Date().getFullYear()} © {APP_NAME}. Crafted automatically by VierrataleAI.</p>
|
|
1348
|
+
</div>
|
|
1349
|
+
</footer>
|
|
1350
|
+
</div>
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
`;
|
|
1354
|
+
|
|
1355
|
+
function tsxUrl(folder, title, topic, css = WEB_CSS) {
|
|
1356
|
+
return [
|
|
1357
|
+
{ name: 'create_file', params: { path: `${folder}/package.json`, content: JSON.stringify({
|
|
1358
|
+
name: folder,
|
|
1359
|
+
private: true,
|
|
1360
|
+
version: '1.0.0',
|
|
1361
|
+
type: 'module',
|
|
1362
|
+
scripts: { dev: 'vite', build: 'tsc -b && vite build', preview: 'vite preview' },
|
|
1363
|
+
dependencies: { react: '^18.3.1', 'react-dom': '^18.3.1' },
|
|
1364
|
+
devDependencies: {
|
|
1365
|
+
'@types/react': '^18.3.10',
|
|
1366
|
+
'@types/react-dom': '^18.3.0',
|
|
1367
|
+
'@vitejs/plugin-react': '^4.3.1',
|
|
1368
|
+
typescript: '^5.6.2',
|
|
1369
|
+
vite: '^5.4.8',
|
|
1370
|
+
},
|
|
1371
|
+
}, null, 2) + '\n' } },
|
|
1372
|
+
{ name: 'create_file', params: { path: `${folder}/tsconfig.json`, content: TSX_TSCONFIG } },
|
|
1373
|
+
{ name: 'create_file', params: { path: `${folder}/vite.config.ts`, content: TSX_VITE_CONFIG } },
|
|
1374
|
+
{ name: 'create_file', params: { path: `${folder}/index.html`, content: TSX_INDEX_HTML.replaceAll('__TITLE__', title) } },
|
|
1375
|
+
{ name: 'create_file', params: { path: `${folder}/src/main.tsx`, content: TSX_MAIN_TSX } },
|
|
1376
|
+
{ name: 'create_file', params: { path: `${folder}/src/App.tsx`, content: TSX_APP_TSX.replaceAll('__TITLE__', title).replaceAll('__TOPIC__', topic) } },
|
|
1377
|
+
{ name: 'create_file', params: { path: `${folder}/src/styles.css`, content: css } },
|
|
1378
|
+
];
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function webIndexHtml({ title, topic, description, stack, style, sections }) {
|
|
1382
|
+
const backend = stack.node ? 'Node.js' : 'Python';
|
|
1383
|
+
const subject = topic || title;
|
|
1384
|
+
const has = (id) => !sections || sections.length === 0 || sections.includes(id);
|
|
1385
|
+
const navItems = WEB_SECTIONS.filter((id) => has(id))
|
|
1386
|
+
.map((id) => `<li><a href="#${id}">${WEB_SECTION_LABELS[id]}</a></li>`)
|
|
1387
|
+
.join('\n ');
|
|
1388
|
+
const heroSub = description || (topic
|
|
1389
|
+
? `Everything you need to know about ${topic} - made automatically, styled beautifully, and brought to life by a ${backend} backend.`
|
|
1390
|
+
: `A beautifully designed page made automatically - styled with CSS, made interactive with JavaScript, and powered by a ${backend} backend.`);
|
|
1391
|
+
const aboutPara = description
|
|
1392
|
+
? `${description} Built automatically from a single request - no manual typing - and fully self-contained: HTML for structure, CSS for the design, JavaScript for the interaction and a ${backend} server for the logic.`
|
|
1393
|
+
: topic
|
|
1394
|
+
? `This is a dedicated page for ${topic}. It was generated automatically from a single request - no manual typing, no copy-paste - and it is fully self-contained: HTML for structure, CSS for the design, JavaScript for the interaction and a ${backend} server for the logic.`
|
|
1395
|
+
: `This site was generated automatically from a single request. It is fully self-contained: HTML for structure, CSS for the design, JavaScript for the interaction and a ${backend} server for the logic.`;
|
|
1396
|
+
const features = [
|
|
1397
|
+
{ t: 'About', d: `A clear, friendly introduction to ${subject} and everything it covers, laid out on one responsive page.` },
|
|
1398
|
+
{ t: 'Live', d: `The page never reloads - JavaScript talks to a ${backend} API for live stats and message handling.` },
|
|
1399
|
+
{ t: 'Responsive', d: `Fluid grids, a touch-friendly menu and accessible markup make ${subject} work on any screen size.` },
|
|
1400
|
+
]
|
|
1401
|
+
.map((f, i) => `<article class="card reveal"><span class="num">0${i + 1}</span><h3>${f.t}</h3><p>${f.d}</p></article>`)
|
|
1402
|
+
.join('\n ');
|
|
1403
|
+
|
|
1404
|
+
const heroSection = ` <section class="hero">
|
|
1405
|
+
<div class="container">
|
|
1406
|
+
<p class="hero-eyebrow">HTML · CSS · JavaScript · ${backend}</p>
|
|
1407
|
+
<h1>Welcome to ${title}</h1>
|
|
1408
|
+
<p class="hero-sub">${heroSub}</p>
|
|
1409
|
+
<div class="hero-actions">
|
|
1410
|
+
<a class="btn btn-primary" href="#about">Explore ${title}</a>
|
|
1411
|
+
<a class="btn btn-ghost" href="#contact">Get in touch</a>
|
|
1412
|
+
</div>
|
|
1413
|
+
</div>
|
|
1414
|
+
</section>`;
|
|
1415
|
+
|
|
1416
|
+
const aboutSection = ` <section id="about" class="section">
|
|
1417
|
+
<div class="container grid-2">
|
|
1418
|
+
<div>
|
|
1419
|
+
<h2>About ${subject}</h2>
|
|
1420
|
+
<p>${aboutPara}</p>
|
|
1421
|
+
<p>Open the folder and run the server below - the whole page, including live stats, the contact form and the gallery, works out of the box.</p>
|
|
1422
|
+
</div>
|
|
1423
|
+
<div class="card-grid">
|
|
1424
|
+
<div class="card reveal"><span class="num">01</span><h3>Structure</h3><p>Semantic HTML with clear sections: hero, about, features, gallery, stats and contact.</p></div>
|
|
1425
|
+
<div class="card reveal"><span class="num">02</span><h3>Design</h3><p>A modern dark theme with gradients, a glass header, cards, gradient tiles and smooth reveal animations.</p></div>
|
|
1426
|
+
<div class="card reveal"><span class="num">03</span><h3>Logic</h3><p>${backend} backend serves the files and exposes an API used by the JavaScript frontend.</p></div>
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
</section>`;
|
|
1430
|
+
|
|
1431
|
+
const featuresSection = ` <section id="features" class="section alt">
|
|
1432
|
+
<div class="container">
|
|
1433
|
+
<h2>Features</h2>
|
|
1434
|
+
<div class="card-grid">
|
|
1435
|
+
${features}
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
</section>`;
|
|
1439
|
+
|
|
1440
|
+
const gallerySection = ` <section id="gallery" class="section">
|
|
1441
|
+
<div class="container">
|
|
1442
|
+
<h2>Gallery</h2>
|
|
1443
|
+
<p class="muted">A splash of color - pure CSS gradients, no images to download.</p>
|
|
1444
|
+
<div class="gallery-grid">
|
|
1445
|
+
<div class="tile">Card 01</div>
|
|
1446
|
+
<div class="tile t2">Card 02</div>
|
|
1447
|
+
<div class="tile t3">Card 03</div>
|
|
1448
|
+
<div class="tile t4">Card 04</div>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1451
|
+
</section>`;
|
|
1452
|
+
|
|
1453
|
+
const statsSection = ` <section id="stats" class="section">
|
|
1454
|
+
<div class="container">
|
|
1455
|
+
<h2>Live stats</h2>
|
|
1456
|
+
<div class="stat-grid">
|
|
1457
|
+
<div class="stat-card"><strong id="stat-visits">-</strong><span>site visitors</span></div>
|
|
1458
|
+
<div class="stat-card"><strong id="stat-name">${title}</strong><span>current topic</span></div>
|
|
1459
|
+
<div class="stat-card"><strong>4</strong><span>generated files</span></div>
|
|
1460
|
+
</div>
|
|
1461
|
+
<p class="muted">Numbers are served live by the ${backend} backend at /api/info - one request per page load.</p>
|
|
1462
|
+
</div>
|
|
1463
|
+
</section>`;
|
|
1464
|
+
|
|
1465
|
+
const contactSection = ` <section id="contact" class="section alt">
|
|
1466
|
+
<div class="container form-wrap">
|
|
1467
|
+
<h2>Contact</h2>
|
|
1468
|
+
<p class="muted">Send a message - the ${backend} backend saves it to messages.json.</p>
|
|
1469
|
+
<form id="contact-form" novalidate>
|
|
1470
|
+
<label for="name">Your name</label>
|
|
1471
|
+
<input id="name" name="name" type="text" required>
|
|
1472
|
+
<label for="message">Message</label>
|
|
1473
|
+
<textarea id="message" name="message" rows="4" required></textarea>
|
|
1474
|
+
<button class="btn btn-primary" type="submit">Send message</button>
|
|
1475
|
+
<p class="form-status" id="form-status" role="status"></p>
|
|
1476
|
+
</form>
|
|
1477
|
+
</div>
|
|
1478
|
+
</section>`;
|
|
1479
|
+
|
|
1480
|
+
const body = [heroSection, has('about') ? aboutSection : '', has('features') ? featuresSection : '', has('gallery') ? gallerySection : '', has('stats') ? statsSection : '', has('contact') ? contactSection : ''].filter(Boolean);
|
|
1481
|
+
|
|
1482
|
+
return `<!doctype html>
|
|
1483
|
+
<html lang="en"${style && style !== 'dark' ? ' data-theme="light"' : ''}>
|
|
1484
|
+
<head>
|
|
1485
|
+
<meta charset="utf-8">
|
|
1486
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1487
|
+
<meta name="description" content="${title} - a designed website built with HTML, CSS, JavaScript and ${backend}.">
|
|
1488
|
+
<title>${title}</title>
|
|
1489
|
+
<link rel="stylesheet" href="style.css">
|
|
1490
|
+
</head>
|
|
1491
|
+
<body>
|
|
1492
|
+
<header class="site-header">
|
|
1493
|
+
<nav class="nav container">
|
|
1494
|
+
<a class="brand" href="#top">${title}</a>
|
|
1495
|
+
<button class="nav-toggle" type="button" aria-expanded="false" aria-label="Toggle navigation">Menu</button>
|
|
1496
|
+
<ul class="nav-links">
|
|
1497
|
+
${navItems}
|
|
1498
|
+
<li><button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle color theme">Theme</button></li>
|
|
1499
|
+
</ul>
|
|
1500
|
+
</nav>
|
|
1501
|
+
</header>
|
|
1502
|
+
|
|
1503
|
+
<main id="top">
|
|
1504
|
+
${body.join('\n\n')}
|
|
1505
|
+
</main>
|
|
1506
|
+
|
|
1507
|
+
<button id="back-to-top" type="button" aria-label="Back to top">Top</button>
|
|
1508
|
+
|
|
1509
|
+
<footer class="site-footer">
|
|
1510
|
+
<div class="container">
|
|
1511
|
+
<p><span id="year">2026</span> © ${title}. Crafted automatically by VierrataleAI.</p>
|
|
1512
|
+
</div>
|
|
1513
|
+
</footer>
|
|
1514
|
+
|
|
1515
|
+
<script src="script.js"></script>
|
|
1516
|
+
</body>
|
|
1517
|
+
</html>
|
|
1518
|
+
`;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function renderWebPlan(config, text, stack = webStack(text)) {
|
|
1522
|
+
const safeTitle = escapeHtml(config.name);
|
|
1523
|
+
const safeTopic = config.topic ? escapeHtml(titleCase(config.topic)) : '';
|
|
1524
|
+
const safeDesc = config.description ? escapeHtml(config.description) : '';
|
|
1525
|
+
const css = webCss(config);
|
|
1526
|
+
|
|
1527
|
+
if (stack.tsx) {
|
|
1528
|
+
return [
|
|
1529
|
+
{ name: 'create_directory', params: { path: config.folder } },
|
|
1530
|
+
...tsxUrl(config.folder, safeTitle, safeTopic || safeTitle, css),
|
|
1531
|
+
];
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const indexContent = webIndexHtml({ title: safeTitle, topic: safeTopic, description: safeDesc, stack, style: config.style, sections: config.sections });
|
|
1535
|
+
const plan = [
|
|
1536
|
+
{ name: 'create_directory', params: { path: config.folder } },
|
|
1537
|
+
{ name: 'create_file', params: { path: `${config.folder}/index.html`, content: indexContent } },
|
|
1538
|
+
{ name: 'create_file', params: { path: `${config.folder}/style.css`, content: css } },
|
|
1539
|
+
];
|
|
1540
|
+
if (stack.js) {
|
|
1541
|
+
plan.push({ name: 'create_file', params: { path: `${config.folder}/script.js`, content: WEB_JS } });
|
|
1542
|
+
}
|
|
1543
|
+
if (stack.node) {
|
|
1544
|
+
plan.push({ name: 'create_file', params: { path: `${config.folder}/package.json`, content: JSON.stringify({ name: config.folder, version: '1.0.0', type: 'module', main: 'server.js', scripts: { start: 'node server.js' } }, null, 2) + '\n' } });
|
|
1545
|
+
plan.push({ name: 'create_file', params: { path: `${config.folder}/server.js`, content: WEB_SERVER_JS } });
|
|
1546
|
+
} else {
|
|
1547
|
+
plan.push({ name: 'create_file', params: { path: `${config.folder}/app.py`, content: WEB_APP_PY } });
|
|
1548
|
+
}
|
|
1549
|
+
return plan;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function webSitePlan(text, folderName, config = null) {
|
|
1553
|
+
return renderWebPlan(config || probeWebConfig(text, folderName), text);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function webFolderPlan(text, folderName) {
|
|
1557
|
+
const makesWeb = /\b(buat|create|make|generate)\b[^.!?\n]{0,40}\b(web\s*(app|page|site)?|website|situs|webpage|halaman)\b/i.test(text) ||
|
|
1558
|
+
/\b(web\s*app|website|situs|webpage)\b[^.!?\n]{0,40}\b(pack|kemas|satukan|folder|dir|directory)\b/i.test(text);
|
|
1559
|
+
if (!makesWeb) return [];
|
|
1560
|
+
return webSitePlan(text, folderName);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// ---------------------------------------------------------------------------
|
|
1564
|
+
// Agent loop
|
|
1565
|
+
// ---------------------------------------------------------------------------
|
|
1566
|
+
|
|
1567
|
+
export async function planOnce(provider, messages, requestText, options = {}) {
|
|
1568
|
+
// Fast path: if the request matches a confident heuristic (folder/file/command),
|
|
1569
|
+
// use it directly and skip the slow model-plan round entirely.
|
|
1570
|
+
if (options.heuristicFirst !== false) {
|
|
1571
|
+
const fast = heuristicPlan(requestText);
|
|
1572
|
+
if (fast.length) {
|
|
1573
|
+
if (isWebSitePlan(fast)) {
|
|
1574
|
+
const cfg = await webDesignConfig(provider, messages, requestText);
|
|
1575
|
+
if (cfg) {
|
|
1576
|
+
const explicit = folderPlanName(requestText);
|
|
1577
|
+
if (explicit) cfg.folder = explicit;
|
|
1578
|
+
logger.log('PLAN', 'plan: web site (AI design config)');
|
|
1579
|
+
return webSitePlan(requestText, cfg.folder, cfg);
|
|
1580
|
+
}
|
|
1581
|
+
logger.log('PLAN', 'plan: web site (heuristic design)');
|
|
1582
|
+
} else {
|
|
1583
|
+
logger.log('PLAN', 'plan: heuristic (fast path)');
|
|
1584
|
+
}
|
|
1585
|
+
return fast;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const planTimeoutMs = options.planTimeoutMs ?? Config.get('planTimeoutMs');
|
|
1589
|
+
const history = (messages && messages.length ? messages.slice(-6) : [{ role: 'user', content: requestText }]);
|
|
1590
|
+
const controller = new AbortController();
|
|
1591
|
+
let text = '';
|
|
1592
|
+
let timer;
|
|
1593
|
+
try {
|
|
1594
|
+
text = await Promise.race([
|
|
1595
|
+
provider.complete([...history, { role: 'user', content: requestText }], {
|
|
1596
|
+
model: Config.getEffectiveModel(provider.name),
|
|
1597
|
+
system_prompt: TOOL_PLAN_PROMPT,
|
|
1598
|
+
maxTokens: 400,
|
|
1599
|
+
signal: controller.signal,
|
|
1600
|
+
}),
|
|
1601
|
+
new Promise((_resolve, reject) => {
|
|
1602
|
+
timer = setTimeout(() => {
|
|
1603
|
+
if (!controller.signal.aborted) controller.abort();
|
|
1604
|
+
reject(Object.assign(new Error(`model plan exceeded ${planTimeoutMs}ms`), { name: 'DeadlineError' }));
|
|
1605
|
+
}, planTimeoutMs);
|
|
1606
|
+
}),
|
|
1607
|
+
]);
|
|
1608
|
+
} catch (err) {
|
|
1609
|
+
logger.log('PLAN', `model plan aborted after ${planTimeoutMs}ms${err && err.message ? ` (${err.message})` : ''}`);
|
|
1610
|
+
} finally {
|
|
1611
|
+
clearTimeout(timer);
|
|
1612
|
+
}
|
|
1613
|
+
const plan = parseToolPlan(text);
|
|
1614
|
+
if (plan.length) return plan;
|
|
1615
|
+
const heuristic = heuristicPlan(requestText);
|
|
1616
|
+
if (heuristic.length) logger.log('PLAN', 'plan fallback: heuristic');
|
|
1617
|
+
return heuristic;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
export async function runWithTools({
|
|
1621
|
+
provider,
|
|
1622
|
+
messages,
|
|
1623
|
+
requestText,
|
|
1624
|
+
systemPrompt,
|
|
1625
|
+
chatUI,
|
|
1626
|
+
cwd,
|
|
1627
|
+
timeoutMs,
|
|
1628
|
+
heuristicFirst = true,
|
|
1629
|
+
} = {}) {
|
|
1630
|
+
const refinedFolder = webRefineRequest(requestText, messages);
|
|
1631
|
+
const isOp = !refinedFolder && looksLikeOperationRequest(requestText);
|
|
1632
|
+
if (!refinedFolder && !isOp) return false;
|
|
1633
|
+
|
|
1634
|
+
const executor = makeExecutor({ cwd, timeoutMs });
|
|
1635
|
+
const results = [];
|
|
1636
|
+
const plannedByModel = [];
|
|
1637
|
+
|
|
1638
|
+
for (let round = 0; round < MAX_PLANNING_ROUNDS; round++) {
|
|
1639
|
+
let plan;
|
|
1640
|
+
if (!isOp && round === 0) {
|
|
1641
|
+
plan = (await webRefine(provider, messages, requestText, cwd)) || [];
|
|
1642
|
+
} else {
|
|
1643
|
+
plan = await planOnce(provider, messages, requestText, { heuristicFirst });
|
|
1644
|
+
}
|
|
1645
|
+
if (!plan.length) break;
|
|
1646
|
+
plannedByModel.push(plan);
|
|
1647
|
+
|
|
1648
|
+
const steps = Math.min(plan.length, MAX_STEPS - results.length);
|
|
1649
|
+
let allOk = true;
|
|
1650
|
+
for (const call of plan.slice(0, steps)) {
|
|
1651
|
+
const label = call.name === 'run_command' ? call.params.command : `${call.name}(${call.params.path})`;
|
|
1652
|
+
if (chatUI) {
|
|
1653
|
+
chatUI.setStatus(`Running: ${label}`);
|
|
1654
|
+
chatUI.setThinking(true);
|
|
1655
|
+
chatUI.render(messages);
|
|
1656
|
+
}
|
|
1657
|
+
const result = await dispatchTool(executor, call);
|
|
1658
|
+
results.push({ name: call.name, params: call.params, result });
|
|
1659
|
+
logger.log('EXEC', `${label} -> ${result.exitCode}${result.timedOut ? ' [timeout]' : ''}`);
|
|
1660
|
+
if (result.success) {
|
|
1661
|
+
if (chatUI) chatUI.notify(`\u2713 ${label}`);
|
|
1662
|
+
if (result.stdout.trim()) logger.log('EXEC', `${label} stdout: ${result.stdout.trim().slice(0, 500)}`);
|
|
1663
|
+
if (result.stderr.trim()) logger.log('WARN', `${label} stderr: ${result.stderr.trim().slice(0, 500)}`);
|
|
1664
|
+
if (call.name === 'create_file' && /\.py$/i.test(call.params.path)) {
|
|
1665
|
+
const check = spawnSync('python3', [
|
|
1666
|
+
'-c', 'import ast,sys; ast.parse(open(sys.argv[1], encoding="utf-8").read())',
|
|
1667
|
+
join(cwd || '.', call.params.path),
|
|
1668
|
+
], { encoding: 'utf-8', timeout: 30000 });
|
|
1669
|
+
if (check.status === 1) {
|
|
1670
|
+
const detail = (check.stderr || '').trim().split('\n').slice(-2).join(' ');
|
|
1671
|
+
if (chatUI) chatUI.notify(`\u2717 python check failed: ${call.params.path} (${detail})`);
|
|
1672
|
+
logger.log('WARN', `${label} failed python syntax check: ${detail}`);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
} else {
|
|
1676
|
+
allOk = false;
|
|
1677
|
+
if (chatUI) chatUI.notify(`\u2717 ${label}: ${(result.stderr || 'failed').trim().slice(0, 300)}`);
|
|
1678
|
+
logger.log('ERROR', `${label} failed: ${(result.stderr || result.stdout || 'exit ' + result.exitCode).trim().slice(0, 500)}`);
|
|
1679
|
+
}
|
|
1680
|
+
if (result.timedOut && !result.success) break;
|
|
1681
|
+
}
|
|
1682
|
+
if (allOk || !results.length || round >= MAX_PLANNING_ROUNDS - 1) break;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
if (results.length) {
|
|
1686
|
+
if (chatUI) {
|
|
1687
|
+
chatUI.setStatus('');
|
|
1688
|
+
chatUI.setThinking(true);
|
|
1689
|
+
chatUI.render(messages);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return { results, plannedByModel };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
export { TOOL_PLAN_PROMPT, TOOL_RESULTS_PROMPT, lastBuiltWebFolder, webRefineRequest, parseWebConfigFromFolder, applyWebRefineHints };
|