@nuraly/lumenjs 0.1.4 → 0.2.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/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-server.d.ts +2 -1
- package/dist/build/build-server.js +10 -1
- package/dist/build/build.js +13 -4
- package/dist/build/scan.d.ts +1 -0
- package/dist/build/scan.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-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-chat-panel.js +27 -1
- package/dist/editor/editor-bridge.js +2 -1
- package/dist/editor/overlay-hmr.js +2 -1
- package/dist/runtime/app-shell.d.ts +1 -1
- package/dist/runtime/app-shell.js +1 -0
- 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 +49 -1
- 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/types.d.ts +1 -0
- package/dist/storage/adapters/s3.js +6 -3
- package/package.json +33 -7
- 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
|
});
|
|
@@ -304,6 +304,12 @@ function positionBubble(el) {
|
|
|
304
304
|
if (wasDragged)
|
|
305
305
|
return;
|
|
306
306
|
const rect = el.getBoundingClientRect();
|
|
307
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
308
|
+
// Element not laid out (shadow DOM, disconnected, or hidden) — park offscreen
|
|
309
|
+
panel.style.left = '-9999px';
|
|
310
|
+
panel.style.top = '-9999px';
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
307
313
|
const pw = 340; // panel width
|
|
308
314
|
const ph = panel.offsetHeight || 420;
|
|
309
315
|
const gap = 6;
|
|
@@ -353,7 +359,27 @@ export function showAiChatForElements(els) {
|
|
|
353
359
|
}
|
|
354
360
|
// Re-render quick actions based on current selection
|
|
355
361
|
renderQuickActions(els);
|
|
356
|
-
//
|
|
362
|
+
// Only show once we have a valid position — prevents flash at top-left
|
|
363
|
+
const rect = primary.getBoundingClientRect();
|
|
364
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
365
|
+
// Element not yet laid out — retry positioning over next few frames
|
|
366
|
+
let retries = 0;
|
|
367
|
+
const tryPosition = () => {
|
|
368
|
+
if (currentTargets[0] !== primary || !primary.isConnected)
|
|
369
|
+
return;
|
|
370
|
+
const r = primary.getBoundingClientRect();
|
|
371
|
+
if (r.width > 0 || r.height > 0) {
|
|
372
|
+
positionBubble(primary);
|
|
373
|
+
panel.classList.add('open');
|
|
374
|
+
}
|
|
375
|
+
else if (retries < 3) {
|
|
376
|
+
retries++;
|
|
377
|
+
requestAnimationFrame(tryPosition);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
requestAnimationFrame(tryPosition);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
357
383
|
positionBubble(primary);
|
|
358
384
|
panel.classList.add('open');
|
|
359
385
|
}
|
|
@@ -87,7 +87,8 @@ function handleHostMessage(event) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
function initEditorBridge() {
|
|
90
|
-
|
|
90
|
+
const forceStandalone = new URLSearchParams(window.location.search).has('_nk_standalone');
|
|
91
|
+
if (!forceStandalone && window.self !== window.top) {
|
|
91
92
|
// Running inside Studio iframe — use postMessage bridge
|
|
92
93
|
startAnnotator();
|
|
93
94
|
setupClickToSelect();
|