@myvillage/cli 1.7.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/create-app.js +5 -4
- package/src/utils/agentic-templates.js +860 -1093
- package/src/utils/api.js +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
3
4
|
|
|
4
5
|
// MyVillage brand colors
|
|
5
6
|
const BRAND = {
|
|
@@ -27,7 +28,7 @@ const TOOL_GROUPS = {
|
|
|
27
28
|
meetings: ['recall_send_bot', 'recall_meeting_attendance'],
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
// ─── Agentic App Template
|
|
31
|
+
// ─── Agentic App Template (Next.js) ─────────────────────────────────────────
|
|
31
32
|
|
|
32
33
|
export function createAgenticAppProject(targetDir, options) {
|
|
33
34
|
const {
|
|
@@ -53,100 +54,113 @@ export function createAgenticAppProject(targetDir, options) {
|
|
|
53
54
|
const dirs = [
|
|
54
55
|
'',
|
|
55
56
|
'public',
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'src/components',
|
|
60
|
-
'src/pages',
|
|
57
|
+
'app',
|
|
58
|
+
'lib',
|
|
59
|
+
'components',
|
|
61
60
|
];
|
|
62
61
|
|
|
63
|
-
if (
|
|
64
|
-
dirs.push(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
if (hasOAuth) {
|
|
63
|
+
dirs.push(
|
|
64
|
+
'app/login',
|
|
65
|
+
'app/api/auth/login',
|
|
66
|
+
'app/api/auth/callback',
|
|
67
|
+
'app/api/auth/session',
|
|
68
|
+
'app/api/auth/logout',
|
|
69
|
+
);
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
dirs.push('app/api/agent/chat');
|
|
73
|
+
|
|
70
74
|
if (includeRestApi) {
|
|
71
|
-
dirs.push('
|
|
75
|
+
dirs.push('app/api/proxy/[...path]');
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
if (hasDashboard) dirs.push('app/dashboard');
|
|
79
|
+
if (hasSettings) dirs.push('app/settings');
|
|
80
|
+
if (hasUsers) dirs.push('app/users');
|
|
81
|
+
if (hasNotifications) dirs.push('app/notifications');
|
|
82
|
+
|
|
74
83
|
for (const dir of dirs) {
|
|
75
84
|
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
// Write root files
|
|
79
88
|
writeFileSync(join(targetDir, 'package.json'), generatePackageJson(slug, description, includeMcp));
|
|
80
|
-
writeFileSync(join(targetDir, '
|
|
89
|
+
writeFileSync(join(targetDir, 'next.config.js'), generateNextConfig());
|
|
81
90
|
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
82
|
-
writeFileSync(join(targetDir, '.env'), generateEnv(oauthCredentials, hasOAuth, includeMcp));
|
|
91
|
+
writeFileSync(join(targetDir, '.env.local'), generateEnv(oauthCredentials, hasOAuth, includeMcp));
|
|
83
92
|
writeFileSync(join(targetDir, '.env.example'), generateEnvExample(hasOAuth, includeMcp));
|
|
84
|
-
writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name));
|
|
85
93
|
writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, hasOAuth, includeMcp, includeRestApi, features));
|
|
94
|
+
writeFileSync(join(targetDir, 'jsconfig.json'), generateJsConfig());
|
|
86
95
|
|
|
87
|
-
//
|
|
88
|
-
writeFileSync(join(targetDir, '
|
|
89
|
-
writeFileSync(join(targetDir, 'server/agent/index.js'), generateAgent(includeMcp));
|
|
96
|
+
// Lib: server-side modules
|
|
97
|
+
writeFileSync(join(targetDir, 'lib/agent.js'), generateAgent(includeMcp));
|
|
90
98
|
|
|
91
99
|
if (includeMcp) {
|
|
92
|
-
writeFileSync(join(targetDir, '
|
|
100
|
+
writeFileSync(join(targetDir, 'lib/mcp-client.js'), generateMcpClient(mcpToolGroups));
|
|
93
101
|
}
|
|
94
102
|
if (hasOAuth) {
|
|
95
|
-
writeFileSync(join(targetDir, '
|
|
103
|
+
writeFileSync(join(targetDir, 'lib/oauth.js'), generateOAuthLib());
|
|
104
|
+
writeFileSync(join(targetDir, 'lib/session.js'), generateSessionLib());
|
|
105
|
+
writeFileSync(join(targetDir, 'middleware.js'), generateMiddleware());
|
|
96
106
|
}
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
|
|
108
|
+
// API routes
|
|
109
|
+
writeFileSync(join(targetDir, 'app/api/agent/chat/route.js'), generateAgentChatRoute(hasOAuth));
|
|
110
|
+
|
|
111
|
+
if (hasOAuth) {
|
|
112
|
+
writeFileSync(join(targetDir, 'app/api/auth/login/route.js'), generateAuthLoginRoute());
|
|
113
|
+
writeFileSync(join(targetDir, 'app/api/auth/callback/route.js'), generateAuthCallbackRoute());
|
|
114
|
+
writeFileSync(join(targetDir, 'app/api/auth/session/route.js'), generateAuthSessionRoute());
|
|
115
|
+
writeFileSync(join(targetDir, 'app/api/auth/logout/route.js'), generateAuthLogoutRoute());
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
writeFileSync(join(targetDir, 'src/App.css'), generateAppCss());
|
|
105
|
-
writeFileSync(join(targetDir, 'src/index.css'), generateIndexCss());
|
|
118
|
+
if (includeRestApi) {
|
|
119
|
+
writeFileSync(join(targetDir, 'app/api/proxy/[...path]/route.js'), generateProxyCatchAll(hasOAuth));
|
|
120
|
+
}
|
|
106
121
|
|
|
107
|
-
//
|
|
108
|
-
writeFileSync(join(targetDir, '
|
|
109
|
-
writeFileSync(join(targetDir, '
|
|
110
|
-
writeFileSync(join(targetDir, '
|
|
111
|
-
writeFileSync(join(targetDir, '
|
|
122
|
+
// App: layout, pages, styles
|
|
123
|
+
writeFileSync(join(targetDir, 'app/layout.jsx'), generateRootLayout(name, hasOAuth, features));
|
|
124
|
+
writeFileSync(join(targetDir, 'app/globals.css'), generateGlobalsCss());
|
|
125
|
+
writeFileSync(join(targetDir, 'app/page.jsx'), generateHomePage(hasDashboard));
|
|
126
|
+
writeFileSync(join(targetDir, 'app/not-found.jsx'), generateNotFoundPage());
|
|
112
127
|
|
|
113
128
|
if (hasOAuth) {
|
|
114
|
-
writeFileSync(join(targetDir, '
|
|
115
|
-
writeFileSync(join(targetDir, 'src/auth/oauth.js'), generateBrowserOAuthModule());
|
|
116
|
-
writeFileSync(join(targetDir, 'src/auth/AuthProvider.jsx'), generateAuthProvider());
|
|
117
|
-
writeFileSync(join(targetDir, 'src/pages/Login.jsx'), generateLoginPage());
|
|
118
|
-
writeFileSync(join(targetDir, 'src/pages/Callback.jsx'), generateCallbackPage());
|
|
129
|
+
writeFileSync(join(targetDir, 'app/login/page.jsx'), generateLoginPage());
|
|
119
130
|
}
|
|
120
|
-
|
|
121
|
-
// Pages
|
|
122
131
|
if (hasDashboard) {
|
|
123
|
-
writeFileSync(join(targetDir, '
|
|
132
|
+
writeFileSync(join(targetDir, 'app/dashboard/page.jsx'), generateDashboardPage(includeMcp));
|
|
124
133
|
}
|
|
125
134
|
if (hasSettings) {
|
|
126
|
-
writeFileSync(join(targetDir, '
|
|
135
|
+
writeFileSync(join(targetDir, 'app/settings/page.jsx'), generateSettingsPage());
|
|
127
136
|
}
|
|
128
137
|
if (hasUsers) {
|
|
129
|
-
writeFileSync(join(targetDir, '
|
|
138
|
+
writeFileSync(join(targetDir, 'app/users/page.jsx'), generateUsersPage());
|
|
130
139
|
}
|
|
131
140
|
if (hasNotifications) {
|
|
132
|
-
writeFileSync(join(targetDir, '
|
|
141
|
+
writeFileSync(join(targetDir, 'app/notifications/page.jsx'), generateNotificationsPage());
|
|
133
142
|
}
|
|
134
143
|
|
|
135
|
-
|
|
144
|
+
// Components
|
|
145
|
+
writeFileSync(join(targetDir, 'components/Sidebar.jsx'), generateSidebarComponent(features));
|
|
146
|
+
writeFileSync(join(targetDir, 'components/Header.jsx'), generateHeaderComponent(hasOAuth));
|
|
147
|
+
writeFileSync(join(targetDir, 'components/AgentChat.jsx'), generateAgentChatComponent());
|
|
148
|
+
|
|
149
|
+
if (hasOAuth) {
|
|
150
|
+
writeFileSync(join(targetDir, 'components/SignOutButton.jsx'), generateSignOutButton());
|
|
151
|
+
}
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
// ───
|
|
154
|
+
// ─── Root Config Generators ─────────────────────────────────────────────────
|
|
139
155
|
|
|
140
156
|
function generatePackageJson(slug, description, includeMcp) {
|
|
141
157
|
const deps = {
|
|
142
|
-
'
|
|
143
|
-
'react
|
|
144
|
-
'react-
|
|
158
|
+
'next': '^15.0.0',
|
|
159
|
+
'react': '^19.0.0',
|
|
160
|
+
'react-dom': '^19.0.0',
|
|
161
|
+
'iron-session': '^8.0.0',
|
|
145
162
|
'ai': '^4.0.0',
|
|
146
163
|
'@ai-sdk/anthropic': '^1.0.0',
|
|
147
|
-
'express': '^4.21.0',
|
|
148
|
-
'cors': '^2.8.5',
|
|
149
|
-
'dotenv': '^16.4.0',
|
|
150
164
|
};
|
|
151
165
|
|
|
152
166
|
if (includeMcp) {
|
|
@@ -157,33 +171,46 @@ function generatePackageJson(slug, description, includeMcp) {
|
|
|
157
171
|
name: slug,
|
|
158
172
|
version: '0.1.0',
|
|
159
173
|
description: description || 'MyVillageOS application',
|
|
160
|
-
type: 'module',
|
|
161
174
|
private: true,
|
|
162
175
|
scripts: {
|
|
163
|
-
dev: '
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
build: 'vite build',
|
|
167
|
-
start: 'node server/index.js',
|
|
168
|
-
preview: 'vite preview',
|
|
176
|
+
dev: 'next dev',
|
|
177
|
+
build: 'next build',
|
|
178
|
+
start: 'next start',
|
|
169
179
|
},
|
|
170
180
|
dependencies: deps,
|
|
171
|
-
devDependencies: {
|
|
172
|
-
'vite': '^5.0.0',
|
|
173
|
-
'@vitejs/plugin-react': '^4.2.0',
|
|
174
|
-
'concurrently': '^8.2.0',
|
|
175
|
-
},
|
|
176
181
|
};
|
|
177
182
|
|
|
178
183
|
return JSON.stringify(pkg, null, 2) + '\n';
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
function generateNextConfig() {
|
|
187
|
+
return `/** @type {import('next').NextConfig} */
|
|
188
|
+
const nextConfig = {
|
|
189
|
+
reactStrictMode: true,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export default nextConfig;
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function generateJsConfig() {
|
|
197
|
+
return `{
|
|
198
|
+
"compilerOptions": {
|
|
199
|
+
"paths": {
|
|
200
|
+
"@/*": ["./*"]
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
|
|
181
207
|
function generateGitignore() {
|
|
182
208
|
return `node_modules/
|
|
183
|
-
|
|
209
|
+
.next/
|
|
210
|
+
out/
|
|
184
211
|
.DS_Store
|
|
185
212
|
*.log
|
|
186
|
-
.env
|
|
213
|
+
.env.local
|
|
187
214
|
`;
|
|
188
215
|
}
|
|
189
216
|
|
|
@@ -193,14 +220,11 @@ function generateEnv(oauthCredentials, hasOAuth, includeMcp) {
|
|
|
193
220
|
if (hasOAuth) {
|
|
194
221
|
const clientId = oauthCredentials?.clientId || '';
|
|
195
222
|
const clientSecret = oauthCredentials?.clientSecret || '';
|
|
196
|
-
// Server-side OAuth credentials
|
|
197
223
|
lines.push(`MYVILLAGEOS_CLIENT_ID=${clientId}`);
|
|
198
224
|
lines.push(`MYVILLAGEOS_CLIENT_SECRET=${clientSecret}`);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
lines.push(`
|
|
202
|
-
lines.push(`VITE_OAUTH_BASE_URL=${OAUTH_BASE_URL}`);
|
|
203
|
-
lines.push('VITE_OAUTH_REDIRECT_URI=http://localhost:5173/callback');
|
|
225
|
+
lines.push(`OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback`);
|
|
226
|
+
// Generate a random session secret
|
|
227
|
+
lines.push(`SESSION_SECRET=${crypto.randomBytes(32).toString('hex')}`);
|
|
204
228
|
}
|
|
205
229
|
|
|
206
230
|
lines.push('MYVILLAGEOS_API_URL=https://portal.myvillageproject.ai');
|
|
@@ -210,7 +234,6 @@ function generateEnv(oauthCredentials, hasOAuth, includeMcp) {
|
|
|
210
234
|
}
|
|
211
235
|
|
|
212
236
|
lines.push('ANTHROPIC_API_KEY=');
|
|
213
|
-
lines.push('PORT=3000');
|
|
214
237
|
|
|
215
238
|
return lines.join('\n') + '\n';
|
|
216
239
|
}
|
|
@@ -221,10 +244,8 @@ function generateEnvExample(hasOAuth, includeMcp) {
|
|
|
221
244
|
if (hasOAuth) {
|
|
222
245
|
lines.push('MYVILLAGEOS_CLIENT_ID=');
|
|
223
246
|
lines.push('MYVILLAGEOS_CLIENT_SECRET=');
|
|
224
|
-
lines.push(
|
|
225
|
-
lines.push(
|
|
226
|
-
lines.push(`VITE_OAUTH_BASE_URL=${OAUTH_BASE_URL}`);
|
|
227
|
-
lines.push('VITE_OAUTH_REDIRECT_URI=http://localhost:5173/callback');
|
|
247
|
+
lines.push('OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback');
|
|
248
|
+
lines.push('SESSION_SECRET=');
|
|
228
249
|
}
|
|
229
250
|
|
|
230
251
|
lines.push('MYVILLAGEOS_API_URL=https://portal.myvillageproject.ai');
|
|
@@ -234,7 +255,6 @@ function generateEnvExample(hasOAuth, includeMcp) {
|
|
|
234
255
|
}
|
|
235
256
|
|
|
236
257
|
lines.push('ANTHROPIC_API_KEY=');
|
|
237
|
-
lines.push('PORT=3000');
|
|
238
258
|
|
|
239
259
|
return lines.join('\n') + '\n';
|
|
240
260
|
}
|
|
@@ -255,15 +275,15 @@ npm install
|
|
|
255
275
|
2. Copy the environment file and fill in your credentials:
|
|
256
276
|
|
|
257
277
|
\`\`\`bash
|
|
258
|
-
cp .env.example .env
|
|
278
|
+
cp .env.example .env.local
|
|
259
279
|
\`\`\`
|
|
260
280
|
|
|
261
|
-
3. Set your \`ANTHROPIC_API_KEY\` in \`.env\`.
|
|
281
|
+
3. Set your \`ANTHROPIC_API_KEY\` in \`.env.local\`.
|
|
262
282
|
`;
|
|
263
283
|
|
|
264
284
|
if (hasOAuth) {
|
|
265
285
|
readme += `
|
|
266
|
-
4.
|
|
286
|
+
4. Your OAuth credentials (\`MYVILLAGEOS_CLIENT_ID\` and \`MYVILLAGEOS_CLIENT_SECRET\`) should already be in \`.env.local\` if you created this project with the CLI. Otherwise, register an OAuth client at [portal.myvillageproject.ai](https://portal.myvillageproject.ai).
|
|
267
287
|
`;
|
|
268
288
|
}
|
|
269
289
|
|
|
@@ -271,50 +291,34 @@ cp .env.example .env
|
|
|
271
291
|
## Running
|
|
272
292
|
|
|
273
293
|
\`\`\`bash
|
|
274
|
-
# Development
|
|
275
|
-
npm run
|
|
276
|
-
|
|
277
|
-
# Or run separately:
|
|
278
|
-
npm run dev:client # React frontend on http://localhost:5173
|
|
279
|
-
npm run dev:server # Express backend on http://localhost:3000
|
|
280
|
-
|
|
281
|
-
# Production
|
|
282
|
-
npm run build # Build React frontend
|
|
283
|
-
npm start # Start Express server (serves built frontend)
|
|
294
|
+
npm run dev # Development server on http://localhost:3000
|
|
295
|
+
npm run build # Production build
|
|
296
|
+
npm start # Start production server
|
|
284
297
|
\`\`\`
|
|
285
298
|
|
|
286
299
|
## Architecture
|
|
287
300
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
- **
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
- **
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
readme += `- **server/auth/oauth.js** - Server-side OAuth 2.0 + PKCE helpers
|
|
305
|
-
`;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (includeRestApi) {
|
|
309
|
-
readme += `- **server/api/routes.js** - REST API proxy routes for platform integration
|
|
310
|
-
`;
|
|
311
|
-
}
|
|
301
|
+
This is a **Next.js App Router** application with server-side OAuth and an AI agent backend.
|
|
302
|
+
|
|
303
|
+
### App Routes
|
|
304
|
+
- **app/layout.jsx** — Root layout with sidebar and header
|
|
305
|
+
- **app/page.jsx** — Home page
|
|
306
|
+
${hasOAuth ? '- **app/login/page.jsx** — Login page\n' : ''}${features.includes('dashboard') ? '- **app/dashboard/page.jsx** — Dashboard with AI agent chat\n' : ''}${features.includes('settings') ? '- **app/settings/page.jsx** — Settings\n' : ''}${features.includes('users') ? '- **app/users/page.jsx** — Users\n' : ''}${features.includes('notifications') ? '- **app/notifications/page.jsx** — Notifications\n' : ''}
|
|
307
|
+
### API Routes
|
|
308
|
+
- **app/api/agent/chat/** — AI agent endpoint (Vercel AI SDK + Anthropic)
|
|
309
|
+
${hasOAuth ? '- **app/api/auth/** — OAuth login, callback, session, logout\n' : ''}${includeRestApi ? '- **app/api/proxy/** — Authenticated proxy to MyVillageOS platform API\n' : ''}
|
|
310
|
+
### Server Libraries
|
|
311
|
+
- **lib/agent.js** — AI agent powered by Vercel AI SDK + Anthropic
|
|
312
|
+
${includeMcp ? '- **lib/mcp-client.js** — MCP client for MyVillageOS tools\n' : ''}${hasOAuth ? '- **lib/oauth.js** — Server-side OAuth 2.0 + PKCE helpers\n- **lib/session.js** — Encrypted session management (iron-session)\n' : ''}
|
|
313
|
+
### Components
|
|
314
|
+
- **components/Sidebar.jsx** — Navigation sidebar
|
|
315
|
+
- **components/Header.jsx** — Top header with user info
|
|
316
|
+
- **components/AgentChat.jsx** — Interactive AI agent chat widget
|
|
312
317
|
|
|
313
|
-
readme += `
|
|
314
318
|
## Brand Colors
|
|
315
319
|
|
|
316
320
|
| Color | Hex |
|
|
317
|
-
|
|
321
|
+
|------------|-----------||
|
|
318
322
|
| Gold | ${BRAND.gold} |
|
|
319
323
|
| Brown | ${BRAND.brown} |
|
|
320
324
|
| Green | ${BRAND.green} |
|
|
@@ -330,133 +334,350 @@ Built with [MyVillageOS](https://myvillageproject.ai)
|
|
|
330
334
|
return readme;
|
|
331
335
|
}
|
|
332
336
|
|
|
333
|
-
|
|
334
|
-
let imports = `import 'dotenv/config';
|
|
335
|
-
import express from 'express';
|
|
336
|
-
import cors from 'cors';
|
|
337
|
-
import { runAgent } from './agent/index.js';
|
|
338
|
-
`;
|
|
337
|
+
// ─── Auth & Session Generators ──────────────────────────────────────────────
|
|
339
338
|
|
|
340
|
-
|
|
341
|
-
|
|
339
|
+
function generateSessionLib() {
|
|
340
|
+
return `import { getIronSession } from 'iron-session';
|
|
341
|
+
import { cookies } from 'next/headers';
|
|
342
|
+
|
|
343
|
+
const sessionOptions = {
|
|
344
|
+
password: process.env.SESSION_SECRET,
|
|
345
|
+
cookieName: 'myvillage_session',
|
|
346
|
+
cookieOptions: {
|
|
347
|
+
secure: process.env.NODE_ENV === 'production',
|
|
348
|
+
httpOnly: true,
|
|
349
|
+
sameSite: 'lax',
|
|
350
|
+
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get the session from cookies (for use in Server Components and API routes).
|
|
356
|
+
*/
|
|
357
|
+
export async function getSession() {
|
|
358
|
+
const cookieStore = await cookies();
|
|
359
|
+
return getIronSession(cookieStore, sessionOptions);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get session options (for use in middleware or route handlers that need raw options).
|
|
364
|
+
*/
|
|
365
|
+
export function getSessionOptions() {
|
|
366
|
+
return sessionOptions;
|
|
367
|
+
}
|
|
342
368
|
`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function generateOAuthLib() {
|
|
372
|
+
return `import crypto from 'crypto';
|
|
373
|
+
|
|
374
|
+
const OAUTH_BASE_URL = process.env.MYVILLAGEOS_API_URL
|
|
375
|
+
? \`\${process.env.MYVILLAGEOS_API_URL}/api/oauth\`
|
|
376
|
+
: '${OAUTH_BASE_URL}';
|
|
377
|
+
|
|
378
|
+
const CLIENT_ID = process.env.MYVILLAGEOS_CLIENT_ID;
|
|
379
|
+
const CLIENT_SECRET = process.env.MYVILLAGEOS_CLIENT_SECRET;
|
|
380
|
+
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI || 'http://localhost:3000/api/auth/callback';
|
|
381
|
+
const SCOPES = '${OAUTH_SCOPES}';
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Generate a cryptographically random PKCE code verifier.
|
|
385
|
+
*/
|
|
386
|
+
export function generateCodeVerifier() {
|
|
387
|
+
return crypto.randomBytes(64).toString('base64url');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Generate the S256 code challenge from a verifier.
|
|
392
|
+
*/
|
|
393
|
+
export function generateCodeChallenge(verifier) {
|
|
394
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Build the authorization URL for initiating the OAuth login flow.
|
|
399
|
+
* Returns the URL and the code verifier (which must be stored for the callback).
|
|
400
|
+
*/
|
|
401
|
+
export function buildAuthorizationUrl() {
|
|
402
|
+
const codeVerifier = generateCodeVerifier();
|
|
403
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
404
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
405
|
+
|
|
406
|
+
const params = new URLSearchParams({
|
|
407
|
+
response_type: 'code',
|
|
408
|
+
client_id: CLIENT_ID,
|
|
409
|
+
redirect_uri: REDIRECT_URI,
|
|
410
|
+
scope: SCOPES,
|
|
411
|
+
state,
|
|
412
|
+
code_challenge: codeChallenge,
|
|
413
|
+
code_challenge_method: 'S256',
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const url = \`\${OAUTH_BASE_URL}/authorize?\${params.toString()}\`;
|
|
417
|
+
return { url, codeVerifier, state };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Exchange an authorization code for tokens (server-side).
|
|
422
|
+
*/
|
|
423
|
+
export async function exchangeCodeForTokens(code, codeVerifier) {
|
|
424
|
+
const body = new URLSearchParams({
|
|
425
|
+
grant_type: 'authorization_code',
|
|
426
|
+
code,
|
|
427
|
+
redirect_uri: REDIRECT_URI,
|
|
428
|
+
client_id: CLIENT_ID,
|
|
429
|
+
client_secret: CLIENT_SECRET,
|
|
430
|
+
code_verifier: codeVerifier,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const response = await fetch(\`\${OAUTH_BASE_URL}/token\`, {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
436
|
+
body: body.toString(),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (!response.ok) {
|
|
440
|
+
const error = await response.text();
|
|
441
|
+
throw new Error(\`Token exchange failed: \${response.status} \${error}\`);
|
|
343
442
|
}
|
|
344
443
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
444
|
+
return response.json();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Fetch the authenticated user's profile from the userinfo endpoint.
|
|
449
|
+
*/
|
|
450
|
+
export async function fetchUserInfo(accessToken) {
|
|
451
|
+
const response = await fetch(\`\${OAUTH_BASE_URL}/userinfo\`, {
|
|
452
|
+
headers: { Authorization: \`Bearer \${accessToken}\` },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
throw new Error(\`Userinfo request failed: \${response.status}\`);
|
|
348
457
|
}
|
|
349
458
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const PORT = process.env.PORT || 3000;
|
|
459
|
+
return response.json();
|
|
460
|
+
}
|
|
353
461
|
|
|
354
|
-
|
|
355
|
-
|
|
462
|
+
/**
|
|
463
|
+
* Refresh an access token using a refresh token.
|
|
464
|
+
*/
|
|
465
|
+
export async function refreshAccessToken(refreshToken) {
|
|
466
|
+
const body = new URLSearchParams({
|
|
467
|
+
grant_type: 'refresh_token',
|
|
468
|
+
refresh_token: refreshToken,
|
|
469
|
+
client_id: CLIENT_ID,
|
|
470
|
+
client_secret: CLIENT_SECRET,
|
|
471
|
+
});
|
|
356
472
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
473
|
+
const response = await fetch(\`\${OAUTH_BASE_URL}/token\`, {
|
|
474
|
+
method: 'POST',
|
|
475
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
476
|
+
body: body.toString(),
|
|
477
|
+
});
|
|
362
478
|
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
app.get('/auth/login', (req, res) => {
|
|
367
|
-
const { url, codeVerifier } = initiateOAuthLogin();
|
|
368
|
-
// Store codeVerifier in session or a temporary store for the callback
|
|
369
|
-
app.locals.pendingAuth = { codeVerifier };
|
|
370
|
-
res.redirect(url);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
app.get('/auth/callback', async (req, res) => {
|
|
374
|
-
try {
|
|
375
|
-
const { code } = req.query;
|
|
376
|
-
const { codeVerifier } = app.locals.pendingAuth || {};
|
|
377
|
-
if (!code || !codeVerifier) {
|
|
378
|
-
return res.status(400).json({ error: 'Missing code or code verifier' });
|
|
379
|
-
}
|
|
380
|
-
const tokens = await handleOAuthCallback(code, codeVerifier);
|
|
381
|
-
app.locals.tokens = tokens;
|
|
382
|
-
delete app.locals.pendingAuth;
|
|
383
|
-
res.json({ message: 'Authenticated successfully' });
|
|
384
|
-
} catch (err) {
|
|
385
|
-
console.error('[Auth] OAuth callback error:', err.message);
|
|
386
|
-
res.status(500).json({ error: 'Authentication failed' });
|
|
479
|
+
if (!response.ok) {
|
|
480
|
+
const error = await response.text();
|
|
481
|
+
throw new Error(\`Token refresh failed: \${response.status} \${error}\`);
|
|
387
482
|
}
|
|
388
|
-
|
|
483
|
+
|
|
484
|
+
return response.json();
|
|
485
|
+
}
|
|
389
486
|
`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function generateMiddleware() {
|
|
490
|
+
return `import { NextResponse } from 'next/server';
|
|
491
|
+
|
|
492
|
+
// Routes that don't require authentication
|
|
493
|
+
const publicPaths = ['/login', '/api/auth/login', '/api/auth/callback', '/_next', '/favicon.ico'];
|
|
494
|
+
|
|
495
|
+
export function middleware(request) {
|
|
496
|
+
const { pathname } = request.nextUrl;
|
|
497
|
+
|
|
498
|
+
// Allow public paths
|
|
499
|
+
if (publicPaths.some(path => pathname.startsWith(path))) {
|
|
500
|
+
return NextResponse.next();
|
|
390
501
|
}
|
|
391
502
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
app.use('/api', createApiRouter(() => app.locals.tokens));
|
|
397
|
-
`;
|
|
398
|
-
} else {
|
|
399
|
-
body += `
|
|
400
|
-
// Mount API routes
|
|
401
|
-
app.use('/api', createApiRouter());
|
|
402
|
-
`;
|
|
403
|
-
}
|
|
503
|
+
// Check for session cookie (actual validation happens in route handlers)
|
|
504
|
+
const sessionCookie = request.cookies.get('myvillage_session');
|
|
505
|
+
if (!sessionCookie) {
|
|
506
|
+
return NextResponse.redirect(new URL('/login', request.url));
|
|
404
507
|
}
|
|
405
508
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
app.post('/agent/chat', async (req, res) => {
|
|
409
|
-
try {
|
|
410
|
-
const { message } = req.body;
|
|
411
|
-
if (!message) {
|
|
412
|
-
return res.status(400).json({ error: 'Message is required' });
|
|
413
|
-
}
|
|
414
|
-
`;
|
|
509
|
+
return NextResponse.next();
|
|
510
|
+
}
|
|
415
511
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
512
|
+
export const config = {
|
|
513
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
514
|
+
};
|
|
419
515
|
`;
|
|
420
|
-
|
|
421
|
-
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─── Auth Route Generators ──────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
function generateAuthLoginRoute() {
|
|
521
|
+
return `import { NextResponse } from 'next/server';
|
|
522
|
+
import { cookies } from 'next/headers';
|
|
523
|
+
import { buildAuthorizationUrl } from '@/lib/oauth';
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* GET /api/auth/login
|
|
527
|
+
* Initiates the OAuth login flow. Generates PKCE verifier, stores it in a
|
|
528
|
+
* temporary cookie, and redirects to the MyVillageOS authorization page.
|
|
529
|
+
*/
|
|
530
|
+
export async function GET() {
|
|
531
|
+
const { url, codeVerifier, state } = buildAuthorizationUrl();
|
|
532
|
+
|
|
533
|
+
const cookieStore = await cookies();
|
|
534
|
+
|
|
535
|
+
// Store verifier and state in short-lived httpOnly cookies for the callback
|
|
536
|
+
cookieStore.set('oauth_verifier', codeVerifier, {
|
|
537
|
+
httpOnly: true,
|
|
538
|
+
secure: process.env.NODE_ENV === 'production',
|
|
539
|
+
sameSite: 'lax',
|
|
540
|
+
maxAge: 600, // 10 minutes
|
|
541
|
+
path: '/',
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
cookieStore.set('oauth_state', state, {
|
|
545
|
+
httpOnly: true,
|
|
546
|
+
secure: process.env.NODE_ENV === 'production',
|
|
547
|
+
sameSite: 'lax',
|
|
548
|
+
maxAge: 600,
|
|
549
|
+
path: '/',
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
return NextResponse.redirect(url);
|
|
553
|
+
}
|
|
422
554
|
`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function generateAuthCallbackRoute() {
|
|
558
|
+
return `import { NextResponse } from 'next/server';
|
|
559
|
+
import { cookies } from 'next/headers';
|
|
560
|
+
import { exchangeCodeForTokens, fetchUserInfo } from '@/lib/oauth';
|
|
561
|
+
import { getSession } from '@/lib/session';
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* GET /api/auth/callback
|
|
565
|
+
* Handles the OAuth callback. Exchanges the authorization code for tokens
|
|
566
|
+
* server-side (no CORS issues), fetches user info, and stores everything
|
|
567
|
+
* in an encrypted session cookie.
|
|
568
|
+
*/
|
|
569
|
+
export async function GET(request) {
|
|
570
|
+
const { searchParams } = new URL(request.url);
|
|
571
|
+
const code = searchParams.get('code');
|
|
572
|
+
const state = searchParams.get('state');
|
|
573
|
+
const error = searchParams.get('error');
|
|
574
|
+
|
|
575
|
+
if (error) {
|
|
576
|
+
const errorDesc = searchParams.get('error_description') || 'Authorization failed';
|
|
577
|
+
console.error('[Auth Callback] OAuth error:', error, errorDesc);
|
|
578
|
+
return NextResponse.redirect(new URL(\`/login?error=\${encodeURIComponent(errorDesc)}\`, request.url));
|
|
423
579
|
}
|
|
424
580
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
console.error('[Agent] Error:', err.message);
|
|
428
|
-
res.status(500).json({ error: 'Agent processing failed' });
|
|
581
|
+
if (!code) {
|
|
582
|
+
return NextResponse.redirect(new URL('/login?error=Missing+authorization+code', request.url));
|
|
429
583
|
}
|
|
430
|
-
});
|
|
431
584
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
`;
|
|
585
|
+
const cookieStore = await cookies();
|
|
586
|
+
const savedVerifier = cookieStore.get('oauth_verifier')?.value;
|
|
587
|
+
const savedState = cookieStore.get('oauth_state')?.value;
|
|
436
588
|
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
`;
|
|
589
|
+
if (!savedVerifier) {
|
|
590
|
+
return NextResponse.redirect(new URL('/login?error=Missing+code+verifier', request.url));
|
|
440
591
|
}
|
|
441
592
|
|
|
442
|
-
if (
|
|
443
|
-
|
|
593
|
+
if (state && savedState && state !== savedState) {
|
|
594
|
+
return NextResponse.redirect(new URL('/login?error=State+mismatch', request.url));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
// Exchange code for tokens (server-to-server, no CORS)
|
|
599
|
+
const tokens = await exchangeCodeForTokens(code, savedVerifier);
|
|
600
|
+
|
|
601
|
+
// Fetch user profile
|
|
602
|
+
const user = await fetchUserInfo(tokens.access_token);
|
|
603
|
+
|
|
604
|
+
// Store in encrypted session
|
|
605
|
+
const session = await getSession();
|
|
606
|
+
session.accessToken = tokens.access_token;
|
|
607
|
+
session.refreshToken = tokens.refresh_token;
|
|
608
|
+
session.expiresAt = Date.now() + (tokens.expires_in * 1000);
|
|
609
|
+
session.user = {
|
|
610
|
+
id: user.sub || user.id,
|
|
611
|
+
name: user.name,
|
|
612
|
+
email: user.email,
|
|
613
|
+
villagerId: user.villager_id,
|
|
614
|
+
};
|
|
615
|
+
await session.save();
|
|
616
|
+
|
|
617
|
+
// Clean up temporary cookies
|
|
618
|
+
cookieStore.delete('oauth_verifier');
|
|
619
|
+
cookieStore.delete('oauth_state');
|
|
620
|
+
|
|
621
|
+
console.log('[Auth Callback] Login successful for', user.name || user.email);
|
|
622
|
+
|
|
623
|
+
return NextResponse.redirect(new URL('/', request.url));
|
|
624
|
+
} catch (err) {
|
|
625
|
+
console.error('[Auth Callback] Error:', err.message);
|
|
626
|
+
return NextResponse.redirect(new URL(\`/login?error=\${encodeURIComponent(err.message)}\`, request.url));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
444
629
|
`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function generateAuthSessionRoute() {
|
|
633
|
+
return `import { NextResponse } from 'next/server';
|
|
634
|
+
import { getSession } from '@/lib/session';
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* GET /api/auth/session
|
|
638
|
+
* Returns the current session user info (for client components).
|
|
639
|
+
*/
|
|
640
|
+
export async function GET() {
|
|
641
|
+
const session = await getSession();
|
|
642
|
+
|
|
643
|
+
if (!session.user) {
|
|
644
|
+
return NextResponse.json({ authenticated: false }, { status: 401 });
|
|
445
645
|
}
|
|
446
646
|
|
|
447
|
-
|
|
647
|
+
return NextResponse.json({
|
|
648
|
+
authenticated: true,
|
|
649
|
+
user: session.user,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
448
652
|
`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function generateAuthLogoutRoute() {
|
|
656
|
+
return `import { NextResponse } from 'next/server';
|
|
657
|
+
import { getSession } from '@/lib/session';
|
|
449
658
|
|
|
450
|
-
|
|
659
|
+
/**
|
|
660
|
+
* POST /api/auth/logout
|
|
661
|
+
* Destroys the session and redirects to the login page.
|
|
662
|
+
*/
|
|
663
|
+
export async function POST() {
|
|
664
|
+
const session = await getSession();
|
|
665
|
+
session.destroy();
|
|
666
|
+
|
|
667
|
+
return NextResponse.redirect(new URL('/login', process.env.NEXTAUTH_URL || 'http://localhost:3000'));
|
|
668
|
+
}
|
|
669
|
+
`;
|
|
451
670
|
}
|
|
452
671
|
|
|
672
|
+
// ─── Agent & MCP Generators ─────────────────────────────────────────────────
|
|
673
|
+
|
|
453
674
|
function generateAgent(includeMcp) {
|
|
454
675
|
let code = `import { generateText } from 'ai';
|
|
455
676
|
import { anthropic } from '@ai-sdk/anthropic';
|
|
456
677
|
`;
|
|
457
678
|
|
|
458
679
|
if (includeMcp) {
|
|
459
|
-
code += `import { getMcpTools } from '
|
|
680
|
+
code += `import { getMcpTools } from './mcp-client.js';
|
|
460
681
|
`;
|
|
461
682
|
}
|
|
462
683
|
|
|
@@ -515,20 +736,11 @@ to fulfill the user's request rather than just describing what could be done.\`;
|
|
|
515
736
|
}
|
|
516
737
|
|
|
517
738
|
function generateMcpClient(selectedGroups) {
|
|
518
|
-
// Build the list of allowed tool names from selected groups
|
|
519
|
-
const allowedTools = [];
|
|
520
|
-
for (const group of selectedGroups) {
|
|
521
|
-
if (TOOL_GROUPS[group]) {
|
|
522
|
-
allowedTools.push(...TOOL_GROUPS[group]);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
739
|
const code = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
527
740
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
528
741
|
import { jsonSchema } from 'ai';
|
|
529
742
|
|
|
530
743
|
// ─── MCP Tool Group Definitions ─────────────────────────────────────────────
|
|
531
|
-
// Each group maps to a set of MyVillageOS MCP tool names.
|
|
532
744
|
|
|
533
745
|
const TOOL_GROUPS = {
|
|
534
746
|
social: ['post_create', 'post_view', 'post_list', 'post_edit', 'post_delete', 'comment_create', 'comment_list', 'vote_cast'],
|
|
@@ -551,7 +763,6 @@ let mcpClient = null;
|
|
|
551
763
|
|
|
552
764
|
/**
|
|
553
765
|
* Initialize the MCP client by spawning the MyVillageOS MCP server as a child process.
|
|
554
|
-
* Uses stdio transport for local communication.
|
|
555
766
|
*/
|
|
556
767
|
async function getClient() {
|
|
557
768
|
if (mcpClient) return mcpClient;
|
|
@@ -576,8 +787,6 @@ async function getClient() {
|
|
|
576
787
|
/**
|
|
577
788
|
* Convert MCP tools to Vercel AI SDK tool format.
|
|
578
789
|
* Filters tools based on the selected tool groups.
|
|
579
|
-
*
|
|
580
|
-
* @returns {object} Tools object compatible with Vercel AI SDK generateText/streamText
|
|
581
790
|
*/
|
|
582
791
|
export async function getMcpTools() {
|
|
583
792
|
const client = await getClient();
|
|
@@ -586,7 +795,6 @@ export async function getMcpTools() {
|
|
|
586
795
|
const aiTools = {};
|
|
587
796
|
|
|
588
797
|
for (const tool of mcpTools) {
|
|
589
|
-
// Filter to only the tools in selected groups (if groups were specified)
|
|
590
798
|
if (ALLOWED_TOOLS.size > 0 && !ALLOWED_TOOLS.has(tool.name)) {
|
|
591
799
|
continue;
|
|
592
800
|
}
|
|
@@ -631,890 +839,339 @@ export async function disconnectMcp() {
|
|
|
631
839
|
return code;
|
|
632
840
|
}
|
|
633
841
|
|
|
634
|
-
function
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
// Server-side OAuth flow for authenticating with MyVillageOS.
|
|
639
|
-
|
|
640
|
-
const OAUTH_BASE_URL = process.env.MYVILLAGEOS_API_URL
|
|
641
|
-
? \`\${process.env.MYVILLAGEOS_API_URL}/api/oauth\`
|
|
642
|
-
: '${OAUTH_BASE_URL}';
|
|
643
|
-
|
|
644
|
-
const CLIENT_ID = process.env.MYVILLAGEOS_CLIENT_ID;
|
|
645
|
-
const CLIENT_SECRET = process.env.MYVILLAGEOS_CLIENT_SECRET;
|
|
646
|
-
const REDIRECT_URI = 'http://localhost:' + (process.env.PORT || '3000') + '/auth/callback';
|
|
647
|
-
const SCOPES = '${OAUTH_SCOPES}';
|
|
842
|
+
function generateAgentChatRoute(hasOAuth) {
|
|
843
|
+
let code = `import { NextResponse } from 'next/server';
|
|
844
|
+
import { runAgent } from '@/lib/agent';
|
|
845
|
+
`;
|
|
648
846
|
|
|
649
|
-
|
|
650
|
-
|
|
847
|
+
if (hasOAuth) {
|
|
848
|
+
code += `import { getSession } from '@/lib/session';
|
|
849
|
+
`;
|
|
850
|
+
}
|
|
651
851
|
|
|
852
|
+
code += `
|
|
652
853
|
/**
|
|
653
|
-
*
|
|
854
|
+
* POST /api/agent/chat
|
|
855
|
+
* Send a message to the AI agent and get a response.
|
|
654
856
|
*/
|
|
655
|
-
function
|
|
656
|
-
|
|
657
|
-
}
|
|
857
|
+
export async function POST(request) {
|
|
858
|
+
try {
|
|
859
|
+
const { message } = await request.json();
|
|
860
|
+
if (!message) {
|
|
861
|
+
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
|
|
862
|
+
}
|
|
658
863
|
|
|
659
|
-
|
|
660
|
-
* Generate the code challenge from a code verifier (S256 method).
|
|
661
|
-
*/
|
|
662
|
-
function generateCodeChallenge(verifier) {
|
|
663
|
-
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
664
|
-
}
|
|
864
|
+
`;
|
|
665
865
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const params = new URLSearchParams({
|
|
676
|
-
response_type: 'code',
|
|
677
|
-
client_id: CLIENT_ID,
|
|
678
|
-
redirect_uri: REDIRECT_URI,
|
|
679
|
-
scope: SCOPES,
|
|
680
|
-
state,
|
|
681
|
-
code_challenge: codeChallenge,
|
|
682
|
-
code_challenge_method: 'S256',
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
const url = \`\${OAUTH_BASE_URL}/authorize?\${params.toString()}\`;
|
|
686
|
-
console.log('[OAuth] Login URL generated');
|
|
687
|
-
|
|
688
|
-
return { url, codeVerifier, state };
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Exchange an authorization code for tokens.
|
|
693
|
-
*/
|
|
694
|
-
export async function handleOAuthCallback(code, codeVerifier) {
|
|
695
|
-
const body = new URLSearchParams({
|
|
696
|
-
grant_type: 'authorization_code',
|
|
697
|
-
code,
|
|
698
|
-
redirect_uri: REDIRECT_URI,
|
|
699
|
-
client_id: CLIENT_ID,
|
|
700
|
-
client_secret: CLIENT_SECRET,
|
|
701
|
-
code_verifier: codeVerifier,
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
const response = await fetch(\`\${OAUTH_BASE_URL}/token\`, {
|
|
705
|
-
method: 'POST',
|
|
706
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
707
|
-
body: body.toString(),
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
if (!response.ok) {
|
|
711
|
-
const error = await response.text();
|
|
712
|
-
throw new Error(\`Token exchange failed: \${response.status} \${error}\`);
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const tokens = await response.json();
|
|
716
|
-
storedTokens = {
|
|
717
|
-
accessToken: tokens.access_token,
|
|
718
|
-
refreshToken: tokens.refresh_token,
|
|
719
|
-
expiresAt: Date.now() + (tokens.expires_in * 1000),
|
|
720
|
-
idToken: tokens.id_token,
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
console.log('[OAuth] Tokens obtained successfully');
|
|
724
|
-
return storedTokens;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Refresh the access token using the stored refresh token.
|
|
729
|
-
*/
|
|
730
|
-
export async function refreshAccessToken() {
|
|
731
|
-
if (!storedTokens?.refreshToken) {
|
|
732
|
-
throw new Error('No refresh token available');
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
const body = new URLSearchParams({
|
|
736
|
-
grant_type: 'refresh_token',
|
|
737
|
-
refresh_token: storedTokens.refreshToken,
|
|
738
|
-
client_id: CLIENT_ID,
|
|
739
|
-
client_secret: CLIENT_SECRET,
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
const response = await fetch(\`\${OAUTH_BASE_URL}/token\`, {
|
|
743
|
-
method: 'POST',
|
|
744
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
745
|
-
body: body.toString(),
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
if (!response.ok) {
|
|
749
|
-
const error = await response.text();
|
|
750
|
-
throw new Error(\`Token refresh failed: \${response.status} \${error}\`);
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const tokens = await response.json();
|
|
754
|
-
storedTokens = {
|
|
755
|
-
accessToken: tokens.access_token,
|
|
756
|
-
refreshToken: tokens.refresh_token || storedTokens.refreshToken,
|
|
757
|
-
expiresAt: Date.now() + (tokens.expires_in * 1000),
|
|
758
|
-
idToken: tokens.id_token,
|
|
759
|
-
};
|
|
760
|
-
|
|
761
|
-
console.log('[OAuth] Tokens refreshed successfully');
|
|
762
|
-
return storedTokens;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* Get a valid access token, refreshing if needed.
|
|
767
|
-
*/
|
|
768
|
-
export async function getValidAccessToken() {
|
|
769
|
-
if (!storedTokens) {
|
|
770
|
-
throw new Error('Not authenticated. Visit /auth/login to authenticate.');
|
|
866
|
+
if (hasOAuth) {
|
|
867
|
+
code += ` const session = await getSession();
|
|
868
|
+
const tokens = session.accessToken ? { accessToken: session.accessToken } : null;
|
|
869
|
+
const result = await runAgent(message, { tokens });
|
|
870
|
+
`;
|
|
871
|
+
} else {
|
|
872
|
+
code += ` const result = await runAgent(message);
|
|
873
|
+
`;
|
|
771
874
|
}
|
|
772
875
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
876
|
+
code += ` return NextResponse.json({ response: result });
|
|
877
|
+
} catch (err) {
|
|
878
|
+
console.error('[Agent] Error:', err.message);
|
|
879
|
+
return NextResponse.json({ error: 'Agent processing failed' }, { status: 500 });
|
|
776
880
|
}
|
|
777
|
-
|
|
778
|
-
return storedTokens.accessToken;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Get the currently stored tokens (may be null if not authenticated).
|
|
783
|
-
*/
|
|
784
|
-
export function getStoredTokens() {
|
|
785
|
-
return storedTokens;
|
|
786
881
|
}
|
|
787
882
|
`;
|
|
788
|
-
}
|
|
789
883
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
// ─── Platform API Routes ────────────────────────────────────────────────────
|
|
794
|
-
// Express router with helpers for making authenticated API calls to MyVillageOS.
|
|
884
|
+
return code;
|
|
885
|
+
}
|
|
795
886
|
|
|
796
|
-
|
|
887
|
+
// ─── API Proxy Generator ────────────────────────────────────────────────────
|
|
797
888
|
|
|
889
|
+
function generateProxyCatchAll(hasOAuth) {
|
|
890
|
+
let code = `import { NextResponse } from 'next/server';
|
|
798
891
|
`;
|
|
799
892
|
|
|
800
893
|
if (hasOAuth) {
|
|
801
|
-
code +=
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
*/
|
|
805
|
-
export function createApiRouter(getTokens) {
|
|
806
|
-
const router = Router();
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Make an authenticated request to the MyVillageOS platform API.
|
|
810
|
-
*/
|
|
811
|
-
async function apiRequest(path, options = {}) {
|
|
812
|
-
const tokens = getTokens?.();
|
|
813
|
-
const headers = {
|
|
814
|
-
'Content-Type': 'application/json',
|
|
815
|
-
...options.headers,
|
|
816
|
-
};
|
|
817
|
-
|
|
818
|
-
if (tokens?.accessToken) {
|
|
819
|
-
headers['Authorization'] = \`Bearer \${tokens.accessToken}\`;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
const url = \`\${API_BASE_URL}\${path}\`;
|
|
823
|
-
console.log(\`[API] \${options.method || 'GET'} \${url}\`);
|
|
824
|
-
|
|
825
|
-
const response = await fetch(url, {
|
|
826
|
-
...options,
|
|
827
|
-
headers,
|
|
828
|
-
});
|
|
894
|
+
code += `import { getSession } from '@/lib/session';
|
|
895
|
+
`;
|
|
896
|
+
}
|
|
829
897
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
throw new Error(\`API request failed: \${response.status} \${error}\`);
|
|
833
|
-
}
|
|
898
|
+
code += `
|
|
899
|
+
const API_BASE_URL = process.env.MYVILLAGEOS_API_URL || 'https://portal.myvillageproject.ai';
|
|
834
900
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
* Create the API router.
|
|
901
|
+
/**
|
|
902
|
+
* Catch-all proxy to the MyVillageOS platform API.
|
|
903
|
+
* Forwards requests with authentication headers.
|
|
904
|
+
*
|
|
905
|
+
* Usage: /api/proxy/network/posts → GET https://portal.myvillageproject.ai/api/network/posts
|
|
841
906
|
*/
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
* Make a request to the MyVillageOS platform API.
|
|
847
|
-
*/
|
|
848
|
-
async function apiRequest(path, options = {}) {
|
|
849
|
-
const headers = {
|
|
850
|
-
'Content-Type': 'application/json',
|
|
851
|
-
...options.headers,
|
|
852
|
-
};
|
|
907
|
+
async function proxyRequest(request, { params }) {
|
|
908
|
+
const { path } = await params;
|
|
909
|
+
const apiPath = '/api/' + path.join('/');
|
|
910
|
+
const url = new URL(apiPath, API_BASE_URL);
|
|
853
911
|
|
|
854
|
-
|
|
855
|
-
|
|
912
|
+
// Forward query parameters
|
|
913
|
+
const { searchParams } = new URL(request.url);
|
|
914
|
+
searchParams.forEach((value, key) => url.searchParams.set(key, value));
|
|
856
915
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
});
|
|
916
|
+
const headers = {
|
|
917
|
+
'Content-Type': 'application/json',
|
|
918
|
+
};
|
|
861
919
|
|
|
862
|
-
|
|
863
|
-
const error = await response.text();
|
|
864
|
-
throw new Error(\`API request failed: \${response.status} \${error}\`);
|
|
865
|
-
}
|
|
920
|
+
`;
|
|
866
921
|
|
|
867
|
-
|
|
922
|
+
if (hasOAuth) {
|
|
923
|
+
code += ` const session = await getSession();
|
|
924
|
+
if (session.accessToken) {
|
|
925
|
+
headers['Authorization'] = \`Bearer \${session.accessToken}\`;
|
|
868
926
|
}
|
|
869
927
|
`;
|
|
870
928
|
}
|
|
871
929
|
|
|
872
930
|
code += `
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
try {
|
|
878
|
-
const data = await apiRequest('/api/network/posts');
|
|
879
|
-
res.json(data);
|
|
880
|
-
} catch (err) {
|
|
881
|
-
console.error('[API] Error fetching posts:', err.message);
|
|
882
|
-
res.status(500).json({ error: err.message });
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
// Create a new post
|
|
887
|
-
router.post('/network/posts', async (req, res) => {
|
|
888
|
-
try {
|
|
889
|
-
const data = await apiRequest('/api/network/posts', {
|
|
890
|
-
method: 'POST',
|
|
891
|
-
body: JSON.stringify(req.body),
|
|
892
|
-
});
|
|
893
|
-
res.json(data);
|
|
894
|
-
} catch (err) {
|
|
895
|
-
console.error('[API] Error creating post:', err.message);
|
|
896
|
-
res.status(500).json({ error: err.message });
|
|
897
|
-
}
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
// ─── Communities ────────────────────────────────────────────────────────────
|
|
901
|
-
|
|
902
|
-
// List communities
|
|
903
|
-
router.get('/communities', async (req, res) => {
|
|
904
|
-
try {
|
|
905
|
-
const data = await apiRequest('/api/network/communities');
|
|
906
|
-
res.json(data);
|
|
907
|
-
} catch (err) {
|
|
908
|
-
console.error('[API] Error fetching communities:', err.message);
|
|
909
|
-
res.status(500).json({ error: err.message });
|
|
910
|
-
}
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
// ─── Villages ───────────────────────────────────────────────────────────────
|
|
931
|
+
const fetchOptions = {
|
|
932
|
+
method: request.method,
|
|
933
|
+
headers,
|
|
934
|
+
};
|
|
914
935
|
|
|
915
|
-
|
|
916
|
-
router.get('/villages', async (req, res) => {
|
|
936
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
917
937
|
try {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
console.error('[API] Error fetching villages:', err.message);
|
|
922
|
-
res.status(500).json({ error: err.message });
|
|
938
|
+
fetchOptions.body = await request.text();
|
|
939
|
+
} catch {
|
|
940
|
+
// No body
|
|
923
941
|
}
|
|
924
|
-
}
|
|
942
|
+
}
|
|
925
943
|
|
|
926
|
-
|
|
944
|
+
console.log(\`[Proxy] \${request.method} \${url}\`);
|
|
927
945
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const data = await apiRequest('/api/oauth/userinfo');
|
|
932
|
-
res.json(data);
|
|
933
|
-
} catch (err) {
|
|
934
|
-
console.error('[API] Error fetching profile:', err.message);
|
|
935
|
-
res.status(500).json({ error: err.message });
|
|
936
|
-
}
|
|
937
|
-
});
|
|
946
|
+
try {
|
|
947
|
+
const response = await fetch(url.toString(), fetchOptions);
|
|
948
|
+
const data = await response.text();
|
|
938
949
|
|
|
939
|
-
|
|
950
|
+
return new NextResponse(data, {
|
|
951
|
+
status: response.status,
|
|
952
|
+
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
|
953
|
+
});
|
|
954
|
+
} catch (err) {
|
|
955
|
+
console.error('[Proxy] Error:', err.message);
|
|
956
|
+
return NextResponse.json({ error: err.message }, { status: 502 });
|
|
957
|
+
}
|
|
940
958
|
}
|
|
959
|
+
|
|
960
|
+
export const GET = proxyRequest;
|
|
961
|
+
export const POST = proxyRequest;
|
|
962
|
+
export const PUT = proxyRequest;
|
|
963
|
+
export const DELETE = proxyRequest;
|
|
964
|
+
export const PATCH = proxyRequest;
|
|
941
965
|
`;
|
|
942
966
|
|
|
943
967
|
return code;
|
|
944
968
|
}
|
|
945
969
|
|
|
946
|
-
// ───
|
|
970
|
+
// ─── App Layout & Page Generators ───────────────────────────────────────────
|
|
947
971
|
|
|
948
|
-
function
|
|
949
|
-
|
|
950
|
-
import
|
|
951
|
-
|
|
952
|
-
export default defineConfig({
|
|
953
|
-
plugins: [react()],
|
|
954
|
-
server: {
|
|
955
|
-
port: 5173,
|
|
956
|
-
open: true,
|
|
957
|
-
proxy: {
|
|
958
|
-
'/api': 'http://localhost:3000',
|
|
959
|
-
'/agent': 'http://localhost:3000',
|
|
960
|
-
'/auth': 'http://localhost:3000',
|
|
961
|
-
'/health': 'http://localhost:3000',
|
|
962
|
-
},
|
|
963
|
-
},
|
|
964
|
-
build: { outDir: 'dist' },
|
|
965
|
-
});
|
|
966
|
-
`;
|
|
967
|
-
}
|
|
972
|
+
function generateRootLayout(name, hasOAuth, features) {
|
|
973
|
+
let code = `import './globals.css';
|
|
974
|
+
import Sidebar from '@/components/Sidebar';
|
|
975
|
+
import Header from '@/components/Header';
|
|
968
976
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
<meta charset="UTF-8" />
|
|
974
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
975
|
-
<title>${name} - MyVillageOS</title>
|
|
976
|
-
</head>
|
|
977
|
-
<body>
|
|
978
|
-
<div id="root"></div>
|
|
979
|
-
<script type="module" src="/src/main.jsx"></script>
|
|
980
|
-
</body>
|
|
981
|
-
</html>
|
|
982
|
-
`;
|
|
983
|
-
}
|
|
977
|
+
export const metadata = {
|
|
978
|
+
title: '${name} - MyVillageOS',
|
|
979
|
+
description: 'A MyVillageOS agentic application',
|
|
980
|
+
};
|
|
984
981
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
import { BrowserRouter } from 'react-router-dom';
|
|
994
|
-
import App from './App.jsx';
|
|
995
|
-
import './index.css';
|
|
996
|
-
|
|
997
|
-
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
998
|
-
<React.StrictMode>
|
|
999
|
-
<BrowserRouter>
|
|
1000
|
-
<App />
|
|
1001
|
-
</BrowserRouter>
|
|
1002
|
-
</React.StrictMode>
|
|
1003
|
-
);
|
|
1004
|
-
`;
|
|
982
|
+
export default function RootLayout({ children }) {
|
|
983
|
+
return (
|
|
984
|
+
<html lang="en">
|
|
985
|
+
<body>
|
|
986
|
+
{children}
|
|
987
|
+
</body>
|
|
988
|
+
</html>
|
|
989
|
+
);
|
|
1005
990
|
}
|
|
1006
991
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const imports = ["import { Routes, Route } from 'react-router-dom';"];
|
|
1014
|
-
imports.push("import Layout from './components/Layout.jsx';");
|
|
1015
|
-
imports.push("import NotFound from './pages/NotFound.jsx';");
|
|
1016
|
-
imports.push("import './App.css';");
|
|
1017
|
-
|
|
1018
|
-
if (hasOAuth) {
|
|
1019
|
-
imports.push("import { AuthProvider } from './auth/AuthProvider.jsx';");
|
|
1020
|
-
imports.push("import ProtectedRoute from './components/ProtectedRoute.jsx';");
|
|
1021
|
-
imports.push("import Login from './pages/Login.jsx';");
|
|
1022
|
-
imports.push("import Callback from './pages/Callback.jsx';");
|
|
1023
|
-
}
|
|
1024
|
-
if (hasDashboard) imports.push("import Dashboard from './pages/Dashboard.jsx';");
|
|
1025
|
-
if (hasSettings) imports.push("import Settings from './pages/Settings.jsx';");
|
|
1026
|
-
if (hasUsers) imports.push("import Users from './pages/Users.jsx';");
|
|
1027
|
-
if (hasNotifications) imports.push("import Notifications from './pages/Notifications.jsx';");
|
|
1028
|
-
|
|
1029
|
-
const routes = [];
|
|
1030
|
-
const homeElement = hasDashboard ? '<Dashboard />' : '<div className="welcome"><h1>Welcome</h1><p>Select a page from the sidebar to get started.</p></div>';
|
|
1031
|
-
|
|
1032
|
-
if (hasOAuth) {
|
|
1033
|
-
routes.push(' <Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>');
|
|
1034
|
-
} else {
|
|
1035
|
-
routes.push(' <Route path="/" element={<Layout />}>');
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
routes.push(' <Route index element={' + homeElement + '} />');
|
|
1039
|
-
if (hasDashboard) routes.push(' <Route path="dashboard" element={<Dashboard />} />');
|
|
1040
|
-
if (hasSettings) routes.push(' <Route path="settings" element={<Settings />} />');
|
|
1041
|
-
if (hasUsers) routes.push(' <Route path="users" element={<Users />} />');
|
|
1042
|
-
if (hasNotifications) routes.push(' <Route path="notifications" element={<Notifications />} />');
|
|
1043
|
-
routes.push(' <Route path="*" element={<NotFound />} />');
|
|
1044
|
-
routes.push(' </Route>');
|
|
1045
|
-
|
|
1046
|
-
if (hasOAuth) {
|
|
1047
|
-
routes.push(' <Route path="/login" element={<Login />} />');
|
|
1048
|
-
routes.push(' <Route path="/callback" element={<Callback />} />');
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
let body;
|
|
1052
|
-
if (hasOAuth) {
|
|
1053
|
-
body = ' <AuthProvider>\n <Routes>\n' + routes.join('\n') + '\n </Routes>\n </AuthProvider>';
|
|
1054
|
-
} else {
|
|
1055
|
-
body = ' <Routes>\n' + routes.join('\n') + '\n </Routes>';
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
return imports.join('\n') + '\n\nexport default function App() {\n return (\n' + body + '\n );\n}\n';
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
function generateAppCss() {
|
|
1062
|
-
return ':root {\n' +
|
|
1063
|
-
' --brand-gold: ' + BRAND.gold + ';\n' +
|
|
1064
|
-
' --brand-brown: ' + BRAND.brown + ';\n' +
|
|
1065
|
-
' --brand-green: ' + BRAND.green + ';\n' +
|
|
1066
|
-
' --brand-primary: ' + BRAND.primary + ';\n' +
|
|
1067
|
-
' --brand-secondary: ' + BRAND.secondary + ';\n' +
|
|
1068
|
-
' --brand-dark-brown: ' + BRAND.darkBrown + ';\n' +
|
|
1069
|
-
' --brand-deep-green: ' + BRAND.deepGreen + ';\n' +
|
|
1070
|
-
' --brand-teal: ' + BRAND.teal + ';\n' +
|
|
1071
|
-
' --sidebar-width: 240px;\n' +
|
|
1072
|
-
' --header-height: 56px;\n' +
|
|
1073
|
-
'}\n' +
|
|
1074
|
-
'\n' +
|
|
1075
|
-
'.layout { display: flex; height: 100vh; }\n' +
|
|
1076
|
-
'.layout-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }\n' +
|
|
1077
|
-
'.layout-content { flex: 1; overflow-y: auto; padding: 24px; background: #f5f3ef; }\n' +
|
|
1078
|
-
'\n' +
|
|
1079
|
-
'.sidebar {\n' +
|
|
1080
|
-
' width: var(--sidebar-width);\n' +
|
|
1081
|
-
' background: var(--brand-dark-brown);\n' +
|
|
1082
|
-
' color: var(--brand-secondary);\n' +
|
|
1083
|
-
' display: flex;\n' +
|
|
1084
|
-
' flex-direction: column;\n' +
|
|
1085
|
-
'}\n' +
|
|
1086
|
-
'.sidebar-brand { padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.1); }\n' +
|
|
1087
|
-
'.sidebar-brand h2 { color: var(--brand-gold); font-size: 18px; margin: 0; }\n' +
|
|
1088
|
-
'.sidebar-nav { flex: 1; padding: 12px 0; list-style: none; }\n' +
|
|
1089
|
-
'.sidebar-nav a {\n' +
|
|
1090
|
-
' display: block; padding: 10px 20px; color: var(--brand-secondary);\n' +
|
|
1091
|
-
' text-decoration: none; font-size: 14px; transition: background 0.2s;\n' +
|
|
1092
|
-
'}\n' +
|
|
1093
|
-
'.sidebar-nav a:hover, .sidebar-nav a.active {\n' +
|
|
1094
|
-
' background: rgba(255,215,0,0.1); color: var(--brand-gold);\n' +
|
|
1095
|
-
'}\n' +
|
|
1096
|
-
'\n' +
|
|
1097
|
-
'.header {\n' +
|
|
1098
|
-
' height: var(--header-height); background: white; border-bottom: 1px solid #e0ddd7;\n' +
|
|
1099
|
-
' display: flex; align-items: center; justify-content: space-between; padding: 0 24px;\n' +
|
|
1100
|
-
'}\n' +
|
|
1101
|
-
'.header-title { font-size: 16px; font-weight: 600; color: var(--brand-dark-brown); }\n' +
|
|
1102
|
-
'.header-user { display: flex; align-items: center; gap: 12px; }\n' +
|
|
1103
|
-
'.header-user button {\n' +
|
|
1104
|
-
' padding: 6px 14px; background: var(--brand-primary); color: white;\n' +
|
|
1105
|
-
' border: none; border-radius: 6px; font-size: 13px; cursor: pointer;\n' +
|
|
1106
|
-
'}\n' +
|
|
1107
|
-
'\n' +
|
|
1108
|
-
'.login-page {\n' +
|
|
1109
|
-
' display: flex; justify-content: center; align-items: center; height: 100vh;\n' +
|
|
1110
|
-
' background: var(--brand-secondary);\n' +
|
|
1111
|
-
'}\n' +
|
|
1112
|
-
'.login-card {\n' +
|
|
1113
|
-
' text-align: center; padding: 48px; background: white;\n' +
|
|
1114
|
-
' border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1);\n' +
|
|
1115
|
-
'}\n' +
|
|
1116
|
-
'.login-btn {\n' +
|
|
1117
|
-
' margin-top: 20px; padding: 12px 32px; background: var(--brand-primary);\n' +
|
|
1118
|
-
' color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer;\n' +
|
|
1119
|
-
'}\n' +
|
|
1120
|
-
'.login-btn:hover { background: var(--brand-gold); color: var(--brand-dark-brown); }\n' +
|
|
1121
|
-
'\n' +
|
|
1122
|
-
'.stat-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }\n' +
|
|
1123
|
-
'.stat-card {\n' +
|
|
1124
|
-
' background: white; border-radius: 8px; padding: 20px;\n' +
|
|
1125
|
-
' box-shadow: 0 1px 3px rgba(0,0,0,0.08);\n' +
|
|
1126
|
-
'}\n' +
|
|
1127
|
-
'.stat-card h3 { font-size: 13px; color: var(--brand-teal); margin-bottom: 8px; }\n' +
|
|
1128
|
-
'.stat-card .value { font-size: 28px; font-weight: 700; color: var(--brand-dark-brown); }\n' +
|
|
1129
|
-
'\n' +
|
|
1130
|
-
'.agent-chat {\n' +
|
|
1131
|
-
' background: white; border-radius: 8px; padding: 20px;\n' +
|
|
1132
|
-
' box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-top: 24px;\n' +
|
|
1133
|
-
'}\n' +
|
|
1134
|
-
'.agent-chat h3 { margin-bottom: 12px; color: var(--brand-dark-brown); }\n' +
|
|
1135
|
-
'.agent-chat-messages {\n' +
|
|
1136
|
-
' max-height: 300px; overflow-y: auto; margin-bottom: 12px;\n' +
|
|
1137
|
-
' border: 1px solid #e0ddd7; border-radius: 6px; padding: 12px;\n' +
|
|
1138
|
-
'}\n' +
|
|
1139
|
-
'.agent-chat-input { display: flex; gap: 8px; }\n' +
|
|
1140
|
-
'.agent-chat-input input {\n' +
|
|
1141
|
-
' flex: 1; padding: 10px 14px; border: 1px solid #e0ddd7;\n' +
|
|
1142
|
-
' border-radius: 6px; font-size: 14px;\n' +
|
|
1143
|
-
'}\n' +
|
|
1144
|
-
'.agent-chat-input button {\n' +
|
|
1145
|
-
' padding: 10px 20px; background: var(--brand-primary); color: white;\n' +
|
|
1146
|
-
' border: none; border-radius: 6px; font-size: 14px; cursor: pointer;\n' +
|
|
1147
|
-
'}\n' +
|
|
1148
|
-
'.message { margin-bottom: 8px; }\n' +
|
|
1149
|
-
'.message.user { color: var(--brand-primary); }\n' +
|
|
1150
|
-
'.message.agent { color: var(--brand-deep-green); }\n' +
|
|
1151
|
-
'\n' +
|
|
1152
|
-
'.page-header { margin-bottom: 24px; }\n' +
|
|
1153
|
-
'.page-header h1 { font-size: 24px; color: var(--brand-dark-brown); }\n' +
|
|
1154
|
-
'.page-header p { color: var(--brand-teal); margin-top: 4px; }\n';
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
function generateIndexCss() {
|
|
1158
|
-
return '*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n' +
|
|
1159
|
-
'html, body {\n' +
|
|
1160
|
-
' height: 100%;\n' +
|
|
1161
|
-
" font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n" +
|
|
1162
|
-
' background: ' + BRAND.secondary + ';\n' +
|
|
1163
|
-
' color: ' + BRAND.darkBrown + ';\n' +
|
|
1164
|
-
' line-height: 1.6;\n' +
|
|
1165
|
-
'}\n' +
|
|
1166
|
-
'#root { height: 100%; }\n' +
|
|
1167
|
-
'a { color: ' + BRAND.primary + '; text-decoration: none; }\n' +
|
|
1168
|
-
'a:hover { text-decoration: underline; }\n' +
|
|
1169
|
-
'button { cursor: pointer; font-family: inherit; }\n';
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
// ─── React Components ───────────────────────────────────────────────────────
|
|
1173
|
-
|
|
1174
|
-
function generateLayoutComponent() {
|
|
1175
|
-
return `import { Outlet } from 'react-router-dom';
|
|
1176
|
-
import Sidebar from './Sidebar.jsx';
|
|
1177
|
-
import Header from './Header.jsx';
|
|
1178
|
-
|
|
1179
|
-
export default function Layout() {
|
|
992
|
+
/**
|
|
993
|
+
* Shared layout for authenticated pages with sidebar and header.
|
|
994
|
+
* Import and use this in page layouts that need the app shell.
|
|
995
|
+
*/
|
|
996
|
+
export function AppShell({ children, user }) {
|
|
1180
997
|
return (
|
|
1181
998
|
<div className="layout">
|
|
1182
999
|
<Sidebar />
|
|
1183
1000
|
<div className="layout-main">
|
|
1184
|
-
<Header />
|
|
1001
|
+
<Header user={user} />
|
|
1185
1002
|
<main className="layout-content">
|
|
1186
|
-
|
|
1003
|
+
{children}
|
|
1187
1004
|
</main>
|
|
1188
1005
|
</div>
|
|
1189
1006
|
</div>
|
|
1190
1007
|
);
|
|
1191
1008
|
}
|
|
1192
1009
|
`;
|
|
1010
|
+
|
|
1011
|
+
return code;
|
|
1193
1012
|
}
|
|
1194
1013
|
|
|
1195
|
-
function
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1014
|
+
function generateGlobalsCss() {
|
|
1015
|
+
return `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1016
|
+
html, body {
|
|
1017
|
+
height: 100%;
|
|
1018
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
1019
|
+
background: ${BRAND.secondary};
|
|
1020
|
+
color: ${BRAND.darkBrown};
|
|
1021
|
+
line-height: 1.6;
|
|
1022
|
+
}
|
|
1023
|
+
#__next { height: 100%; }
|
|
1024
|
+
a { color: ${BRAND.primary}; text-decoration: none; }
|
|
1025
|
+
a:hover { text-decoration: underline; }
|
|
1026
|
+
button { cursor: pointer; font-family: inherit; }
|
|
1027
|
+
|
|
1028
|
+
:root {
|
|
1029
|
+
--brand-gold: ${BRAND.gold};
|
|
1030
|
+
--brand-brown: ${BRAND.brown};
|
|
1031
|
+
--brand-green: ${BRAND.green};
|
|
1032
|
+
--brand-primary: ${BRAND.primary};
|
|
1033
|
+
--brand-secondary: ${BRAND.secondary};
|
|
1034
|
+
--brand-dark-brown: ${BRAND.darkBrown};
|
|
1035
|
+
--brand-deep-green: ${BRAND.deepGreen};
|
|
1036
|
+
--brand-teal: ${BRAND.teal};
|
|
1037
|
+
--sidebar-width: 240px;
|
|
1038
|
+
--header-height: 56px;
|
|
1214
1039
|
}
|
|
1215
1040
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1041
|
+
.layout { display: flex; height: 100vh; }
|
|
1042
|
+
.layout-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
1043
|
+
.layout-content { flex: 1; overflow-y: auto; padding: 24px; background: #f5f3ef; }
|
|
1219
1044
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1045
|
+
.sidebar {
|
|
1046
|
+
width: var(--sidebar-width);
|
|
1047
|
+
background: var(--brand-dark-brown);
|
|
1048
|
+
color: var(--brand-secondary);
|
|
1049
|
+
display: flex;
|
|
1050
|
+
flex-direction: column;
|
|
1051
|
+
}
|
|
1052
|
+
.sidebar-brand { padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
|
1053
|
+
.sidebar-brand h2 { color: var(--brand-gold); font-size: 18px; margin: 0; }
|
|
1054
|
+
.sidebar-nav { flex: 1; padding: 12px 0; list-style: none; }
|
|
1055
|
+
.sidebar-nav a {
|
|
1056
|
+
display: block; padding: 10px 20px; color: var(--brand-secondary);
|
|
1057
|
+
text-decoration: none; font-size: 14px; transition: background 0.2s;
|
|
1058
|
+
}
|
|
1059
|
+
.sidebar-nav a:hover, .sidebar-nav a.active {
|
|
1060
|
+
background: rgba(255,215,0,0.1); color: var(--brand-gold);
|
|
1061
|
+
}
|
|
1222
1062
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1063
|
+
.header {
|
|
1064
|
+
height: var(--header-height); background: white; border-bottom: 1px solid #e0ddd7;
|
|
1065
|
+
display: flex; align-items: center; justify-content: space-between; padding: 0 24px;
|
|
1066
|
+
}
|
|
1067
|
+
.header-title { font-size: 16px; font-weight: 600; color: var(--brand-dark-brown); }
|
|
1068
|
+
.header-user { display: flex; align-items: center; gap: 12px; }
|
|
1069
|
+
.header-user button {
|
|
1070
|
+
padding: 6px 14px; background: var(--brand-primary); color: white;
|
|
1071
|
+
border: none; border-radius: 6px; font-size: 13px; cursor: pointer;
|
|
1232
1072
|
}
|
|
1233
|
-
`;
|
|
1234
|
-
}
|
|
1235
1073
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
<span className="header-title">MyVillageOS App</span>
|
|
1240
|
-
</header>
|
|
1241
|
-
);
|
|
1074
|
+
.login-page {
|
|
1075
|
+
display: flex; justify-content: center; align-items: center; height: 100vh;
|
|
1076
|
+
background: var(--brand-secondary);
|
|
1242
1077
|
}
|
|
1243
|
-
|
|
1078
|
+
.login-card {
|
|
1079
|
+
text-align: center; padding: 48px; background: white;
|
|
1080
|
+
border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
1244
1081
|
}
|
|
1082
|
+
.login-btn {
|
|
1083
|
+
margin-top: 20px; padding: 12px 32px; background: var(--brand-primary);
|
|
1084
|
+
color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer;
|
|
1085
|
+
}
|
|
1086
|
+
.login-btn:hover { background: var(--brand-gold); color: var(--brand-dark-brown); }
|
|
1245
1087
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
' const [messages, setMessages] = useState([]);\n' +
|
|
1251
|
-
" const [input, setInput] = useState('');\n" +
|
|
1252
|
-
' const [loading, setLoading] = useState(false);\n' +
|
|
1253
|
-
'\n' +
|
|
1254
|
-
' const sendMessage = async () => {\n' +
|
|
1255
|
-
' if (!input.trim() || loading) return;\n' +
|
|
1256
|
-
'\n' +
|
|
1257
|
-
' const userMsg = input.trim();\n' +
|
|
1258
|
-
" setMessages(prev => [...prev, { role: 'user', text: userMsg }]);\n" +
|
|
1259
|
-
" setInput('');\n" +
|
|
1260
|
-
' setLoading(true);\n' +
|
|
1261
|
-
'\n' +
|
|
1262
|
-
' try {\n' +
|
|
1263
|
-
" const res = await fetch('/agent/chat', {\n" +
|
|
1264
|
-
" method: 'POST',\n" +
|
|
1265
|
-
" headers: { 'Content-Type': 'application/json' },\n" +
|
|
1266
|
-
' body: JSON.stringify({ message: userMsg }),\n' +
|
|
1267
|
-
' });\n' +
|
|
1268
|
-
' const data = await res.json();\n' +
|
|
1269
|
-
" setMessages(prev => [...prev, { role: 'agent', text: data.response || data.error }]);\n" +
|
|
1270
|
-
' } catch (err) {\n' +
|
|
1271
|
-
" setMessages(prev => [...prev, { role: 'agent', text: 'Error: ' + err.message }]);\n" +
|
|
1272
|
-
' } finally {\n' +
|
|
1273
|
-
' setLoading(false);\n' +
|
|
1274
|
-
' }\n' +
|
|
1275
|
-
' };\n' +
|
|
1276
|
-
'\n' +
|
|
1277
|
-
' return (\n' +
|
|
1278
|
-
' <div className="agent-chat">\n' +
|
|
1279
|
-
' <h3>AI Agent</h3>\n' +
|
|
1280
|
-
' <div className="agent-chat-messages">\n' +
|
|
1281
|
-
" {messages.length === 0 && <p style={{ color: '#999' }}>Ask the agent anything about your village...</p>}\n" +
|
|
1282
|
-
' {messages.map((msg, i) => (\n' +
|
|
1283
|
-
' <div key={i} className={`message ${msg.role}`}>\n' +
|
|
1284
|
-
" <strong>{msg.role === 'user' ? 'You' : 'Agent'}:</strong> {msg.text}\n" +
|
|
1285
|
-
' </div>\n' +
|
|
1286
|
-
' ))}\n' +
|
|
1287
|
-
' {loading && <div className="message agent"><em>Thinking...</em></div>}\n' +
|
|
1288
|
-
' </div>\n' +
|
|
1289
|
-
' <div className="agent-chat-input">\n' +
|
|
1290
|
-
' <input\n' +
|
|
1291
|
-
' value={input}\n' +
|
|
1292
|
-
' onChange={e => setInput(e.target.value)}\n' +
|
|
1293
|
-
" onKeyDown={e => e.key === 'Enter' && sendMessage()}\n" +
|
|
1294
|
-
' placeholder="Ask the AI agent..."\n' +
|
|
1295
|
-
' disabled={loading}\n' +
|
|
1296
|
-
' />\n' +
|
|
1297
|
-
' <button onClick={sendMessage} disabled={loading}>Send</button>\n' +
|
|
1298
|
-
' </div>\n' +
|
|
1299
|
-
' </div>\n' +
|
|
1300
|
-
' );\n' +
|
|
1301
|
-
'}\n';
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
function generateProtectedRoute() {
|
|
1305
|
-
return `import { Navigate } from 'react-router-dom';
|
|
1306
|
-
import { useAuth } from '../auth/AuthProvider.jsx';
|
|
1307
|
-
|
|
1308
|
-
export default function ProtectedRoute({ children }) {
|
|
1309
|
-
const { isAuthenticated, isLoading } = useAuth();
|
|
1310
|
-
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>Loading...</div>;
|
|
1311
|
-
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
|
1312
|
-
return children;
|
|
1088
|
+
.stat-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
1089
|
+
.stat-card {
|
|
1090
|
+
background: white; border-radius: 8px; padding: 20px;
|
|
1091
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
1313
1092
|
}
|
|
1093
|
+
.stat-card h3 { font-size: 13px; color: var(--brand-teal); margin-bottom: 8px; }
|
|
1094
|
+
.stat-card .value { font-size: 28px; font-weight: 700; color: var(--brand-dark-brown); }
|
|
1095
|
+
|
|
1096
|
+
.agent-chat {
|
|
1097
|
+
background: white; border-radius: 8px; padding: 20px;
|
|
1098
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-top: 24px;
|
|
1099
|
+
}
|
|
1100
|
+
.agent-chat h3 { margin-bottom: 12px; color: var(--brand-dark-brown); }
|
|
1101
|
+
.agent-chat-messages {
|
|
1102
|
+
max-height: 300px; overflow-y: auto; margin-bottom: 12px;
|
|
1103
|
+
border: 1px solid #e0ddd7; border-radius: 6px; padding: 12px;
|
|
1104
|
+
}
|
|
1105
|
+
.agent-chat-input { display: flex; gap: 8px; }
|
|
1106
|
+
.agent-chat-input input {
|
|
1107
|
+
flex: 1; padding: 10px 14px; border: 1px solid #e0ddd7;
|
|
1108
|
+
border-radius: 6px; font-size: 14px;
|
|
1109
|
+
}
|
|
1110
|
+
.agent-chat-input button {
|
|
1111
|
+
padding: 10px 20px; background: var(--brand-primary); color: white;
|
|
1112
|
+
border: none; border-radius: 6px; font-size: 14px; cursor: pointer;
|
|
1113
|
+
}
|
|
1114
|
+
.message { margin-bottom: 8px; }
|
|
1115
|
+
.message.user { color: var(--brand-primary); }
|
|
1116
|
+
.message.agent { color: var(--brand-deep-green); }
|
|
1117
|
+
|
|
1118
|
+
.page-header { margin-bottom: 24px; }
|
|
1119
|
+
.page-header h1 { font-size: 24px; color: var(--brand-dark-brown); }
|
|
1120
|
+
.page-header p { color: var(--brand-teal); margin-top: 4px; }
|
|
1121
|
+
|
|
1122
|
+
.welcome { padding: 40px; text-align: center; }
|
|
1123
|
+
.welcome h1 { color: var(--brand-dark-brown); margin-bottom: 8px; }
|
|
1124
|
+
.welcome p { color: var(--brand-teal); }
|
|
1314
1125
|
`;
|
|
1315
1126
|
}
|
|
1316
1127
|
|
|
1317
|
-
function
|
|
1318
|
-
|
|
1319
|
-
'
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
' const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));\n' +
|
|
1344
|
-
'\n' +
|
|
1345
|
-
" sessionStorage.setItem('oauth_verifier', verifier);\n" +
|
|
1346
|
-
" sessionStorage.setItem('oauth_state', state);\n" +
|
|
1347
|
-
'\n' +
|
|
1348
|
-
' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
|
|
1349
|
-
' const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;\n' +
|
|
1350
|
-
' const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI;\n' +
|
|
1351
|
-
'\n' +
|
|
1352
|
-
' const params = new URLSearchParams({\n' +
|
|
1353
|
-
' client_id: clientId,\n' +
|
|
1354
|
-
' redirect_uri: redirectUri,\n' +
|
|
1355
|
-
" response_type: 'code',\n" +
|
|
1356
|
-
" scope: 'openid profile email villager offline_access',\n" +
|
|
1357
|
-
' state,\n' +
|
|
1358
|
-
' code_challenge: challenge,\n' +
|
|
1359
|
-
" code_challenge_method: 'S256',\n" +
|
|
1360
|
-
' });\n' +
|
|
1361
|
-
'\n' +
|
|
1362
|
-
' window.location.href = `${baseUrl}/authorize?${params.toString()}`;\n' +
|
|
1363
|
-
'}\n' +
|
|
1364
|
-
'\n' +
|
|
1365
|
-
'export async function handleCallback(code, state) {\n' +
|
|
1366
|
-
" const savedState = sessionStorage.getItem('oauth_state');\n" +
|
|
1367
|
-
" const verifier = sessionStorage.getItem('oauth_verifier');\n" +
|
|
1368
|
-
" if (state !== savedState) throw new Error('OAuth state mismatch');\n" +
|
|
1369
|
-
'\n' +
|
|
1370
|
-
' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
|
|
1371
|
-
' const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;\n' +
|
|
1372
|
-
' const clientSecret = import.meta.env.VITE_OAUTH_CLIENT_SECRET;\n' +
|
|
1373
|
-
' const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI;\n' +
|
|
1374
|
-
'\n' +
|
|
1375
|
-
' const body = new URLSearchParams({\n' +
|
|
1376
|
-
" grant_type: 'authorization_code',\n" +
|
|
1377
|
-
' code,\n' +
|
|
1378
|
-
' redirect_uri: redirectUri,\n' +
|
|
1379
|
-
' client_id: clientId,\n' +
|
|
1380
|
-
' client_secret: clientSecret,\n' +
|
|
1381
|
-
' code_verifier: verifier,\n' +
|
|
1382
|
-
' });\n' +
|
|
1383
|
-
'\n' +
|
|
1384
|
-
' const response = await fetch(`${baseUrl}/token`, {\n' +
|
|
1385
|
-
" method: 'POST',\n" +
|
|
1386
|
-
" headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n" +
|
|
1387
|
-
' body: body.toString(),\n' +
|
|
1388
|
-
' });\n' +
|
|
1389
|
-
'\n' +
|
|
1390
|
-
" if (!response.ok) throw new Error('Token exchange failed');\n" +
|
|
1391
|
-
'\n' +
|
|
1392
|
-
' const tokens = await response.json();\n' +
|
|
1393
|
-
' storeTokens(tokens);\n' +
|
|
1394
|
-
" sessionStorage.removeItem('oauth_verifier');\n" +
|
|
1395
|
-
" sessionStorage.removeItem('oauth_state');\n" +
|
|
1396
|
-
' return tokens;\n' +
|
|
1397
|
-
'}\n' +
|
|
1398
|
-
'\n' +
|
|
1399
|
-
'export async function refreshToken(token) {\n' +
|
|
1400
|
-
' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
|
|
1401
|
-
' const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;\n' +
|
|
1402
|
-
' const clientSecret = import.meta.env.VITE_OAUTH_CLIENT_SECRET;\n' +
|
|
1403
|
-
'\n' +
|
|
1404
|
-
' const body = new URLSearchParams({\n' +
|
|
1405
|
-
" grant_type: 'refresh_token',\n" +
|
|
1406
|
-
' refresh_token: token,\n' +
|
|
1407
|
-
' client_id: clientId,\n' +
|
|
1408
|
-
' client_secret: clientSecret,\n' +
|
|
1409
|
-
' });\n' +
|
|
1410
|
-
'\n' +
|
|
1411
|
-
' const response = await fetch(`${baseUrl}/token`, {\n' +
|
|
1412
|
-
" method: 'POST',\n" +
|
|
1413
|
-
" headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n" +
|
|
1414
|
-
' body: body.toString(),\n' +
|
|
1415
|
-
' });\n' +
|
|
1416
|
-
'\n' +
|
|
1417
|
-
" if (!response.ok) throw new Error('Token refresh failed');\n" +
|
|
1418
|
-
'\n' +
|
|
1419
|
-
' const tokens = await response.json();\n' +
|
|
1420
|
-
' storeTokens(tokens);\n' +
|
|
1421
|
-
' return tokens;\n' +
|
|
1422
|
-
'}\n' +
|
|
1423
|
-
'\n' +
|
|
1424
|
-
'export function getStoredTokens() {\n' +
|
|
1425
|
-
" const raw = localStorage.getItem('myvillage_tokens');\n" +
|
|
1426
|
-
' return raw ? JSON.parse(raw) : null;\n' +
|
|
1427
|
-
'}\n' +
|
|
1428
|
-
'\n' +
|
|
1429
|
-
'export function storeTokens(tokens) {\n' +
|
|
1430
|
-
" localStorage.setItem('myvillage_tokens', JSON.stringify(tokens));\n" +
|
|
1431
|
-
'}\n' +
|
|
1432
|
-
'\n' +
|
|
1433
|
-
'export function clearTokens() {\n' +
|
|
1434
|
-
" localStorage.removeItem('myvillage_tokens');\n" +
|
|
1435
|
-
'}\n';
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
function generateAuthProvider() {
|
|
1439
|
-
return "import { createContext, useContext, useState, useEffect, useCallback } from 'react';\n" +
|
|
1440
|
-
"import { useNavigate } from 'react-router-dom';\n" +
|
|
1441
|
-
"import { startLogin, getStoredTokens, clearTokens, refreshToken } from './oauth.js';\n" +
|
|
1442
|
-
'\n' +
|
|
1443
|
-
'const AuthContext = createContext(null);\n' +
|
|
1444
|
-
'\n' +
|
|
1445
|
-
'export function useAuth() {\n' +
|
|
1446
|
-
' const ctx = useContext(AuthContext);\n' +
|
|
1447
|
-
" if (!ctx) throw new Error('useAuth must be used within AuthProvider');\n" +
|
|
1448
|
-
' return ctx;\n' +
|
|
1449
|
-
'}\n' +
|
|
1450
|
-
'\n' +
|
|
1451
|
-
'export function AuthProvider({ children }) {\n' +
|
|
1452
|
-
' const [user, setUser] = useState(null);\n' +
|
|
1453
|
-
' const [isLoading, setIsLoading] = useState(true);\n' +
|
|
1454
|
-
' const navigate = useNavigate();\n' +
|
|
1455
|
-
'\n' +
|
|
1456
|
-
' const fetchUser = useCallback(async (accessToken) => {\n' +
|
|
1457
|
-
' const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;\n' +
|
|
1458
|
-
' const res = await fetch(`${baseUrl}/userinfo`, {\n' +
|
|
1459
|
-
' headers: { Authorization: `Bearer ${accessToken}` },\n' +
|
|
1460
|
-
' });\n' +
|
|
1461
|
-
' if (res.ok) {\n' +
|
|
1462
|
-
' setUser(await res.json());\n' +
|
|
1463
|
-
' } else {\n' +
|
|
1464
|
-
' clearTokens();\n' +
|
|
1465
|
-
' setUser(null);\n' +
|
|
1466
|
-
' }\n' +
|
|
1467
|
-
' }, []);\n' +
|
|
1468
|
-
'\n' +
|
|
1469
|
-
' useEffect(() => {\n' +
|
|
1470
|
-
' const tokens = getStoredTokens();\n' +
|
|
1471
|
-
' if (tokens?.access_token) {\n' +
|
|
1472
|
-
' fetchUser(tokens.access_token).finally(() => setIsLoading(false));\n' +
|
|
1473
|
-
' } else {\n' +
|
|
1474
|
-
' setIsLoading(false);\n' +
|
|
1475
|
-
' }\n' +
|
|
1476
|
-
' }, [fetchUser]);\n' +
|
|
1477
|
-
'\n' +
|
|
1478
|
-
' useEffect(() => {\n' +
|
|
1479
|
-
' const tokens = getStoredTokens();\n' +
|
|
1480
|
-
' if (!tokens?.expires_in || !tokens?.refresh_token) return;\n' +
|
|
1481
|
-
' const refreshMs = (tokens.expires_in - 60) * 1000;\n' +
|
|
1482
|
-
' if (refreshMs <= 0) return;\n' +
|
|
1483
|
-
' const timer = setTimeout(async () => {\n' +
|
|
1484
|
-
' try {\n' +
|
|
1485
|
-
' const newTokens = await refreshToken(tokens.refresh_token);\n' +
|
|
1486
|
-
' await fetchUser(newTokens.access_token);\n' +
|
|
1487
|
-
' } catch {\n' +
|
|
1488
|
-
' clearTokens();\n' +
|
|
1489
|
-
' setUser(null);\n' +
|
|
1490
|
-
' }\n' +
|
|
1491
|
-
' }, refreshMs);\n' +
|
|
1492
|
-
' return () => clearTimeout(timer);\n' +
|
|
1493
|
-
' }, [user, fetchUser]);\n' +
|
|
1494
|
-
'\n' +
|
|
1495
|
-
' const login = () => startLogin();\n' +
|
|
1496
|
-
" const logout = () => { clearTokens(); setUser(null); navigate('/login'); };\n" +
|
|
1497
|
-
' const isAuthenticated = !!user;\n' +
|
|
1498
|
-
'\n' +
|
|
1499
|
-
' return (\n' +
|
|
1500
|
-
' <AuthContext.Provider value={{ user, login, logout, isAuthenticated, isLoading }}>\n' +
|
|
1501
|
-
' {children}\n' +
|
|
1502
|
-
' </AuthContext.Provider>\n' +
|
|
1503
|
-
' );\n' +
|
|
1504
|
-
'}\n';
|
|
1128
|
+
function generateHomePage(hasDashboard) {
|
|
1129
|
+
if (hasDashboard) {
|
|
1130
|
+
return `import { redirect } from 'next/navigation';
|
|
1131
|
+
|
|
1132
|
+
export default function Home() {
|
|
1133
|
+
redirect('/dashboard');
|
|
1134
|
+
}
|
|
1135
|
+
`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
return `import { AppShell } from './layout';
|
|
1139
|
+
import { getSession } from '@/lib/session';
|
|
1140
|
+
|
|
1141
|
+
export default async function Home() {
|
|
1142
|
+
const session = await getSession();
|
|
1143
|
+
|
|
1144
|
+
return (
|
|
1145
|
+
<AppShell user={session.user}>
|
|
1146
|
+
<div className="welcome">
|
|
1147
|
+
<h1>Welcome</h1>
|
|
1148
|
+
<p>Select a page from the sidebar to get started.</p>
|
|
1149
|
+
</div>
|
|
1150
|
+
</AppShell>
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
`;
|
|
1505
1154
|
}
|
|
1506
1155
|
|
|
1507
1156
|
function generateLoginPage() {
|
|
1508
|
-
return `
|
|
1157
|
+
return `export default function LoginPage({ searchParams }) {
|
|
1158
|
+
const error = searchParams?.error;
|
|
1509
1159
|
|
|
1510
|
-
export default function Login() {
|
|
1511
|
-
const { login } = useAuth();
|
|
1512
1160
|
return (
|
|
1513
1161
|
<div className="login-page">
|
|
1514
1162
|
<div className="login-card">
|
|
1515
|
-
<h1>
|
|
1516
|
-
<p
|
|
1517
|
-
|
|
1163
|
+
<h1 style={{ color: 'var(--brand-dark-brown)', marginBottom: '8px' }}>Welcome</h1>
|
|
1164
|
+
<p style={{ color: 'var(--brand-teal)', marginBottom: '24px' }}>
|
|
1165
|
+
Sign in with your MyVillageOS account to continue.
|
|
1166
|
+
</p>
|
|
1167
|
+
{error && (
|
|
1168
|
+
<p style={{ color: '#dc2626', marginBottom: '16px', fontSize: '14px' }}>
|
|
1169
|
+
{decodeURIComponent(error)}
|
|
1170
|
+
</p>
|
|
1171
|
+
)}
|
|
1172
|
+
<a href="/api/auth/login" className="login-btn" style={{ textDecoration: 'none' }}>
|
|
1173
|
+
Sign in with MyVillageOS
|
|
1174
|
+
</a>
|
|
1518
1175
|
</div>
|
|
1519
1176
|
</div>
|
|
1520
1177
|
);
|
|
@@ -1522,131 +1179,241 @@ export default function Login() {
|
|
|
1522
1179
|
`;
|
|
1523
1180
|
}
|
|
1524
1181
|
|
|
1525
|
-
function
|
|
1526
|
-
return `import
|
|
1527
|
-
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
1528
|
-
import { handleCallback } from '../auth/oauth.js';
|
|
1529
|
-
|
|
1530
|
-
export default function Callback() {
|
|
1531
|
-
const [searchParams] = useSearchParams();
|
|
1532
|
-
const navigate = useNavigate();
|
|
1533
|
-
const [error, setError] = useState(null);
|
|
1534
|
-
|
|
1535
|
-
useEffect(() => {
|
|
1536
|
-
const code = searchParams.get('code');
|
|
1537
|
-
const state = searchParams.get('state');
|
|
1538
|
-
if (!code || !state) { setError('Missing authorization code or state'); return; }
|
|
1539
|
-
handleCallback(code, state)
|
|
1540
|
-
.then(() => navigate('/', { replace: true }))
|
|
1541
|
-
.catch((err) => setError(err.message));
|
|
1542
|
-
}, [searchParams, navigate]);
|
|
1182
|
+
function generateNotFoundPage() {
|
|
1183
|
+
return `import Link from 'next/link';
|
|
1543
1184
|
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
<
|
|
1549
|
-
<
|
|
1185
|
+
export default function NotFound() {
|
|
1186
|
+
return (
|
|
1187
|
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
|
1188
|
+
<div style={{ textAlign: 'center' }}>
|
|
1189
|
+
<h1 style={{ fontSize: '48px', color: 'var(--brand-dark-brown)' }}>404</h1>
|
|
1190
|
+
<p style={{ color: 'var(--brand-teal)', marginBottom: '16px' }}>Page not found</p>
|
|
1191
|
+
<Link href="/" style={{ color: 'var(--brand-primary)' }}>Go home</Link>
|
|
1550
1192
|
</div>
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>Completing sign in...</div>;
|
|
1193
|
+
</div>
|
|
1194
|
+
);
|
|
1555
1195
|
}
|
|
1556
1196
|
`;
|
|
1557
1197
|
}
|
|
1558
1198
|
|
|
1559
|
-
// ─── Page Generators ────────────────────────────────────────────────────────
|
|
1560
|
-
|
|
1561
1199
|
function generateDashboardPage(includeMcp) {
|
|
1562
|
-
return `import
|
|
1200
|
+
return `import { AppShell } from '../layout';
|
|
1201
|
+
import { getSession } from '@/lib/session';
|
|
1202
|
+
import AgentChat from '@/components/AgentChat';
|
|
1203
|
+
|
|
1204
|
+
export default async function DashboardPage() {
|
|
1205
|
+
const session = await getSession();
|
|
1563
1206
|
|
|
1564
|
-
export default function Dashboard() {
|
|
1565
1207
|
return (
|
|
1566
|
-
<
|
|
1208
|
+
<AppShell user={session.user}>
|
|
1567
1209
|
<div className="page-header">
|
|
1568
1210
|
<h1>Dashboard</h1>
|
|
1569
|
-
<p>Your
|
|
1211
|
+
<p>Welcome back${includeMcp ? '. Your AI agent is ready to help.' : '.'}</p>
|
|
1570
1212
|
</div>
|
|
1571
1213
|
<div className="stat-cards">
|
|
1572
1214
|
<div className="stat-card">
|
|
1573
|
-
<h3>
|
|
1574
|
-
<div className="value"
|
|
1575
|
-
</div>
|
|
1576
|
-
<div className="stat-card">
|
|
1577
|
-
<h3>Posts</h3>
|
|
1578
|
-
<div className="value">--</div>
|
|
1579
|
-
</div>
|
|
1580
|
-
<div className="stat-card">
|
|
1581
|
-
<h3>Members</h3>
|
|
1582
|
-
<div className="value">--</div>
|
|
1215
|
+
<h3>Status</h3>
|
|
1216
|
+
<div className="value" style={{ color: 'var(--brand-green)' }}>Active</div>
|
|
1583
1217
|
</div>
|
|
1584
1218
|
</div>
|
|
1585
1219
|
<AgentChat />
|
|
1586
|
-
</
|
|
1220
|
+
</AppShell>
|
|
1587
1221
|
);
|
|
1588
1222
|
}
|
|
1589
1223
|
`;
|
|
1590
1224
|
}
|
|
1591
1225
|
|
|
1592
1226
|
function generateSettingsPage() {
|
|
1593
|
-
return `
|
|
1227
|
+
return `import { AppShell } from '../layout';
|
|
1228
|
+
import { getSession } from '@/lib/session';
|
|
1229
|
+
|
|
1230
|
+
export default async function SettingsPage() {
|
|
1231
|
+
const session = await getSession();
|
|
1232
|
+
|
|
1594
1233
|
return (
|
|
1595
|
-
<
|
|
1234
|
+
<AppShell user={session.user}>
|
|
1596
1235
|
<div className="page-header">
|
|
1597
1236
|
<h1>Settings</h1>
|
|
1598
|
-
<p>
|
|
1237
|
+
<p>Manage your application settings.</p>
|
|
1599
1238
|
</div>
|
|
1600
|
-
<div style={{ background: 'white', borderRadius:
|
|
1601
|
-
<p>Settings page
|
|
1239
|
+
<div style={{ background: 'white', borderRadius: '8px', padding: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
1240
|
+
<p style={{ color: 'var(--brand-teal)' }}>Settings page \u2014 customize this for your application.</p>
|
|
1602
1241
|
</div>
|
|
1603
|
-
</
|
|
1242
|
+
</AppShell>
|
|
1604
1243
|
);
|
|
1605
1244
|
}
|
|
1606
1245
|
`;
|
|
1607
1246
|
}
|
|
1608
1247
|
|
|
1609
1248
|
function generateUsersPage() {
|
|
1610
|
-
return `
|
|
1249
|
+
return `import { AppShell } from '../layout';
|
|
1250
|
+
import { getSession } from '@/lib/session';
|
|
1251
|
+
|
|
1252
|
+
export default async function UsersPage() {
|
|
1253
|
+
const session = await getSession();
|
|
1254
|
+
|
|
1611
1255
|
return (
|
|
1612
|
-
<
|
|
1256
|
+
<AppShell user={session.user}>
|
|
1613
1257
|
<div className="page-header">
|
|
1614
1258
|
<h1>Users</h1>
|
|
1615
|
-
<p>Manage users and permissions
|
|
1259
|
+
<p>Manage users and permissions.</p>
|
|
1616
1260
|
</div>
|
|
1617
|
-
<div style={{ background: 'white', borderRadius:
|
|
1618
|
-
<p
|
|
1261
|
+
<div style={{ background: 'white', borderRadius: '8px', padding: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
1262
|
+
<p style={{ color: 'var(--brand-teal)' }}>Users page \u2014 customize this for your application.</p>
|
|
1619
1263
|
</div>
|
|
1620
|
-
</
|
|
1264
|
+
</AppShell>
|
|
1621
1265
|
);
|
|
1622
1266
|
}
|
|
1623
1267
|
`;
|
|
1624
1268
|
}
|
|
1625
1269
|
|
|
1626
1270
|
function generateNotificationsPage() {
|
|
1627
|
-
return `
|
|
1271
|
+
return `import { AppShell } from '../layout';
|
|
1272
|
+
import { getSession } from '@/lib/session';
|
|
1273
|
+
|
|
1274
|
+
export default async function NotificationsPage() {
|
|
1275
|
+
const session = await getSession();
|
|
1276
|
+
|
|
1628
1277
|
return (
|
|
1629
|
-
<
|
|
1278
|
+
<AppShell user={session.user}>
|
|
1630
1279
|
<div className="page-header">
|
|
1631
1280
|
<h1>Notifications</h1>
|
|
1632
|
-
<p>
|
|
1281
|
+
<p>View your latest notifications.</p>
|
|
1633
1282
|
</div>
|
|
1634
|
-
<div style={{ background: 'white', borderRadius:
|
|
1635
|
-
<p>
|
|
1283
|
+
<div style={{ background: 'white', borderRadius: '8px', padding: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
1284
|
+
<p style={{ color: 'var(--brand-teal)' }}>Notifications page \u2014 customize this for your application.</p>
|
|
1636
1285
|
</div>
|
|
1637
|
-
</
|
|
1286
|
+
</AppShell>
|
|
1638
1287
|
);
|
|
1639
1288
|
}
|
|
1640
1289
|
`;
|
|
1641
1290
|
}
|
|
1642
1291
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1292
|
+
// ─── Component Generators ───────────────────────────────────────────────────
|
|
1293
|
+
|
|
1294
|
+
function generateSidebarComponent(features) {
|
|
1295
|
+
const links = [" <li><a href=\"/\">Home</a></li>"];
|
|
1296
|
+
if (features.includes('dashboard')) links.push(" <li><a href=\"/dashboard\">Dashboard</a></li>");
|
|
1297
|
+
if (features.includes('settings')) links.push(" <li><a href=\"/settings\">Settings</a></li>");
|
|
1298
|
+
if (features.includes('users')) links.push(" <li><a href=\"/users\">Users</a></li>");
|
|
1299
|
+
if (features.includes('notifications')) links.push(" <li><a href=\"/notifications\">Notifications</a></li>");
|
|
1300
|
+
|
|
1301
|
+
return `import Link from 'next/link';
|
|
1302
|
+
|
|
1303
|
+
export default function Sidebar() {
|
|
1304
|
+
return (
|
|
1305
|
+
<aside className="sidebar">
|
|
1306
|
+
<div className="sidebar-brand">
|
|
1307
|
+
<h2>MyVillageOS</h2>
|
|
1308
|
+
</div>
|
|
1309
|
+
<ul className="sidebar-nav">
|
|
1310
|
+
${links.map(l => l.replace(/<a href="/g, '<Link href="').replace(/<\/a>/g, '</Link>')).join('\n')}
|
|
1311
|
+
</ul>
|
|
1312
|
+
</aside>
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
`;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function generateHeaderComponent(hasOAuth) {
|
|
1319
|
+
if (hasOAuth) {
|
|
1320
|
+
return `import SignOutButton from './SignOutButton';
|
|
1321
|
+
|
|
1322
|
+
export default function Header({ user }) {
|
|
1323
|
+
return (
|
|
1324
|
+
<header className="header">
|
|
1325
|
+
<span className="header-title">MyVillageOS App</span>
|
|
1326
|
+
<div className="header-user">
|
|
1327
|
+
{user && <span>{user.name || user.email}</span>}
|
|
1328
|
+
{user && <SignOutButton />}
|
|
1329
|
+
</div>
|
|
1330
|
+
</header>
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
`;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
return `export default function Header() {
|
|
1337
|
+
return (
|
|
1338
|
+
<header className="header">
|
|
1339
|
+
<span className="header-title">MyVillageOS App</span>
|
|
1340
|
+
</header>
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
`;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function generateSignOutButton() {
|
|
1347
|
+
return `'use client';
|
|
1348
|
+
|
|
1349
|
+
export default function SignOutButton() {
|
|
1350
|
+
const handleSignOut = async () => {
|
|
1351
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
1352
|
+
window.location.href = '/login';
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1645
1355
|
return (
|
|
1646
|
-
<
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1356
|
+
<button onClick={handleSignOut}>Sign out</button>
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function generateAgentChatComponent() {
|
|
1363
|
+
return `'use client';
|
|
1364
|
+
|
|
1365
|
+
import { useState } from 'react';
|
|
1366
|
+
|
|
1367
|
+
export default function AgentChat() {
|
|
1368
|
+
const [messages, setMessages] = useState([]);
|
|
1369
|
+
const [input, setInput] = useState('');
|
|
1370
|
+
const [loading, setLoading] = useState(false);
|
|
1371
|
+
|
|
1372
|
+
const sendMessage = async () => {
|
|
1373
|
+
if (!input.trim() || loading) return;
|
|
1374
|
+
|
|
1375
|
+
const userMsg = input.trim();
|
|
1376
|
+
setMessages(prev => [...prev, { role: 'user', text: userMsg }]);
|
|
1377
|
+
setInput('');
|
|
1378
|
+
setLoading(true);
|
|
1379
|
+
|
|
1380
|
+
try {
|
|
1381
|
+
const res = await fetch('/api/agent/chat', {
|
|
1382
|
+
method: 'POST',
|
|
1383
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1384
|
+
body: JSON.stringify({ message: userMsg }),
|
|
1385
|
+
});
|
|
1386
|
+
const data = await res.json();
|
|
1387
|
+
setMessages(prev => [...prev, { role: 'agent', text: data.response || data.error }]);
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
setMessages(prev => [...prev, { role: 'agent', text: 'Error: ' + err.message }]);
|
|
1390
|
+
} finally {
|
|
1391
|
+
setLoading(false);
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
return (
|
|
1396
|
+
<div className="agent-chat">
|
|
1397
|
+
<h3>AI Agent</h3>
|
|
1398
|
+
<div className="agent-chat-messages">
|
|
1399
|
+
{messages.length === 0 && <p style={{ color: '#999' }}>Ask the agent anything about your village...</p>}
|
|
1400
|
+
{messages.map((msg, i) => (
|
|
1401
|
+
<div key={i} className={\`message \${msg.role}\`}>
|
|
1402
|
+
<strong>{msg.role === 'user' ? 'You' : 'Agent'}:</strong> {msg.text}
|
|
1403
|
+
</div>
|
|
1404
|
+
))}
|
|
1405
|
+
{loading && <div className="message agent"><em>Thinking...</em></div>}
|
|
1406
|
+
</div>
|
|
1407
|
+
<div className="agent-chat-input">
|
|
1408
|
+
<input
|
|
1409
|
+
value={input}
|
|
1410
|
+
onChange={e => setInput(e.target.value)}
|
|
1411
|
+
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
|
1412
|
+
placeholder="Ask the AI agent..."
|
|
1413
|
+
disabled={loading}
|
|
1414
|
+
/>
|
|
1415
|
+
<button onClick={sendMessage} disabled={loading}>Send</button>
|
|
1416
|
+
</div>
|
|
1650
1417
|
</div>
|
|
1651
1418
|
);
|
|
1652
1419
|
}
|