thepopebot 1.2.73 → 1.2.74-beta.10

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 (125) hide show
  1. package/api/index.js +5 -4
  2. package/bin/cli.js +6 -30
  3. package/bin/docker-build.js +11 -11
  4. package/bin/managed-paths.js +1 -2
  5. package/bin/sync.js +23 -23
  6. package/config/instrumentation.js +8 -0
  7. package/lib/ai/CLAUDE.md +3 -1
  8. package/lib/ai/model.js +42 -29
  9. package/lib/ai/web-search.js +4 -2
  10. package/lib/auth/actions.js +173 -1
  11. package/lib/auth/middleware.js +5 -0
  12. package/lib/chat/actions.js +427 -13
  13. package/lib/chat/components/icons.js +21 -0
  14. package/lib/chat/components/icons.jsx +19 -0
  15. package/lib/chat/components/index.js +7 -1
  16. package/lib/chat/components/profile-page.js +141 -0
  17. package/lib/chat/components/profile-page.jsx +168 -0
  18. package/lib/chat/components/settings-chat-page.js +709 -0
  19. package/lib/chat/components/settings-chat-page.jsx +757 -0
  20. package/lib/chat/components/settings-general-page.js +56 -0
  21. package/lib/chat/components/settings-general-page.jsx +69 -0
  22. package/lib/chat/components/settings-github-page.js +760 -0
  23. package/lib/chat/components/settings-github-page.jsx +793 -0
  24. package/lib/chat/components/settings-layout.js +9 -5
  25. package/lib/chat/components/settings-layout.jsx +9 -5
  26. package/lib/chat/components/settings-secrets-layout.js +56 -0
  27. package/lib/chat/components/settings-secrets-layout.jsx +77 -0
  28. package/lib/chat/components/settings-secrets-page.js +420 -114
  29. package/lib/chat/components/settings-secrets-page.jsx +438 -132
  30. package/lib/chat/components/settings-users-page.js +409 -0
  31. package/lib/chat/components/settings-users-page.jsx +406 -0
  32. package/lib/chat/components/sidebar-user-nav.js +8 -4
  33. package/lib/chat/components/sidebar-user-nav.jsx +13 -5
  34. package/lib/chat/components/ui/dropdown-menu.js +1 -1
  35. package/lib/chat/components/ui/dropdown-menu.jsx +1 -1
  36. package/lib/chat/components/ui/sidebar.js +1 -1
  37. package/lib/chat/components/ui/sidebar.jsx +1 -1
  38. package/lib/cluster/actions.js +6 -7
  39. package/lib/cluster/components/cluster-console-page.js +53 -34
  40. package/lib/cluster/components/cluster-console-page.jsx +53 -41
  41. package/lib/cluster/execute.js +43 -18
  42. package/lib/cluster/runtime.js +15 -19
  43. package/lib/code/actions.js +16 -7
  44. package/lib/code/code-page.js +206 -57
  45. package/lib/code/code-page.jsx +228 -60
  46. package/lib/code/ws-proxy.js +12 -4
  47. package/lib/config.js +105 -0
  48. package/lib/cron.js +56 -20
  49. package/lib/db/api-keys.js +73 -50
  50. package/lib/db/config.js +306 -0
  51. package/lib/db/crypto.js +59 -0
  52. package/lib/db/notifications.js +2 -1
  53. package/lib/db/users.js +88 -0
  54. package/lib/github-api.js +169 -0
  55. package/lib/llm-providers.js +73 -0
  56. package/lib/tools/docker.js +13 -8
  57. package/lib/tools/github.js +6 -4
  58. package/lib/tools/openai.js +4 -2
  59. package/lib/voice/actions.js +2 -1
  60. package/package.json +2 -1
  61. package/setup/lib/sync.mjs +41 -4
  62. package/setup/lib/targets.mjs +24 -22
  63. package/setup/setup.mjs +86 -23
  64. package/templates/.github/workflows/rebuild-event-handler.yml +20 -63
  65. package/templates/.github/workflows/upgrade-event-handler.yml +1 -1
  66. package/templates/.gitignore.template +4 -6
  67. package/templates/CLAUDE.md +5 -6
  68. package/templates/CLAUDE.md.template +12 -14
  69. package/templates/docker-compose.custom.yml +119 -0
  70. package/templates/docker-compose.yml +10 -3
  71. package/templates/traefik-dynamic.yml.example +7 -0
  72. package/templates/app/api/[...thepopebot]/route.js +0 -1
  73. package/templates/app/api/auth/[...nextauth]/route.js +0 -1
  74. package/templates/app/chat/[chatId]/page.js +0 -8
  75. package/templates/app/chat/finalize-chat/route.js +0 -1
  76. package/templates/app/chats/page.js +0 -7
  77. package/templates/app/cluster/[clusterId]/console/page.js +0 -8
  78. package/templates/app/cluster/[clusterId]/logs/page.js +0 -8
  79. package/templates/app/cluster/[clusterId]/page.js +0 -8
  80. package/templates/app/cluster/[clusterId]/role/[roleId]/page.js +0 -8
  81. package/templates/app/clusters/layout.js +0 -7
  82. package/templates/app/clusters/list/page.js +0 -5
  83. package/templates/app/clusters/page.js +0 -5
  84. package/templates/app/code/[codeWorkspaceId]/page.js +0 -8
  85. package/templates/app/crons/page.js +0 -5
  86. package/templates/app/globals.css +0 -114
  87. package/templates/app/icon.svg +0 -12
  88. package/templates/app/layout.js +0 -34
  89. package/templates/app/login/page.js +0 -13
  90. package/templates/app/notifications/page.js +0 -7
  91. package/templates/app/page.js +0 -7
  92. package/templates/app/pull-requests/page.js +0 -7
  93. package/templates/app/runners/page.js +0 -7
  94. package/templates/app/settings/crons/page.js +0 -5
  95. package/templates/app/settings/layout.js +0 -7
  96. package/templates/app/settings/page.js +0 -5
  97. package/templates/app/settings/secrets/page.js +0 -5
  98. package/templates/app/settings/triggers/page.js +0 -5
  99. package/templates/app/stream/chat/route.js +0 -1
  100. package/templates/app/stream/cluster/[clusterId]/logs/route.js +0 -1
  101. package/templates/app/triggers/page.js +0 -5
  102. package/templates/docker/CLAUDE.md +0 -25
  103. package/templates/docker/claude-code-cluster-worker/Dockerfile +0 -49
  104. package/templates/docker/claude-code-cluster-worker/entrypoint.sh +0 -77
  105. package/templates/docker/claude-code-headless/Dockerfile +0 -55
  106. package/templates/docker/claude-code-headless/commands/ai-merge-back.md +0 -103
  107. package/templates/docker/claude-code-headless/commands/commit-changes.md +0 -14
  108. package/templates/docker/claude-code-headless/entrypoint.sh +0 -91
  109. package/templates/docker/claude-code-job/Dockerfile +0 -34
  110. package/templates/docker/claude-code-job/entrypoint.sh +0 -149
  111. package/templates/docker/claude-code-workspace/.tmux.conf +0 -5
  112. package/templates/docker/claude-code-workspace/Dockerfile +0 -64
  113. package/templates/docker/claude-code-workspace/commands/ai-merge-back.md +0 -103
  114. package/templates/docker/claude-code-workspace/commands/commit-changes.md +0 -14
  115. package/templates/docker/claude-code-workspace/entrypoint.sh +0 -101
  116. package/templates/docker/event-handler/Dockerfile +0 -37
  117. package/templates/docker/event-handler/ecosystem.config.cjs +0 -7
  118. package/templates/docker/pi-coding-agent-job/Dockerfile +0 -51
  119. package/templates/docker/pi-coding-agent-job/entrypoint.sh +0 -164
  120. package/templates/instrumentation.js +0 -6
  121. package/templates/middleware.js +0 -1
  122. package/templates/next.config.mjs +0 -3
  123. package/templates/postcss.config.mjs +0 -5
  124. package/templates/server.js +0 -25
  125. package/templates/theme.css +0 -5
package/api/index.js CHANGED
@@ -7,8 +7,9 @@ import { chat, summarizeJob } from '../lib/ai/index.js';
7
7
  import { createNotification } from '../lib/db/notifications.js';
8
8
  import { loadTriggers } from '../lib/triggers.js';
9
9
  import { verifyApiKey } from '../lib/db/api-keys.js';
10
+ import { getConfig } from '../lib/config.js';
10
11
 
11
- // Bot token from env, can be overridden by /telegram/register
12
+ // Bot token — resolved from DB/env, can be overridden by /telegram/register
12
13
  let telegramBotToken = null;
13
14
 
14
15
  // Cached trigger firing function (initialized on first request)
@@ -16,7 +17,7 @@ let _fireTriggers = null;
16
17
 
17
18
  function getTelegramBotToken() {
18
19
  if (!telegramBotToken) {
19
- telegramBotToken = process.env.TELEGRAM_BOT_TOKEN || null;
20
+ telegramBotToken = getConfig('TELEGRAM_BOT_TOKEN') || null;
20
21
  }
21
22
  return telegramBotToken;
22
23
  }
@@ -103,7 +104,7 @@ async function handleTelegramRegister(request) {
103
104
  }
104
105
 
105
106
  try {
106
- const result = await setWebhook(bot_token, webhook_url, process.env.TELEGRAM_WEBHOOK_SECRET);
107
+ const result = await setWebhook(bot_token, webhook_url, getConfig('TELEGRAM_WEBHOOK_SECRET'));
107
108
  telegramBotToken = bot_token;
108
109
  return Response.json({ success: true, result });
109
110
  } catch (err) {
@@ -159,7 +160,7 @@ async function processChannelMessage(adapter, normalized) {
159
160
  }
160
161
 
161
162
  async function handleGithubWebhook(request) {
162
- const { GH_WEBHOOK_SECRET } = process.env;
163
+ const GH_WEBHOOK_SECRET = getConfig('GH_WEBHOOK_SECRET');
163
164
 
164
165
  // Validate webhook secret (timing-safe, required)
165
166
  if (!GH_WEBHOOK_SECRET || !safeCompare(request.headers.get('x-github-webhook-secret-token'), GH_WEBHOOK_SECRET)) {
package/bin/cli.js CHANGED
@@ -248,22 +248,12 @@ async function init() {
248
248
  name: dirName,
249
249
  private: true,
250
250
  scripts: {
251
- dev: 'next dev --turbopack',
252
- build: 'next build',
253
- start: 'next start',
254
251
  setup: 'thepopebot setup',
255
252
  'setup-telegram': 'thepopebot setup-telegram',
256
253
  'reset-auth': 'thepopebot reset-auth',
257
254
  },
258
255
  dependencies: {
259
256
  thepopebot: thepopebotDep,
260
- next: '^15.5.12',
261
- 'next-auth': '5.0.0-beta.30',
262
- 'next-themes': '^0.4.0',
263
- react: '^19.0.0',
264
- 'react-dom': '^19.0.0',
265
- tailwindcss: '^4.0.0',
266
- '@tailwindcss/postcss': '^4.0.0',
267
257
  },
268
258
  };
269
259
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
@@ -354,6 +344,10 @@ async function init() {
354
344
  AUTH_SECRET=${authSecret}
355
345
  AUTH_TRUST_HOST=true
356
346
  THEPOPEBOT_VERSION=${version}
347
+
348
+ # Uncomment to use a custom docker-compose file that won't be overwritten by upgrades.
349
+ # Edit docker-compose.custom.yml with your changes, then uncomment:
350
+ # COMPOSE_FILE=docker-compose.custom.yml
357
351
  `;
358
352
  fs.writeFileSync(envPath, seedEnv);
359
353
  console.log(` Created .env (AUTH_SECRET, THEPOPEBOT_VERSION=${version})`);
@@ -618,24 +612,6 @@ async function upgrade() {
618
612
  process.exit(1);
619
613
  }
620
614
 
621
- // --- Clear .next ---
622
- try {
623
- fs.rmSync(path.join(cwd, '.next'), { recursive: true, force: true });
624
- } catch {}
625
-
626
- // --- Build ---
627
- console.log('\n Building...\n');
628
- try {
629
- execSync('npm run build', { stdio: 'inherit', cwd });
630
- } catch {
631
- console.error('\n Build failed. The upgrade has been applied but the project does not build.');
632
- console.error(' Fix the build errors, then run:\n');
633
- console.error(` npm run build`);
634
- console.error(` git add -A && git commit -m "upgrade thepopebot to ${targetVersion}"`);
635
- console.error(' git push\n');
636
- process.exit(1);
637
- }
638
-
639
615
  // --- Commit upgrade ---
640
616
  const changes = execSync('git status --porcelain', { encoding: 'utf8', cwd }).trim();
641
617
  if (changes) {
@@ -664,8 +640,8 @@ async function upgrade() {
664
640
  try {
665
641
  const running = execSync('docker compose ps --status running -q', { encoding: 'utf8', cwd }).trim();
666
642
  if (running) {
667
- console.log(' Restarting Docker containers...\n');
668
- execSync('docker compose down && docker compose up -d', { stdio: 'inherit', cwd });
643
+ console.log(' Pulling new image and restarting Docker containers...\n');
644
+ execSync('docker compose pull event-handler && docker compose up -d --force-recreate event-handler', { stdio: 'inherit', cwd });
669
645
  }
670
646
  } catch {
671
647
  // Docker not available or not running — skip
@@ -26,33 +26,33 @@ const REPO = 'stephengpope/thepopebot';
26
26
  const IMAGES = [
27
27
  {
28
28
  name: 'pi-coding-agent-job',
29
- context: 'templates/docker/pi-coding-agent-job',
30
- dockerfile: 'templates/docker/pi-coding-agent-job/Dockerfile',
29
+ context: 'docker/pi-coding-agent-job',
30
+ dockerfile: 'docker/pi-coding-agent-job/Dockerfile',
31
31
  },
32
32
  {
33
33
  name: 'claude-code-job',
34
- context: 'templates/docker/claude-code-job',
35
- dockerfile: 'templates/docker/claude-code-job/Dockerfile',
34
+ context: 'docker/claude-code-job',
35
+ dockerfile: 'docker/claude-code-job/Dockerfile',
36
36
  },
37
37
  {
38
38
  name: 'claude-code-workspace',
39
- context: 'templates/docker/claude-code-workspace',
40
- dockerfile: 'templates/docker/claude-code-workspace/Dockerfile',
39
+ context: 'docker/claude-code-workspace',
40
+ dockerfile: 'docker/claude-code-workspace/Dockerfile',
41
41
  },
42
42
  {
43
43
  name: 'claude-code-headless',
44
- context: 'templates/docker/claude-code-headless',
45
- dockerfile: 'templates/docker/claude-code-headless/Dockerfile',
44
+ context: 'docker/claude-code-headless',
45
+ dockerfile: 'docker/claude-code-headless/Dockerfile',
46
46
  },
47
47
  {
48
48
  name: 'claude-code-cluster-worker',
49
- context: 'templates/docker/claude-code-cluster-worker',
50
- dockerfile: 'templates/docker/claude-code-cluster-worker/Dockerfile',
49
+ context: 'docker/claude-code-cluster-worker',
50
+ dockerfile: 'docker/claude-code-cluster-worker/Dockerfile',
51
51
  },
52
52
  {
53
53
  name: 'event-handler',
54
54
  context: '.',
55
- dockerfile: 'templates/docker/event-handler/Dockerfile',
55
+ dockerfile: 'docker/event-handler/Dockerfile',
56
56
  },
57
57
  ];
58
58
 
@@ -4,11 +4,10 @@
4
4
  // Paths ending with '/' are directories (all contents are managed).
5
5
  export const MANAGED_PATHS = [
6
6
  '.github/workflows/',
7
- 'docker/',
7
+
8
8
  'docker-compose.yml',
9
9
  '.dockerignore',
10
10
  'CLAUDE.md',
11
- 'app/',
12
11
  ];
13
12
 
14
13
  export function isManaged(relPath) {
package/bin/sync.js CHANGED
@@ -29,10 +29,9 @@
29
29
  * 2. npm pack → copy tarball to project
30
30
  * 3. mirrorTemplates() — overwrite + delete stale
31
31
  * 4. npm install tarball on host (--no-save)
32
- * 5. Next.js build on host
33
- * 6. Docker image build (patches Dockerfile for local tarball)
34
- * 7. docker compose up -d -V event-handler
35
- * 8. Cleanup tarball
32
+ * 5. Docker image build (patches Dockerfile for local tarball, includes Next.js build)
33
+ * 6. docker compose up -d -V event-handler
34
+ * 7. Cleanup tarball
36
35
  */
37
36
  import { execSync } from 'child_process';
38
37
  import fs from 'fs';
@@ -158,7 +157,7 @@ function mirrorTemplates(projectPath) {
158
157
  function buildDockerImage(projectPath) {
159
158
  console.log('\n Building Docker event handler image...');
160
159
 
161
- const dockerfilePath = path.join(projectPath, 'docker', 'event-handler', 'Dockerfile');
160
+ const dockerfilePath = path.join(PACKAGE_DIR, 'docker', 'event-handler', 'Dockerfile');
162
161
  let dockerfile = fs.readFileSync(dockerfilePath, 'utf8');
163
162
 
164
163
  // Add COPY for tarball after the package.json COPY line in builder stage
@@ -169,24 +168,30 @@ function buildDockerImage(projectPath) {
169
168
 
170
169
  // Replace npm install from registry with local tarball install
171
170
  dockerfile = dockerfile.replace(
172
- /RUN npm install --omit=dev && \\\n\s+npm install --no-save thepopebot@\$\(node -p "require\('\.\/package\.json'\)\.version"\)/,
173
- 'RUN npm install --omit=dev && \\\n npm install --no-save /tmp/thepopebot.tgz && rm /tmp/thepopebot.tgz'
171
+ /RUN TPB_VERSION=.*\n\s+echo.*\n\s+npm install --no-save "thepopebot@\$\{TPB_VERSION\}" tailwindcss @tailwindcss\/postcss/,
172
+ 'RUN echo \'{"private":true}\' > package.json && \\\n npm install --no-save /tmp/thepopebot.tgz tailwindcss @tailwindcss/postcss && \\\n rm /tmp/thepopebot.tgz'
174
173
  );
175
174
 
176
- // Fix template paths for project context (templates/docker/... → docker/...)
177
- dockerfile = dockerfile.replace(/COPY templates\//g, 'COPY ');
178
-
179
175
  // Read version from package.json
180
176
  const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf8'));
181
177
  const version = pkg.version;
182
178
  const imageTag = `stephengpope/thepopebot:event-handler-${version}`;
183
179
 
184
- // Build using stdin Dockerfile with project dir as context (no cache to ensure fresh package)
185
- execSync(`docker build --no-cache -f - -t ${imageTag} .`, {
186
- input: dockerfile,
187
- stdio: ['pipe', 'inherit', 'inherit'],
188
- cwd: projectPath,
189
- });
180
+ // Copy web/ to project for Docker build context
181
+ const webSrc = path.join(PACKAGE_DIR, 'web');
182
+ const webDest = path.join(projectPath, 'web');
183
+ fs.cpSync(webSrc, webDest, { recursive: true });
184
+
185
+ try {
186
+ // Build using stdin Dockerfile with project dir as context (no cache to ensure fresh package)
187
+ execSync(`docker build --no-cache -f - -t ${imageTag} .`, {
188
+ input: dockerfile,
189
+ stdio: ['pipe', 'inherit', 'inherit'],
190
+ cwd: projectPath,
191
+ });
192
+ } finally {
193
+ fs.rmSync(webDest, { recursive: true, force: true });
194
+ }
190
195
 
191
196
  // Update THEPOPEBOT_VERSION in .env
192
197
  const envPath = path.join(projectPath, '.env');
@@ -239,15 +244,10 @@ export async function sync(projectPath) {
239
244
  console.log('\n Installing package on host...');
240
245
  execSync(`npm install --no-save ${tarballDest}`, { stdio: 'inherit', cwd: projectPath });
241
246
 
242
- // 5. Build Next.js on host (avoids OOM inside container)
243
- console.log('\n Building Next.js...');
244
- fs.rmSync(path.join(projectPath, '.next'), { recursive: true, force: true });
245
- execSync('npm run build', { stdio: 'inherit', cwd: projectPath });
246
-
247
- // 6. Build Docker image with patched Dockerfile
247
+ // 5. Build Docker image with patched Dockerfile (includes Next.js build)
248
248
  buildDockerImage(projectPath);
249
249
 
250
- // 7. Restart container (fresh start picks up .next via volume mount)
250
+ // 6. Restart container with new image
251
251
  console.log('\n Restarting event handler...');
252
252
  execSync('docker compose up -d -V event-handler', { stdio: 'inherit', cwd: projectPath });
253
253
 
@@ -43,6 +43,14 @@ export async function register() {
43
43
  const { initDatabase } = await import('../lib/db/index.js');
44
44
  initDatabase();
45
45
 
46
+ // Migrate env vars to DB on first run (idempotent)
47
+ try {
48
+ const { migrateEnvToDb } = await import('../lib/db/config.js');
49
+ migrateEnvToDb();
50
+ } catch (err) {
51
+ console.warn('Config migration:', err.message);
52
+ }
53
+
46
54
  // Start cron scheduler
47
55
  const { loadCrons } = await import('../lib/cron.js');
48
56
  loadCrons();
package/lib/ai/CLAUDE.md CHANGED
@@ -30,11 +30,13 @@ Two agent types, both using `createReactAgent` from `@langchain/langgraph/prebui
30
30
  |----------|----------------|---------------|-------------|
31
31
  | Anthropic | `anthropic` (default) | `claude-sonnet-4-20250514` | `ANTHROPIC_API_KEY` |
32
32
  | OpenAI | `openai` | `gpt-4o` | `OPENAI_API_KEY` |
33
- | Google | `google` | `gemini-2.5-pro` | `GOOGLE_API_KEY` |
33
+ | Google | `google` | `gemini-2.5-flash` | `GOOGLE_API_KEY` |
34
34
  | Custom | `custom` | — | `OPENAI_BASE_URL`, `CUSTOM_API_KEY` (optional) |
35
35
 
36
36
  `LLM_MAX_TOKENS` defaults to 4096. Web search available for `anthropic` and `openai` providers only (disable with `WEB_SEARCH=false`).
37
37
 
38
+ > **Google model compatibility note:** `gemini-2.5-pro` and all `gemini-3.*` models require `thought_signature` round-tripping that `@langchain/google-genai` does not yet support. Setting `LLM_MODEL` to one of these will automatically fall back to `gemini-2.5-flash` at runtime with a warning. Supported Gemini models: `gemini-2.5-flash` (default), `gemini-2.5-flash-lite`. Full support for thinking models is tracked in issue #201.
39
+
38
40
  ## Chat Streaming
39
41
 
40
42
  `chatStream()` in `index.js` yields chunks: `{ type: 'text', content }`, `{ type: 'tool-call', name, args }`, `{ type: 'tool-result', name, result }`. Called by `lib/chat/api.js` (the `/stream/chat` endpoint).
package/lib/ai/model.js CHANGED
@@ -1,36 +1,43 @@
1
1
  import { ChatAnthropic } from '@langchain/anthropic';
2
+ import { getConfig } from '../config.js';
3
+ import { BUILTIN_PROVIDERS } from '../llm-providers.js';
2
4
 
3
- const DEFAULT_MODELS = {
4
- anthropic: 'claude-sonnet-4-20250514',
5
- openai: 'gpt-4o',
6
- google: 'gemini-2.5-pro',
7
- };
5
+ // These models require thought_signature round-tripping which @langchain/google-genai doesn't support.
6
+ // Auto-replace with gemini-2.5-flash until we migrate to @langchain/google (see issue #201).
7
+ const GEMINI_UNSUPPORTED_MODELS = ['gemini-2.5-pro', 'gemini-3'];
8
+ const GEMINI_FALLBACK = 'gemini-2.5-flash';
8
9
 
9
10
  /**
10
- * Create a LangChain chat model based on environment configuration.
11
- *
12
- * Config env vars:
13
- * LLM_PROVIDER — "anthropic" (default), "openai", "google"
14
- * LLM_MODEL — Model name override (e.g. "claude-sonnet-4-20250514")
15
- * ANTHROPIC_API_KEY — Required for anthropic provider
16
- * OPENAI_API_KEY — Required for openai provider (optional with OPENAI_BASE_URL)
17
- * OPENAI_BASE_URL — Custom OpenAI-compatible base URL (e.g. http://localhost:11434/v1 for Ollama)
18
- * GOOGLE_API_KEY — Required for google provider
11
+ * Create a LangChain chat model based on DB/env configuration.
19
12
  *
20
13
  * @param {object} [options]
21
- * @param {number} [options.maxTokens=4096] - Max tokens for the response
14
+ * @param {number} [options.maxTokens] - Max tokens for the response
22
15
  * @returns {import('@langchain/core/language_models/chat_models').BaseChatModel}
23
16
  */
24
17
  export async function createModel(options = {}) {
25
- const provider = process.env.LLM_PROVIDER || 'anthropic';
26
- const modelName = process.env.LLM_MODEL || DEFAULT_MODELS[provider] || DEFAULT_MODELS.anthropic;
27
- const maxTokens = options.maxTokens || Number(process.env.LLM_MAX_TOKENS) || 4096;
18
+ const provider = getConfig('LLM_PROVIDER');
19
+ const modelName = getConfig('LLM_MODEL');
20
+ const maxTokens = options.maxTokens || Number(getConfig('LLM_MAX_TOKENS')) || 4096;
21
+
22
+ // Custom provider (not in BUILTIN_PROVIDERS) → OpenAI-compatible
23
+ if (!BUILTIN_PROVIDERS[provider]) {
24
+ const { ChatOpenAI } = await import('@langchain/openai');
25
+ const { getCustomProvider } = await import('../db/config.js');
26
+ const custom = getCustomProvider(provider);
27
+ if (!custom) throw new Error(`Unknown LLM provider: ${provider}`);
28
+ const config = { modelName: custom.model || modelName, maxTokens };
29
+ config.apiKey = custom.apiKey || 'not-needed';
30
+ if (custom.baseUrl) {
31
+ config.configuration = { baseURL: custom.baseUrl };
32
+ }
33
+ return new ChatOpenAI(config);
34
+ }
28
35
 
29
36
  switch (provider) {
30
37
  case 'anthropic': {
31
- const apiKey = process.env.ANTHROPIC_API_KEY;
38
+ const apiKey = getConfig('ANTHROPIC_API_KEY');
32
39
  if (!apiKey) {
33
- throw new Error('ANTHROPIC_API_KEY environment variable is required');
40
+ throw new Error('ANTHROPIC_API_KEY is required set it on the Settings > Chat page');
34
41
  }
35
42
  return new ChatAnthropic({
36
43
  modelName,
@@ -38,15 +45,12 @@ export async function createModel(options = {}) {
38
45
  anthropicApiKey: apiKey,
39
46
  });
40
47
  }
41
- case 'custom':
42
48
  case 'openai': {
43
49
  const { ChatOpenAI } = await import('@langchain/openai');
44
- const apiKey = provider === 'custom'
45
- ? (process.env.CUSTOM_API_KEY || 'not-needed')
46
- : process.env.OPENAI_API_KEY;
47
- const baseURL = process.env.OPENAI_BASE_URL;
50
+ const apiKey = getConfig('OPENAI_API_KEY');
51
+ const baseURL = getConfig('OPENAI_BASE_URL');
48
52
  if (!apiKey && !baseURL) {
49
- throw new Error('OPENAI_API_KEY environment variable is required (or set OPENAI_BASE_URL for local models)');
53
+ throw new Error('OPENAI_API_KEY is required set it on the Settings > Chat page');
50
54
  }
51
55
  const config = { modelName, maxTokens };
52
56
  config.apiKey = apiKey || 'not-needed';
@@ -57,12 +61,21 @@ export async function createModel(options = {}) {
57
61
  }
58
62
  case 'google': {
59
63
  const { ChatGoogleGenerativeAI } = await import('@langchain/google-genai');
60
- const apiKey = process.env.GOOGLE_API_KEY;
64
+ const apiKey = getConfig('GOOGLE_API_KEY');
61
65
  if (!apiKey) {
62
- throw new Error('GOOGLE_API_KEY environment variable is required');
66
+ throw new Error('GOOGLE_API_KEY is required set it on the Settings > Chat page');
67
+ }
68
+ let resolvedModel = modelName;
69
+ const isUnsupported = GEMINI_UNSUPPORTED_MODELS.some(m => resolvedModel.startsWith(m));
70
+ if (isUnsupported) {
71
+ console.warn(
72
+ `[model] ${resolvedModel} requires thought_signature support not yet available in @langchain/google-genai. ` +
73
+ `Falling back to ${GEMINI_FALLBACK}. See https://github.com/stephengpope/thepopebot/issues/201.`
74
+ );
75
+ resolvedModel = GEMINI_FALLBACK;
63
76
  }
64
77
  return new ChatGoogleGenerativeAI({
65
- model: modelName,
78
+ model: resolvedModel,
66
79
  maxOutputTokens: maxTokens,
67
80
  apiKey,
68
81
  });
@@ -4,12 +4,14 @@
4
4
  * runs the search and the model's synthesized answer streams normally.
5
5
  */
6
6
 
7
+ import { getConfig } from '../config.js';
8
+
7
9
  export function getProvider() {
8
- return process.env.LLM_PROVIDER || 'anthropic';
10
+ return getConfig('LLM_PROVIDER');
9
11
  }
10
12
 
11
13
  export function isWebSearchAvailable() {
12
- if (process.env.WEB_SEARCH === 'false') return false;
14
+ if (getConfig('WEB_SEARCH') === 'false') return false;
13
15
  const provider = getProvider();
14
16
  return provider === 'anthropic' || provider === 'openai';
15
17
  }
@@ -1,6 +1,29 @@
1
1
  'use server';
2
2
 
3
- import { createFirstUser } from '../db/users.js';
3
+ import { auth } from './index.js';
4
+ import {
5
+ createFirstUser,
6
+ createUser,
7
+ getAllUsers,
8
+ deleteUser,
9
+ getUserByEmail,
10
+ updateUserEmail,
11
+ updateUserRole,
12
+ updateUserPasswordById,
13
+ verifyPassword,
14
+ } from '../db/users.js';
15
+
16
+ async function requireAuth() {
17
+ const session = await auth();
18
+ if (!session?.user?.id) throw new Error('Unauthorized');
19
+ return session.user;
20
+ }
21
+
22
+ async function requireAdmin() {
23
+ const user = await requireAuth();
24
+ if (user.role !== 'admin') throw new Error('Forbidden');
25
+ return user;
26
+ }
4
27
 
5
28
  /**
6
29
  * Create the first admin user (setup action).
@@ -26,3 +49,152 @@ export async function setupAdmin(email, password) {
26
49
 
27
50
  return { success: true };
28
51
  }
52
+
53
+ /**
54
+ * Get all users.
55
+ */
56
+ export async function getUsers() {
57
+ await requireAdmin();
58
+ return getAllUsers();
59
+ }
60
+
61
+ /**
62
+ * Add a new user.
63
+ */
64
+ export async function addUser(email, password, role) {
65
+ await requireAdmin();
66
+
67
+ if (!email || !password) {
68
+ return { error: 'Email and password are required.' };
69
+ }
70
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
71
+ return { error: 'Invalid email format.' };
72
+ }
73
+ if (password.length < 8) {
74
+ return { error: 'Password must be at least 8 characters.' };
75
+ }
76
+
77
+ try {
78
+ await createUser(email, password);
79
+ if (role && role !== 'admin') {
80
+ // createUser defaults to admin; update if different role requested
81
+ const users = getAllUsers();
82
+ const created = users.find((u) => u.email === email.toLowerCase());
83
+ if (created) updateUserRole(created.id, role);
84
+ }
85
+ return { success: true };
86
+ } catch (err) {
87
+ if (err.message?.includes('UNIQUE constraint')) {
88
+ return { error: 'A user with this email already exists.' };
89
+ }
90
+ return { error: 'Failed to create user.' };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Edit a user's email and/or role.
96
+ */
97
+ export async function editUser(id, { email, role }) {
98
+ const user = await requireAdmin();
99
+
100
+ if (role !== undefined && id === user.id) {
101
+ return { error: 'Cannot change your own role.' };
102
+ }
103
+
104
+ try {
105
+ if (email !== undefined) {
106
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
107
+ return { error: 'Invalid email format.' };
108
+ }
109
+ updateUserEmail(id, email);
110
+ }
111
+ if (role !== undefined) {
112
+ updateUserRole(id, role);
113
+ }
114
+ return { success: true };
115
+ } catch (err) {
116
+ if (err.message?.includes('UNIQUE constraint')) {
117
+ return { error: 'A user with this email already exists.' };
118
+ }
119
+ return { error: 'Failed to update user.' };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Remove a user.
125
+ */
126
+ export async function removeUser(id) {
127
+ const user = await requireAdmin();
128
+
129
+ if (id === user.id) {
130
+ return { error: 'Cannot delete yourself.' };
131
+ }
132
+
133
+ const deleted = deleteUser(id);
134
+ if (!deleted) {
135
+ return { error: 'User not found.' };
136
+ }
137
+ return { success: true };
138
+ }
139
+
140
+ /**
141
+ * Reset a user's password.
142
+ */
143
+ export async function resetPassword(id, newPassword) {
144
+ await requireAdmin();
145
+
146
+ if (!newPassword || newPassword.length < 8) {
147
+ return { error: 'Password must be at least 8 characters.' };
148
+ }
149
+
150
+ const updated = updateUserPasswordById(id, newPassword);
151
+ if (!updated) {
152
+ return { error: 'User not found.' };
153
+ }
154
+ return { success: true };
155
+ }
156
+
157
+ /**
158
+ * Update the current user's own email and/or password.
159
+ * Requires current password for verification.
160
+ */
161
+ export async function updateProfile({ email, currentPassword, newPassword }) {
162
+ const sessionUser = await requireAuth();
163
+
164
+ if (!currentPassword) {
165
+ return { error: 'Current password is required.' };
166
+ }
167
+
168
+ const user = getUserByEmail(sessionUser.email);
169
+ if (!user) {
170
+ return { error: 'User not found.' };
171
+ }
172
+
173
+ const valid = await verifyPassword(user, currentPassword);
174
+ if (!valid) {
175
+ return { error: 'Current password is incorrect.' };
176
+ }
177
+
178
+ try {
179
+ if (email !== undefined && email !== sessionUser.email) {
180
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
181
+ return { error: 'Invalid email format.' };
182
+ }
183
+ updateUserEmail(user.id, email);
184
+ }
185
+
186
+ if (newPassword) {
187
+ if (newPassword.length < 8) {
188
+ return { error: 'New password must be at least 8 characters.' };
189
+ }
190
+ updateUserPasswordById(user.id, newPassword);
191
+ }
192
+
193
+ return { success: true };
194
+ } catch (err) {
195
+ if (err.message?.includes('UNIQUE constraint')) {
196
+ return { error: 'A user with this email already exists.' };
197
+ }
198
+ return { error: 'Failed to update profile.' };
199
+ }
200
+ }
@@ -45,6 +45,11 @@ export const middleware = auth((req) => {
45
45
 
46
46
  return response;
47
47
  }
48
+
49
+ // Admin panel requires admin role (after auth check above)
50
+ if (pathname.startsWith('/admin') && req.auth?.user?.role !== 'admin') {
51
+ return NextResponse.redirect(new URL('/forbidden', req.url));
52
+ }
48
53
  });
49
54
 
50
55
  export const config = {