thepopebot 1.1.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/api/index.js +72 -165
- package/bin/cli.js +36 -6
- package/bin/local.sh +31 -0
- package/bin/postinstall.js +6 -2
- package/config/index.js +2 -11
- package/config/instrumentation.js +17 -5
- package/lib/actions.js +7 -6
- package/lib/ai/agent.js +36 -0
- package/lib/ai/index.js +274 -0
- package/lib/ai/model.js +67 -0
- package/lib/ai/tools.js +49 -0
- package/lib/auth/actions.js +28 -0
- package/lib/auth/config.js +45 -0
- package/lib/auth/index.js +27 -0
- package/lib/auth/middleware.js +30 -0
- package/lib/channels/base.js +56 -0
- package/lib/channels/index.js +15 -0
- package/lib/channels/telegram.js +146 -0
- package/lib/chat/actions.js +239 -0
- package/lib/chat/api.js +103 -0
- package/lib/chat/components/app-sidebar.js +161 -0
- package/lib/chat/components/app-sidebar.jsx +214 -0
- package/lib/chat/components/chat-header.js +9 -0
- package/lib/chat/components/chat-header.jsx +14 -0
- package/lib/chat/components/chat-input.js +230 -0
- package/lib/chat/components/chat-input.jsx +232 -0
- package/lib/chat/components/chat-nav-context.js +11 -0
- package/lib/chat/components/chat-nav-context.jsx +11 -0
- package/lib/chat/components/chat-page.js +70 -0
- package/lib/chat/components/chat-page.jsx +89 -0
- package/lib/chat/components/chat.js +78 -0
- package/lib/chat/components/chat.jsx +91 -0
- package/lib/chat/components/chats-page.js +170 -0
- package/lib/chat/components/chats-page.jsx +203 -0
- package/lib/chat/components/crons-page.js +144 -0
- package/lib/chat/components/crons-page.jsx +204 -0
- package/lib/chat/components/greeting.js +11 -0
- package/lib/chat/components/greeting.jsx +14 -0
- package/lib/chat/components/icons.js +518 -0
- package/lib/chat/components/icons.jsx +482 -0
- package/lib/chat/components/index.js +19 -0
- package/lib/chat/components/message.js +66 -0
- package/lib/chat/components/message.jsx +92 -0
- package/lib/chat/components/messages.js +63 -0
- package/lib/chat/components/messages.jsx +72 -0
- package/lib/chat/components/notifications-page.js +54 -0
- package/lib/chat/components/notifications-page.jsx +83 -0
- package/lib/chat/components/page-layout.js +21 -0
- package/lib/chat/components/page-layout.jsx +28 -0
- package/lib/chat/components/settings-layout.js +37 -0
- package/lib/chat/components/settings-layout.jsx +51 -0
- package/lib/chat/components/settings-secrets-page.js +216 -0
- package/lib/chat/components/settings-secrets-page.jsx +264 -0
- package/lib/chat/components/sidebar-history-item.js +54 -0
- package/lib/chat/components/sidebar-history-item.jsx +50 -0
- package/lib/chat/components/sidebar-history.js +92 -0
- package/lib/chat/components/sidebar-history.jsx +132 -0
- package/lib/chat/components/sidebar-user-nav.js +59 -0
- package/lib/chat/components/sidebar-user-nav.jsx +69 -0
- package/lib/chat/components/swarm-page.js +250 -0
- package/lib/chat/components/swarm-page.jsx +356 -0
- package/lib/chat/components/triggers-page.js +121 -0
- package/lib/chat/components/triggers-page.jsx +177 -0
- package/lib/chat/components/ui/dropdown-menu.js +98 -0
- package/lib/chat/components/ui/dropdown-menu.jsx +116 -0
- package/lib/chat/components/ui/scroll-area.js +13 -0
- package/lib/chat/components/ui/scroll-area.jsx +17 -0
- package/lib/chat/components/ui/separator.js +21 -0
- package/lib/chat/components/ui/separator.jsx +18 -0
- package/lib/chat/components/ui/sheet.js +75 -0
- package/lib/chat/components/ui/sheet.jsx +95 -0
- package/lib/chat/components/ui/sidebar.js +227 -0
- package/lib/chat/components/ui/sidebar.jsx +245 -0
- package/lib/chat/components/ui/tooltip.js +56 -0
- package/lib/chat/components/ui/tooltip.jsx +66 -0
- package/lib/chat/utils.js +11 -0
- package/lib/cron.js +7 -8
- package/lib/db/api-keys.js +160 -0
- package/lib/db/chats.js +129 -0
- package/lib/db/index.js +106 -0
- package/lib/db/notifications.js +99 -0
- package/lib/db/schema.js +51 -0
- package/lib/db/users.js +89 -0
- package/lib/paths.js +23 -17
- package/lib/tools/create-job.js +3 -3
- package/lib/tools/github.js +145 -1
- package/lib/tools/openai.js +1 -1
- package/lib/tools/telegram.js +4 -3
- package/lib/triggers.js +6 -7
- package/lib/utils/render-md.js +6 -6
- package/package.json +43 -6
- package/setup/lib/auth.mjs +22 -9
- package/setup/lib/prerequisites.mjs +10 -3
- package/setup/lib/telegram-verify.mjs +3 -16
- package/setup/setup-telegram.mjs +31 -62
- package/setup/setup.mjs +58 -98
- package/templates/.dockerignore +5 -0
- package/templates/.env.example +18 -2
- package/templates/.github/workflows/auto-merge.yml +1 -1
- package/templates/.github/workflows/build-image.yml +6 -4
- package/templates/.github/workflows/notify-job-failed.yml +2 -2
- package/templates/.github/workflows/notify-pr-complete.yml +2 -2
- package/templates/.github/workflows/run-job.yml +24 -10
- package/templates/CLAUDE.md +5 -3
- package/templates/app/api/auth/[...nextauth]/route.js +1 -0
- package/templates/app/api/chat/route.js +1 -0
- package/templates/app/chat/[chatId]/page.js +8 -0
- package/templates/app/chats/page.js +7 -0
- package/templates/app/components/ascii-logo.jsx +10 -0
- package/templates/app/components/login-form.jsx +81 -0
- package/templates/app/components/setup-form.jsx +82 -0
- package/templates/app/components/theme-provider.jsx +11 -0
- package/templates/app/components/theme-toggle.jsx +38 -0
- package/templates/app/components/ui/button.jsx +21 -0
- package/templates/app/components/ui/card.jsx +23 -0
- package/templates/app/components/ui/input.jsx +10 -0
- package/templates/app/components/ui/label.jsx +10 -0
- package/templates/app/crons/page.js +7 -0
- package/templates/app/globals.css +66 -0
- package/templates/app/layout.js +9 -2
- package/templates/app/login/page.js +15 -0
- package/templates/app/notifications/page.js +7 -0
- package/templates/app/page.js +6 -30
- package/templates/app/settings/layout.js +7 -0
- package/templates/app/settings/page.js +5 -0
- package/templates/app/settings/secrets/page.js +5 -0
- package/templates/app/swarm/page.js +7 -0
- package/templates/app/triggers/page.js +7 -0
- package/templates/config/CRONS.json +2 -2
- package/templates/config/TRIGGERS.json +2 -2
- package/templates/docker/event_handler/Dockerfile +19 -0
- package/templates/docker/{entrypoint.sh → job/entrypoint.sh} +4 -4
- package/templates/docker/runner/Dockerfile +38 -0
- package/templates/docker/runner/entrypoint.sh +41 -0
- package/templates/docker-compose.yml +52 -0
- package/templates/instrumentation.js +6 -1
- package/templates/middleware.js +1 -0
- package/templates/postcss.config.mjs +5 -0
- package/lib/claude/conversation.js +0 -76
- package/lib/claude/index.js +0 -142
- package/lib/claude/tools.js +0 -54
- /package/templates/docker/{Dockerfile → job/Dockerfile} +0 -0
package/lib/ai/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
|
2
|
+
import { getAgent } from './agent.js';
|
|
3
|
+
import { createModel } from './model.js';
|
|
4
|
+
import { jobSummaryMd } from '../paths.js';
|
|
5
|
+
import { render_md } from '../utils/render-md.js';
|
|
6
|
+
import { getChatById, createChat, saveMessage, updateChatTitle } from '../db/chats.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensure a chat exists in the DB and save a message.
|
|
10
|
+
* Centralized so every channel gets persistence automatically.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} threadId - Chat/thread ID
|
|
13
|
+
* @param {string} role - 'user' or 'assistant'
|
|
14
|
+
* @param {string} text - Message text
|
|
15
|
+
* @param {object} [options] - { userId, chatTitle }
|
|
16
|
+
*/
|
|
17
|
+
function persistMessage(threadId, role, text, options = {}) {
|
|
18
|
+
try {
|
|
19
|
+
if (!getChatById(threadId)) {
|
|
20
|
+
createChat(options.userId || 'unknown', options.chatTitle || 'New Chat', threadId);
|
|
21
|
+
}
|
|
22
|
+
saveMessage(threadId, role, text);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
// DB persistence is best-effort — don't break chat if DB fails
|
|
25
|
+
console.error('Failed to persist message:', err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Process a chat message through the LangGraph agent.
|
|
31
|
+
* Saves user and assistant messages to the DB automatically.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} threadId - Conversation thread ID (from channel adapter)
|
|
34
|
+
* @param {string} message - User's message text
|
|
35
|
+
* @param {Array} [attachments=[]] - Normalized attachments from adapter
|
|
36
|
+
* @param {object} [options] - { userId, chatTitle } for DB persistence
|
|
37
|
+
* @returns {Promise<string>} AI response text
|
|
38
|
+
*/
|
|
39
|
+
async function chat(threadId, message, attachments = [], options = {}) {
|
|
40
|
+
const agent = await getAgent();
|
|
41
|
+
|
|
42
|
+
// Save user message to DB
|
|
43
|
+
persistMessage(threadId, 'user', message || '[attachment]', options);
|
|
44
|
+
|
|
45
|
+
// Build content blocks: text + any image attachments as base64 vision
|
|
46
|
+
const content = [];
|
|
47
|
+
|
|
48
|
+
if (message) {
|
|
49
|
+
content.push({ type: 'text', text: message });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const att of attachments) {
|
|
53
|
+
if (att.category === 'image') {
|
|
54
|
+
content.push({
|
|
55
|
+
type: 'image_url',
|
|
56
|
+
image_url: {
|
|
57
|
+
url: `data:${att.mimeType};base64,${att.data.toString('base64')}`,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Documents: future handling
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// If only text and no attachments, simplify to a string
|
|
65
|
+
const messageContent = content.length === 1 && content[0].type === 'text'
|
|
66
|
+
? content[0].text
|
|
67
|
+
: content;
|
|
68
|
+
|
|
69
|
+
const result = await agent.invoke(
|
|
70
|
+
{ messages: [new HumanMessage({ content: messageContent })] },
|
|
71
|
+
{ configurable: { thread_id: threadId } }
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const lastMessage = result.messages[result.messages.length - 1];
|
|
75
|
+
|
|
76
|
+
// LangChain message content can be a string or an array of content blocks
|
|
77
|
+
let response;
|
|
78
|
+
if (typeof lastMessage.content === 'string') {
|
|
79
|
+
response = lastMessage.content;
|
|
80
|
+
} else {
|
|
81
|
+
response = lastMessage.content
|
|
82
|
+
.filter((block) => block.type === 'text')
|
|
83
|
+
.map((block) => block.text)
|
|
84
|
+
.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Save assistant response to DB
|
|
88
|
+
persistMessage(threadId, 'assistant', response, options);
|
|
89
|
+
|
|
90
|
+
// Auto-generate title for new chats
|
|
91
|
+
if (options.userId && message) {
|
|
92
|
+
autoTitle(threadId, message).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Process a chat message with streaming (for channels that support it).
|
|
100
|
+
* Saves user and assistant messages to the DB automatically.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} threadId - Conversation thread ID
|
|
103
|
+
* @param {string} message - User's message text
|
|
104
|
+
* @param {Array} [attachments=[]] - Image/PDF attachments: { category, mimeType, dataUrl }
|
|
105
|
+
* @param {object} [options] - { userId, chatTitle } for DB persistence
|
|
106
|
+
* @returns {AsyncIterableIterator<string>} Stream of text chunks
|
|
107
|
+
*/
|
|
108
|
+
async function* chatStream(threadId, message, attachments = [], options = {}) {
|
|
109
|
+
const agent = await getAgent();
|
|
110
|
+
|
|
111
|
+
// Save user message to DB
|
|
112
|
+
persistMessage(threadId, 'user', message || '[attachment]', options);
|
|
113
|
+
|
|
114
|
+
// Build content blocks: text + any image/PDF attachments as vision
|
|
115
|
+
const content = [];
|
|
116
|
+
|
|
117
|
+
if (message) {
|
|
118
|
+
content.push({ type: 'text', text: message });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const att of attachments) {
|
|
122
|
+
if (att.category === 'image') {
|
|
123
|
+
// Support both dataUrl (web) and Buffer (Telegram) formats
|
|
124
|
+
const url = att.dataUrl
|
|
125
|
+
? att.dataUrl
|
|
126
|
+
: `data:${att.mimeType};base64,${att.data.toString('base64')}`;
|
|
127
|
+
content.push({
|
|
128
|
+
type: 'image_url',
|
|
129
|
+
image_url: { url },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// If only text and no attachments, simplify to a string
|
|
135
|
+
const messageContent = content.length === 1 && content[0].type === 'text'
|
|
136
|
+
? content[0].text
|
|
137
|
+
: content;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const stream = await agent.stream(
|
|
141
|
+
{ messages: [new HumanMessage({ content: messageContent })] },
|
|
142
|
+
{ configurable: { thread_id: threadId }, streamMode: 'messages' }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
let fullText = '';
|
|
146
|
+
|
|
147
|
+
for await (const event of stream) {
|
|
148
|
+
// streamMode: 'messages' yields [message, metadata] tuples
|
|
149
|
+
const msg = Array.isArray(event) ? event[0] : event;
|
|
150
|
+
const isAI = msg._getType?.() === 'ai';
|
|
151
|
+
if (!isAI) continue;
|
|
152
|
+
|
|
153
|
+
// Content can be a string or an array of content blocks
|
|
154
|
+
let text = '';
|
|
155
|
+
if (typeof msg.content === 'string') {
|
|
156
|
+
text = msg.content;
|
|
157
|
+
} else if (Array.isArray(msg.content)) {
|
|
158
|
+
text = msg.content
|
|
159
|
+
.filter((b) => b.type === 'text' && b.text)
|
|
160
|
+
.map((b) => b.text)
|
|
161
|
+
.join('');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (text) {
|
|
165
|
+
fullText += text;
|
|
166
|
+
yield text;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Save assistant response to DB
|
|
171
|
+
if (fullText) {
|
|
172
|
+
persistMessage(threadId, 'assistant', fullText, options);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Auto-generate title for new chats
|
|
176
|
+
if (options.userId && message) {
|
|
177
|
+
autoTitle(threadId, message).catch(() => {});
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error('[chatStream] error:', err);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Auto-generate a chat title from the first user message (fire-and-forget).
|
|
187
|
+
*/
|
|
188
|
+
async function autoTitle(threadId, firstMessage) {
|
|
189
|
+
try {
|
|
190
|
+
const chat = getChatById(threadId);
|
|
191
|
+
if (!chat || chat.title !== 'New Chat') return;
|
|
192
|
+
|
|
193
|
+
const model = await createModel({ maxTokens: 50 });
|
|
194
|
+
const response = await model.invoke([
|
|
195
|
+
['system', 'Generate a short (3-6 word) title for this chat based on the user\'s first message. Return ONLY the title, nothing else.'],
|
|
196
|
+
['human', firstMessage],
|
|
197
|
+
]);
|
|
198
|
+
const title = typeof response.content === 'string'
|
|
199
|
+
? response.content
|
|
200
|
+
: response.content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
201
|
+
const cleaned = title.replace(/^["']+|["']+$/g, '').trim();
|
|
202
|
+
if (cleaned) {
|
|
203
|
+
updateChatTitle(threadId, cleaned);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// Title generation is best-effort
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One-shot summarization with a different system prompt and no memory.
|
|
212
|
+
* Used for job completion summaries sent via GitHub webhook.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} results - Job results from webhook payload
|
|
215
|
+
* @returns {Promise<string>} Summary text
|
|
216
|
+
*/
|
|
217
|
+
async function summarizeJob(results) {
|
|
218
|
+
try {
|
|
219
|
+
const model = await createModel({ maxTokens: 1024 });
|
|
220
|
+
const systemPrompt = render_md(jobSummaryMd);
|
|
221
|
+
|
|
222
|
+
const userMessage = [
|
|
223
|
+
results.job ? `## Task\n${results.job}` : '',
|
|
224
|
+
results.commit_message ? `## Commit Message\n${results.commit_message}` : '',
|
|
225
|
+
results.changed_files?.length ? `## Changed Files\n${results.changed_files.join('\n')}` : '',
|
|
226
|
+
results.status ? `## Status\n${results.status}` : '',
|
|
227
|
+
results.merge_result ? `## Merge Result\n${results.merge_result}` : '',
|
|
228
|
+
results.pr_url ? `## PR URL\n${results.pr_url}` : '',
|
|
229
|
+
results.run_url ? `## Run URL\n${results.run_url}` : '',
|
|
230
|
+
results.log ? `## Agent Log\n${results.log}` : '',
|
|
231
|
+
]
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.join('\n\n');
|
|
234
|
+
|
|
235
|
+
const response = await model.invoke([
|
|
236
|
+
['system', systemPrompt],
|
|
237
|
+
['human', userMessage],
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const text =
|
|
241
|
+
typeof response.content === 'string'
|
|
242
|
+
? response.content
|
|
243
|
+
: response.content
|
|
244
|
+
.filter((block) => block.type === 'text')
|
|
245
|
+
.map((block) => block.text)
|
|
246
|
+
.join('\n');
|
|
247
|
+
|
|
248
|
+
return text.trim() || 'Job finished.';
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error('Failed to summarize job:', err);
|
|
251
|
+
return 'Job finished.';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Inject a message into a thread's memory so the agent has context
|
|
257
|
+
* for future conversations (e.g., job completion summaries).
|
|
258
|
+
*
|
|
259
|
+
* @param {string} threadId - Conversation thread ID
|
|
260
|
+
* @param {string} text - Message text to inject as an assistant message
|
|
261
|
+
*/
|
|
262
|
+
async function addToThread(threadId, text) {
|
|
263
|
+
try {
|
|
264
|
+
const agent = await getAgent();
|
|
265
|
+
await agent.updateState(
|
|
266
|
+
{ configurable: { thread_id: threadId } },
|
|
267
|
+
{ messages: [new AIMessage(text)] }
|
|
268
|
+
);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error('Failed to add message to thread:', err);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export { chat, chatStream, summarizeJob, addToThread, persistMessage };
|
package/lib/ai/model.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ChatAnthropic } from '@langchain/anthropic';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MODELS = {
|
|
4
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
5
|
+
openai: 'gpt-4o',
|
|
6
|
+
google: 'gemini-2.5-pro',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
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
|
|
17
|
+
* GOOGLE_API_KEY — Required for google provider
|
|
18
|
+
*
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param {number} [options.maxTokens=4096] - Max tokens for the response
|
|
21
|
+
* @returns {import('@langchain/core/language_models/chat_models').BaseChatModel}
|
|
22
|
+
*/
|
|
23
|
+
export async function createModel(options = {}) {
|
|
24
|
+
const provider = process.env.LLM_PROVIDER || 'anthropic';
|
|
25
|
+
const modelName = process.env.LLM_MODEL || DEFAULT_MODELS[provider] || DEFAULT_MODELS.anthropic;
|
|
26
|
+
const maxTokens = options.maxTokens || 4096;
|
|
27
|
+
|
|
28
|
+
switch (provider) {
|
|
29
|
+
case 'anthropic': {
|
|
30
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
31
|
+
if (!apiKey) {
|
|
32
|
+
throw new Error('ANTHROPIC_API_KEY environment variable is required');
|
|
33
|
+
}
|
|
34
|
+
return new ChatAnthropic({
|
|
35
|
+
modelName,
|
|
36
|
+
maxTokens,
|
|
37
|
+
anthropicApiKey: apiKey,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
case 'openai': {
|
|
41
|
+
const { ChatOpenAI } = await import('@langchain/openai');
|
|
42
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
43
|
+
if (!apiKey) {
|
|
44
|
+
throw new Error('OPENAI_API_KEY environment variable is required');
|
|
45
|
+
}
|
|
46
|
+
return new ChatOpenAI({
|
|
47
|
+
modelName,
|
|
48
|
+
maxTokens,
|
|
49
|
+
openAIApiKey: apiKey,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
case 'google': {
|
|
53
|
+
const { ChatGoogleGenerativeAI } = await import('@langchain/google-genai');
|
|
54
|
+
const apiKey = process.env.GOOGLE_API_KEY;
|
|
55
|
+
if (!apiKey) {
|
|
56
|
+
throw new Error('GOOGLE_API_KEY environment variable is required');
|
|
57
|
+
}
|
|
58
|
+
return new ChatGoogleGenerativeAI({
|
|
59
|
+
modelName,
|
|
60
|
+
maxOutputTokens: maxTokens,
|
|
61
|
+
apiKey,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
default:
|
|
65
|
+
throw new Error(`Unknown LLM provider: ${provider}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
package/lib/ai/tools.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { tool } from '@langchain/core/tools';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createJob } from '../tools/create-job.js';
|
|
4
|
+
import { getJobStatus } from '../tools/github.js';
|
|
5
|
+
|
|
6
|
+
const createJobTool = tool(
|
|
7
|
+
async ({ job_description }) => {
|
|
8
|
+
const result = await createJob(job_description);
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
success: true,
|
|
11
|
+
job_id: result.job_id,
|
|
12
|
+
branch: result.branch,
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'create_job',
|
|
17
|
+
description:
|
|
18
|
+
'Create an autonomous job for thepopebot to execute. Use this tool liberally - if the user asks for ANY task to be done, create a job. Jobs can handle code changes, file updates, research tasks, web scraping, data analysis, or anything requiring autonomous work. When the user explicitly asks for a job, ALWAYS use this tool. Returns the job ID and branch name.',
|
|
19
|
+
schema: z.object({
|
|
20
|
+
job_description: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe(
|
|
23
|
+
'Detailed job description including context and requirements. Be specific about what needs to be done.'
|
|
24
|
+
),
|
|
25
|
+
}),
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const getJobStatusTool = tool(
|
|
30
|
+
async ({ job_id }) => {
|
|
31
|
+
const result = await getJobStatus(job_id);
|
|
32
|
+
return JSON.stringify(result);
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'get_job_status',
|
|
36
|
+
description:
|
|
37
|
+
'Check status of running jobs. Returns list of active workflow runs with timing and current step. Use when user asks about job progress, running jobs, or job status.',
|
|
38
|
+
schema: z.object({
|
|
39
|
+
job_id: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe(
|
|
43
|
+
'Optional: specific job ID to check. If omitted, returns all running jobs.'
|
|
44
|
+
),
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export { createJobTool, getJobStatusTool };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { createFirstUser } from '../db/users.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create the first admin user (setup action).
|
|
7
|
+
* Uses atomic createFirstUser() to prevent race conditions.
|
|
8
|
+
* No session/token is created — the admin must log in through the normal auth flow.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} email
|
|
11
|
+
* @param {string} password
|
|
12
|
+
* @returns {Promise<{ success?: boolean, error?: string }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function setupAdmin(email, password) {
|
|
15
|
+
if (!email || !password) {
|
|
16
|
+
return { error: 'Email and password are required.' };
|
|
17
|
+
}
|
|
18
|
+
if (password.length < 8) {
|
|
19
|
+
return { error: 'Password must be at least 8 characters.' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const created = createFirstUser(email, password);
|
|
23
|
+
if (!created) {
|
|
24
|
+
return { error: 'Setup already completed.' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { success: true };
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import NextAuth from 'next-auth';
|
|
2
|
+
import Credentials from 'next-auth/providers/credentials';
|
|
3
|
+
|
|
4
|
+
// DB imports are dynamic inside authorize() so they don't get pulled in
|
|
5
|
+
// at module level — middleware runs on Edge which has no fs/better-sqlite3.
|
|
6
|
+
|
|
7
|
+
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
8
|
+
providers: [
|
|
9
|
+
Credentials({
|
|
10
|
+
credentials: {
|
|
11
|
+
email: { label: 'Email', type: 'email' },
|
|
12
|
+
password: { label: 'Password', type: 'password' },
|
|
13
|
+
},
|
|
14
|
+
async authorize(credentials) {
|
|
15
|
+
if (!credentials?.email || !credentials?.password) return null;
|
|
16
|
+
|
|
17
|
+
const { getUserByEmail, verifyPassword } = await import('../db/users.js');
|
|
18
|
+
const user = getUserByEmail(credentials.email);
|
|
19
|
+
if (!user) return null;
|
|
20
|
+
|
|
21
|
+
const valid = await verifyPassword(user, credentials.password);
|
|
22
|
+
if (!valid) return null;
|
|
23
|
+
|
|
24
|
+
return { id: user.id, email: user.email, role: user.role };
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
session: { strategy: 'jwt' },
|
|
29
|
+
pages: { signIn: '/login' },
|
|
30
|
+
callbacks: {
|
|
31
|
+
jwt({ token, user }) {
|
|
32
|
+
if (user) {
|
|
33
|
+
token.role = user.role;
|
|
34
|
+
}
|
|
35
|
+
return token;
|
|
36
|
+
},
|
|
37
|
+
session({ session, token }) {
|
|
38
|
+
if (session.user) {
|
|
39
|
+
session.user.id = token.sub;
|
|
40
|
+
session.user.role = token.role;
|
|
41
|
+
}
|
|
42
|
+
return session;
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { handlers, auth } from './config.js';
|
|
2
|
+
|
|
3
|
+
// Re-export Auth.js route handlers (GET + POST for [...nextauth])
|
|
4
|
+
export const { GET, POST } = handlers;
|
|
5
|
+
|
|
6
|
+
// Re-export auth for session checking
|
|
7
|
+
export { auth };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the auth state for the main page (server component).
|
|
11
|
+
* Returns both the session and whether setup is needed, in one call.
|
|
12
|
+
* DB import is dynamic so it doesn't get pulled in at module level.
|
|
13
|
+
*
|
|
14
|
+
* @returns {Promise<{ session: object|null, needsSetup: boolean }>}
|
|
15
|
+
*/
|
|
16
|
+
export async function getPageAuthState() {
|
|
17
|
+
const { getUserCount } = await import('../db/users.js');
|
|
18
|
+
const [session, userCount] = await Promise.all([
|
|
19
|
+
auth(),
|
|
20
|
+
Promise.resolve(getUserCount()),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
session,
|
|
25
|
+
needsSetup: userCount === 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { auth } from './config.js';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
|
|
4
|
+
export const middleware = auth((req) => {
|
|
5
|
+
const { pathname } = req.nextUrl;
|
|
6
|
+
|
|
7
|
+
// API routes use their own centralized auth (checkAuth in api/index.js)
|
|
8
|
+
if (pathname.startsWith('/api')) return;
|
|
9
|
+
|
|
10
|
+
// Static assets from public/ — skip auth for common file extensions
|
|
11
|
+
if (/\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|woff2?|ttf|eot|mp4|webm)$/i.test(pathname)) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// /login is the only unprotected page (login + first-user setup)
|
|
16
|
+
if (pathname === '/login') {
|
|
17
|
+
if (req.auth) return NextResponse.redirect(new URL('/', req.url));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Everything else requires auth
|
|
22
|
+
if (!req.auth) {
|
|
23
|
+
return NextResponse.redirect(new URL('/login', req.url));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const config = {
|
|
28
|
+
// Exclude all _next internal paths (static chunks, HMR, images, Turbopack dev assets)
|
|
29
|
+
matcher: ['/((?!_next|favicon.ico).*)'],
|
|
30
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base channel adapter interface.
|
|
3
|
+
* Every chat channel (Telegram, Slack, web, etc.) implements this contract.
|
|
4
|
+
*/
|
|
5
|
+
class ChannelAdapter {
|
|
6
|
+
/**
|
|
7
|
+
* Handle an incoming webhook request from this channel.
|
|
8
|
+
* Returns normalized message data or null if no action needed.
|
|
9
|
+
*
|
|
10
|
+
* @param {Request} request - Incoming HTTP request
|
|
11
|
+
* @returns {Promise<{ threadId: string, text: string, attachments: Array, metadata: object } | null>}
|
|
12
|
+
*
|
|
13
|
+
* Attachments array (may be empty) — only non-text content that the LLM needs to see:
|
|
14
|
+
* { category: "image", mimeType: "image/png", data: Buffer } — send to LLM as vision
|
|
15
|
+
* { category: "document", mimeType: "application/pdf", data: Buffer } — future: extract/attach
|
|
16
|
+
*
|
|
17
|
+
* The adapter downloads authenticated files and normalizes them.
|
|
18
|
+
* Voice/audio messages are fully resolved by the adapter — transcribed to text
|
|
19
|
+
* and included in the `text` field. They are NOT passed as attachments.
|
|
20
|
+
*/
|
|
21
|
+
async receive(request) {
|
|
22
|
+
throw new Error('Not implemented');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Called when message is received — adapter shows acknowledgment.
|
|
27
|
+
* Telegram: thumbs up reaction. Slack: emoji reaction. Web: no-op.
|
|
28
|
+
*/
|
|
29
|
+
async acknowledge(metadata) {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Called while AI is processing — adapter shows activity.
|
|
33
|
+
* Telegram: typing indicator. Slack: typing indicator. Web: no-op (streaming handles this).
|
|
34
|
+
* Returns a stop function.
|
|
35
|
+
*/
|
|
36
|
+
startProcessingIndicator(metadata) {
|
|
37
|
+
return () => {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Send a complete (non-streaming) response back to the channel.
|
|
42
|
+
*/
|
|
43
|
+
async sendResponse(threadId, text, metadata) {
|
|
44
|
+
throw new Error('Not implemented');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether this channel supports real streaming (e.g., web chat via Vercel AI SDK).
|
|
49
|
+
* If true, the AI layer provides a stream instead of a complete response.
|
|
50
|
+
*/
|
|
51
|
+
get supportsStreaming() {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { ChannelAdapter };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TelegramAdapter } from './telegram.js';
|
|
2
|
+
|
|
3
|
+
let _telegramAdapter = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the Telegram channel adapter (lazy singleton).
|
|
7
|
+
* @param {string} botToken - Telegram bot token
|
|
8
|
+
* @returns {TelegramAdapter}
|
|
9
|
+
*/
|
|
10
|
+
export function getTelegramAdapter(botToken) {
|
|
11
|
+
if (!_telegramAdapter || _telegramAdapter.botToken !== botToken) {
|
|
12
|
+
_telegramAdapter = new TelegramAdapter(botToken);
|
|
13
|
+
}
|
|
14
|
+
return _telegramAdapter;
|
|
15
|
+
}
|