@sulala/agent 0.1.6 → 0.1.7
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 +3 -2
- package/context/airtable.md +35 -0
- package/context/asana.md +37 -0
- package/context/bluesky.md +26 -91
- package/context/calendar.md +63 -0
- package/context/country-info.md +13 -0
- package/context/create-skill.md +128 -0
- package/context/discord.md +30 -0
- package/context/docs.md +29 -0
- package/context/drive.md +49 -0
- package/context/dropbox.md +39 -0
- package/context/facebook.md +47 -0
- package/context/fetch-form-api.md +16 -0
- package/context/figma.md +30 -0
- package/context/github.md +58 -0
- package/context/gmail.md +52 -0
- package/context/google.md +28 -0
- package/context/hellohub.md +29 -0
- package/context/jira.md +46 -0
- package/context/linear.md +40 -0
- package/context/notion.md +45 -0
- package/context/portal-integrations.md +42 -0
- package/context/post-to-x.md +50 -0
- package/context/sheets.md +47 -0
- package/context/slack.md +48 -0
- package/context/slides.md +35 -0
- package/context/stripe.md +38 -0
- package/context/tes.md +7 -0
- package/context/test.md +7 -0
- package/context/zoom.md +28 -0
- package/dist/agent/google/calendar.d.ts +2 -0
- package/dist/agent/google/calendar.d.ts.map +1 -0
- package/dist/agent/google/calendar.js +119 -0
- package/dist/agent/google/calendar.js.map +1 -0
- package/dist/agent/google/drive.d.ts +2 -0
- package/dist/agent/google/drive.d.ts.map +1 -0
- package/dist/agent/google/drive.js +51 -0
- package/dist/agent/google/drive.js.map +1 -0
- package/dist/agent/google/get-token.d.ts +7 -0
- package/dist/agent/google/get-token.d.ts.map +1 -0
- package/dist/agent/google/get-token.js +37 -0
- package/dist/agent/google/get-token.js.map +1 -0
- package/dist/agent/google/gmail.d.ts +2 -0
- package/dist/agent/google/gmail.d.ts.map +1 -0
- package/dist/agent/google/gmail.js +138 -0
- package/dist/agent/google/gmail.js.map +1 -0
- package/dist/agent/google/index.d.ts +2 -0
- package/dist/agent/google/index.d.ts.map +1 -0
- package/dist/agent/google/index.js +13 -0
- package/dist/agent/google/index.js.map +1 -0
- package/dist/agent/loop.d.ts +8 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +226 -40
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/memory.d.ts +21 -0
- package/dist/agent/memory.d.ts.map +1 -0
- package/dist/agent/memory.js +33 -0
- package/dist/agent/memory.js.map +1 -0
- package/dist/agent/pending-actions.d.ts +21 -0
- package/dist/agent/pending-actions.d.ts.map +1 -0
- package/dist/agent/pending-actions.js +65 -0
- package/dist/agent/pending-actions.js.map +1 -0
- package/dist/agent/pi-runner.d.ts +27 -0
- package/dist/agent/pi-runner.d.ts.map +1 -0
- package/dist/agent/pi-runner.js +300 -0
- package/dist/agent/pi-runner.js.map +1 -0
- package/dist/agent/skill-generate.d.ts +63 -0
- package/dist/agent/skill-generate.d.ts.map +1 -0
- package/dist/agent/skill-generate.js +128 -0
- package/dist/agent/skill-generate.js.map +1 -0
- package/dist/agent/skill-install.d.ts.map +1 -1
- package/dist/agent/skill-install.js +80 -31
- package/dist/agent/skill-install.js.map +1 -1
- package/dist/agent/skill-templates.d.ts +17 -0
- package/dist/agent/skill-templates.d.ts.map +1 -0
- package/dist/agent/skill-templates.js +26 -0
- package/dist/agent/skill-templates.js.map +1 -0
- package/dist/agent/skills-config.d.ts +24 -2
- package/dist/agent/skills-config.d.ts.map +1 -1
- package/dist/agent/skills-config.js +107 -8
- package/dist/agent/skills-config.js.map +1 -1
- package/dist/agent/skills-watcher.js +1 -1
- package/dist/agent/skills.d.ts +9 -3
- package/dist/agent/skills.d.ts.map +1 -1
- package/dist/agent/skills.js +104 -9
- package/dist/agent/skills.js.map +1 -1
- package/dist/agent/tools.d.ts +25 -3
- package/dist/agent/tools.d.ts.map +1 -1
- package/dist/agent/tools.integrations.test.d.ts +2 -0
- package/dist/agent/tools.integrations.test.d.ts.map +1 -0
- package/dist/agent/tools.integrations.test.js +269 -0
- package/dist/agent/tools.integrations.test.js.map +1 -0
- package/dist/agent/tools.js +692 -39
- package/dist/agent/tools.js.map +1 -1
- package/dist/ai/orchestrator.d.ts +4 -1
- package/dist/ai/orchestrator.d.ts.map +1 -1
- package/dist/ai/orchestrator.js +246 -14
- package/dist/ai/orchestrator.js.map +1 -1
- package/dist/ai/pricing.d.ts +6 -0
- package/dist/ai/pricing.d.ts.map +1 -0
- package/dist/ai/pricing.js +39 -0
- package/dist/ai/pricing.js.map +1 -0
- package/dist/channels/discord.d.ts +15 -0
- package/dist/channels/discord.d.ts.map +1 -0
- package/dist/channels/discord.js +55 -0
- package/dist/channels/discord.js.map +1 -0
- package/dist/channels/stripe.d.ts +15 -0
- package/dist/channels/stripe.d.ts.map +1 -0
- package/dist/channels/stripe.js +58 -0
- package/dist/channels/stripe.js.map +1 -0
- package/dist/channels/telegram.d.ts +60 -0
- package/dist/channels/telegram.d.ts.map +1 -0
- package/dist/channels/telegram.js +562 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/cli.js +66 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +85 -1
- package/dist/config.js.map +1 -1
- package/dist/db/index.d.ts +83 -0
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +174 -2
- package/dist/db/index.js.map +1 -1
- package/dist/db/schema.sql +35 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +1219 -27
- package/dist/gateway/server.js.map +1 -1
- package/dist/index.js +149 -6
- package/dist/index.js.map +1 -1
- package/dist/ollama-setup.d.ts +27 -0
- package/dist/ollama-setup.d.ts.map +1 -0
- package/dist/ollama-setup.js +191 -0
- package/dist/ollama-setup.js.map +1 -0
- package/dist/onboard-env.d.ts +1 -1
- package/dist/onboard-env.d.ts.map +1 -1
- package/dist/onboard-env.js +2 -0
- package/dist/onboard-env.js.map +1 -1
- package/dist/onboard.d.ts +3 -1
- package/dist/onboard.d.ts.map +1 -1
- package/dist/onboard.js +7 -2
- package/dist/onboard.js.map +1 -1
- package/dist/plugins/index.d.ts +10 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +32 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/redact.d.ts +15 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +56 -0
- package/dist/redact.js.map +1 -0
- package/dist/scheduler/cron.d.ts +21 -0
- package/dist/scheduler/cron.d.ts.map +1 -1
- package/dist/scheduler/cron.js +60 -0
- package/dist/scheduler/cron.js.map +1 -1
- package/dist/system-capabilities.d.ts +11 -0
- package/dist/system-capabilities.d.ts.map +1 -0
- package/dist/system-capabilities.js +109 -0
- package/dist/system-capabilities.js.map +1 -0
- package/dist/types.d.ts +62 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher/index.d.ts +2 -0
- package/dist/watcher/index.d.ts.map +1 -1
- package/dist/watcher/index.js +31 -1
- package/dist/watcher/index.js.map +1 -1
- package/dist/workspace-automations.d.ts +16 -0
- package/dist/workspace-automations.d.ts.map +1 -0
- package/dist/workspace-automations.js +133 -0
- package/dist/workspace-automations.js.map +1 -0
- package/package.json +19 -3
- package/registry/bluesky.md +12 -89
- package/registry/skills-registry.json +6 -0
- package/src/db/schema.sql +35 -0
- package/src/index.ts +159 -6
package/dist/gateway/server.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createServer } from 'http';
|
|
3
|
-
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
6
7
|
import { WebSocketServer } from 'ws';
|
|
7
8
|
import cors from 'cors';
|
|
8
|
-
import { config, getSulalaEnvPath } from '../config.js';
|
|
9
|
+
import { config, getSulalaEnvPath, getPortalGatewayBase, getEffectivePortalApiKey } from '../config.js';
|
|
9
10
|
import { readOnboardEnvKeys, writeOnboardEnvKeys, ONBOARD_ENV_WHITELIST, } from '../onboard-env.js';
|
|
10
|
-
import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, listAgentSessions, } from '../db/index.js';
|
|
11
|
+
import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, appendAgentMessage, updateAgentMessageToolResult, listAgentMemories, listAgentMemoryScopeKeys, listAgentSessions, listScheduledJobs, getScheduledJob, insertScheduledJob, updateScheduledJob, deleteScheduledJob, getTasksForJob, getChannelConfig, setChannelConfig, } from '../db/index.js';
|
|
12
|
+
import { scheduleCronById, unscheduleJob } from '../scheduler/cron.js';
|
|
13
|
+
import { getTelegramChannelState, setTelegramChannelConfig } from '../channels/telegram.js';
|
|
14
|
+
import { getDiscordChannelState, setDiscordChannelConfig } from '../channels/discord.js';
|
|
15
|
+
import { getStripeChannelState, setStripeChannelConfig } from '../channels/stripe.js';
|
|
11
16
|
import { runAgentTurn, runAgentTurnStream } from '../agent/loop.js';
|
|
17
|
+
import { runAgentTurnWithPi, isPiAvailable } from '../agent/pi-runner.js';
|
|
18
|
+
import { listTools, executeTool } from '../agent/tools.js';
|
|
12
19
|
import { listSkills, getAllRequiredBins } from '../agent/skills.js';
|
|
13
20
|
import { getRegistrySkills, getAvailableUpdates, installSkill, uninstallSkill, updateSkillsAll } from '../agent/skill-install.js';
|
|
14
|
-
import {
|
|
21
|
+
import { getTemplates } from '../agent/skill-templates.js';
|
|
22
|
+
import { generateSkillSpec, writeGeneratedSkill, WIZARD_APPS, WIZARD_TRIGGERS } from '../agent/skill-generate.js';
|
|
23
|
+
import { loadSkillsConfig, saveSkillsConfig, getConfigPath, setSkillEnabled, removeSkillEntry, migrateConfigNameKeysToSlug, getOnboardingComplete, setOnboardingComplete } from '../agent/skills-config.js';
|
|
24
|
+
import { redactSkillsConfig } from '../redact.js';
|
|
15
25
|
import { withSessionLock } from '../agent/session-queue.js';
|
|
26
|
+
import { listPendingActions, getPendingAction, getPendingActionForReplay, setPendingActionApproved, setPendingActionRejected, sanitizeArgsForDisplay, } from '../agent/pending-actions.js';
|
|
27
|
+
import { isOllamaReachable, startOllamaServeForApi, runOllamaInstall, setPullProgressCallback, pullOllamaModel } from '../ollama-setup.js';
|
|
28
|
+
import { getSystemCapabilities } from '../system-capabilities.js';
|
|
16
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
30
|
const projectRoot = join(__dirname, '..', '..');
|
|
18
31
|
const dashboardDist = join(projectRoot, 'dashboard', 'dist');
|
|
@@ -21,6 +34,25 @@ const rateLimitMap = new Map();
|
|
|
21
34
|
function rateLimitMiddleware(req, res, next) {
|
|
22
35
|
if (!config.rateLimitMax || config.rateLimitMax <= 0)
|
|
23
36
|
return next();
|
|
37
|
+
if (req.path === '/health')
|
|
38
|
+
return next();
|
|
39
|
+
// Don't rate-limit read-only or bootstrap endpoints the dashboard calls often
|
|
40
|
+
if (req.path.startsWith('/api/onboard'))
|
|
41
|
+
return next();
|
|
42
|
+
if (req.path === '/api/agent/pending-actions' && req.method === 'GET')
|
|
43
|
+
return next();
|
|
44
|
+
if (req.path === '/api/config' && req.method === 'GET')
|
|
45
|
+
return next();
|
|
46
|
+
if (req.path === '/api/integrations/connections' && req.method === 'GET')
|
|
47
|
+
return next();
|
|
48
|
+
if (req.path === '/api/integrations/connect' && req.method === 'POST')
|
|
49
|
+
return next();
|
|
50
|
+
if (req.path.startsWith('/api/integrations/connections/') && req.method === 'DELETE')
|
|
51
|
+
return next();
|
|
52
|
+
if (req.path === '/api/oauth/connect-url' && req.method === 'GET')
|
|
53
|
+
return next();
|
|
54
|
+
if (req.path === '/api/oauth/callback' && req.method === 'GET')
|
|
55
|
+
return next();
|
|
24
56
|
const ip = (req.ip || req.socket?.remoteAddress || 'unknown');
|
|
25
57
|
const now = Date.now();
|
|
26
58
|
let entry = rateLimitMap.get(ip);
|
|
@@ -40,11 +72,18 @@ export function createGateway(appMount = null) {
|
|
|
40
72
|
app.use(cors());
|
|
41
73
|
app.use(express.json());
|
|
42
74
|
app.use(rateLimitMiddleware);
|
|
75
|
+
try {
|
|
76
|
+
const skills = listSkills(config, { includeDisabled: true });
|
|
77
|
+
migrateConfigNameKeysToSlug(skills.map((s) => ({ name: s.name, slug: s.slug ?? s.name })));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* migration best-effort */
|
|
81
|
+
}
|
|
43
82
|
if (config.gatewayApiKey) {
|
|
44
83
|
app.use((req, res, next) => {
|
|
45
84
|
if (req.path === '/health')
|
|
46
85
|
return next();
|
|
47
|
-
if (req.path === '/onboard' || req.path.startsWith('/api/onboard'))
|
|
86
|
+
if (req.path === '/onboard' || req.path.startsWith('/api/onboard') || req.path.startsWith('/api/ollama'))
|
|
48
87
|
return next();
|
|
49
88
|
const key = req.headers['x-api-key'] || req.query.api_key;
|
|
50
89
|
if (key === config.gatewayApiKey)
|
|
@@ -93,9 +132,47 @@ export function createGateway(appMount = null) {
|
|
|
93
132
|
res.status(500).json({ error: e.message });
|
|
94
133
|
}
|
|
95
134
|
});
|
|
135
|
+
app.get('/api/system/capabilities', (_req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
const caps = getSystemCapabilities();
|
|
138
|
+
res.json(caps);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
log('gateway', 'error', e.message);
|
|
142
|
+
res.status(500).json({
|
|
143
|
+
storageFreeBytes: null,
|
|
144
|
+
isCloudOrContainer: false,
|
|
145
|
+
cloudReason: '',
|
|
146
|
+
ollamaSuitable: true,
|
|
147
|
+
ollamaSuitableReason: 'Check unavailable',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
96
151
|
app.get('/health', (_req, res) => {
|
|
97
152
|
res.json({ status: 'ok', service: 'sulala-gateway' });
|
|
98
153
|
});
|
|
154
|
+
/** Onboard: check if onboarding is complete (first-launch detection). */
|
|
155
|
+
app.get('/api/onboard/status', (_req, res) => {
|
|
156
|
+
try {
|
|
157
|
+
const complete = getOnboardingComplete();
|
|
158
|
+
res.json({ complete });
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
log('gateway', 'error', e.message);
|
|
162
|
+
res.status(500).json({ complete: false, error: e.message });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
/** Onboard: mark onboarding as complete. */
|
|
166
|
+
app.put('/api/onboard/complete', (_req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
setOnboardingComplete(true);
|
|
169
|
+
res.json({ ok: true, complete: true });
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
log('gateway', 'error', e.message);
|
|
173
|
+
res.status(500).json({ error: e.message });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
99
176
|
/** Onboard: which API keys are set (values never returned). */
|
|
100
177
|
app.get('/api/onboard/env', (_req, res) => {
|
|
101
178
|
try {
|
|
@@ -124,6 +201,76 @@ export function createGateway(appMount = null) {
|
|
|
124
201
|
res.status(500).json({ error: e.message });
|
|
125
202
|
}
|
|
126
203
|
});
|
|
204
|
+
/** Ollama status and setup (for onboard and dashboard). */
|
|
205
|
+
app.get('/api/ollama/status', async (_req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
const running = await isOllamaReachable();
|
|
208
|
+
const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
209
|
+
res.json({ running, baseUrl });
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
log('gateway', 'error', e.message);
|
|
213
|
+
res.status(500).json({ running: false, error: e.message });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
app.post('/api/ollama/start', async (_req, res) => {
|
|
217
|
+
try {
|
|
218
|
+
const result = await startOllamaServeForApi();
|
|
219
|
+
res.json(result);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
log('gateway', 'error', e.message);
|
|
223
|
+
res.status(500).json({ started: false, error: e.message });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
app.post('/api/ollama/install', (_req, res) => {
|
|
227
|
+
try {
|
|
228
|
+
runOllamaInstall();
|
|
229
|
+
res.json({ ok: true, message: 'Install started. Check your terminal or install from https://ollama.com' });
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
log('gateway', 'error', e.message);
|
|
233
|
+
res.status(500).json({ error: e.message });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
app.locals.ollamaPullState = { inProgress: false, model: '', lastLine: '', percent: 0 };
|
|
237
|
+
app.get('/api/ollama/pull-status', (_req, res) => {
|
|
238
|
+
res.json(app.locals.ollamaPullState ?? { inProgress: false, model: '', lastLine: '', percent: 0 });
|
|
239
|
+
});
|
|
240
|
+
setPullProgressCallback((model, line, percent) => {
|
|
241
|
+
const state = { inProgress: percent >= 0 && percent < 100, model, lastLine: line, percent };
|
|
242
|
+
app.locals.ollamaPullState = state;
|
|
243
|
+
const b = app.locals.wsBroadcast;
|
|
244
|
+
if (b)
|
|
245
|
+
b({ type: 'ollama_pull_progress', data: state });
|
|
246
|
+
});
|
|
247
|
+
app.post('/api/ollama/pull', (req, res) => {
|
|
248
|
+
try {
|
|
249
|
+
const { model } = req.body || {};
|
|
250
|
+
if (!model || typeof model !== 'string' || !model.trim()) {
|
|
251
|
+
res.status(400).json({ error: 'model required' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
pullOllamaModel(model.trim());
|
|
255
|
+
res.json({ ok: true, model: model.trim(), message: 'Pull started. Check pull-status for progress.' });
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
log('gateway', 'error', e.message);
|
|
259
|
+
res.status(500).json({ error: e.message });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
/** Recommended Ollama models for onboarding (size, RAM, CPU/GPU). */
|
|
263
|
+
const RECOMMENDED_OLLAMA_MODELS = [
|
|
264
|
+
{ id: 'llama3.2', name: 'Llama 3.2', size: '~2GB', ram: '8GB', cpu: 'Any', gpu: 'Optional', description: 'Good balance of speed and quality' },
|
|
265
|
+
{ id: 'llama3.2:1b', name: 'Llama 3.2 1B', size: '~1.3GB', ram: '4GB', cpu: 'Any', gpu: 'None', description: 'Fast, low resource' },
|
|
266
|
+
{ id: 'mistral', name: 'Mistral 7B', size: '~4.1GB', ram: '8GB', cpu: '4+ cores', gpu: 'Recommended', description: 'Strong general model' },
|
|
267
|
+
{ id: 'codellama', name: 'Code Llama', size: '~3.8GB', ram: '8GB', cpu: '4+ cores', gpu: 'Recommended', description: 'Optimized for code' },
|
|
268
|
+
{ id: 'phi3', name: 'Phi-3 Mini', size: '~2.3GB', ram: '8GB', cpu: 'Any', gpu: 'Optional', description: 'Efficient small model' },
|
|
269
|
+
{ id: 'gemma2:2b', name: 'Gemma 2 2B', size: '~1.6GB', ram: '4GB', cpu: 'Any', gpu: 'None', description: 'Lightweight, capable' },
|
|
270
|
+
];
|
|
271
|
+
app.get('/api/onboard/recommended-models', (_req, res) => {
|
|
272
|
+
res.json({ models: RECOMMENDED_OLLAMA_MODELS });
|
|
273
|
+
});
|
|
127
274
|
/** Onboard: minimal HTML page to add API keys (no dashboard required). */
|
|
128
275
|
app.get('/onboard', (_req, res) => {
|
|
129
276
|
const keys = ONBOARD_ENV_WHITELIST.map((k) => `<div class="row"><label for="${k}">${k}</label><input type="password" id="${k}" name="${k}" placeholder="optional" autocomplete="off" /></div>`).join('\n');
|
|
@@ -137,22 +284,37 @@ export function createGateway(appMount = null) {
|
|
|
137
284
|
* { box-sizing: border-box; }
|
|
138
285
|
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
|
|
139
286
|
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
|
287
|
+
h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; color: #374151; }
|
|
140
288
|
p { color: #666; font-size: 0.875rem; margin-bottom: 1.25rem; }
|
|
141
289
|
.row { margin-bottom: 0.75rem; }
|
|
142
290
|
label { display: block; font-size: 0.75rem; font-weight: 600; margin-bottom: 0.25rem; color: #374151; }
|
|
143
291
|
input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; }
|
|
144
292
|
input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
145
|
-
button { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; cursor: pointer; margin-top: 0.5rem; }
|
|
293
|
+
button { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; cursor: pointer; margin-top: 0.5rem; margin-right: 0.5rem; }
|
|
146
294
|
button:hover { background: #1d4ed8; }
|
|
295
|
+
button.secondary { background: #6b7280; }
|
|
296
|
+
button.secondary:hover { background: #4b5563; }
|
|
147
297
|
.msg { margin-top: 1rem; font-size: 0.875rem; }
|
|
148
298
|
.msg.success { color: #059669; }
|
|
149
299
|
.msg.err { color: #dc2626; }
|
|
300
|
+
.ollama-ok { color: #059669; font-size: 0.875rem; }
|
|
150
301
|
a { color: #2563eb; }
|
|
302
|
+
#ollama-section { margin-bottom: 1rem; padding: 1rem; background: #f9fafb; border-radius: 8px; }
|
|
151
303
|
</style>
|
|
152
304
|
</head>
|
|
153
305
|
<body>
|
|
154
|
-
<
|
|
155
|
-
|
|
306
|
+
<div id="ollama-section">
|
|
307
|
+
<h2>Ollama (default AI)</h2>
|
|
308
|
+
<p id="ollama-status">Checking…</p>
|
|
309
|
+
<div id="ollama-actions" style="display: none;">
|
|
310
|
+
<button type="button" id="ollama-install">Install Ollama</button>
|
|
311
|
+
<button type="button" id="ollama-start">Start Ollama</button>
|
|
312
|
+
<button type="button" id="ollama-check" class="secondary">Check again</button>
|
|
313
|
+
<p style="margin-top: 0.75rem; font-size: 0.8rem;">Or install from <a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a> or run in terminal: <code>ollama serve</code></p>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
<h1>API keys (optional)</h1>
|
|
317
|
+
<p>Ollama works with no key. Add keys below to use OpenAI, Claude, Gemini, or OpenRouter. Saved to <code>~/.sulala/.env</code>. Restart the agent for changes.</p>
|
|
156
318
|
<form id="f">
|
|
157
319
|
${keys}
|
|
158
320
|
<button type="submit">Save</button>
|
|
@@ -162,6 +324,47 @@ export function createGateway(appMount = null) {
|
|
|
162
324
|
<script>
|
|
163
325
|
const form = document.getElementById('f');
|
|
164
326
|
const msg = document.getElementById('msg');
|
|
327
|
+
const ollamaStatus = document.getElementById('ollama-status');
|
|
328
|
+
const ollamaActions = document.getElementById('ollama-actions');
|
|
329
|
+
async function checkOllama() {
|
|
330
|
+
ollamaStatus.textContent = 'Checking…';
|
|
331
|
+
ollamaActions.style.display = 'none';
|
|
332
|
+
try {
|
|
333
|
+
const r = await fetch('/api/ollama/status');
|
|
334
|
+
const d = await r.json();
|
|
335
|
+
if (d.running) {
|
|
336
|
+
ollamaStatus.textContent = 'Ollama is running. You can use Chat.';
|
|
337
|
+
ollamaStatus.className = 'ollama-ok';
|
|
338
|
+
} else {
|
|
339
|
+
ollamaStatus.textContent = 'Ollama is not running.';
|
|
340
|
+
ollamaStatus.className = '';
|
|
341
|
+
ollamaActions.style.display = 'block';
|
|
342
|
+
}
|
|
343
|
+
} catch (e) {
|
|
344
|
+
ollamaStatus.textContent = 'Could not check Ollama.';
|
|
345
|
+
ollamaActions.style.display = 'block';
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
document.getElementById('ollama-install').onclick = async () => {
|
|
349
|
+
ollamaStatus.textContent = 'Starting install… (see terminal or install from ollama.com)';
|
|
350
|
+
try {
|
|
351
|
+
await fetch('/api/ollama/install', { method: 'POST' });
|
|
352
|
+
ollamaStatus.textContent = 'Install started. After it finishes, run: ollama serve';
|
|
353
|
+
ollamaActions.style.display = 'block';
|
|
354
|
+
} catch (e) { ollamaStatus.textContent = e.message; }
|
|
355
|
+
};
|
|
356
|
+
document.getElementById('ollama-start').onclick = async () => {
|
|
357
|
+
ollamaStatus.textContent = 'Starting Ollama…';
|
|
358
|
+
try {
|
|
359
|
+
const r = await fetch('/api/ollama/start', { method: 'POST' });
|
|
360
|
+
const d = await r.json();
|
|
361
|
+
if (d.started) ollamaStatus.textContent = 'Ollama starting… Check again in a few seconds.';
|
|
362
|
+
else ollamaStatus.textContent = 'Ollama not found. Install it first (button above or ollama.com).';
|
|
363
|
+
ollamaActions.style.display = 'block';
|
|
364
|
+
} catch (e) { ollamaStatus.textContent = e.message; }
|
|
365
|
+
};
|
|
366
|
+
document.getElementById('ollama-check').onclick = checkOllama;
|
|
367
|
+
checkOllama();
|
|
165
368
|
fetch('/api/onboard/env').then(r => r.json()).then(d => {
|
|
166
369
|
for (const [key, status] of Object.entries(d.keys || {})) {
|
|
167
370
|
const el = document.getElementById(key);
|
|
@@ -241,10 +444,377 @@ export function createGateway(appMount = null) {
|
|
|
241
444
|
res.status(500).json({ error: e.message });
|
|
242
445
|
}
|
|
243
446
|
});
|
|
447
|
+
app.get('/api/channels/telegram', (_req, res) => {
|
|
448
|
+
try {
|
|
449
|
+
const state = getTelegramChannelState();
|
|
450
|
+
res.json(state);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
log('gateway', 'error', e.message);
|
|
454
|
+
res.status(500).json({ error: e.message });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
app.put('/api/channels/telegram', async (req, res) => {
|
|
458
|
+
try {
|
|
459
|
+
const { enabled, botToken, dmPolicy, allowFrom, notificationChatId, defaultProvider, defaultModel } = req.body || {};
|
|
460
|
+
const result = await setTelegramChannelConfig({
|
|
461
|
+
enabled: typeof enabled === 'boolean' ? enabled : undefined,
|
|
462
|
+
botToken: botToken !== undefined ? botToken : undefined,
|
|
463
|
+
dmPolicy: typeof dmPolicy === 'string' ? dmPolicy : undefined,
|
|
464
|
+
allowFrom: Array.isArray(allowFrom) ? allowFrom : undefined,
|
|
465
|
+
notificationChatId: notificationChatId !== undefined ? (typeof notificationChatId === 'number' ? notificationChatId : parseInt(String(notificationChatId), 10)) : undefined,
|
|
466
|
+
defaultProvider: defaultProvider !== undefined ? (typeof defaultProvider === 'string' ? defaultProvider : null) : undefined,
|
|
467
|
+
defaultModel: defaultModel !== undefined ? (typeof defaultModel === 'string' ? defaultModel : null) : undefined,
|
|
468
|
+
});
|
|
469
|
+
if (!result.ok) {
|
|
470
|
+
res.status(400).json({ error: result.error || 'Failed to save' });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const state = getTelegramChannelState();
|
|
474
|
+
res.json(state);
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
log('gateway', 'error', e.message);
|
|
478
|
+
res.status(500).json({ error: e.message });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
app.get('/api/channels/discord', (_req, res) => {
|
|
482
|
+
try {
|
|
483
|
+
const state = getDiscordChannelState();
|
|
484
|
+
res.json(state);
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
log('gateway', 'error', e.message);
|
|
488
|
+
res.status(500).json({ error: e.message });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
app.put('/api/channels/discord', async (req, res) => {
|
|
492
|
+
try {
|
|
493
|
+
const { botToken } = req.body || {};
|
|
494
|
+
const result = await setDiscordChannelConfig({
|
|
495
|
+
botToken: botToken !== undefined ? (typeof botToken === 'string' ? botToken : null) : undefined,
|
|
496
|
+
});
|
|
497
|
+
if (!result.ok) {
|
|
498
|
+
res.status(400).json({ error: result.error || 'Failed to save' });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const state = getDiscordChannelState();
|
|
502
|
+
res.json(state);
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
log('gateway', 'error', e.message);
|
|
506
|
+
res.status(500).json({ error: e.message });
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
app.get('/api/channels/stripe', (_req, res) => {
|
|
510
|
+
try {
|
|
511
|
+
const state = getStripeChannelState();
|
|
512
|
+
res.json(state);
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
log('gateway', 'error', e.message);
|
|
516
|
+
res.status(500).json({ error: e.message });
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
app.put('/api/channels/stripe', async (req, res) => {
|
|
520
|
+
try {
|
|
521
|
+
const { secretKey } = req.body || {};
|
|
522
|
+
const result = await setStripeChannelConfig({
|
|
523
|
+
secretKey: secretKey !== undefined ? (typeof secretKey === 'string' ? secretKey : null) : undefined,
|
|
524
|
+
});
|
|
525
|
+
if (!result.ok) {
|
|
526
|
+
res.status(400).json({ error: result.error || 'Failed to save' });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const state = getStripeChannelState();
|
|
530
|
+
res.json(state);
|
|
531
|
+
}
|
|
532
|
+
catch (e) {
|
|
533
|
+
log('gateway', 'error', e.message);
|
|
534
|
+
res.status(500).json({ error: e.message });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
/** Returns connected integration provider ids (from Portal). When Portal is not configured, returns []. */
|
|
538
|
+
async function getConnectedIntegrationIds() {
|
|
539
|
+
const base = getPortalGatewayBase();
|
|
540
|
+
const key = getEffectivePortalApiKey();
|
|
541
|
+
if (!base || !key)
|
|
542
|
+
return [];
|
|
543
|
+
try {
|
|
544
|
+
const portalRes = await fetch(`${base}/connections`, {
|
|
545
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
546
|
+
});
|
|
547
|
+
if (!portalRes.ok)
|
|
548
|
+
return [];
|
|
549
|
+
const data = (await portalRes.json());
|
|
550
|
+
const raw = data.connections ?? [];
|
|
551
|
+
const ids = new Set();
|
|
552
|
+
for (const c of raw) {
|
|
553
|
+
const p = (c.provider ?? '').trim();
|
|
554
|
+
if (p)
|
|
555
|
+
ids.add(p);
|
|
556
|
+
}
|
|
557
|
+
return Array.from(ids);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** GET /api/integrations/connections — When Portal is configured, proxy list from Portal (dashboard can show connections like direct mode). */
|
|
564
|
+
app.get('/api/integrations/connections', async (_req, res) => {
|
|
565
|
+
try {
|
|
566
|
+
const base = getPortalGatewayBase();
|
|
567
|
+
const key = getEffectivePortalApiKey();
|
|
568
|
+
if (!base || !key) {
|
|
569
|
+
res.status(404).json({ error: 'Portal not configured', connections: [] });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const portalRes = await fetch(`${base}/connections`, {
|
|
573
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
574
|
+
});
|
|
575
|
+
if (!portalRes.ok) {
|
|
576
|
+
const text = await portalRes.text();
|
|
577
|
+
let err = `Portal: ${portalRes.status}`;
|
|
578
|
+
try {
|
|
579
|
+
const j = JSON.parse(text);
|
|
580
|
+
if (j.error)
|
|
581
|
+
err = j.error;
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
if (text)
|
|
585
|
+
err = text.slice(0, 200);
|
|
586
|
+
}
|
|
587
|
+
res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err, connections: [] });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const data = (await portalRes.json());
|
|
591
|
+
const raw = data.connections ?? [];
|
|
592
|
+
const connections = raw.map((c) => {
|
|
593
|
+
const created = c.created_at;
|
|
594
|
+
const createdAt = typeof created === 'number' ? created : created ? new Date(created).getTime() : 0;
|
|
595
|
+
return {
|
|
596
|
+
id: c.connection_id ?? c.id ?? '',
|
|
597
|
+
provider: c.provider ?? '',
|
|
598
|
+
scopes: [],
|
|
599
|
+
createdAt,
|
|
600
|
+
updatedAt: undefined,
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
res.json({ connections });
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
log('gateway', 'error', e.message);
|
|
607
|
+
res.status(500).json({ error: e.message, connections: [] });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
/** POST /api/integrations/connect — When Portal configured, start OAuth via Portal. Body: { provider, redirect_success }. Returns { authUrl, connectionId }. */
|
|
611
|
+
app.post('/api/integrations/connect', async (req, res) => {
|
|
612
|
+
try {
|
|
613
|
+
const base = getPortalGatewayBase();
|
|
614
|
+
const key = getEffectivePortalApiKey();
|
|
615
|
+
if (!base || !key) {
|
|
616
|
+
res.status(404).json({ error: 'Portal not configured' });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const body = req.body;
|
|
620
|
+
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
621
|
+
if (!provider) {
|
|
622
|
+
res.status(400).json({ error: 'provider required' });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const portalRes = await fetch(`${base}/connect`, {
|
|
626
|
+
method: 'POST',
|
|
627
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
628
|
+
body: JSON.stringify({ provider, redirect_success: body.redirect_success || undefined }),
|
|
629
|
+
});
|
|
630
|
+
const text = await portalRes.text();
|
|
631
|
+
if (!portalRes.ok) {
|
|
632
|
+
let err = `Portal: ${portalRes.status}`;
|
|
633
|
+
try {
|
|
634
|
+
const j = JSON.parse(text);
|
|
635
|
+
if (j.error)
|
|
636
|
+
err = j.error;
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
if (text)
|
|
640
|
+
err = text.slice(0, 200);
|
|
641
|
+
}
|
|
642
|
+
res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const data = JSON.parse(text);
|
|
646
|
+
if (!data.authUrl) {
|
|
647
|
+
res.status(502).json({ error: 'No authUrl from Portal' });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
res.json({ authUrl: data.authUrl, connectionId: data.connectionId ?? null });
|
|
651
|
+
}
|
|
652
|
+
catch (e) {
|
|
653
|
+
log('gateway', 'error', e.message);
|
|
654
|
+
res.status(500).json({ error: e.message });
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
/** DELETE /api/integrations/connections/:id — When Portal configured, disconnect via Portal. */
|
|
658
|
+
app.delete('/api/integrations/connections/:id', async (req, res) => {
|
|
659
|
+
try {
|
|
660
|
+
const base = getPortalGatewayBase();
|
|
661
|
+
const key = getEffectivePortalApiKey();
|
|
662
|
+
if (!base || !key) {
|
|
663
|
+
res.status(404).json({ error: 'Portal not configured' });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const rawId = req.params?.id;
|
|
667
|
+
const id = Array.isArray(rawId) ? rawId[0] : rawId;
|
|
668
|
+
if (!id || typeof id !== 'string') {
|
|
669
|
+
res.status(400).json({ error: 'connection id required' });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const portalRes = await fetch(`${base}/connections/${encodeURIComponent(id)}`, {
|
|
673
|
+
method: 'DELETE',
|
|
674
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
675
|
+
});
|
|
676
|
+
if (!portalRes.ok) {
|
|
677
|
+
const text = await portalRes.text();
|
|
678
|
+
let err = `Portal: ${portalRes.status}`;
|
|
679
|
+
try {
|
|
680
|
+
const j = JSON.parse(text);
|
|
681
|
+
if (j.error)
|
|
682
|
+
err = j.error;
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
if (text)
|
|
686
|
+
err = text.slice(0, 200);
|
|
687
|
+
}
|
|
688
|
+
res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
res.json({ ok: true });
|
|
692
|
+
}
|
|
693
|
+
catch (e) {
|
|
694
|
+
log('gateway', 'error', e.message);
|
|
695
|
+
res.status(500).json({ error: e.message });
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
/** GET /api/oauth/connect-url — Build Portal "Connect with Sulala" URL for dashboard. Requires PORTAL_GATEWAY_URL, PORTAL_OAUTH_CLIENT_ID; redirect_uri = this gateway base + /api/oauth/callback. Optional return_to encoded in state for callback redirect. */
|
|
699
|
+
app.get('/api/oauth/connect-url', (req, res) => {
|
|
700
|
+
try {
|
|
701
|
+
const portalGateway = getPortalGatewayBase();
|
|
702
|
+
const portalBase = portalGateway ? portalGateway.replace(/\/api\/gateway$/i, '') : '';
|
|
703
|
+
const clientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
|
|
704
|
+
if (!portalBase || !clientId) {
|
|
705
|
+
res.status(503).json({
|
|
706
|
+
error: 'OAuth not configured',
|
|
707
|
+
hint: 'Set PORTAL_GATEWAY_URL and PORTAL_OAUTH_CLIENT_ID (and PORTAL_OAUTH_CLIENT_SECRET for callback). Register redirect_uri in the Portal.',
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const proto = req.headers['x-forwarded-proto'] || (req.secure ? 'https' : 'http');
|
|
712
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
|
713
|
+
const publicBase = (process.env.PUBLIC_URL || process.env.GATEWAY_PUBLIC_URL || '').trim() || `${proto}://${host}`;
|
|
714
|
+
const callbackUrl = `${publicBase.replace(/\/$/, '')}/api/oauth/callback`;
|
|
715
|
+
const return_to = req.query.return_to?.trim() || undefined;
|
|
716
|
+
const statePayload = JSON.stringify({ r: randomBytes(12).toString('base64url'), return_to });
|
|
717
|
+
const state = Buffer.from(statePayload, 'utf8').toString('base64url');
|
|
718
|
+
const url = `${portalBase}/connect?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
|
|
719
|
+
res.json({ url });
|
|
720
|
+
}
|
|
721
|
+
catch (e) {
|
|
722
|
+
log('gateway', 'error', e.message);
|
|
723
|
+
res.status(500).json({ error: e.message });
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
function parseOAuthReturnTo(req) {
|
|
727
|
+
try {
|
|
728
|
+
const stateRaw = req.query.state || '';
|
|
729
|
+
const statePayload = JSON.parse(Buffer.from(stateRaw, 'base64url').toString('utf8'));
|
|
730
|
+
if (statePayload && typeof statePayload.return_to === 'string' && statePayload.return_to.trim()) {
|
|
731
|
+
return statePayload.return_to.trim();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
/* state may be legacy random string */
|
|
736
|
+
}
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
/** GET /api/oauth/callback — Portal redirects here with code & state. Exchange for access token, save as PORTAL_API_KEY, redirect to dashboard. */
|
|
740
|
+
app.get('/api/oauth/callback', async (req, res) => {
|
|
741
|
+
const dashboardOrigin = (req.headers['x-forwarded-proto'] ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` : null) || (req.headers.origin || `${req.protocol}://${req.get('host')}`);
|
|
742
|
+
const return_to = parseOAuthReturnTo(req);
|
|
743
|
+
const returnFragment = return_to ? `&return_to=${encodeURIComponent(return_to)}` : '';
|
|
744
|
+
try {
|
|
745
|
+
const code = req.query.code || '';
|
|
746
|
+
const portalGateway = getPortalGatewayBase();
|
|
747
|
+
const portalBase = portalGateway ? portalGateway.replace(/\/api\/gateway$/i, '') : '';
|
|
748
|
+
const clientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
|
|
749
|
+
const clientSecret = (process.env.PORTAL_OAUTH_CLIENT_SECRET || '').trim();
|
|
750
|
+
if (!code || !portalBase || !clientId || !clientSecret) {
|
|
751
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=missing_config${returnFragment}`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const proto = req.headers['x-forwarded-proto'] || (req.secure ? 'https' : 'http');
|
|
755
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
|
756
|
+
const publicBase = (process.env.PUBLIC_URL || process.env.GATEWAY_PUBLIC_URL || '').trim() || `${proto}://${host}`;
|
|
757
|
+
const redirectUri = `${publicBase.replace(/\/$/, '')}/api/oauth/callback`;
|
|
758
|
+
const tokenRes = await fetch(`${portalBase}/api/oauth/token`, {
|
|
759
|
+
method: 'POST',
|
|
760
|
+
headers: { 'Content-Type': 'application/json' },
|
|
761
|
+
body: JSON.stringify({
|
|
762
|
+
grant_type: 'authorization_code',
|
|
763
|
+
code,
|
|
764
|
+
client_id: clientId,
|
|
765
|
+
client_secret: clientSecret,
|
|
766
|
+
redirect_uri: redirectUri,
|
|
767
|
+
}),
|
|
768
|
+
});
|
|
769
|
+
const tokenText = await tokenRes.text();
|
|
770
|
+
if (!tokenRes.ok) {
|
|
771
|
+
log('gateway', 'error', `OAuth token exchange failed: ${tokenRes.status} ${tokenText}`);
|
|
772
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=exchange_failed${returnFragment}`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
let tokenData;
|
|
776
|
+
try {
|
|
777
|
+
tokenData = JSON.parse(tokenText);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=invalid_response${returnFragment}`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const accessToken = tokenData.access_token?.trim();
|
|
784
|
+
if (!accessToken) {
|
|
785
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=no_token${returnFragment}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
writeOnboardEnvKeys({ PORTAL_API_KEY: accessToken });
|
|
789
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=success${returnFragment}`);
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
log('gateway', 'error', e.message);
|
|
793
|
+
const dashboardOrigin = (req.headers.origin || (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host'] ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` : null) || `${req.protocol}://${req.get('host')}`);
|
|
794
|
+
const return_to = parseOAuthReturnTo(req);
|
|
795
|
+
const returnFragment = return_to ? `&return_to=${encodeURIComponent(return_to)}` : '';
|
|
796
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=server_error${returnFragment}`);
|
|
797
|
+
}
|
|
798
|
+
});
|
|
244
799
|
app.get('/api/config', (_req, res) => {
|
|
245
800
|
try {
|
|
801
|
+
const portalUrl = getPortalGatewayBase();
|
|
802
|
+
const portalKey = getEffectivePortalApiKey();
|
|
803
|
+
const portalSet = !!(portalUrl && portalKey);
|
|
804
|
+
const directSet = !!config.integrationsUrl?.trim();
|
|
805
|
+
const integrationsMode = portalSet ? 'portal' : directSet ? 'direct' : null;
|
|
806
|
+
const portalOAuthClientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
|
|
246
807
|
res.json({
|
|
247
808
|
watchFolders: config.watchFolders || [],
|
|
809
|
+
agentUsePi: config.agentUsePi,
|
|
810
|
+
piAvailable: isPiAvailable(),
|
|
811
|
+
/** When set, dashboard can use this for Integrations page instead of VITE_INTEGRATIONS_URL. */
|
|
812
|
+
integrationsUrl: config.integrationsUrl || null,
|
|
813
|
+
/** 'portal' = agent uses Portal for connections; 'direct' = agent uses INTEGRATIONS_URL; null = neither. */
|
|
814
|
+
integrationsMode,
|
|
815
|
+
portalGatewayUrl: portalUrl || config.portalGatewayUrl || null,
|
|
816
|
+
/** When set, dashboard can show "Connect with Sulala (OAuth)" and use /api/oauth/connect-url. */
|
|
817
|
+
portalOAuthConnectAvailable: !!(portalUrl && portalOAuthClientId),
|
|
248
818
|
aiProviders: [
|
|
249
819
|
{ id: 'openai', label: 'OpenAI', defaultModel: process.env.AI_OPENAI_DEFAULT_MODEL || 'gpt-4o-mini' },
|
|
250
820
|
{ id: 'openrouter', label: 'OpenRouter', defaultModel: process.env.AI_OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini' },
|
|
@@ -259,6 +829,51 @@ export function createGateway(appMount = null) {
|
|
|
259
829
|
res.status(500).json({ error: e.message });
|
|
260
830
|
}
|
|
261
831
|
});
|
|
832
|
+
const JOB_DEFAULT_KEY = 'job_default';
|
|
833
|
+
app.get('/api/settings/job-default', (_req, res) => {
|
|
834
|
+
try {
|
|
835
|
+
const raw = getChannelConfig(JOB_DEFAULT_KEY);
|
|
836
|
+
if (!raw?.trim()) {
|
|
837
|
+
res.json({ defaultProvider: null, defaultModel: null });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const o = JSON.parse(raw);
|
|
841
|
+
const defaultProvider = typeof o.defaultProvider === 'string' ? o.defaultProvider.trim() || null : null;
|
|
842
|
+
const defaultModel = typeof o.defaultModel === 'string' ? o.defaultModel.trim() || null : null;
|
|
843
|
+
res.json({ defaultProvider, defaultModel });
|
|
844
|
+
}
|
|
845
|
+
catch (e) {
|
|
846
|
+
log('gateway', 'error', e.message);
|
|
847
|
+
res.status(500).json({ error: e.message });
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
app.put('/api/settings/job-default', (req, res) => {
|
|
851
|
+
try {
|
|
852
|
+
const { defaultProvider, defaultModel } = req.body || {};
|
|
853
|
+
const provider = defaultProvider !== undefined ? (typeof defaultProvider === 'string' ? defaultProvider.trim() || null : null) : undefined;
|
|
854
|
+
const model = defaultModel !== undefined ? (typeof defaultModel === 'string' ? defaultModel.trim() || null : null) : undefined;
|
|
855
|
+
const current = getChannelConfig(JOB_DEFAULT_KEY);
|
|
856
|
+
let next = {};
|
|
857
|
+
if (current?.trim()) {
|
|
858
|
+
try {
|
|
859
|
+
next = JSON.parse(current);
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
// ignore
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (provider !== undefined)
|
|
866
|
+
next.defaultProvider = provider;
|
|
867
|
+
if (model !== undefined)
|
|
868
|
+
next.defaultModel = model;
|
|
869
|
+
setChannelConfig(JOB_DEFAULT_KEY, JSON.stringify(next));
|
|
870
|
+
res.json({ defaultProvider: next.defaultProvider ?? null, defaultModel: next.defaultModel ?? null });
|
|
871
|
+
}
|
|
872
|
+
catch (e) {
|
|
873
|
+
log('gateway', 'error', e.message);
|
|
874
|
+
res.status(500).json({ error: e.message });
|
|
875
|
+
}
|
|
876
|
+
});
|
|
262
877
|
app.get('/api/agent/skills/required-bins', (_req, res) => {
|
|
263
878
|
try {
|
|
264
879
|
const bins = getAllRequiredBins(config);
|
|
@@ -282,7 +897,7 @@ export function createGateway(appMount = null) {
|
|
|
282
897
|
app.get('/api/agent/skills/config', (_req, res) => {
|
|
283
898
|
try {
|
|
284
899
|
const cfg = loadSkillsConfig();
|
|
285
|
-
res.json({ skills: cfg, configPath: getConfigPath() });
|
|
900
|
+
res.json({ skills: redactSkillsConfig(cfg), configPath: getConfigPath() });
|
|
286
901
|
}
|
|
287
902
|
catch (e) {
|
|
288
903
|
log('gateway', 'error', e.message);
|
|
@@ -294,7 +909,22 @@ export function createGateway(appMount = null) {
|
|
|
294
909
|
const { skills } = req.body || {};
|
|
295
910
|
if (skills && typeof skills === 'object')
|
|
296
911
|
saveSkillsConfig(skills);
|
|
297
|
-
res.json({ skills: loadSkillsConfig(), configPath: getConfigPath() });
|
|
912
|
+
res.json({ skills: redactSkillsConfig(loadSkillsConfig()), configPath: getConfigPath() });
|
|
913
|
+
}
|
|
914
|
+
catch (e) {
|
|
915
|
+
log('gateway', 'error', e.message);
|
|
916
|
+
res.status(500).json({ error: e.message });
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
app.post('/api/agent/skills/config/remove', (req, res) => {
|
|
920
|
+
try {
|
|
921
|
+
const { slug } = req.body || {};
|
|
922
|
+
if (!slug || typeof slug !== 'string') {
|
|
923
|
+
res.status(400).json({ error: 'slug required' });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
removeSkillEntry(slug);
|
|
927
|
+
res.json({ removed: slug, skills: redactSkillsConfig(loadSkillsConfig()), configPath: getConfigPath() });
|
|
298
928
|
}
|
|
299
929
|
catch (e) {
|
|
300
930
|
log('gateway', 'error', e.message);
|
|
@@ -311,8 +941,17 @@ export function createGateway(appMount = null) {
|
|
|
311
941
|
res.status(500).json({ error: e.message });
|
|
312
942
|
}
|
|
313
943
|
});
|
|
314
|
-
app.get('/api/agent/skills/registry', async (
|
|
944
|
+
app.get('/api/agent/skills/registry', async (req, res) => {
|
|
315
945
|
try {
|
|
946
|
+
const url = req.query.url?.trim();
|
|
947
|
+
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
|
|
948
|
+
const resp = await fetch(url);
|
|
949
|
+
if (!resp.ok)
|
|
950
|
+
throw new Error(`Registry fetch failed: ${resp.status}`);
|
|
951
|
+
const data = (await resp.json());
|
|
952
|
+
res.json({ skills: data.skills ?? [] });
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
316
955
|
const skills = await getRegistrySkills();
|
|
317
956
|
res.json({ skills });
|
|
318
957
|
}
|
|
@@ -374,9 +1013,150 @@ export function createGateway(appMount = null) {
|
|
|
374
1013
|
res.status(500).json({ error: e.message });
|
|
375
1014
|
}
|
|
376
1015
|
});
|
|
377
|
-
/**
|
|
1016
|
+
/** Skill Wizard: generate spec and optionally write skill to workspace. */
|
|
1017
|
+
app.get('/api/agent/skills/wizard-apps', (_req, res) => {
|
|
1018
|
+
res.json({ apps: WIZARD_APPS, triggers: WIZARD_TRIGGERS });
|
|
1019
|
+
});
|
|
1020
|
+
app.get('/api/agent/skills/templates', async (_req, res) => {
|
|
1021
|
+
try {
|
|
1022
|
+
const registrySkills = await getRegistrySkills();
|
|
1023
|
+
const templates = getTemplates(registrySkills);
|
|
1024
|
+
res.json({ templates });
|
|
1025
|
+
}
|
|
1026
|
+
catch (e) {
|
|
1027
|
+
log('gateway', 'error', e.message);
|
|
1028
|
+
res.status(500).json({ error: e.message });
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
app.post('/api/agent/skills/generate', (req, res) => {
|
|
1032
|
+
try {
|
|
1033
|
+
const { goal = '', app: appId = 'other', trigger: triggerId = 'manual', write } = req.body || {};
|
|
1034
|
+
const spec = generateSkillSpec(typeof goal === 'string' ? goal : '', typeof appId === 'string' ? appId : 'other', typeof triggerId === 'string' ? triggerId : 'manual');
|
|
1035
|
+
if (write) {
|
|
1036
|
+
const { path: filePath, slug } = writeGeneratedSkill(config, spec);
|
|
1037
|
+
res.status(201).json({ spec, path: filePath, slug, written: true });
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
res.json({ spec, written: false });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch (e) {
|
|
1044
|
+
log('gateway', 'error', e.message);
|
|
1045
|
+
res.status(500).json({ error: e.message });
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
/** Execution preview: list pending tool actions awaiting approval. */
|
|
1049
|
+
app.get('/api/agent/pending-actions', (req, res) => {
|
|
1050
|
+
try {
|
|
1051
|
+
const sessionId = typeof req.query.session_id === 'string' ? req.query.session_id : undefined;
|
|
1052
|
+
const list = listPendingActions(sessionId).map((a) => ({
|
|
1053
|
+
...a,
|
|
1054
|
+
args: sanitizeArgsForDisplay(a.args),
|
|
1055
|
+
}));
|
|
1056
|
+
res.json({ pendingActions: list });
|
|
1057
|
+
}
|
|
1058
|
+
catch (e) {
|
|
1059
|
+
log('gateway', 'error', e.message);
|
|
1060
|
+
res.status(500).json({ error: e.message });
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
app.post('/api/agent/pending-actions/:id/approve', async (req, res) => {
|
|
1064
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1065
|
+
if (!id) {
|
|
1066
|
+
res.status(400).json({ error: 'Pending action id required' });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
try {
|
|
1070
|
+
const pending = getPendingActionForReplay(id);
|
|
1071
|
+
if (!pending) {
|
|
1072
|
+
res.status(404).json({ error: 'Pending action not found or already handled' });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
let result;
|
|
1076
|
+
try {
|
|
1077
|
+
result = await executeTool(pending.toolName, pending.args, {
|
|
1078
|
+
sessionId: pending.sessionId,
|
|
1079
|
+
toolCallId: pending.toolCallId,
|
|
1080
|
+
skipApproval: true,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
catch (err) {
|
|
1084
|
+
result = { error: err instanceof Error ? err.message : String(err) };
|
|
1085
|
+
}
|
|
1086
|
+
const resultContent = typeof result === 'string' ? result : JSON.stringify(result);
|
|
1087
|
+
const updated = updateAgentMessageToolResult(pending.sessionId, pending.toolCallId, resultContent);
|
|
1088
|
+
if (!updated) {
|
|
1089
|
+
appendAgentMessage({
|
|
1090
|
+
session_id: pending.sessionId,
|
|
1091
|
+
role: 'tool',
|
|
1092
|
+
tool_call_id: pending.toolCallId,
|
|
1093
|
+
name: pending.toolName,
|
|
1094
|
+
content: resultContent,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
setPendingActionApproved(id, result);
|
|
1098
|
+
res.json({ ok: true, result });
|
|
1099
|
+
}
|
|
1100
|
+
catch (e) {
|
|
1101
|
+
log('gateway', 'error', e.message);
|
|
1102
|
+
res.status(500).json({ error: e.message });
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
app.post('/api/agent/pending-actions/:id/reject', (req, res) => {
|
|
1106
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1107
|
+
if (!id) {
|
|
1108
|
+
res.status(400).json({ error: 'Pending action id required' });
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
const pending = getPendingAction(id);
|
|
1113
|
+
if (!pending || pending.status !== 'pending') {
|
|
1114
|
+
res.status(404).json({ error: 'Pending action not found or already handled' });
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
setPendingActionRejected(id);
|
|
1118
|
+
const rejectedContent = JSON.stringify({ error: 'User rejected this action.' });
|
|
1119
|
+
const updated = updateAgentMessageToolResult(pending.sessionId, pending.toolCallId, rejectedContent);
|
|
1120
|
+
if (!updated) {
|
|
1121
|
+
appendAgentMessage({
|
|
1122
|
+
session_id: pending.sessionId,
|
|
1123
|
+
role: 'tool',
|
|
1124
|
+
tool_call_id: pending.toolCallId,
|
|
1125
|
+
name: pending.toolName,
|
|
1126
|
+
content: rejectedContent,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
res.json({ ok: true });
|
|
1130
|
+
}
|
|
1131
|
+
catch (e) {
|
|
1132
|
+
log('gateway', 'error', e.message);
|
|
1133
|
+
res.status(500).json({ error: e.message });
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
/** List models for a provider. Ollama: local /api/tags; OpenRouter: proxies OpenRouter API. */
|
|
378
1137
|
app.get('/api/agent/models', async (req, res) => {
|
|
379
1138
|
const provider = req.query.provider || '';
|
|
1139
|
+
if (provider === 'ollama') {
|
|
1140
|
+
try {
|
|
1141
|
+
const base = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
1142
|
+
const r = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
1143
|
+
if (!r.ok) {
|
|
1144
|
+
res.json({ models: [] });
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const data = (await r.json());
|
|
1148
|
+
const toolsWhitelist = /^(qwen3|qwen3\.5|qwen3-vl|qwen3-next|deepseek-r1|deepseek-v3|gpt-oss|glm-4\.7-flash|nemotron-3-nano|magistral|llama3|hermes3|mistral|gemma2|codellama|solar|qwen2|wizardlm2|neural-chat|starling-lm)(:|$)/i;
|
|
1149
|
+
const models = (data.models || [])
|
|
1150
|
+
.filter((m) => toolsWhitelist.test(m.name))
|
|
1151
|
+
.filter((m) => !/:1b$|:1\.5b$|:0\.6b$|:2b$/i.test(m.name))
|
|
1152
|
+
.map((m) => ({ id: m.name, name: m.name }));
|
|
1153
|
+
res.json({ models });
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
res.json({ models: [] });
|
|
1157
|
+
}
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
380
1160
|
if (provider !== 'openrouter') {
|
|
381
1161
|
res.json({ models: [] });
|
|
382
1162
|
return;
|
|
@@ -416,6 +1196,33 @@ export function createGateway(appMount = null) {
|
|
|
416
1196
|
res.status(500).json({ error: e.message });
|
|
417
1197
|
}
|
|
418
1198
|
});
|
|
1199
|
+
/** POST /api/tools/invoke — direct tool execution (OpenClaw-style). Same policy as agent run. */
|
|
1200
|
+
app.post('/api/tools/invoke', async (req, res) => {
|
|
1201
|
+
try {
|
|
1202
|
+
const body = (req.body || {});
|
|
1203
|
+
const toolName = typeof body.tool === 'string' ? body.tool.trim() : '';
|
|
1204
|
+
if (!toolName) {
|
|
1205
|
+
res.status(400).json({ error: 'body.tool (string) required' });
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const args = body.args != null && typeof body.args === 'object' && !Array.isArray(body.args)
|
|
1209
|
+
? body.args
|
|
1210
|
+
: {};
|
|
1211
|
+
const allowed = listTools();
|
|
1212
|
+
const tool = allowed.find((t) => t.name === toolName);
|
|
1213
|
+
if (!tool) {
|
|
1214
|
+
res.status(404).json({ error: `Tool "${toolName}" not found or not allowed` });
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const result = await executeTool(toolName, args);
|
|
1218
|
+
res.json({ ok: true, result });
|
|
1219
|
+
}
|
|
1220
|
+
catch (e) {
|
|
1221
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1222
|
+
log('gateway', 'error', msg, { stack: e instanceof Error ? e.stack : undefined });
|
|
1223
|
+
res.status(500).json({ error: msg });
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
419
1226
|
// --- Agent runner (sessions + tool loop) ---
|
|
420
1227
|
app.get('/api/agent/sessions', (req, res) => {
|
|
421
1228
|
try {
|
|
@@ -428,6 +1235,42 @@ export function createGateway(appMount = null) {
|
|
|
428
1235
|
res.status(500).json({ error: e.message });
|
|
429
1236
|
}
|
|
430
1237
|
});
|
|
1238
|
+
/** GET /api/agent/memory/scope-keys — distinct scope_key per scope (for Settings > Memory dropdowns). */
|
|
1239
|
+
app.get('/api/agent/memory/scope-keys', (_req, res) => {
|
|
1240
|
+
try {
|
|
1241
|
+
const keys = listAgentMemoryScopeKeys();
|
|
1242
|
+
res.json(keys);
|
|
1243
|
+
}
|
|
1244
|
+
catch (e) {
|
|
1245
|
+
log('gateway', 'error', e.message);
|
|
1246
|
+
res.status(500).json({ error: e.message });
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
/** GET /api/agent/memory — list memory entries. Query: scope (session|shared), scope_key, limit (default 100). */
|
|
1250
|
+
app.get('/api/agent/memory', (req, res) => {
|
|
1251
|
+
try {
|
|
1252
|
+
const scope = req.query.scope === 'shared' ? 'shared' : 'session';
|
|
1253
|
+
const scopeKey = typeof req.query.scope_key === 'string' ? req.query.scope_key.trim() : '';
|
|
1254
|
+
if (!scopeKey) {
|
|
1255
|
+
res.status(400).json({ error: 'scope_key is required' });
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
|
|
1259
|
+
const rows = listAgentMemories(scope, scopeKey, limit);
|
|
1260
|
+
const entries = rows.map((r) => ({
|
|
1261
|
+
id: r.id,
|
|
1262
|
+
scope: r.scope,
|
|
1263
|
+
scope_key: r.scope_key,
|
|
1264
|
+
content: r.content,
|
|
1265
|
+
created_at: r.created_at,
|
|
1266
|
+
}));
|
|
1267
|
+
res.json({ entries });
|
|
1268
|
+
}
|
|
1269
|
+
catch (e) {
|
|
1270
|
+
log('gateway', 'error', e.message);
|
|
1271
|
+
res.status(500).json({ error: e.message });
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
431
1274
|
app.post('/api/agent/sessions', (req, res) => {
|
|
432
1275
|
try {
|
|
433
1276
|
const sessionKey = req.body?.session_key || `default_${Date.now()}`;
|
|
@@ -463,6 +1306,17 @@ export function createGateway(appMount = null) {
|
|
|
463
1306
|
msg.tool_calls = [];
|
|
464
1307
|
}
|
|
465
1308
|
}
|
|
1309
|
+
if (r.usage) {
|
|
1310
|
+
try {
|
|
1311
|
+
msg.usage = JSON.parse(r.usage);
|
|
1312
|
+
}
|
|
1313
|
+
catch {
|
|
1314
|
+
// ignore
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const costUsd = r.cost_usd;
|
|
1318
|
+
if (costUsd != null)
|
|
1319
|
+
msg.cost_usd = costUsd;
|
|
466
1320
|
return msg;
|
|
467
1321
|
});
|
|
468
1322
|
res.json({ ...session, messages });
|
|
@@ -485,19 +1339,55 @@ export function createGateway(appMount = null) {
|
|
|
485
1339
|
}
|
|
486
1340
|
const controller = new AbortController();
|
|
487
1341
|
req.on('close', () => controller.abort());
|
|
488
|
-
const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
|
|
1342
|
+
const { message, system_prompt, provider, model, max_tokens, timeout_ms, use_pi, required_integrations, attachment_urls } = req.body || {};
|
|
1343
|
+
const urls = Array.isArray(attachment_urls) ? attachment_urls.filter((u) => typeof u === 'string' && u.trim()) : [];
|
|
1344
|
+
const userMessageText = typeof message === 'string' ? message : '';
|
|
1345
|
+
const fullUserMessage = urls.length > 0 ? `${userMessageText}\n\n[Attached media (use these URLs when posting images, e.g. Facebook photo post): ${urls.join(', ')}]` : userMessageText;
|
|
1346
|
+
const required = Array.isArray(required_integrations)
|
|
1347
|
+
? required_integrations.filter((k) => typeof k === 'string' && k.trim())
|
|
1348
|
+
: [];
|
|
1349
|
+
if (required.length > 0) {
|
|
1350
|
+
const connected = await getConnectedIntegrationIds();
|
|
1351
|
+
const missing = required.filter((k) => !connected.includes(k));
|
|
1352
|
+
if (missing.length > 0) {
|
|
1353
|
+
res.status(422).json({
|
|
1354
|
+
type: 'missing_integrations',
|
|
1355
|
+
error: 'Missing required integrations',
|
|
1356
|
+
missing,
|
|
1357
|
+
});
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
489
1361
|
const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
|
|
1362
|
+
const usePi = config.agentUsePi || use_pi === true || use_pi === '1';
|
|
1363
|
+
if (usePi && !isPiAvailable()) {
|
|
1364
|
+
res.status(503).json({
|
|
1365
|
+
error: 'Pi runner requested but not available. Install optional deps: npm install @mariozechner/pi-agent-core @mariozechner/pi-ai @mariozechner/pi-coding-agent',
|
|
1366
|
+
});
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
490
1369
|
try {
|
|
491
|
-
const result = await withSessionLock(id, () =>
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1370
|
+
const result = await withSessionLock(id, () => usePi
|
|
1371
|
+
? runAgentTurnWithPi({
|
|
1372
|
+
sessionId: id,
|
|
1373
|
+
userMessage: fullUserMessage || null,
|
|
1374
|
+
systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
|
|
1375
|
+
provider: typeof provider === 'string' ? provider : undefined,
|
|
1376
|
+
model: typeof model === 'string' ? model : undefined,
|
|
1377
|
+
max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
|
|
1378
|
+
timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
|
|
1379
|
+
signal: controller.signal,
|
|
1380
|
+
})
|
|
1381
|
+
: runAgentTurn({
|
|
1382
|
+
sessionId: id,
|
|
1383
|
+
userMessage: fullUserMessage || null,
|
|
1384
|
+
systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
|
|
1385
|
+
provider: typeof provider === 'string' ? provider : undefined,
|
|
1386
|
+
model: typeof model === 'string' ? model : undefined,
|
|
1387
|
+
max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
|
|
1388
|
+
timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
|
|
1389
|
+
signal: controller.signal,
|
|
1390
|
+
}));
|
|
501
1391
|
res.json(result);
|
|
502
1392
|
}
|
|
503
1393
|
catch (e) {
|
|
@@ -506,6 +1396,7 @@ export function createGateway(appMount = null) {
|
|
|
506
1396
|
res.status(499).json({ error: 'Client closed request or run timed out' });
|
|
507
1397
|
return;
|
|
508
1398
|
}
|
|
1399
|
+
console.error('[gateway] POST /api/agent/sessions/:id/messages error:', err.message, err.stack);
|
|
509
1400
|
log('gateway', 'error', err.message, { stack: err.stack });
|
|
510
1401
|
res.status(500).json({ error: err.message });
|
|
511
1402
|
}
|
|
@@ -521,6 +1412,21 @@ export function createGateway(appMount = null) {
|
|
|
521
1412
|
res.status(404).json({ error: 'Session not found' });
|
|
522
1413
|
return;
|
|
523
1414
|
}
|
|
1415
|
+
const bodyRequired = Array.isArray(req.body?.required_integrations)
|
|
1416
|
+
? (req.body.required_integrations.filter((k) => typeof k === 'string' && k.trim()))
|
|
1417
|
+
: [];
|
|
1418
|
+
if (bodyRequired.length > 0) {
|
|
1419
|
+
const connected = await getConnectedIntegrationIds();
|
|
1420
|
+
const missing = bodyRequired.filter((k) => !connected.includes(k));
|
|
1421
|
+
if (missing.length > 0) {
|
|
1422
|
+
res.status(422).json({
|
|
1423
|
+
type: 'missing_integrations',
|
|
1424
|
+
error: 'Missing required integrations',
|
|
1425
|
+
missing,
|
|
1426
|
+
});
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
524
1430
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
525
1431
|
res.setHeader('Cache-Control', 'no-cache');
|
|
526
1432
|
res.setHeader('Connection', 'keep-alive');
|
|
@@ -538,12 +1444,17 @@ export function createGateway(appMount = null) {
|
|
|
538
1444
|
send('start', { runId });
|
|
539
1445
|
const controller = new AbortController();
|
|
540
1446
|
req.on('close', () => controller.abort());
|
|
541
|
-
const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
|
|
1447
|
+
const { message, continue: continueAfterApproval, system_prompt, provider, model, max_tokens, timeout_ms, attachment_urls: streamAttachmentUrls } = req.body || {};
|
|
542
1448
|
const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
|
|
1449
|
+
const isContinue = continueAfterApproval === true || continueAfterApproval === 'true';
|
|
1450
|
+
const streamUrls = Array.isArray(streamAttachmentUrls) ? streamAttachmentUrls.filter((u) => typeof u === 'string' && u.trim()) : [];
|
|
1451
|
+
const streamMsg = typeof message === 'string' ? message : '';
|
|
1452
|
+
const streamFullMessage = streamUrls.length > 0 ? `${streamMsg}\n\n[Attached media (use these URLs when posting images, e.g. Facebook photo post): ${streamUrls.join(', ')}]` : streamMsg;
|
|
1453
|
+
const userMessage = isContinue ? null : (streamFullMessage || null);
|
|
543
1454
|
try {
|
|
544
|
-
await withSessionLock(id, () => runAgentTurnStream({
|
|
1455
|
+
const runResult = await withSessionLock(id, () => runAgentTurnStream({
|
|
545
1456
|
sessionId: id,
|
|
546
|
-
userMessage
|
|
1457
|
+
userMessage,
|
|
547
1458
|
systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
|
|
548
1459
|
provider: typeof provider === 'string' ? provider : undefined,
|
|
549
1460
|
model: typeof model === 'string' ? model : undefined,
|
|
@@ -553,19 +1464,25 @@ export function createGateway(appMount = null) {
|
|
|
553
1464
|
}, (ev) => {
|
|
554
1465
|
if (ev.type === 'assistant')
|
|
555
1466
|
send('assistant', { delta: ev.delta });
|
|
1467
|
+
else if (ev.type === 'thinking')
|
|
1468
|
+
send('thinking', { delta: ev.delta });
|
|
556
1469
|
else if (ev.type === 'tool_call')
|
|
557
1470
|
send('tool_call', { name: ev.name, result: ev.result });
|
|
558
1471
|
else if (ev.type === 'done')
|
|
559
|
-
send('done', { finalContent: ev.finalContent, turnCount: ev.turnCount });
|
|
1472
|
+
send('done', { finalContent: ev.finalContent, turnCount: ev.turnCount, usage: ev.usage });
|
|
560
1473
|
else if (ev.type === 'error')
|
|
561
1474
|
send('error', { message: ev.message });
|
|
562
1475
|
}));
|
|
1476
|
+
if (runResult.pendingActionId) {
|
|
1477
|
+
send('pending_approval', { pendingActionId: runResult.pendingActionId });
|
|
1478
|
+
}
|
|
563
1479
|
}
|
|
564
1480
|
catch (e) {
|
|
565
1481
|
const err = e;
|
|
566
1482
|
if (err.name === 'AbortError')
|
|
567
1483
|
send('error', { message: 'Client closed request or run timed out' });
|
|
568
1484
|
else {
|
|
1485
|
+
console.error('[gateway] POST /api/agent/sessions/:id/messages/stream error:', err.message, err.stack);
|
|
569
1486
|
log('gateway', 'error', err.message, { stack: err.stack });
|
|
570
1487
|
send('error', { message: err.message });
|
|
571
1488
|
}
|
|
@@ -610,6 +1527,8 @@ export function createGateway(appMount = null) {
|
|
|
610
1527
|
}, (ev) => {
|
|
611
1528
|
if (ev.type === 'assistant')
|
|
612
1529
|
sendWs('assistant', { delta: ev.delta });
|
|
1530
|
+
else if (ev.type === 'thinking')
|
|
1531
|
+
sendWs('thinking', { delta: ev.delta });
|
|
613
1532
|
else if (ev.type === 'tool_call')
|
|
614
1533
|
sendWs('tool_call', { name: ev.name, result: ev.result });
|
|
615
1534
|
else if (ev.type === 'done')
|
|
@@ -666,6 +1585,279 @@ export function createGateway(appMount = null) {
|
|
|
666
1585
|
res.status(500).json({ error: e.message });
|
|
667
1586
|
}
|
|
668
1587
|
});
|
|
1588
|
+
// Scheduled jobs (cron → task type)
|
|
1589
|
+
app.get('/api/schedules', (_req, res) => {
|
|
1590
|
+
try {
|
|
1591
|
+
const jobs = listScheduledJobs(false);
|
|
1592
|
+
res.json({ schedules: jobs });
|
|
1593
|
+
}
|
|
1594
|
+
catch (e) {
|
|
1595
|
+
log('gateway', 'error', e.message);
|
|
1596
|
+
res.status(500).json({ error: e.message });
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
app.post('/api/schedules', (req, res) => {
|
|
1600
|
+
try {
|
|
1601
|
+
const { name, description, cron_expression, task_type, payload, prompt, delivery, provider, model } = req.body || {};
|
|
1602
|
+
if (!cron_expression?.trim()) {
|
|
1603
|
+
res.status(400).json({ error: 'cron_expression required' });
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
const isAgentJob = typeof prompt === 'string' && prompt.trim().length > 0;
|
|
1607
|
+
const effectiveType = isAgentJob ? 'agent_job' : (task_type?.trim() || 'agent_job');
|
|
1608
|
+
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
1609
|
+
const payloadStr = !isAgentJob && payload != null ? JSON.stringify(payload) : null;
|
|
1610
|
+
const deliveryStr = isAgentJob && delivery != null ? JSON.stringify(delivery) : null;
|
|
1611
|
+
const providerStr = typeof provider === 'string' ? provider.trim() || null : null;
|
|
1612
|
+
const modelStr = typeof model === 'string' ? model.trim() || null : null;
|
|
1613
|
+
insertScheduledJob({
|
|
1614
|
+
id,
|
|
1615
|
+
name: name?.trim() ?? '',
|
|
1616
|
+
description: description?.trim() ?? '',
|
|
1617
|
+
cron_expression: cron_expression.trim(),
|
|
1618
|
+
task_type: effectiveType,
|
|
1619
|
+
payload: payloadStr,
|
|
1620
|
+
prompt: isAgentJob ? prompt.trim() : null,
|
|
1621
|
+
delivery: deliveryStr,
|
|
1622
|
+
provider: providerStr,
|
|
1623
|
+
model: modelStr,
|
|
1624
|
+
enabled: 1,
|
|
1625
|
+
});
|
|
1626
|
+
try {
|
|
1627
|
+
if (isAgentJob) {
|
|
1628
|
+
const deliveryList = Array.isArray(delivery) ? delivery : (delivery ? [delivery] : []);
|
|
1629
|
+
const agentPayload = {
|
|
1630
|
+
jobId: id,
|
|
1631
|
+
name: name?.trim() || id,
|
|
1632
|
+
prompt: prompt.trim(),
|
|
1633
|
+
delivery: deliveryList,
|
|
1634
|
+
};
|
|
1635
|
+
if (providerStr)
|
|
1636
|
+
agentPayload.provider = providerStr;
|
|
1637
|
+
if (modelStr)
|
|
1638
|
+
agentPayload.model = modelStr;
|
|
1639
|
+
scheduleCronById(id, cron_expression.trim(), 'agent_job', agentPayload);
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
scheduleCronById(id, cron_expression.trim(), effectiveType, payload ?? null);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
catch (e) {
|
|
1646
|
+
deleteScheduledJob(id);
|
|
1647
|
+
throw e;
|
|
1648
|
+
}
|
|
1649
|
+
const row = getScheduledJob(id);
|
|
1650
|
+
log('gateway', 'info', 'Schedule created', { id, task_type: effectiveType });
|
|
1651
|
+
res.status(201).json(row);
|
|
1652
|
+
}
|
|
1653
|
+
catch (e) {
|
|
1654
|
+
log('gateway', 'error', e.message);
|
|
1655
|
+
res.status(500).json({ error: e.message });
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
app.patch('/api/schedules/:id', (req, res) => {
|
|
1659
|
+
try {
|
|
1660
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1661
|
+
if (!id) {
|
|
1662
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const row = getScheduledJob(id);
|
|
1666
|
+
if (!row) {
|
|
1667
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
const { name, description, cron_expression, task_type, payload, prompt, delivery, provider, model, enabled } = req.body || {};
|
|
1671
|
+
const updates = {};
|
|
1672
|
+
if (typeof name === 'string')
|
|
1673
|
+
updates.name = name;
|
|
1674
|
+
if (typeof description === 'string')
|
|
1675
|
+
updates.description = description;
|
|
1676
|
+
if (typeof cron_expression === 'string')
|
|
1677
|
+
updates.cron_expression = cron_expression;
|
|
1678
|
+
if (typeof task_type === 'string')
|
|
1679
|
+
updates.task_type = task_type;
|
|
1680
|
+
if (payload !== undefined)
|
|
1681
|
+
updates.payload = payload == null ? null : JSON.stringify(payload);
|
|
1682
|
+
if (prompt !== undefined)
|
|
1683
|
+
updates.prompt = prompt == null ? null : String(prompt).trim() || null;
|
|
1684
|
+
if (delivery !== undefined)
|
|
1685
|
+
updates.delivery = delivery == null ? null : JSON.stringify(delivery);
|
|
1686
|
+
if (provider !== undefined)
|
|
1687
|
+
updates.provider = provider == null ? null : (typeof provider === 'string' ? provider.trim() || null : null);
|
|
1688
|
+
if (model !== undefined)
|
|
1689
|
+
updates.model = model == null ? null : (typeof model === 'string' ? model.trim() || null : null);
|
|
1690
|
+
if (enabled !== undefined)
|
|
1691
|
+
updates.enabled = enabled ? 1 : 0;
|
|
1692
|
+
updateScheduledJob(id, updates);
|
|
1693
|
+
unscheduleJob(id);
|
|
1694
|
+
const updated = getScheduledJob(id);
|
|
1695
|
+
if (updated.enabled) {
|
|
1696
|
+
if (updated.prompt?.trim()) {
|
|
1697
|
+
const deliveryList = updated.delivery?.trim() ? JSON.parse(updated.delivery) : [];
|
|
1698
|
+
const agentPayload = {
|
|
1699
|
+
jobId: id,
|
|
1700
|
+
name: updated.name || id,
|
|
1701
|
+
prompt: updated.prompt,
|
|
1702
|
+
delivery: deliveryList,
|
|
1703
|
+
};
|
|
1704
|
+
const prov = updated.provider;
|
|
1705
|
+
const mod = updated.model;
|
|
1706
|
+
if (prov?.trim())
|
|
1707
|
+
agentPayload.provider = prov.trim();
|
|
1708
|
+
if (mod?.trim())
|
|
1709
|
+
agentPayload.model = mod.trim();
|
|
1710
|
+
scheduleCronById(id, updated.cron_expression, 'agent_job', agentPayload);
|
|
1711
|
+
}
|
|
1712
|
+
else {
|
|
1713
|
+
const payloadVal = updated.payload ? JSON.parse(updated.payload) : null;
|
|
1714
|
+
scheduleCronById(id, updated.cron_expression, updated.task_type, payloadVal);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
log('gateway', 'info', 'Schedule updated', { id });
|
|
1718
|
+
res.json(updated);
|
|
1719
|
+
}
|
|
1720
|
+
catch (e) {
|
|
1721
|
+
log('gateway', 'error', e.message);
|
|
1722
|
+
res.status(500).json({ error: e.message });
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
app.delete('/api/schedules/:id', (req, res) => {
|
|
1726
|
+
try {
|
|
1727
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1728
|
+
if (!id) {
|
|
1729
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const row = getScheduledJob(id);
|
|
1733
|
+
if (!row) {
|
|
1734
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
unscheduleJob(id);
|
|
1738
|
+
deleteScheduledJob(id);
|
|
1739
|
+
log('gateway', 'info', 'Schedule deleted', { id });
|
|
1740
|
+
res.status(204).send();
|
|
1741
|
+
}
|
|
1742
|
+
catch (e) {
|
|
1743
|
+
log('gateway', 'error', e.message);
|
|
1744
|
+
res.status(500).json({ error: e.message });
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
/** Run a scheduled job immediately (test run). */
|
|
1748
|
+
app.post('/api/schedules/:id/run', (req, res) => {
|
|
1749
|
+
try {
|
|
1750
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1751
|
+
if (!id) {
|
|
1752
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
const row = getScheduledJob(id);
|
|
1756
|
+
if (!row) {
|
|
1757
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
let payload;
|
|
1761
|
+
let taskType;
|
|
1762
|
+
if (row.prompt?.trim()) {
|
|
1763
|
+
const deliveryList = row.delivery?.trim() ? JSON.parse(row.delivery) : [];
|
|
1764
|
+
const r = row;
|
|
1765
|
+
const agentPayload = { jobId: id, name: row.name || id, prompt: row.prompt, delivery: deliveryList };
|
|
1766
|
+
if (r.provider?.trim())
|
|
1767
|
+
agentPayload.provider = r.provider.trim();
|
|
1768
|
+
if (r.model?.trim())
|
|
1769
|
+
agentPayload.model = r.model.trim();
|
|
1770
|
+
payload = agentPayload;
|
|
1771
|
+
taskType = 'agent_job';
|
|
1772
|
+
}
|
|
1773
|
+
else {
|
|
1774
|
+
payload = row.payload ? JSON.parse(row.payload) : null;
|
|
1775
|
+
taskType = row.task_type;
|
|
1776
|
+
}
|
|
1777
|
+
const taskId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
1778
|
+
insertTask({ id: taskId, type: taskType, payload, scheduled_at: Date.now() });
|
|
1779
|
+
const enqueueTaskId = app.locals.enqueueTaskId;
|
|
1780
|
+
if (typeof enqueueTaskId === 'function')
|
|
1781
|
+
enqueueTaskId(taskId);
|
|
1782
|
+
log('gateway', 'info', 'Schedule test run enqueued', { scheduleId: id, taskId });
|
|
1783
|
+
res.status(201).json({ id: taskId, type: taskType, status: 'pending' });
|
|
1784
|
+
}
|
|
1785
|
+
catch (e) {
|
|
1786
|
+
log('gateway', 'error', e.message);
|
|
1787
|
+
res.status(500).json({ error: e.message });
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
/** Get run history for a scheduled job. */
|
|
1791
|
+
app.get('/api/schedules/:id/runs', (req, res) => {
|
|
1792
|
+
try {
|
|
1793
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1794
|
+
if (!id) {
|
|
1795
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const row = getScheduledJob(id);
|
|
1799
|
+
if (!row) {
|
|
1800
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
const limit = Math.min(parseInt(req.query.limit || '30', 10), 100);
|
|
1804
|
+
const runs = getTasksForJob(id, limit);
|
|
1805
|
+
res.json({ runs });
|
|
1806
|
+
}
|
|
1807
|
+
catch (e) {
|
|
1808
|
+
log('gateway', 'error', e.message);
|
|
1809
|
+
res.status(500).json({ error: e.message });
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
/** Chat attachments: upload image/file, get a URL the agent can use (e.g. for Facebook photo posts). */
|
|
1813
|
+
const uploadsDir = join(projectRoot, 'uploads');
|
|
1814
|
+
const ALLOWED_EXT = /\.(jpg|jpeg|png|gif|webp)$/i;
|
|
1815
|
+
app.post('/api/upload', express.json({ limit: '10mb' }), (req, res) => {
|
|
1816
|
+
try {
|
|
1817
|
+
const { filename, data } = req.body || {};
|
|
1818
|
+
if (typeof data !== 'string') {
|
|
1819
|
+
res.status(400).json({ error: 'Missing or invalid body: { filename, data (base64) }' });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const base = typeof filename === 'string' ? filename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) : 'file';
|
|
1823
|
+
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : '';
|
|
1824
|
+
const safeExt = ALLOWED_EXT.test(ext) ? ext : '.jpg';
|
|
1825
|
+
const name = `${randomBytes(8).toString('hex')}${safeExt}`;
|
|
1826
|
+
if (!existsSync(uploadsDir))
|
|
1827
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
1828
|
+
const path = join(uploadsDir, name);
|
|
1829
|
+
const buf = Buffer.from(data, 'base64');
|
|
1830
|
+
if (buf.length > 8 * 1024 * 1024) {
|
|
1831
|
+
res.status(400).json({ error: 'File too large (max 8MB)' });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
writeFileSync(path, buf);
|
|
1835
|
+
const host = req.get('host') || `${config.host}:${config.port}`;
|
|
1836
|
+
const protocol = req.get('x-forwarded-proto') || req.protocol || 'http';
|
|
1837
|
+
const url = `${protocol}://${host}/api/uploads/${name}`;
|
|
1838
|
+
res.json({ url, name });
|
|
1839
|
+
}
|
|
1840
|
+
catch (e) {
|
|
1841
|
+
log('gateway', 'error', e.message);
|
|
1842
|
+
res.status(500).json({ error: e.message });
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
app.get('/api/uploads/:name', (req, res) => {
|
|
1846
|
+
const name = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
|
|
1847
|
+
if (!name || !/^[a-f0-9]{16}\.(jpg|jpeg|png|gif|webp)$/i.test(name)) {
|
|
1848
|
+
res.status(400).json({ error: 'Invalid upload name' });
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
const path = join(uploadsDir, name);
|
|
1852
|
+
if (!existsSync(path)) {
|
|
1853
|
+
res.status(404).json({ error: 'Not found' });
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
res.sendFile(path, { maxAge: 86400 * 7 }, (err) => {
|
|
1857
|
+
if (err)
|
|
1858
|
+
res.status(500).json({ error: err.message });
|
|
1859
|
+
});
|
|
1860
|
+
});
|
|
669
1861
|
if (existsSync(dashboardDist)) {
|
|
670
1862
|
app.use(express.static(dashboardDist));
|
|
671
1863
|
app.get(/^\/(?!api|health|ws)/, (_req, res) => {
|