@nuraly/lumenjs 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -7
- package/dist/auth/native-auth.d.ts +9 -0
- package/dist/auth/native-auth.js +49 -2
- package/dist/auth/routes/login.js +24 -1
- package/dist/auth/routes/totp.d.ts +22 -0
- package/dist/auth/routes/totp.js +232 -0
- package/dist/auth/routes.js +14 -0
- package/dist/auth/token.js +2 -2
- package/dist/build/build-markdown.d.ts +15 -0
- package/dist/build/build-markdown.js +90 -0
- package/dist/build/build-server.d.ts +2 -1
- package/dist/build/build-server.js +12 -4
- package/dist/build/build.js +46 -5
- package/dist/build/scan.d.ts +1 -0
- package/dist/build/scan.js +2 -1
- package/dist/build/serve-static.js +2 -1
- package/dist/build/serve.js +131 -11
- package/dist/dev-server/config.js +18 -1
- package/dist/dev-server/index-html.d.ts +1 -0
- package/dist/dev-server/index-html.js +4 -1
- package/dist/dev-server/plugins/vite-plugin-llms.js +1 -0
- package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +4 -3
- package/dist/dev-server/plugins/vite-plugin-loaders.js +4 -3
- package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
- package/dist/dev-server/server.js +146 -88
- package/dist/dev-server/ssr-render.js +10 -2
- package/dist/editor/ai/backend.js +11 -2
- package/dist/editor/ai/deepseek-client.d.ts +7 -0
- package/dist/editor/ai/deepseek-client.js +113 -0
- package/dist/editor/ai/opencode-client.d.ts +1 -1
- package/dist/editor/ai/opencode-client.js +21 -47
- package/dist/editor/ai/types.d.ts +1 -1
- package/dist/editor/ai/types.js +2 -2
- package/dist/editor/ai-chat-panel.js +27 -1
- package/dist/editor/editor-bridge.js +2 -1
- package/dist/editor/overlay-hmr.js +2 -1
- package/dist/llms/generate.d.ts +15 -1
- package/dist/llms/generate.js +54 -44
- package/dist/runtime/app-shell.d.ts +1 -1
- package/dist/runtime/app-shell.js +1 -0
- package/dist/runtime/communication.d.ts +65 -36
- package/dist/runtime/communication.js +117 -57
- package/dist/runtime/island.d.ts +16 -0
- package/dist/runtime/island.js +80 -0
- package/dist/runtime/router-hydration.js +9 -2
- package/dist/runtime/router.d.ts +3 -1
- package/dist/runtime/router.js +51 -3
- package/dist/runtime/webrtc.d.ts +44 -0
- package/dist/runtime/webrtc.js +263 -13
- package/dist/shared/dom-shims.js +4 -2
- package/dist/shared/html-to-markdown.d.ts +6 -0
- package/dist/shared/html-to-markdown.js +73 -0
- package/dist/shared/types.d.ts +1 -0
- package/dist/storage/adapters/s3.js +6 -3
- package/package.json +33 -7
- package/templates/blog/pages/index.ts +3 -3
- package/templates/blog/pages/posts/[slug].ts +17 -6
- package/templates/blog/pages/tag/[tag].ts +6 -6
- package/templates/dashboard/pages/index.ts +7 -7
- package/templates/default/pages/index.ts +3 -3
- package/templates/social/api/posts/[id].ts +0 -14
- package/templates/social/api/posts.ts +0 -11
- package/templates/social/api/profile/[username].ts +0 -10
- package/templates/social/api/upload.ts +0 -19
- package/templates/social/data/migrations/001_init.sql +0 -78
- package/templates/social/data/migrations/002_add_image_url.sql +0 -1
- package/templates/social/data/migrations/003_auth.sql +0 -7
- package/templates/social/docs/architecture.md +0 -76
- package/templates/social/docs/components.md +0 -100
- package/templates/social/docs/data.md +0 -89
- package/templates/social/docs/pages.md +0 -96
- package/templates/social/docs/theming.md +0 -52
- package/templates/social/lib/media.ts +0 -130
- package/templates/social/lumenjs.auth.ts +0 -21
- package/templates/social/lumenjs.config.ts +0 -3
- package/templates/social/package.json +0 -5
- package/templates/social/pages/_layout.ts +0 -239
- package/templates/social/pages/apps/[id].ts +0 -173
- package/templates/social/pages/apps/index.ts +0 -116
- package/templates/social/pages/auth/login.ts +0 -92
- package/templates/social/pages/bookmarks.ts +0 -57
- package/templates/social/pages/explore.ts +0 -73
- package/templates/social/pages/index.ts +0 -351
- package/templates/social/pages/messages.ts +0 -298
- package/templates/social/pages/new.ts +0 -77
- package/templates/social/pages/notifications.ts +0 -73
- package/templates/social/pages/post/[id].ts +0 -124
- package/templates/social/pages/profile/[username].ts +0 -100
- package/templates/social/pages/settings/accessibility.ts +0 -153
- package/templates/social/pages/settings/account.ts +0 -260
- package/templates/social/pages/settings/help.ts +0 -141
- package/templates/social/pages/settings/language.ts +0 -103
- package/templates/social/pages/settings/privacy.ts +0 -183
- package/templates/social/pages/settings/security.ts +0 -133
- package/templates/social/pages/settings.ts +0 -185
|
@@ -117,8 +117,29 @@ export async function createDevServer(options) {
|
|
|
117
117
|
setProjectDir(projectDir);
|
|
118
118
|
process.env.LUMENJS_PROJECT_DIR = projectDir;
|
|
119
119
|
const shared = getSharedViteConfig(projectDir, { integrations });
|
|
120
|
+
// Load user-defined Vite plugins from lumenjs.plugins.js (if present).
|
|
121
|
+
// This allows apps to add custom Vite plugins (e.g. proxy middleware)
|
|
122
|
+
// that run at the raw Connect level, before LumenJS's own middleware.
|
|
123
|
+
let userPlugins = [];
|
|
124
|
+
const pluginsPath = path.join(projectDir, 'lumenjs.plugins.js');
|
|
125
|
+
if (fs.existsSync(pluginsPath)) {
|
|
126
|
+
try {
|
|
127
|
+
// Use a temporary Vite server to load the TS file via ssrLoadModule
|
|
128
|
+
// would be circular, so we import it directly via dynamic import
|
|
129
|
+
// after Vite transforms it. Instead, read and eval the JS-compatible parts.
|
|
130
|
+
// Simplest: the file exports an array of plugin objects.
|
|
131
|
+
const pluginsMod = await import(pathToFileURL(pluginsPath).href);
|
|
132
|
+
const exported = pluginsMod.default || pluginsMod.plugins || pluginsMod;
|
|
133
|
+
if (Array.isArray(exported))
|
|
134
|
+
userPlugins = exported;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
console.warn(`[LumenJS] Failed to load lumenjs.plugins.js:`, err?.message);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
120
140
|
const server = await createViteServer({
|
|
121
141
|
root: projectDir,
|
|
142
|
+
base,
|
|
122
143
|
publicDir: fs.existsSync(publicDir) ? publicDir : undefined,
|
|
123
144
|
server: {
|
|
124
145
|
port,
|
|
@@ -126,13 +147,22 @@ export async function createDevServer(options) {
|
|
|
126
147
|
strictPort: false,
|
|
127
148
|
allowedHosts: true,
|
|
128
149
|
cors: true,
|
|
129
|
-
hmr: process.env.HMR_CLIENT_PORT ? {
|
|
150
|
+
hmr: process.env.HMR_CLIENT_PORT ? {
|
|
151
|
+
clientPort: parseInt(process.env.HMR_CLIENT_PORT),
|
|
152
|
+
port: parseInt(process.env.HMR_CLIENT_PORT),
|
|
153
|
+
...(process.env.HMR_PROTOCOL ? { protocol: process.env.HMR_PROTOCOL } : {}),
|
|
154
|
+
...(process.env.HMR_HOST ? { host: process.env.HMR_HOST } : {}),
|
|
155
|
+
} : true,
|
|
130
156
|
fs: {
|
|
131
157
|
allow: [projectDir, getLumenJSNodeModules(), path.resolve(getLumenJSNodeModules(), '..')],
|
|
132
158
|
},
|
|
133
159
|
},
|
|
134
160
|
resolve: shared.resolve,
|
|
161
|
+
// 'custom' prevents Vite from adding SPA fallback and indexHtml middleware,
|
|
162
|
+
// which would interfere with LumenJS's own HTML handler (especially when base != '/')
|
|
163
|
+
appType: 'custom',
|
|
135
164
|
plugins: [
|
|
165
|
+
...userPlugins,
|
|
136
166
|
...(integrations.includes('auth') ? [authPlugin(projectDir)] : []),
|
|
137
167
|
...shared.plugins,
|
|
138
168
|
...(integrations.includes('communication') ? [communicationPlugin(projectDir)] : []),
|
|
@@ -142,6 +172,29 @@ export async function createDevServer(options) {
|
|
|
142
172
|
...(i18nConfig ? [i18nPlugin(projectDir, i18nConfig)] : []),
|
|
143
173
|
...(editorMode ? [sourceAnnotatorPlugin(projectDir), editorApiPlugin(projectDir)] : []),
|
|
144
174
|
lumenSocketIOPlugin(pagesDir),
|
|
175
|
+
...(base !== '/' ? [{
|
|
176
|
+
// Fix HMR fetch URLs when Vite runs behind a base path.
|
|
177
|
+
// Vite's import analysis injects createHotContext() with the full filesystem
|
|
178
|
+
// path (e.g. /data/user-apps/.../bb2/pages/index.ts) instead of root-relative.
|
|
179
|
+
// When @vite/client fetches the updated module it builds the URL as
|
|
180
|
+
// base + filesystemPath.slice(1) → /__app_dev/{id}/data/.../pages/index.ts
|
|
181
|
+
// which 404s because transformMiddleware resolves relative to root, doubling
|
|
182
|
+
// the path. This pre-hook middleware strips the projectDir prefix so
|
|
183
|
+
// baseMiddleware sees the correct root-relative path.
|
|
184
|
+
name: 'lumenjs-hmr-path-fix',
|
|
185
|
+
configureServer(server) {
|
|
186
|
+
const projSlash = projectDir.replace(/\\/g, '/');
|
|
187
|
+
server.middlewares.use((req, _res, next) => {
|
|
188
|
+
if (req.url) {
|
|
189
|
+
const prefix = base + projSlash.slice(1) + '/';
|
|
190
|
+
if (req.url.startsWith(prefix)) {
|
|
191
|
+
req.url = base + req.url.slice(prefix.length);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
next();
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
}] : []),
|
|
145
198
|
{
|
|
146
199
|
// Clear SSR module cache on file changes so the next SSR request uses fresh code.
|
|
147
200
|
// Without this, HMR updates the client but SSR keeps serving stale modules.
|
|
@@ -183,103 +236,108 @@ export async function createDevServer(options) {
|
|
|
183
236
|
}
|
|
184
237
|
},
|
|
185
238
|
configureServer(server) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const middlewareEntries = scanMiddleware(pagesDir);
|
|
192
|
-
if (middlewareEntries.length === 0)
|
|
193
|
-
return next();
|
|
194
|
-
const matchingDirs = getMiddlewareDirsForPathname(pathname, middlewareEntries);
|
|
195
|
-
if (matchingDirs.length === 0)
|
|
196
|
-
return next();
|
|
197
|
-
const allMw = [];
|
|
198
|
-
for (const entry of matchingDirs) {
|
|
199
|
-
try {
|
|
200
|
-
const mod = await server.ssrLoadModule(entry.filePath);
|
|
201
|
-
allMw.push(...extractMiddleware(mod));
|
|
239
|
+
return () => {
|
|
240
|
+
server.middlewares.use(async (req, res, next) => {
|
|
241
|
+
const pathname = (req.url || '/').split('?')[0];
|
|
242
|
+
if (pathname.startsWith('/@') || pathname.startsWith('/node_modules') || pathname.includes('.')) {
|
|
243
|
+
return next();
|
|
202
244
|
}
|
|
203
|
-
|
|
204
|
-
|
|
245
|
+
const middlewareEntries = scanMiddleware(pagesDir);
|
|
246
|
+
if (middlewareEntries.length === 0)
|
|
247
|
+
return next();
|
|
248
|
+
const matchingDirs = getMiddlewareDirsForPathname(pathname, middlewareEntries);
|
|
249
|
+
if (matchingDirs.length === 0)
|
|
250
|
+
return next();
|
|
251
|
+
const allMw = [];
|
|
252
|
+
for (const entry of matchingDirs) {
|
|
253
|
+
try {
|
|
254
|
+
const mod = await server.ssrLoadModule(entry.filePath);
|
|
255
|
+
allMw.push(...extractMiddleware(mod));
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
console.error(`[LumenJS] Failed to load _middleware.ts (${entry.dir || 'root'}):`, err);
|
|
259
|
+
}
|
|
205
260
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
261
|
+
if (allMw.length === 0)
|
|
262
|
+
return next();
|
|
263
|
+
runMiddlewareChain(allMw, req, res, next);
|
|
264
|
+
});
|
|
265
|
+
};
|
|
211
266
|
}
|
|
212
267
|
},
|
|
213
268
|
{
|
|
214
269
|
name: 'lumenjs-index-html',
|
|
215
270
|
configureServer(server) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
if (req.url && !req.url.startsWith('/@') && !req.url.startsWith('/node_modules') &&
|
|
229
|
-
!req.url.startsWith('/api/') && !req.url.startsWith('/__nk_loader/') &&
|
|
230
|
-
!req.url.startsWith('/__nk_i18n/') &&
|
|
231
|
-
!req.url.includes('.') && req.method === 'GET') {
|
|
232
|
-
let pathname = req.url.split('?')[0];
|
|
233
|
-
// Resolve locale from URL/cookie/header
|
|
234
|
-
let locale;
|
|
235
|
-
let translations;
|
|
236
|
-
if (i18nConfig) {
|
|
237
|
-
const localeResult = resolveLocale(pathname, i18nConfig, req.headers);
|
|
238
|
-
locale = localeResult.locale;
|
|
239
|
-
pathname = localeResult.pathname;
|
|
240
|
-
translations = loadTranslationsFromDisk(projectDir, locale);
|
|
241
|
-
}
|
|
242
|
-
const SSR_PLACEHOLDER = '<!--__NK_SSR_CONTENT__-->';
|
|
243
|
-
ssrRenderPage(server, pagesDir, pathname, req.headers, locale, req.nkAuth?.user ?? undefined).then(async (ssrResult) => {
|
|
244
|
-
if (ssrResult?.redirect) {
|
|
245
|
-
res.writeHead(ssrResult.redirect.status, { Location: ssrResult.redirect.location });
|
|
246
|
-
res.end();
|
|
271
|
+
return () => {
|
|
272
|
+
server.middlewares.use((req, res, next) => {
|
|
273
|
+
// Guard against malformed percent-encoded URLs that crash Vite's transformIndexHtml
|
|
274
|
+
if (req.url) {
|
|
275
|
+
try {
|
|
276
|
+
decodeURIComponent(req.url);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
res.statusCode = 400;
|
|
280
|
+
res.end('Bad Request');
|
|
247
281
|
return;
|
|
248
282
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
283
|
+
}
|
|
284
|
+
if (req.url && !req.url.startsWith('/@') && !req.url.startsWith('/node_modules') &&
|
|
285
|
+
!req.url.startsWith('/api/') && !req.url.startsWith('/__nk_loader/') &&
|
|
286
|
+
!req.url.startsWith('/__nk_i18n/') &&
|
|
287
|
+
!req.url.includes('.') && req.method === 'GET') {
|
|
288
|
+
let pathname = req.url.split('?')[0];
|
|
289
|
+
// Resolve locale from URL/cookie/header
|
|
290
|
+
let locale;
|
|
291
|
+
let translations;
|
|
292
|
+
if (i18nConfig) {
|
|
293
|
+
const localeResult = resolveLocale(pathname, i18nConfig, req.headers);
|
|
294
|
+
locale = localeResult.locale;
|
|
295
|
+
pathname = localeResult.pathname;
|
|
296
|
+
translations = loadTranslationsFromDisk(projectDir, locale);
|
|
297
|
+
}
|
|
298
|
+
const SSR_PLACEHOLDER = '<!--__NK_SSR_CONTENT__-->';
|
|
299
|
+
ssrRenderPage(server, pagesDir, pathname, req.headers, locale, req.nkAuth?.user ?? undefined).then(async (ssrResult) => {
|
|
300
|
+
if (ssrResult?.redirect) {
|
|
301
|
+
res.writeHead(ssrResult.redirect.status, { Location: ssrResult.redirect.location });
|
|
302
|
+
res.end();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const shellHtml = generateIndexHtml({
|
|
306
|
+
title,
|
|
307
|
+
editorMode,
|
|
308
|
+
ssrContent: ssrResult ? SSR_PLACEHOLDER : undefined,
|
|
309
|
+
loaderData: ssrResult?.loaderData,
|
|
310
|
+
layoutsData: ssrResult?.layoutsData,
|
|
311
|
+
integrations,
|
|
312
|
+
locale,
|
|
313
|
+
i18nConfig: i18nConfig || undefined,
|
|
314
|
+
translations,
|
|
315
|
+
prefetch: prefetchStrategy,
|
|
316
|
+
authUser: ssrResult?.authUser ?? req.nkAuth?.user ?? undefined,
|
|
317
|
+
headContent,
|
|
318
|
+
base,
|
|
319
|
+
});
|
|
320
|
+
const transformed = await server.transformIndexHtml(req.url, shellHtml);
|
|
321
|
+
const finalHtml = ssrResult
|
|
322
|
+
? transformed.replace(SSR_PLACEHOLDER, ssrResult.html)
|
|
323
|
+
: transformed;
|
|
274
324
|
res.setHeader('Content-Type', 'text/html');
|
|
275
325
|
res.setHeader('Cache-Control', 'no-store');
|
|
276
|
-
res.end(
|
|
277
|
-
}).catch(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
326
|
+
res.end(finalHtml);
|
|
327
|
+
}).catch(err => {
|
|
328
|
+
console.error('[LumenJS] SSR/HTML generation error:', err);
|
|
329
|
+
const html = generateIndexHtml({ title, editorMode, integrations, locale, i18nConfig: i18nConfig || undefined, translations, prefetch: prefetchStrategy, headContent, base });
|
|
330
|
+
server.transformIndexHtml(req.url, html).then(transformed => {
|
|
331
|
+
res.setHeader('Content-Type', 'text/html');
|
|
332
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
333
|
+
res.end(transformed);
|
|
334
|
+
}).catch(next);
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
next();
|
|
339
|
+
});
|
|
340
|
+
};
|
|
283
341
|
}
|
|
284
342
|
}
|
|
285
343
|
],
|
|
@@ -299,7 +357,7 @@ export async function createDevServer(options) {
|
|
|
299
357
|
},
|
|
300
358
|
ssr: {
|
|
301
359
|
noExternal: true,
|
|
302
|
-
external: ['node-domexception', 'socket.io-client', 'xmlhttprequest-ssl', 'engine.io-client', 'better-sqlite3', '@lumenjs/db', '@lumenjs/permissions'],
|
|
360
|
+
external: ['node-domexception', 'socket.io-client', 'xmlhttprequest-ssl', 'engine.io-client', 'better-sqlite3', '@lumenjs/db', '@lumenjs/permissions', 'amqplib'],
|
|
303
361
|
resolve: {
|
|
304
362
|
conditions: ['node', 'import'],
|
|
305
363
|
},
|
|
@@ -29,12 +29,19 @@ export async function ssrRenderPage(server, pagesDir, pathname, headers, locale,
|
|
|
29
29
|
const g = globalThis;
|
|
30
30
|
invalidateSsrModule(server, filePath);
|
|
31
31
|
clearSsrCustomElement(g);
|
|
32
|
+
// Use root-relative paths for ssrLoadModule so the module graph entry
|
|
33
|
+
// gets url='/pages/index.ts' instead of the filesystem path. Vite's
|
|
34
|
+
// idToModuleMap is shared between SSR and client — if SSR creates the
|
|
35
|
+
// entry first with a filesystem-path URL, the client inherits it and
|
|
36
|
+
// HMR breaks (createHotContext gets the wrong path).
|
|
37
|
+
const projectRoot = path.resolve(pagesDir, '..');
|
|
38
|
+
const pageModuleUrl = '/' + path.relative(projectRoot, filePath).replace(/\\/g, '/');
|
|
32
39
|
// Load the page module via Vite (registers the custom element, applies transforms)
|
|
33
40
|
// Bypass get() so auto-define re-registers fresh classes
|
|
34
41
|
const registry = g.customElements;
|
|
35
42
|
if (registry)
|
|
36
43
|
registry.__nk_bypass_get = true;
|
|
37
|
-
const mod = await server.ssrLoadModule(
|
|
44
|
+
const mod = await server.ssrLoadModule(pageModuleUrl);
|
|
38
45
|
if (registry)
|
|
39
46
|
registry.__nk_bypass_get = false;
|
|
40
47
|
// Run loader if present
|
|
@@ -57,9 +64,10 @@ export async function ssrRenderPage(server, pagesDir, pathname, headers, locale,
|
|
|
57
64
|
// Invalidate layout module cache and clear SSR element registry
|
|
58
65
|
invalidateSsrModule(server, layout.filePath);
|
|
59
66
|
clearSsrCustomElement(g);
|
|
67
|
+
const layoutModuleUrl = '/' + path.relative(projectRoot, layout.filePath).replace(/\\/g, '/');
|
|
60
68
|
if (registry)
|
|
61
69
|
registry.__nk_bypass_get = true;
|
|
62
|
-
const layoutMod = await server.ssrLoadModule(
|
|
70
|
+
const layoutMod = await server.ssrLoadModule(layoutModuleUrl);
|
|
63
71
|
if (registry)
|
|
64
72
|
registry.__nk_bypass_get = false;
|
|
65
73
|
let layoutLoaderData = undefined;
|
|
@@ -8,12 +8,18 @@ async function detectBackend() {
|
|
|
8
8
|
if (resolvedBackend)
|
|
9
9
|
return resolvedBackend;
|
|
10
10
|
const explicit = process.env.AI_BACKEND;
|
|
11
|
-
if (explicit === 'claude-code' || explicit === 'opencode') {
|
|
11
|
+
if (explicit === 'claude-code' || explicit === 'opencode' || explicit === 'deepseek') {
|
|
12
12
|
resolvedBackend = explicit;
|
|
13
13
|
console.log(`[LumenJS] AI backend: ${explicit} (from AI_BACKEND env)`);
|
|
14
14
|
return resolvedBackend;
|
|
15
15
|
}
|
|
16
|
-
// Auto-detect: try
|
|
16
|
+
// Auto-detect: try DeepSeek if API key is set
|
|
17
|
+
if (process.env.DEEPSEEK_API_KEY) {
|
|
18
|
+
resolvedBackend = 'deepseek';
|
|
19
|
+
console.log('[LumenJS] AI backend: deepseek (auto-detected from DEEPSEEK_API_KEY)');
|
|
20
|
+
return resolvedBackend;
|
|
21
|
+
}
|
|
22
|
+
// Auto-detect: try Claude Code (subscription-based, no server needed)
|
|
17
23
|
try {
|
|
18
24
|
const cc = await import('./claude-code-client.js');
|
|
19
25
|
const status = await cc.checkAiStatus();
|
|
@@ -36,6 +42,9 @@ async function getClient() {
|
|
|
36
42
|
if (backend === 'claude-code') {
|
|
37
43
|
return import('./claude-code-client.js');
|
|
38
44
|
}
|
|
45
|
+
if (backend === 'deepseek') {
|
|
46
|
+
return import('./deepseek-client.js');
|
|
47
|
+
}
|
|
39
48
|
return import('./opencode-client.js');
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek AI client — uses the OpenAI-compatible chat completions API with SSE streaming.
|
|
3
|
+
* Configure via DEEPSEEK_API_KEY env var. Optionally set DEEPSEEK_BASE_URL and DEEPSEEK_MODEL.
|
|
4
|
+
*/
|
|
5
|
+
import type { AiChatOptions, AiChatResult, AiStatusResult } from './types.js';
|
|
6
|
+
export declare function streamAiChat(projectDir: string, options: AiChatOptions): AiChatResult;
|
|
7
|
+
export declare function checkAiStatus(): Promise<AiStatusResult>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek AI client — uses the OpenAI-compatible chat completions API with SSE streaming.
|
|
3
|
+
* Configure via DEEPSEEK_API_KEY env var. Optionally set DEEPSEEK_BASE_URL and DEEPSEEK_MODEL.
|
|
4
|
+
*/
|
|
5
|
+
import { SYSTEM_PROMPT, buildPrompt } from './types.js';
|
|
6
|
+
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
|
|
7
|
+
const DEEPSEEK_BASE_URL = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com';
|
|
8
|
+
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
|
|
9
|
+
// Simple per-session message history
|
|
10
|
+
const sessions = new Map();
|
|
11
|
+
export function streamAiChat(projectDir, options) {
|
|
12
|
+
const tokenCallbacks = [];
|
|
13
|
+
const doneCallbacks = [];
|
|
14
|
+
const errorCallbacks = [];
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
let sessionId = options.sessionId || `ds-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
17
|
+
const enrichedPrompt = buildPrompt(options);
|
|
18
|
+
const run = async () => {
|
|
19
|
+
try {
|
|
20
|
+
// Build message history
|
|
21
|
+
if (!sessions.has(sessionId)) {
|
|
22
|
+
sessions.set(sessionId, [{ role: 'system', content: SYSTEM_PROMPT }]);
|
|
23
|
+
}
|
|
24
|
+
const messages = sessions.get(sessionId);
|
|
25
|
+
messages.push({ role: 'user', content: enrichedPrompt });
|
|
26
|
+
const res = await fetch(`${DEEPSEEK_BASE_URL}/v1/chat/completions`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: {
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`,
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
model: DEEPSEEK_MODEL,
|
|
34
|
+
messages,
|
|
35
|
+
stream: true,
|
|
36
|
+
}),
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const errText = await res.text().catch(() => '');
|
|
41
|
+
throw new Error(`DeepSeek API error: ${res.status} ${errText}`);
|
|
42
|
+
}
|
|
43
|
+
// Parse SSE stream
|
|
44
|
+
const reader = res.body?.getReader();
|
|
45
|
+
if (!reader)
|
|
46
|
+
throw new Error('No response body');
|
|
47
|
+
const decoder = new TextDecoder();
|
|
48
|
+
let fullText = '';
|
|
49
|
+
let buffer = '';
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done)
|
|
53
|
+
break;
|
|
54
|
+
buffer += decoder.decode(value, { stream: true });
|
|
55
|
+
const lines = buffer.split('\n');
|
|
56
|
+
buffer = lines.pop() || '';
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed || !trimmed.startsWith('data:'))
|
|
60
|
+
continue;
|
|
61
|
+
const data = trimmed.slice(5).trim();
|
|
62
|
+
if (data === '[DONE]')
|
|
63
|
+
continue;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(data);
|
|
66
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
67
|
+
if (delta) {
|
|
68
|
+
fullText += delta;
|
|
69
|
+
for (const cb of tokenCallbacks)
|
|
70
|
+
cb(delta);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Skip malformed SSE lines
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Save assistant response to history
|
|
79
|
+
messages.push({ role: 'assistant', content: fullText });
|
|
80
|
+
for (const cb of doneCallbacks)
|
|
81
|
+
cb(fullText);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err?.name === 'AbortError')
|
|
85
|
+
return;
|
|
86
|
+
for (const cb of errorCallbacks)
|
|
87
|
+
cb(err instanceof Error ? err : new Error(String(err)));
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
run();
|
|
91
|
+
return {
|
|
92
|
+
get sessionId() { return sessionId; },
|
|
93
|
+
onToken: (cb) => { tokenCallbacks.push(cb); },
|
|
94
|
+
onDone: (cb) => { doneCallbacks.push(cb); },
|
|
95
|
+
onError: (cb) => { errorCallbacks.push(cb); },
|
|
96
|
+
abort: () => controller.abort(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export async function checkAiStatus() {
|
|
100
|
+
if (!DEEPSEEK_API_KEY) {
|
|
101
|
+
return { configured: false, backend: 'opencode' };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${DEEPSEEK_BASE_URL}/v1/models`, {
|
|
105
|
+
headers: { 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` },
|
|
106
|
+
signal: AbortSignal.timeout(5000),
|
|
107
|
+
});
|
|
108
|
+
return { configured: res.ok, backend: 'opencode' };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { configured: false, backend: 'opencode' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { AiChatOptions, AiChatResult, AiStatusResult } from './types.js';
|
|
6
6
|
/**
|
|
7
7
|
* Stream an AI chat message via OpenCode's REST API.
|
|
8
|
-
*
|
|
8
|
+
* Creates a session, sends message, and parses the response.
|
|
9
9
|
*/
|
|
10
10
|
export declare function streamAiChat(projectDir: string, options: AiChatOptions): AiChatResult;
|
|
11
11
|
/**
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* OpenCode AI client — wraps the OpenCode REST API for AI coding agent integration.
|
|
3
3
|
* Connects to an OpenCode server (`opencode serve`) that handles LLM calls and file editing.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
const OPENCODE_URL = process.env.OPENCODE_URL || 'http://localhost:
|
|
5
|
+
import { buildPrompt } from './types.js';
|
|
6
|
+
const OPENCODE_URL = process.env.OPENCODE_URL || 'http://localhost:4096';
|
|
7
7
|
const OPENCODE_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || '';
|
|
8
8
|
function buildHeaders() {
|
|
9
9
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -14,7 +14,7 @@ function buildHeaders() {
|
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* Stream an AI chat message via OpenCode's REST API.
|
|
17
|
-
*
|
|
17
|
+
* Creates a session, sends message, and parses the response.
|
|
18
18
|
*/
|
|
19
19
|
export function streamAiChat(projectDir, options) {
|
|
20
20
|
const tokenCallbacks = [];
|
|
@@ -27,10 +27,10 @@ export function streamAiChat(projectDir, options) {
|
|
|
27
27
|
try {
|
|
28
28
|
// Create a new session if we don't have one
|
|
29
29
|
if (!sessionId) {
|
|
30
|
-
const createRes = await fetch(`${OPENCODE_URL}/
|
|
30
|
+
const createRes = await fetch(`${OPENCODE_URL}/session`, {
|
|
31
31
|
method: 'POST',
|
|
32
32
|
headers: buildHeaders(),
|
|
33
|
-
body: JSON.stringify({ path: projectDir
|
|
33
|
+
body: JSON.stringify({ path: projectDir }),
|
|
34
34
|
signal: controller.signal,
|
|
35
35
|
});
|
|
36
36
|
if (!createRes.ok) {
|
|
@@ -39,53 +39,27 @@ export function streamAiChat(projectDir, options) {
|
|
|
39
39
|
const sessionData = await createRes.json();
|
|
40
40
|
sessionId = sessionData.id || sessionData.sessionId || '';
|
|
41
41
|
}
|
|
42
|
-
// Send message
|
|
43
|
-
const msgRes = await fetch(`${OPENCODE_URL}/
|
|
42
|
+
// Send message — OpenCode returns JSON with parts array
|
|
43
|
+
const msgRes = await fetch(`${OPENCODE_URL}/session/${sessionId}/message`, {
|
|
44
44
|
method: 'POST',
|
|
45
|
-
headers:
|
|
46
|
-
body: JSON.stringify({
|
|
45
|
+
headers: buildHeaders(),
|
|
46
|
+
body: JSON.stringify({ parts: [{ type: 'text', text: enrichedPrompt }] }),
|
|
47
47
|
signal: controller.signal,
|
|
48
48
|
});
|
|
49
49
|
if (!msgRes.ok) {
|
|
50
|
-
|
|
50
|
+
const errText = await msgRes.text().catch(() => '');
|
|
51
|
+
throw new Error(`OpenCode message failed: ${msgRes.status} ${errText}`);
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
throw new Error('No response body from OpenCode');
|
|
55
|
-
const decoder = new TextDecoder();
|
|
53
|
+
// OpenCode returns JSON response with parts array
|
|
54
|
+
const data = await msgRes.json();
|
|
56
55
|
let fullText = '';
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
buffer = lines.pop() || '';
|
|
65
|
-
for (const line of lines) {
|
|
66
|
-
if (line.startsWith('data: ')) {
|
|
67
|
-
const data = line.slice(6).trim();
|
|
68
|
-
if (data === '[DONE]')
|
|
69
|
-
continue;
|
|
70
|
-
try {
|
|
71
|
-
const parsed = JSON.parse(data);
|
|
72
|
-
// Handle different event formats from OpenCode
|
|
73
|
-
const text = parsed.content || parsed.text || parsed.delta || '';
|
|
74
|
-
if (text) {
|
|
75
|
-
fullText += text;
|
|
76
|
-
for (const cb of tokenCallbacks)
|
|
77
|
-
cb(text);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
// Non-JSON data line, treat as raw text
|
|
82
|
-
if (data) {
|
|
83
|
-
fullText += data;
|
|
84
|
-
for (const cb of tokenCallbacks)
|
|
85
|
-
cb(data);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
56
|
+
// Extract text from response parts
|
|
57
|
+
const parts = data.parts || [];
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
if (part.type === 'text' && part.text) {
|
|
60
|
+
fullText += part.text;
|
|
61
|
+
for (const cb of tokenCallbacks)
|
|
62
|
+
cb(part.text);
|
|
89
63
|
}
|
|
90
64
|
}
|
|
91
65
|
for (const cb of doneCallbacks)
|
|
@@ -113,7 +87,7 @@ export function streamAiChat(projectDir, options) {
|
|
|
113
87
|
*/
|
|
114
88
|
export async function checkAiStatus() {
|
|
115
89
|
try {
|
|
116
|
-
const res = await fetch(`${OPENCODE_URL}/
|
|
90
|
+
const res = await fetch(`${OPENCODE_URL}/global/health`, {
|
|
117
91
|
headers: buildHeaders(),
|
|
118
92
|
signal: AbortSignal.timeout(3000),
|
|
119
93
|
});
|
|
@@ -26,5 +26,5 @@ export interface AiStatusResult {
|
|
|
26
26
|
configured: boolean;
|
|
27
27
|
backend: 'claude-code' | 'opencode';
|
|
28
28
|
}
|
|
29
|
-
export declare const SYSTEM_PROMPT = "You are an AI coding assistant working inside a LumenJS project.\n\nLumenJS is a full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes.\n\nKey conventions:\n- Pages live in `pages/` directory \u2014 file path maps to URL route\n- Components are Lit web components (LitElement) auto-registered by file path\n- Layouts: `_layout.ts` in any directory for nested layouts (use <slot>)\n- API routes: `api/` directory with named exports (GET, POST, PUT, DELETE)\n- Server loaders: `export async function loader()` for server-side data fetching\n- Styles: use Tailwind CSS classes or Lit's `static styles` with css template tag\n- Config: `lumenjs.config.ts` at project root\n\nAuto-registration:\n- Pages and layouts are auto-registered by file path \u2014 do NOT add @customElement decorators.\n `pages/about.ts` \u2192 `<page-about>`, `pages/blog/_layout.ts` \u2192 `<layout-blog>`\n\nServer loaders:\n- `export async function loader({ params, query, url, headers, locale })` at file top level.\n- Return a data object \u2192
|
|
29
|
+
export declare const SYSTEM_PROMPT = "You are an AI coding assistant working inside a LumenJS project.\n\nLumenJS is a full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes.\n\nKey conventions:\n- Pages live in `pages/` directory \u2014 file path maps to URL route\n- Components are Lit web components (LitElement) auto-registered by file path\n- Layouts: `_layout.ts` in any directory for nested layouts (use <slot>)\n- API routes: `api/` directory with named exports (GET, POST, PUT, DELETE)\n- Server loaders: `export async function loader()` for server-side data fetching\n- Styles: use Tailwind CSS classes or Lit's `static styles` with css template tag\n- Config: `lumenjs.config.ts` at project root\n\nAuto-registration:\n- Pages and layouts are auto-registered by file path \u2014 do NOT add @customElement decorators.\n `pages/about.ts` \u2192 `<page-about>`, `pages/blog/_layout.ts` \u2192 `<layout-blog>`\n\nServer loaders:\n- `export async function loader({ params, query, url, headers, locale })` at file top level.\n- Return a data object \u2192 each key is spread as an individual property on the page element (e.g., return `{ posts }` \u2192 access as `this.posts`).\n\nSubscribe (SSE):\n- `export async function subscribe({ params, headers, locale, push })` for real-time data.\n- Call `push(data)` to send events \u2192 each key is spread as an individual property on the page element (same as loader data).\n\nMiddleware:\n- `_middleware.ts` in any directory applies to that route subtree.\n\nDynamic routes:\n- `[slug]` for dynamic params, `[...rest]` for catch-all.\n\nProperties:\n- Use `@property()` for public reactive props, `@state()` for internal state (from `lit/decorators.js`).\n\nExample \u2014 adding a new page (`pages/contact.ts`):\n```\nimport { LitElement, html, css } from 'lit';\nimport { property } from 'lit/decorators.js';\n\nexport default class extends LitElement {\n static styles = css\\`/* styles here */\\`;\n render() { return html\\`<h1>Contact</h1>\\`; }\n}\n```\n\nIMPORTANT \u2014 Styling rules:\n- When asked to change a style (color, font, spacing, etc.), find and UPDATE the EXISTING CSS rule in `static styles = css\\`...\\``. Do NOT add a new class or duplicate rule.\n- Never add inline `style=\"...\"` attributes on HTML template elements. Always modify the CSS rule in `static styles`.\n- Example: to change the h1 color, find the `h1 { ... }` rule in `static styles` and update its `color` property. Do not create a new class.\n- If no CSS rule exists for the element, add one to the existing `static styles` block \u2014 do not add a separate `<style>` tag.\n\nIMPORTANT \u2014 i18n / translation rules (when the project uses i18n):\n- Text content in templates uses `t('key')` from `@lumenjs/i18n` \u2014 NEVER replace a `t()` call with hardcoded text.\n- To change displayed text, edit the translation value in `locales/<locale>.json` \u2014 do NOT modify the template.\n- Example: to change the subtitle, update `\"home.subtitle\"` in `locales/en.json` (and other locale files like `locales/fr.json`).\n- To add new text, add a key to ALL locale JSON files and use `t('new.key')` in the template.\n- The dev server watches locale files and updates the page automatically via HMR.\n\nYou have full access to the filesystem and can run shell commands.\nWhen a task requires a new npm package, install it with `npm install <package>`.\nAfter npm install, the dev server will automatically restart to load the new dependency.\nVite's HMR will pick up file changes automatically \u2014 no manual restart needed.\n\nIMPORTANT \u2014 Be fast and direct:\n- Make changes immediately \u2014 do not explain what you will do before doing it.\n- Read the file, make the edit, done. Minimize tool calls.\n- For simple CSS/text changes, edit directly without reading first if you have the source context.\n- Keep responses under 2 sentences. The user sees the diff, not your explanation.\n";
|
|
30
30
|
export declare function buildPrompt(options: AiChatOptions): string;
|
package/dist/editor/ai/types.js
CHANGED
|
@@ -20,11 +20,11 @@ Auto-registration:
|
|
|
20
20
|
|
|
21
21
|
Server loaders:
|
|
22
22
|
- \`export async function loader({ params, query, url, headers, locale })\` at file top level.
|
|
23
|
-
- Return a data object →
|
|
23
|
+
- Return a data object → each key is spread as an individual property on the page element (e.g., return \`{ posts }\` → access as \`this.posts\`).
|
|
24
24
|
|
|
25
25
|
Subscribe (SSE):
|
|
26
26
|
- \`export async function subscribe({ params, headers, locale, push })\` for real-time data.
|
|
27
|
-
- Call \`push(data)\` to send events →
|
|
27
|
+
- Call \`push(data)\` to send events → each key is spread as an individual property on the page element (same as loader data).
|
|
28
28
|
|
|
29
29
|
Middleware:
|
|
30
30
|
- \`_middleware.ts\` in any directory applies to that route subtree.
|