@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.
Files changed (76) hide show
  1. package/dist/auth/native-auth.d.ts +9 -0
  2. package/dist/auth/native-auth.js +49 -2
  3. package/dist/auth/routes/login.js +24 -1
  4. package/dist/auth/routes/totp.d.ts +22 -0
  5. package/dist/auth/routes/totp.js +232 -0
  6. package/dist/auth/routes.js +14 -0
  7. package/dist/auth/token.js +2 -2
  8. package/dist/build/build-server.d.ts +2 -1
  9. package/dist/build/build-server.js +10 -1
  10. package/dist/build/build.js +13 -4
  11. package/dist/build/scan.d.ts +1 -0
  12. package/dist/build/scan.js +2 -1
  13. package/dist/build/serve.js +131 -11
  14. package/dist/dev-server/config.js +18 -1
  15. package/dist/dev-server/index-html.d.ts +1 -0
  16. package/dist/dev-server/index-html.js +4 -1
  17. package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
  18. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
  19. package/dist/dev-server/server.js +146 -88
  20. package/dist/dev-server/ssr-render.js +10 -2
  21. package/dist/editor/ai/backend.js +11 -2
  22. package/dist/editor/ai/deepseek-client.d.ts +7 -0
  23. package/dist/editor/ai/deepseek-client.js +113 -0
  24. package/dist/editor/ai/opencode-client.d.ts +1 -1
  25. package/dist/editor/ai/opencode-client.js +21 -47
  26. package/dist/editor/ai-chat-panel.js +27 -1
  27. package/dist/editor/editor-bridge.js +2 -1
  28. package/dist/editor/overlay-hmr.js +2 -1
  29. package/dist/runtime/app-shell.d.ts +1 -1
  30. package/dist/runtime/app-shell.js +1 -0
  31. package/dist/runtime/island.d.ts +16 -0
  32. package/dist/runtime/island.js +80 -0
  33. package/dist/runtime/router-hydration.js +9 -2
  34. package/dist/runtime/router.d.ts +3 -1
  35. package/dist/runtime/router.js +49 -1
  36. package/dist/runtime/webrtc.d.ts +44 -0
  37. package/dist/runtime/webrtc.js +263 -13
  38. package/dist/shared/dom-shims.js +4 -2
  39. package/dist/shared/types.d.ts +1 -0
  40. package/dist/storage/adapters/s3.js +6 -3
  41. package/package.json +33 -7
  42. package/templates/social/api/posts/[id].ts +0 -14
  43. package/templates/social/api/posts.ts +0 -11
  44. package/templates/social/api/profile/[username].ts +0 -10
  45. package/templates/social/api/upload.ts +0 -19
  46. package/templates/social/data/migrations/001_init.sql +0 -78
  47. package/templates/social/data/migrations/002_add_image_url.sql +0 -1
  48. package/templates/social/data/migrations/003_auth.sql +0 -7
  49. package/templates/social/docs/architecture.md +0 -76
  50. package/templates/social/docs/components.md +0 -100
  51. package/templates/social/docs/data.md +0 -89
  52. package/templates/social/docs/pages.md +0 -96
  53. package/templates/social/docs/theming.md +0 -52
  54. package/templates/social/lib/media.ts +0 -130
  55. package/templates/social/lumenjs.auth.ts +0 -21
  56. package/templates/social/lumenjs.config.ts +0 -3
  57. package/templates/social/package.json +0 -5
  58. package/templates/social/pages/_layout.ts +0 -239
  59. package/templates/social/pages/apps/[id].ts +0 -173
  60. package/templates/social/pages/apps/index.ts +0 -116
  61. package/templates/social/pages/auth/login.ts +0 -92
  62. package/templates/social/pages/bookmarks.ts +0 -57
  63. package/templates/social/pages/explore.ts +0 -73
  64. package/templates/social/pages/index.ts +0 -351
  65. package/templates/social/pages/messages.ts +0 -298
  66. package/templates/social/pages/new.ts +0 -77
  67. package/templates/social/pages/notifications.ts +0 -73
  68. package/templates/social/pages/post/[id].ts +0 -124
  69. package/templates/social/pages/profile/[username].ts +0 -100
  70. package/templates/social/pages/settings/accessibility.ts +0 -153
  71. package/templates/social/pages/settings/account.ts +0 -260
  72. package/templates/social/pages/settings/help.ts +0 -141
  73. package/templates/social/pages/settings/language.ts +0 -103
  74. package/templates/social/pages/settings/privacy.ts +0 -183
  75. package/templates/social/pages/settings/security.ts +0 -133
  76. 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 ? { clientPort: parseInt(process.env.HMR_CLIENT_PORT), protocol: process.env.HMR_PROTOCOL || 'wss', host: process.env.HMR_HOST || undefined } : true,
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
- server.middlewares.use(async (req, res, next) => {
187
- const pathname = (req.url || '/').split('?')[0];
188
- if (pathname.startsWith('/@') || pathname.startsWith('/node_modules') || pathname.includes('.')) {
189
- return next();
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
- catch (err) {
204
- console.error(`[LumenJS] Failed to load _middleware.ts (${entry.dir || 'root'}):`, err);
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
- if (allMw.length === 0)
208
- return next();
209
- runMiddlewareChain(allMw, req, res, next);
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
- server.middlewares.use((req, res, next) => {
217
- // Guard against malformed percent-encoded URLs that crash Vite's transformIndexHtml
218
- if (req.url) {
219
- try {
220
- decodeURIComponent(req.url);
221
- }
222
- catch {
223
- res.statusCode = 400;
224
- res.end('Bad Request');
225
- return;
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
- const shellHtml = generateIndexHtml({
250
- title,
251
- editorMode,
252
- ssrContent: ssrResult ? SSR_PLACEHOLDER : undefined,
253
- loaderData: ssrResult?.loaderData,
254
- layoutsData: ssrResult?.layoutsData,
255
- integrations,
256
- locale,
257
- i18nConfig: i18nConfig || undefined,
258
- translations,
259
- prefetch: prefetchStrategy,
260
- authUser: ssrResult?.authUser ?? req.nkAuth?.user ?? undefined,
261
- headContent,
262
- });
263
- const transformed = await server.transformIndexHtml(req.url, shellHtml);
264
- const finalHtml = ssrResult
265
- ? transformed.replace(SSR_PLACEHOLDER, ssrResult.html)
266
- : transformed;
267
- res.setHeader('Content-Type', 'text/html');
268
- res.setHeader('Cache-Control', 'no-store');
269
- res.end(finalHtml);
270
- }).catch(err => {
271
- console.error('[LumenJS] SSR/HTML generation error:', err);
272
- const html = generateIndexHtml({ title, editorMode, integrations, locale, i18nConfig: i18nConfig || undefined, translations, prefetch: prefetchStrategy, headContent });
273
- server.transformIndexHtml(req.url, html).then(transformed => {
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(transformed);
277
- }).catch(next);
278
- });
279
- return;
280
- }
281
- next();
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(filePath);
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(layout.filePath);
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 Claude Code first (subscription-based, no server needed)
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
- * Uses the /api/session and /api/message endpoints.
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 { SYSTEM_PROMPT, buildPrompt } from './types.js';
6
- const OPENCODE_URL = process.env.OPENCODE_URL || 'http://localhost:3500';
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
- * Uses the /api/session and /api/message endpoints.
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}/api/session`, {
30
+ const createRes = await fetch(`${OPENCODE_URL}/session`, {
31
31
  method: 'POST',
32
32
  headers: buildHeaders(),
33
- body: JSON.stringify({ path: projectDir, system: SYSTEM_PROMPT }),
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 and stream response
43
- const msgRes = await fetch(`${OPENCODE_URL}/api/session/${sessionId}/message`, {
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: { ...buildHeaders(), 'Accept': 'text/event-stream' },
46
- body: JSON.stringify({ content: enrichedPrompt }),
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
- throw new Error(`OpenCode message failed: ${msgRes.status}`);
50
+ const errText = await msgRes.text().catch(() => '');
51
+ throw new Error(`OpenCode message failed: ${msgRes.status} ${errText}`);
51
52
  }
52
- const reader = msgRes.body?.getReader();
53
- if (!reader)
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
- let buffer = '';
58
- while (true) {
59
- const { done, value } = await reader.read();
60
- if (done)
61
- break;
62
- buffer += decoder.decode(value, { stream: true });
63
- const lines = buffer.split('\n');
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}/api/status`, {
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
- // Position first, then make visible — prevents flash at default position
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
- if (window.self !== window.top) {
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();