@aprovan/chat-backend 0.1.0-dev.4d82df8
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/.turbo/turbo-build.log +20 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +1158 -0
- package/dist/index.js.map +1 -0
- package/dist/lambda.d.ts +5 -0
- package/dist/lambda.js +1145 -0
- package/dist/lambda.js.map +1 -0
- package/package.json +47 -0
- package/src/app.ts +33 -0
- package/src/env.ts +27 -0
- package/src/fallback-prompts.ts +343 -0
- package/src/gateway-session.ts +148 -0
- package/src/index.ts +19 -0
- package/src/lambda.ts +17 -0
- package/src/middleware/.gitkeep +0 -0
- package/src/middleware/auth.ts +38 -0
- package/src/middleware/plan.ts +67 -0
- package/src/middleware/workspace.ts +96 -0
- package/src/posthog.ts +48 -0
- package/src/providers/openrouter.ts +37 -0
- package/src/routes/chat.ts +235 -0
- package/src/routes/edit.ts +57 -0
- package/src/routes/health.ts +7 -0
- package/src/routes/proxy.ts +60 -0
- package/src/routes/services.ts +82 -0
- package/src/routes/workspaces.ts +89 -0
- package/src/session.ts +80 -0
- package/src/tool-docs.ts +77 -0
- package/src/types.ts +29 -0
- package/test/app.test.ts +62 -0
- package/test/gateway-session.test.ts +211 -0
- package/test/middleware/auth.test.ts +87 -0
- package/test/middleware/plan.test.ts +99 -0
- package/test/middleware/workspace.test.ts +122 -0
- package/test/routes/chat.test.ts +587 -0
- package/test/routes/proxy.test.ts +133 -0
- package/test/routes/services.test.ts +128 -0
- package/test/routes/workspaces.test.ts +164 -0
- package/test/session.test.ts +56 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +17 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
|
|
4
|
+
// src/app.ts
|
|
5
|
+
import { Hono as Hono7 } from "hono";
|
|
6
|
+
|
|
7
|
+
// src/middleware/auth.ts
|
|
8
|
+
import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
9
|
+
var verifier = null;
|
|
10
|
+
function getVerifier() {
|
|
11
|
+
if (!verifier) {
|
|
12
|
+
verifier = CognitoJwtVerifier.create({
|
|
13
|
+
userPoolId: process.env["COGNITO_USER_POOL_ID"],
|
|
14
|
+
clientId: process.env["COGNITO_CLIENT_ID"],
|
|
15
|
+
tokenUse: "access"
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return verifier;
|
|
19
|
+
}
|
|
20
|
+
var authMiddleware = async (c, next) => {
|
|
21
|
+
const authHeader = c.req.header("Authorization");
|
|
22
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
23
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
24
|
+
}
|
|
25
|
+
const token = authHeader.slice(7);
|
|
26
|
+
try {
|
|
27
|
+
const payload = await getVerifier().verify(token);
|
|
28
|
+
c.set("claims", payload);
|
|
29
|
+
return next();
|
|
30
|
+
} catch {
|
|
31
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// src/middleware/plan.ts
|
|
36
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
37
|
+
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
|
|
38
|
+
var WORKSPACE_CACHE_TTL_MS = 6e4;
|
|
39
|
+
var workspaceCache = /* @__PURE__ */ new Map();
|
|
40
|
+
var ddbClient = null;
|
|
41
|
+
function getDdb() {
|
|
42
|
+
if (!ddbClient) {
|
|
43
|
+
ddbClient = DynamoDBDocumentClient.from(
|
|
44
|
+
new DynamoDBClient({ region: process.env["AWS_REGION"] })
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return ddbClient;
|
|
48
|
+
}
|
|
49
|
+
async function getWorkspace(workspaceId) {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const cached = workspaceCache.get(workspaceId);
|
|
52
|
+
if (cached && now - cached.fetchedAt < WORKSPACE_CACHE_TTL_MS) {
|
|
53
|
+
return cached.workspace;
|
|
54
|
+
}
|
|
55
|
+
const result = await getDdb().send(
|
|
56
|
+
new GetCommand({
|
|
57
|
+
TableName: process.env["WORKSPACE_TABLE_NAME"],
|
|
58
|
+
Key: { workspaceId }
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
if (!result.Item) return null;
|
|
62
|
+
const workspace = result.Item;
|
|
63
|
+
workspaceCache.set(workspaceId, { workspace, fetchedAt: now });
|
|
64
|
+
return workspace;
|
|
65
|
+
}
|
|
66
|
+
var planMiddleware = async (c, next) => {
|
|
67
|
+
const workspaceId = c.get("workspaceId");
|
|
68
|
+
const workspace = await getWorkspace(workspaceId);
|
|
69
|
+
if (!workspace) {
|
|
70
|
+
return c.json({ error: "Workspace not found" }, 404);
|
|
71
|
+
}
|
|
72
|
+
if ("chat" in workspace.features && !workspace.features.chat) {
|
|
73
|
+
return c.json(
|
|
74
|
+
{ error: "Chat is not available on your current plan", plan: workspace.plan },
|
|
75
|
+
402
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
c.set("workspace", workspace);
|
|
79
|
+
return next();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// src/middleware/workspace.ts
|
|
83
|
+
import { DynamoDBClient as DynamoDBClient3 } from "@aws-sdk/client-dynamodb";
|
|
84
|
+
import { DynamoDBDocumentClient as DynamoDBDocumentClient3, QueryCommand } from "@aws-sdk/lib-dynamodb";
|
|
85
|
+
|
|
86
|
+
// src/session.ts
|
|
87
|
+
import { DynamoDBClient as DynamoDBClient2 } from "@aws-sdk/client-dynamodb";
|
|
88
|
+
import {
|
|
89
|
+
DynamoDBDocumentClient as DynamoDBDocumentClient2,
|
|
90
|
+
GetCommand as GetCommand2,
|
|
91
|
+
PutCommand
|
|
92
|
+
} from "@aws-sdk/lib-dynamodb";
|
|
93
|
+
var SESSION_CACHE_TTL_MS = 15e3;
|
|
94
|
+
var sessionCache = /* @__PURE__ */ new Map();
|
|
95
|
+
var ddbClient2 = null;
|
|
96
|
+
function getDdb2() {
|
|
97
|
+
if (!ddbClient2) {
|
|
98
|
+
ddbClient2 = DynamoDBDocumentClient2.from(
|
|
99
|
+
new DynamoDBClient2({ region: process.env["AWS_REGION"] })
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return ddbClient2;
|
|
103
|
+
}
|
|
104
|
+
function tableName() {
|
|
105
|
+
return process.env["USER_SESSIONS_TABLE_NAME"];
|
|
106
|
+
}
|
|
107
|
+
async function getSessionWorkspaceId(userSub) {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const cached = sessionCache.get(userSub);
|
|
110
|
+
if (cached && now - cached.fetchedAt < SESSION_CACHE_TTL_MS) {
|
|
111
|
+
return cached.activeWorkspaceId;
|
|
112
|
+
}
|
|
113
|
+
const result = await getDdb2().send(
|
|
114
|
+
new GetCommand2({
|
|
115
|
+
TableName: tableName(),
|
|
116
|
+
Key: { userSub }
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
const activeWorkspaceId = result.Item?.activeWorkspaceId ?? null;
|
|
120
|
+
sessionCache.set(userSub, { activeWorkspaceId, fetchedAt: now });
|
|
121
|
+
return activeWorkspaceId;
|
|
122
|
+
}
|
|
123
|
+
async function setSessionWorkspaceId(userSub, workspaceId) {
|
|
124
|
+
await getDdb2().send(
|
|
125
|
+
new PutCommand({
|
|
126
|
+
TableName: tableName(),
|
|
127
|
+
Item: {
|
|
128
|
+
userSub,
|
|
129
|
+
activeWorkspaceId: workspaceId,
|
|
130
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
sessionCache.set(userSub, {
|
|
135
|
+
activeWorkspaceId: workspaceId,
|
|
136
|
+
fetchedAt: Date.now()
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/middleware/workspace.ts
|
|
141
|
+
var MEMBERSHIP_CACHE_TTL_MS = 3e5;
|
|
142
|
+
var membershipCache = /* @__PURE__ */ new Map();
|
|
143
|
+
var ddbClient3 = null;
|
|
144
|
+
function getDdb3() {
|
|
145
|
+
if (!ddbClient3) {
|
|
146
|
+
ddbClient3 = DynamoDBDocumentClient3.from(
|
|
147
|
+
new DynamoDBClient3({ region: process.env["AWS_REGION"] })
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return ddbClient3;
|
|
151
|
+
}
|
|
152
|
+
async function listWorkspaceMemberships(userSub) {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const cached = membershipCache.get(userSub);
|
|
155
|
+
if (cached && now - cached.fetchedAt < MEMBERSHIP_CACHE_TTL_MS) {
|
|
156
|
+
return cached.workspaceIds;
|
|
157
|
+
}
|
|
158
|
+
const result = await getDdb3().send(
|
|
159
|
+
new QueryCommand({
|
|
160
|
+
TableName: process.env["MEMBERSHIPS_TABLE_NAME"],
|
|
161
|
+
IndexName: "ByUserSub",
|
|
162
|
+
KeyConditionExpression: "userSub = :sub",
|
|
163
|
+
ExpressionAttributeValues: { ":sub": userSub }
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
const workspaceIds = (result.Items ?? []).map(
|
|
167
|
+
(item) => item.workspaceId
|
|
168
|
+
);
|
|
169
|
+
membershipCache.set(userSub, { workspaceIds, fetchedAt: now });
|
|
170
|
+
return workspaceIds;
|
|
171
|
+
}
|
|
172
|
+
async function resolveWorkspaceId(userSub) {
|
|
173
|
+
const [sessionWsId, workspaceIds] = await Promise.all([
|
|
174
|
+
getSessionWorkspaceId(userSub),
|
|
175
|
+
listWorkspaceMemberships(userSub)
|
|
176
|
+
]);
|
|
177
|
+
if (workspaceIds.length === 0) return null;
|
|
178
|
+
if (sessionWsId && workspaceIds.includes(sessionWsId)) {
|
|
179
|
+
return sessionWsId;
|
|
180
|
+
}
|
|
181
|
+
return workspaceIds[0] ?? null;
|
|
182
|
+
}
|
|
183
|
+
var workspaceMiddleware = async (c, next) => {
|
|
184
|
+
const claims = c.get("claims");
|
|
185
|
+
const workspaceId = await resolveWorkspaceId(claims.sub);
|
|
186
|
+
if (!workspaceId) {
|
|
187
|
+
return c.json({ error: "No workspace membership" }, 403);
|
|
188
|
+
}
|
|
189
|
+
c.set("workspaceId", workspaceId);
|
|
190
|
+
return next();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/routes/chat.ts
|
|
194
|
+
import { withTracing } from "@posthog/ai";
|
|
195
|
+
import {
|
|
196
|
+
streamText,
|
|
197
|
+
convertToModelMessages,
|
|
198
|
+
wrapLanguageModel,
|
|
199
|
+
stepCountIs,
|
|
200
|
+
jsonSchema
|
|
201
|
+
} from "ai";
|
|
202
|
+
import { Hono } from "hono";
|
|
203
|
+
import { z } from "zod";
|
|
204
|
+
|
|
205
|
+
// src/fallback-prompts.ts
|
|
206
|
+
var PATCHWORK_PROMPT = `
|
|
207
|
+
You are a friendly assistant! When responding to the user, you _must_ respond with JSX files!
|
|
208
|
+
|
|
209
|
+
Look at 'patchwork.compilers' to see what specific runtime components and libraries are supported. (e.g. '['@aprovan/patchwork-image-shadcn' supports React, Tailwind, & ShadCN components). If there are no compilers, respond as you normally would. If compilers are available, ALWAYS respond with a component following [Component Generation](#component-generation).
|
|
210
|
+
|
|
211
|
+
Look at 'patchwork.services' to see what services are available for widgets to call. If services are listed, you can generate widgets that make service calls using global namespace objects.
|
|
212
|
+
|
|
213
|
+
**IMPORTANT: If you need to discover available services or get details about a specific service, use the \`search_services\` tool.**
|
|
214
|
+
|
|
215
|
+
## Component Generation
|
|
216
|
+
|
|
217
|
+
Respond with code blocks using tagged attributes on the fence line. The \`note\` attribute (optional but encouraged) provides a brief description visible in the UI. The \`path\` attribute specifies the virtual file path.
|
|
218
|
+
|
|
219
|
+
### Code Block Format
|
|
220
|
+
|
|
221
|
+
\`\`\`tsx note="Main component" path="components/weather/main.tsx"
|
|
222
|
+
export default function WeatherWidget() {
|
|
223
|
+
// component code
|
|
224
|
+
}
|
|
225
|
+
\`\`\`
|
|
226
|
+
|
|
227
|
+
### Attribute Order
|
|
228
|
+
Put \`note\` first so it's available soonest in streaming UI.
|
|
229
|
+
|
|
230
|
+
### Multi-File Generation
|
|
231
|
+
|
|
232
|
+
When generating complex widgets, you can output multiple files. Use the \`@/\` prefix for virtual file system paths. ALWAYS prefer to generate visible components before metadata.
|
|
233
|
+
|
|
234
|
+
**Example multi-file widget:**
|
|
235
|
+
|
|
236
|
+
\`\`\`json note="Widget configuration" path="components/dashboard/package.json"
|
|
237
|
+
{
|
|
238
|
+
"description": "Interactive dashboard widget",
|
|
239
|
+
"patchwork": {
|
|
240
|
+
"inputs": {
|
|
241
|
+
"type": "object",
|
|
242
|
+
"properties": {
|
|
243
|
+
"title": { "type": "string" }
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
"services": {
|
|
247
|
+
"analytics": ["getMetrics", "getChartData"]
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
\`\`\`
|
|
252
|
+
|
|
253
|
+
\`\`\`tsx note="Main widget component" path="components/dashboard/main.tsx"
|
|
254
|
+
import { Card } from './Card';
|
|
255
|
+
import { Chart } from './Chart';
|
|
256
|
+
|
|
257
|
+
export default function Dashboard({ title = "Dashboard" }) {
|
|
258
|
+
return (
|
|
259
|
+
<div>
|
|
260
|
+
<h1>{title}</h1>
|
|
261
|
+
<Card />
|
|
262
|
+
<Chart />
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
\`\`\`
|
|
267
|
+
|
|
268
|
+
\`\`\`tsx note="Card subcomponent" path="components/dashboard/Card.tsx"
|
|
269
|
+
export function Card() {
|
|
270
|
+
return <div className="p-4 rounded border">Card content</div>;
|
|
271
|
+
}
|
|
272
|
+
\`\`\`
|
|
273
|
+
|
|
274
|
+
### Requirements
|
|
275
|
+
- DO think heavily about correctness of code and syntax
|
|
276
|
+
- DO keep things simple and self-contained
|
|
277
|
+
- ALWAYS include the \`path\` attribute specifying the file location.
|
|
278
|
+
- ALWAYS use generic component/path names that is not dependent on arguments (e.g. 'dinner.tsx' not 'spaghetti.tsx')
|
|
279
|
+
- ALWAYS output the COMPLETE code block with opening \`\`\`tsx and closing \`\`\` markers
|
|
280
|
+
- Use \`note\` attribute to describe what each code block does (optional but encouraged)
|
|
281
|
+
- NEVER truncate or cut off code - finish the entire component before stopping
|
|
282
|
+
- If the component is complex, simplify it rather than leaving it incomplete
|
|
283
|
+
- Do NOT include: a heading/title
|
|
284
|
+
|
|
285
|
+
### Visual Design Guidelines
|
|
286
|
+
Create professional, polished interfaces that present information **spatially** rather than as vertical lists:
|
|
287
|
+
- Use **cards, grids, and flexbox layouts** to organize related data into visual groups
|
|
288
|
+
- Leverage **icons** (from lucide-react) alongside text to communicate meaning at a glance
|
|
289
|
+
- Apply **visual hierarchy** through typography scale, weight, and color contrast
|
|
290
|
+
- Use **whitespace strategically** to create breathing room and separation
|
|
291
|
+
- Prefer **horizontal arrangements** where data fits naturally (e.g., stats in a row, badges inline)
|
|
292
|
+
- Group related metrics into **compact visual clusters** rather than separate line items
|
|
293
|
+
- Use **subtle backgrounds, borders, and shadows** to define sections without heavy dividers
|
|
294
|
+
|
|
295
|
+
### Root Element Constraints
|
|
296
|
+
The component will be rendered inside a parent container that handles positioning. Your root element should:
|
|
297
|
+
- \u2705 Use intrinsic sizing (let content determine dimensions)
|
|
298
|
+
- \u2705 Handle internal padding (e.g., \`p-4\`, \`p-6\`)
|
|
299
|
+
- \u274C NEVER add centering utilities (\`items-center\`, \`justify-center\`) to position itself
|
|
300
|
+
- \u274C NEVER add viewport-relative sizing (\`min-h-screen\`, \`h-screen\`, \`w-screen\`)
|
|
301
|
+
- \u274C NEVER add flex/grid on root just for self-centering
|
|
302
|
+
|
|
303
|
+
### Using Services in Widgets (CRITICAL)
|
|
304
|
+
|
|
305
|
+
**MANDATORY workflow - you must follow these steps IN ORDER:**
|
|
306
|
+
|
|
307
|
+
1. **Use \`search_services\`** to discover the service schema
|
|
308
|
+
2. **STOP. Make an actual call to the service tool itself** (e.g., \`weather_get_forecast\`, \`github_get_repo\`) with real arguments. This is NOT optional. Do NOT skip this step.
|
|
309
|
+
3. **Observe the response** - verify it succeeded and note the exact data structure
|
|
310
|
+
4. **Only then generate the widget** that fetches the same data at runtime
|
|
311
|
+
|
|
312
|
+
**\`search_services\` is NOT a substitute for calling the actual service.** It only returns documentation. You MUST invoke the real service tool to validate your arguments work.
|
|
313
|
+
|
|
314
|
+
**Tool naming:** Service tools use underscores, not dots. For example: \`weather_get_forecast\`, \`github_list_repos\`.
|
|
315
|
+
|
|
316
|
+
**Example workflow for a weather widget:**
|
|
317
|
+
\`\`\`
|
|
318
|
+
Step 1: search_services({ query: "weather" })
|
|
319
|
+
\u2192 Learn that weather_get_current_conditions exists with params: { latitude, longitude }
|
|
320
|
+
|
|
321
|
+
Step 2: weather_get_current_conditions({ latitude: 29.7604, longitude: -95.3698 }) \u2190 REQUIRED!
|
|
322
|
+
\u2192 Verify it returns { temp: 72, humidity: 65, ... }
|
|
323
|
+
|
|
324
|
+
Step 3: Generate widget that calls weather.get_current_conditions at runtime
|
|
325
|
+
\`\`\`
|
|
326
|
+
|
|
327
|
+
**If you skip Step 2, you will generate broken widgets.** Arguments that look correct in the schema may fail at runtime due to validation rules, required formats, or service-specific constraints.
|
|
328
|
+
|
|
329
|
+
**NEVER embed static data directly in the component.**
|
|
330
|
+
|
|
331
|
+
\u274C **WRONG** - Embedding data directly:
|
|
332
|
+
\`\`\`tsx path="components/weather/bad-example.tsx"
|
|
333
|
+
// DON'T DO THIS - calling tool, then embedding the response as static data
|
|
334
|
+
export default function WeatherWidget() {
|
|
335
|
+
// Static data embedded at generation time - BAD!
|
|
336
|
+
const weather = { temp: 72, condition: "sunny", humidity: 45 };
|
|
337
|
+
return <div>Temperature: {weather.temp}\xB0F</div>;
|
|
338
|
+
}
|
|
339
|
+
\`\`\`
|
|
340
|
+
|
|
341
|
+
\u2705 **CORRECT** - Fetching data at runtime:
|
|
342
|
+
\`\`\`tsx note="Weather widget with runtime data" path="components/weather/main.tsx"
|
|
343
|
+
export default function WeatherWidget() {
|
|
344
|
+
const [data, setData] = useState(null);
|
|
345
|
+
const [loading, setLoading] = useState(true);
|
|
346
|
+
const [error, setError] = useState(null);
|
|
347
|
+
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
// Fetch data at runtime - GOOD!
|
|
350
|
+
weather.get_forecast({ latitude: 48.8566, longitude: 2.3522 })
|
|
351
|
+
.then(setData)
|
|
352
|
+
.catch(setError)
|
|
353
|
+
.finally(() => setLoading(false));
|
|
354
|
+
}, []);
|
|
355
|
+
|
|
356
|
+
if (loading) return <Skeleton className="h-32 w-full" />;
|
|
357
|
+
if (error) return <Alert variant="destructive">{error.message}</Alert>;
|
|
358
|
+
|
|
359
|
+
return <div>Temperature: {data.temp}\xB0F</div>;
|
|
360
|
+
}
|
|
361
|
+
\`\`\`
|
|
362
|
+
|
|
363
|
+
**Why this matters:**
|
|
364
|
+
- Widgets with runtime service calls show **live data** that updates when refreshed
|
|
365
|
+
- Static embedded data becomes **stale immediately** after generation
|
|
366
|
+
- The proxy pattern allows widgets to be **reusable** across different contexts
|
|
367
|
+
- Error handling and loading states improve **user experience**
|
|
368
|
+
|
|
369
|
+
**Service call pattern:**
|
|
370
|
+
\`\`\`tsx
|
|
371
|
+
// Services are available as global namespace objects
|
|
372
|
+
// Call format: namespace.procedure_name({ ...args })
|
|
373
|
+
|
|
374
|
+
const [data, setData] = useState(null);
|
|
375
|
+
const [loading, setLoading] = useState(true);
|
|
376
|
+
const [error, setError] = useState(null);
|
|
377
|
+
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
serviceName.procedure_name({ param1: value1 })
|
|
380
|
+
.then(setData)
|
|
381
|
+
.catch(setError)
|
|
382
|
+
.finally(() => setLoading(false));
|
|
383
|
+
}, [/* dependencies */]);
|
|
384
|
+
\`\`\`
|
|
385
|
+
|
|
386
|
+
**Required for service-using widgets:**
|
|
387
|
+
- Always show loading indicators (Skeleton, Loader2 spinner, etc.)
|
|
388
|
+
- Always handle errors gracefully with user-friendly messages
|
|
389
|
+
- Use appropriate React hooks (useState, useEffect) for async data
|
|
390
|
+
- Services are injected as globals - NO imports needed
|
|
391
|
+
|
|
392
|
+
### Validating Service Calls (CRITICAL - READ CAREFULLY)
|
|
393
|
+
|
|
394
|
+
**Calling \`search_services\` multiple times is NOT validation.** You must call the actual service tool.
|
|
395
|
+
|
|
396
|
+
\u274C **WRONG workflow (will produce broken widgets):**
|
|
397
|
+
\`\`\`
|
|
398
|
+
1. search_services({ query: "weather" }) \u2190 Only gets schema
|
|
399
|
+
2. search_services({ query: "location" }) \u2190 Still only schema
|
|
400
|
+
3. Generate widget \u2190 BROKEN - never tested the actual service!
|
|
401
|
+
\`\`\`
|
|
402
|
+
|
|
403
|
+
\u2705 **CORRECT workflow:**
|
|
404
|
+
\`\`\`
|
|
405
|
+
1. search_services({ query: "weather" }) \u2190 Get schema
|
|
406
|
+
2. weather_get_forecast({ latitude: 29.76, longitude: -95.37 }) \u2190 ACTUALLY CALL IT
|
|
407
|
+
3. Observe response: { temp: 72, conditions: "sunny", ... }
|
|
408
|
+
4. Generate widget that calls weather.get_forecast at runtime
|
|
409
|
+
\`\`\`
|
|
410
|
+
|
|
411
|
+
**The service tool (e.g., \`weather_get_forecast\`, \`github_list_repos\`) is a DIFFERENT tool from \`search_services\`.** You have access to both. Use both.
|
|
412
|
+
|
|
413
|
+
**Only after a successful test call to the actual service should you generate the widget.**
|
|
414
|
+
|
|
415
|
+
### Component Parameterization (IMPORTANT)
|
|
416
|
+
|
|
417
|
+
**Widgets should accept props for dynamic values instead of hardcoding:**
|
|
418
|
+
|
|
419
|
+
\u274C **WRONG** - Hardcoded values:
|
|
420
|
+
\`\`\`tsx path="components/weather/bad-example.tsx"
|
|
421
|
+
export default function WeatherWidget() {
|
|
422
|
+
// Location hardcoded - BAD!
|
|
423
|
+
const [lat, lon] = [48.8566, 2.3522]; // Paris
|
|
424
|
+
// ...
|
|
425
|
+
}
|
|
426
|
+
\`\`\`
|
|
427
|
+
|
|
428
|
+
\u2705 **CORRECT** - Parameterized with props and defaults:
|
|
429
|
+
\`\`\`tsx note="Parameterized weather widget" path="components/weather/main.tsx"
|
|
430
|
+
interface WeatherWidgetProps {
|
|
431
|
+
location?: string; // e.g., "Paris, France"
|
|
432
|
+
latitude?: number; // Direct coordinates (optional)
|
|
433
|
+
longitude?: number;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export default function WeatherWidget({
|
|
437
|
+
location = "Paris, France",
|
|
438
|
+
latitude,
|
|
439
|
+
longitude
|
|
440
|
+
}: WeatherWidgetProps) {
|
|
441
|
+
// Use provided coordinates or look up from location name
|
|
442
|
+
// ...
|
|
443
|
+
}
|
|
444
|
+
\`\`\`
|
|
445
|
+
|
|
446
|
+
**Why parameterize:**
|
|
447
|
+
- Components become **reusable** across different contexts
|
|
448
|
+
- Users can **customize behavior** without editing code
|
|
449
|
+
- Enables **composition** - parent components can pass different values
|
|
450
|
+
- Supports **testing** with various inputs
|
|
451
|
+
|
|
452
|
+
**What to parameterize:**
|
|
453
|
+
- Location names, coordinates, IDs
|
|
454
|
+
- Search queries and filters
|
|
455
|
+
- Display options (count, format, theme)
|
|
456
|
+
- API-specific identifiers (usernames, repo names, etc.)
|
|
457
|
+
|
|
458
|
+
### Anti-patterns to Avoid
|
|
459
|
+
- \u274C Bulleted or numbered lists of key-value pairs
|
|
460
|
+
- \u274C Vertical stacks where horizontal layouts would fit
|
|
461
|
+
- \u274C Plain text labels without visual treatment
|
|
462
|
+
- \u274C Uniform styling that doesn't distinguish primary from secondary information
|
|
463
|
+
- \u274C Wrapping components in centering containers (parent handles this)
|
|
464
|
+
- \u274C **Embedding API response data directly in components instead of fetching at runtime**
|
|
465
|
+
- \u274C **Calling a tool, then putting the response as static JSX/JSON in the generated code**
|
|
466
|
+
- \u274C **Hardcoding values that should be component props**
|
|
467
|
+
- \u274C **Calling \`search_services\` multiple times instead of calling the actual service tool**
|
|
468
|
+
- \u274C **Generating a widget without first making a real call to the service with your intended arguments**
|
|
469
|
+
- \u274C **Treating schema documentation as proof that a service call will work**
|
|
470
|
+
- \u274C **Omitting the \`path\` attribute on code blocks**
|
|
471
|
+
`;
|
|
472
|
+
var EDIT_PROMPT = `
|
|
473
|
+
You are editing an existing JSX component. The user will provide the current code and describe the changes they want.
|
|
474
|
+
|
|
475
|
+
## Response Format
|
|
476
|
+
|
|
477
|
+
Use code fences with tagged attributes. The \`note\` attribute (optional but encouraged) provides a brief description visible in the UI. The \`path\` attribute specifies the target file.
|
|
478
|
+
|
|
479
|
+
\`\`\`diff note="Brief description of this change" path="@/components/Button.tsx"
|
|
480
|
+
<<<<<<< SEARCH
|
|
481
|
+
exact code to find
|
|
482
|
+
=======
|
|
483
|
+
replacement code
|
|
484
|
+
>>>>>>> REPLACE
|
|
485
|
+
\`\`\`
|
|
486
|
+
|
|
487
|
+
### Attribute Order
|
|
488
|
+
Put \`note\` first so it's available soonest in streaming UI.
|
|
489
|
+
|
|
490
|
+
### Multi-File Edits
|
|
491
|
+
When editing multiple files, use the \`path\` attribute with virtual paths (\`@/\` prefix for generated files):
|
|
492
|
+
|
|
493
|
+
\`\`\`diff note="Update button handler" path="@/components/Button.tsx"
|
|
494
|
+
<<<<<<< SEARCH
|
|
495
|
+
onClick={() => {}}
|
|
496
|
+
=======
|
|
497
|
+
onClick={() => handleClick()}
|
|
498
|
+
>>>>>>> REPLACE
|
|
499
|
+
\`\`\`
|
|
500
|
+
|
|
501
|
+
\`\`\`diff note="Add utility function" path="@/lib/utils.ts"
|
|
502
|
+
<<<<<<< SEARCH
|
|
503
|
+
export const formatDate = ...
|
|
504
|
+
=======
|
|
505
|
+
export const formatDate = ...
|
|
506
|
+
|
|
507
|
+
export const handleClick = () => console.log('clicked');
|
|
508
|
+
>>>>>>> REPLACE
|
|
509
|
+
\`\`\`
|
|
510
|
+
|
|
511
|
+
## Rules
|
|
512
|
+
- SEARCH block must match the existing code EXACTLY (whitespace, indentation, everything)
|
|
513
|
+
- You can include multiple diff blocks for multiple changes
|
|
514
|
+
- Each diff block should have its own \`note\` attribute annotation
|
|
515
|
+
- Keep changes minimal and targeted
|
|
516
|
+
- Do NOT output the full file - only the diffs
|
|
517
|
+
- If clarification is needed, ask briefly before any diffs
|
|
518
|
+
|
|
519
|
+
## CRITICAL: Diff Marker Safety
|
|
520
|
+
- NEVER include the strings "<<<<<<< SEARCH", "=======", or ">>>>>>> REPLACE" inside your replacement code
|
|
521
|
+
- These are reserved markers for parsing the diff format
|
|
522
|
+
- If you need to show diff-like content, use alternative notation (e.g., "// old code" / "// new code")
|
|
523
|
+
- Malformed diff markers will cause the edit to fail
|
|
524
|
+
|
|
525
|
+
## Summary
|
|
526
|
+
After all diffs, provide a brief markdown summary of the changes made. Use formatting like:
|
|
527
|
+
- **Bold** for emphasis on key changes
|
|
528
|
+
- Bullet points for listing multiple changes
|
|
529
|
+
- Keep it concise (2-4 lines max)
|
|
530
|
+
- Do NOT include: a heading/title
|
|
531
|
+
`;
|
|
532
|
+
var PLAIN_PROMPT = `You are a helpful assistant.
|
|
533
|
+
|
|
534
|
+
{{tool_docs}}`;
|
|
535
|
+
var FALLBACK_PROMPTS = {
|
|
536
|
+
"chat-patchwork-widget": `---
|
|
537
|
+
patchwork:
|
|
538
|
+
compilers: {{compilers}}
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
${PATCHWORK_PROMPT}
|
|
542
|
+
|
|
543
|
+
{{tool_docs}}`,
|
|
544
|
+
"chat-plain": PLAIN_PROMPT,
|
|
545
|
+
"edit-patchwork-widget": `Current component code:
|
|
546
|
+
\`\`\`tsx
|
|
547
|
+
{{code}}
|
|
548
|
+
\`\`\`
|
|
549
|
+
|
|
550
|
+
${EDIT_PROMPT}`
|
|
551
|
+
};
|
|
552
|
+
var CHAT_PROMPT_ALLOWLIST = /* @__PURE__ */ new Set(["chat-patchwork-widget", "chat-plain"]);
|
|
553
|
+
var EDIT_PROMPT_ID = "edit-patchwork-widget";
|
|
554
|
+
|
|
555
|
+
// src/gateway-session.ts
|
|
556
|
+
var sessionCache2 = /* @__PURE__ */ new Map();
|
|
557
|
+
var toolsCache = /* @__PURE__ */ new Map();
|
|
558
|
+
function getCachedTools(sub) {
|
|
559
|
+
return toolsCache.get(sub);
|
|
560
|
+
}
|
|
561
|
+
function setCachedTools(sub, tools) {
|
|
562
|
+
toolsCache.set(sub, tools);
|
|
563
|
+
}
|
|
564
|
+
function gatewayUrl() {
|
|
565
|
+
const url = process.env["GATEWAY_URL"];
|
|
566
|
+
if (!url) throw new Error("GATEWAY_URL is not set");
|
|
567
|
+
return url.replace(/\/$/, "");
|
|
568
|
+
}
|
|
569
|
+
async function getGatewaySession(claims, workspaceId, cognitoToken) {
|
|
570
|
+
const sub = claims.sub;
|
|
571
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
572
|
+
const cached = sessionCache2.get(sub);
|
|
573
|
+
if (cached && cached.expires_at > now + 60) {
|
|
574
|
+
return cached;
|
|
575
|
+
}
|
|
576
|
+
const entry = await exchangeSession(cognitoToken, workspaceId, claims.exp);
|
|
577
|
+
sessionCache2.set(sub, entry);
|
|
578
|
+
return entry;
|
|
579
|
+
}
|
|
580
|
+
async function exchangeSession(cognitoToken, workspaceId, exp) {
|
|
581
|
+
let res;
|
|
582
|
+
try {
|
|
583
|
+
res = await callAuthSessions(cognitoToken, workspaceId);
|
|
584
|
+
} catch {
|
|
585
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
586
|
+
res = await callAuthSessions(cognitoToken, workspaceId);
|
|
587
|
+
}
|
|
588
|
+
if (res.status === 401) {
|
|
589
|
+
const retryRes = await callAuthSessions(cognitoToken, workspaceId);
|
|
590
|
+
if (!retryRes.ok) {
|
|
591
|
+
throw new GatewaySessionError(retryRes.status, await retryRes.text());
|
|
592
|
+
}
|
|
593
|
+
return { token: cognitoToken, expires_at: exp };
|
|
594
|
+
}
|
|
595
|
+
if (res.status >= 500) {
|
|
596
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
597
|
+
const retryRes = await callAuthSessions(cognitoToken, workspaceId);
|
|
598
|
+
if (!retryRes.ok) {
|
|
599
|
+
throw new GatewaySessionError(retryRes.status, await retryRes.text());
|
|
600
|
+
}
|
|
601
|
+
return { token: cognitoToken, expires_at: exp };
|
|
602
|
+
}
|
|
603
|
+
if (!res.ok) {
|
|
604
|
+
throw new GatewaySessionError(res.status, await res.text());
|
|
605
|
+
}
|
|
606
|
+
return { token: cognitoToken, expires_at: exp };
|
|
607
|
+
}
|
|
608
|
+
async function callAuthSessions(cognitoToken, workspaceId) {
|
|
609
|
+
return fetch(`${gatewayUrl()}/auth/sessions`, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
headers: {
|
|
612
|
+
"Content-Type": "application/json",
|
|
613
|
+
Authorization: `Bearer ${cognitoToken}`
|
|
614
|
+
},
|
|
615
|
+
body: JSON.stringify({ workspace_id: workspaceId })
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function evictGatewaySession(sub) {
|
|
619
|
+
sessionCache2.delete(sub);
|
|
620
|
+
toolsCache.delete(sub);
|
|
621
|
+
}
|
|
622
|
+
var GatewaySessionError = class extends Error {
|
|
623
|
+
constructor(status, message) {
|
|
624
|
+
super(`Gateway session exchange failed (${status}): ${message}`);
|
|
625
|
+
this.status = status;
|
|
626
|
+
this.name = "GatewaySessionError";
|
|
627
|
+
}
|
|
628
|
+
status;
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// src/posthog.ts
|
|
632
|
+
import { Prompts } from "@posthog/ai";
|
|
633
|
+
import { PostHog } from "posthog-node";
|
|
634
|
+
var _client = null;
|
|
635
|
+
var _prompts = null;
|
|
636
|
+
function initPostHog(env3) {
|
|
637
|
+
if (!env3.POSTHOG_PROJECT_API_KEY || !env3.POSTHOG_PERSONAL_API_KEY) return;
|
|
638
|
+
_client = new PostHog(env3.POSTHOG_PROJECT_API_KEY, {
|
|
639
|
+
host: env3.POSTHOG_HOST,
|
|
640
|
+
personalApiKey: env3.POSTHOG_PERSONAL_API_KEY
|
|
641
|
+
});
|
|
642
|
+
_prompts = new Prompts({ posthog: _client });
|
|
643
|
+
}
|
|
644
|
+
async function getPrompt(name, cacheTtlSeconds = 300) {
|
|
645
|
+
const fallback = FALLBACK_PROMPTS[name] ?? "";
|
|
646
|
+
if (!_prompts) {
|
|
647
|
+
return { source: "code_fallback", prompt: fallback, name: void 0, version: void 0 };
|
|
648
|
+
}
|
|
649
|
+
return _prompts.get(name, { cacheTtlSeconds, fallback });
|
|
650
|
+
}
|
|
651
|
+
function compilePrompt(template, vars) {
|
|
652
|
+
if (_prompts) {
|
|
653
|
+
return _prompts.compile(template, vars);
|
|
654
|
+
}
|
|
655
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
656
|
+
}
|
|
657
|
+
function getPostHogClient() {
|
|
658
|
+
return _client;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/providers/openrouter.ts
|
|
662
|
+
import {
|
|
663
|
+
SecretsManagerClient,
|
|
664
|
+
GetSecretValueCommand
|
|
665
|
+
} from "@aws-sdk/client-secrets-manager";
|
|
666
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
667
|
+
var secretsClient = null;
|
|
668
|
+
var cachedKey = null;
|
|
669
|
+
function getSecretsClient() {
|
|
670
|
+
if (!secretsClient) {
|
|
671
|
+
secretsClient = new SecretsManagerClient({
|
|
672
|
+
region: process.env["AWS_REGION"]
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return secretsClient;
|
|
676
|
+
}
|
|
677
|
+
async function getOpenRouterKey() {
|
|
678
|
+
if (cachedKey) return cachedKey;
|
|
679
|
+
const result = await getSecretsClient().send(
|
|
680
|
+
new GetSecretValueCommand({
|
|
681
|
+
SecretId: process.env["OPENROUTER_SECRET_ARN"]
|
|
682
|
+
})
|
|
683
|
+
);
|
|
684
|
+
cachedKey = result.SecretString;
|
|
685
|
+
return cachedKey;
|
|
686
|
+
}
|
|
687
|
+
function createOpenRouterProvider(apiKey) {
|
|
688
|
+
return createOpenAICompatible({
|
|
689
|
+
name: "openrouter",
|
|
690
|
+
apiKey,
|
|
691
|
+
baseURL: "https://openrouter.ai/api/v1"
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/tool-docs.ts
|
|
696
|
+
var _cachedToolDocs = "";
|
|
697
|
+
var _toolDocsCachedAt = 0;
|
|
698
|
+
var TOOL_DOCS_TTL_MS = 6e4;
|
|
699
|
+
async function getToolDocs(gateway) {
|
|
700
|
+
if (!gateway) return "";
|
|
701
|
+
const now = Date.now();
|
|
702
|
+
if (_cachedToolDocs && now - _toolDocsCachedAt < TOOL_DOCS_TTL_MS) {
|
|
703
|
+
return _cachedToolDocs;
|
|
704
|
+
}
|
|
705
|
+
const tools = await gateway.listTools();
|
|
706
|
+
_cachedToolDocs = renderToolDocs(tools);
|
|
707
|
+
_toolDocsCachedAt = now;
|
|
708
|
+
return _cachedToolDocs;
|
|
709
|
+
}
|
|
710
|
+
function renderToolDocs(tools) {
|
|
711
|
+
if (tools.length === 0) return "";
|
|
712
|
+
const byNamespace = /* @__PURE__ */ new Map();
|
|
713
|
+
for (const tool of tools) {
|
|
714
|
+
const existing = byNamespace.get(tool.namespace) ?? [];
|
|
715
|
+
existing.push(tool);
|
|
716
|
+
byNamespace.set(tool.namespace, existing);
|
|
717
|
+
}
|
|
718
|
+
const namespaces = [...byNamespace.keys()];
|
|
719
|
+
let doc = `## Services
|
|
720
|
+
|
|
721
|
+
The following services are available for generated widgets to call:
|
|
722
|
+
|
|
723
|
+
`;
|
|
724
|
+
for (const [ns, nsTools] of byNamespace) {
|
|
725
|
+
doc += `### \`${ns}\`
|
|
726
|
+
`;
|
|
727
|
+
for (const tool of nsTools) {
|
|
728
|
+
doc += `- \`${ns}.${tool.name}()\``;
|
|
729
|
+
if (tool.description) doc += `: ${tool.description}`;
|
|
730
|
+
doc += "\n";
|
|
731
|
+
}
|
|
732
|
+
doc += "\n";
|
|
733
|
+
}
|
|
734
|
+
const firstNs = namespaces[0] ?? "service";
|
|
735
|
+
const firstTool = byNamespace.get(firstNs)?.[0]?.name ?? "example";
|
|
736
|
+
doc += `**Usage in widgets:**
|
|
737
|
+
\`\`\`tsx
|
|
738
|
+
// Services are available as global namespaces
|
|
739
|
+
const result = await ${firstNs}.${firstTool}({ /* args */ });
|
|
740
|
+
\`\`\`
|
|
741
|
+
|
|
742
|
+
Make sure to handle loading states and errors when calling services.
|
|
743
|
+
`;
|
|
744
|
+
return doc;
|
|
745
|
+
}
|
|
746
|
+
function makeHttpGatewayClient(gatewayUrl2) {
|
|
747
|
+
return {
|
|
748
|
+
async listTools() {
|
|
749
|
+
const res = await fetch(`${gatewayUrl2}/tools`);
|
|
750
|
+
if (!res.ok) throw new Error(`Gateway returned ${res.status}`);
|
|
751
|
+
return res.json();
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// src/routes/chat.ts
|
|
757
|
+
var chatBodySchema = z.object({
|
|
758
|
+
id: z.string(),
|
|
759
|
+
messages: z.array(z.any()),
|
|
760
|
+
trigger: z.string(),
|
|
761
|
+
model: z.string().optional(),
|
|
762
|
+
metadata: z.unknown().optional(),
|
|
763
|
+
prompt: z.object({
|
|
764
|
+
id: z.string(),
|
|
765
|
+
vars: z.object({
|
|
766
|
+
compilers: z.array(z.string()).optional()
|
|
767
|
+
}).optional()
|
|
768
|
+
}).optional()
|
|
769
|
+
});
|
|
770
|
+
var retryAtStartMiddleware = {
|
|
771
|
+
specificationVersion: "v3",
|
|
772
|
+
async wrapStream({ doStream }) {
|
|
773
|
+
try {
|
|
774
|
+
return await doStream();
|
|
775
|
+
} catch {
|
|
776
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
777
|
+
return doStream();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
var chatRoute = new Hono();
|
|
782
|
+
async function fetchGatewayTools(gatewayUrl2, bearerToken) {
|
|
783
|
+
const res = await fetch(`${gatewayUrl2}/tools`, {
|
|
784
|
+
headers: { Authorization: `Bearer ${bearerToken}` }
|
|
785
|
+
});
|
|
786
|
+
if (!res.ok) return [];
|
|
787
|
+
const data = await res.json();
|
|
788
|
+
return data.tools ?? [];
|
|
789
|
+
}
|
|
790
|
+
function buildTools(gatewayTools, gatewayUrl2, bearerToken) {
|
|
791
|
+
const tools = {};
|
|
792
|
+
for (const t of gatewayTools) {
|
|
793
|
+
const toolKey = t.name.replace(/\./g, "_");
|
|
794
|
+
const rawSchema = t.inputSchema && typeof t.inputSchema === "object" ? t.inputSchema : { type: "object", properties: {} };
|
|
795
|
+
const parameters = jsonSchema(
|
|
796
|
+
rawSchema
|
|
797
|
+
);
|
|
798
|
+
const execute = async (args) => {
|
|
799
|
+
const res = await fetch(`${gatewayUrl2}/tools/${t.provider}/${t.operation}`, {
|
|
800
|
+
method: "POST",
|
|
801
|
+
headers: {
|
|
802
|
+
"Content-Type": "application/json",
|
|
803
|
+
Authorization: `Bearer ${bearerToken}`
|
|
804
|
+
},
|
|
805
|
+
body: JSON.stringify(args)
|
|
806
|
+
});
|
|
807
|
+
if (!res.ok) {
|
|
808
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
809
|
+
return { error: err.error ?? res.statusText };
|
|
810
|
+
}
|
|
811
|
+
return res.json();
|
|
812
|
+
};
|
|
813
|
+
tools[toolKey] = {
|
|
814
|
+
description: t.description ?? `Call ${t.name}`,
|
|
815
|
+
parameters,
|
|
816
|
+
execute
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
return tools;
|
|
820
|
+
}
|
|
821
|
+
chatRoute.post("/", async (c) => {
|
|
822
|
+
const body = await c.req.json().catch(() => null);
|
|
823
|
+
const parsed = chatBodySchema.safeParse(body);
|
|
824
|
+
if (!parsed.success) {
|
|
825
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
826
|
+
}
|
|
827
|
+
const { messages, model: requestedModel, prompt: promptBody } = parsed.data;
|
|
828
|
+
const claims = c.get("claims");
|
|
829
|
+
const workspaceId = c.get("workspaceId");
|
|
830
|
+
const workspace = c.get("workspace");
|
|
831
|
+
if (requestedModel !== void 0 && !workspace.limits.maxModels.includes(requestedModel)) {
|
|
832
|
+
return c.json(
|
|
833
|
+
{ error: "Model not allowed on your current plan", model: requestedModel },
|
|
834
|
+
403
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
const promptId = promptBody?.id ?? "chat-patchwork-widget";
|
|
838
|
+
if (!CHAT_PROMPT_ALLOWLIST.has(promptId)) {
|
|
839
|
+
return c.json({ error: "Unknown prompt id" }, 400);
|
|
840
|
+
}
|
|
841
|
+
const gatewayUrl2 = process.env["GATEWAY_URL"]?.replace(/\/$/, "");
|
|
842
|
+
let sessionToken = null;
|
|
843
|
+
if (gatewayUrl2) {
|
|
844
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
845
|
+
const cognitoToken = authHeader.replace(/^Bearer /, "");
|
|
846
|
+
try {
|
|
847
|
+
const session = await getGatewaySession(claims, workspaceId, cognitoToken);
|
|
848
|
+
sessionToken = session.token;
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
let gatewayTools = [];
|
|
853
|
+
if (sessionToken && gatewayUrl2) {
|
|
854
|
+
const cached = getCachedTools(claims.sub);
|
|
855
|
+
if (cached) {
|
|
856
|
+
gatewayTools = cached;
|
|
857
|
+
} else {
|
|
858
|
+
gatewayTools = await fetchGatewayTools(gatewayUrl2, sessionToken).catch(
|
|
859
|
+
() => []
|
|
860
|
+
);
|
|
861
|
+
if (gatewayTools.length === 0) {
|
|
862
|
+
evictGatewaySession(claims.sub);
|
|
863
|
+
} else {
|
|
864
|
+
setCachedTools(claims.sub, gatewayTools);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const tools = sessionToken && gatewayUrl2 ? buildTools(gatewayTools, gatewayUrl2, sessionToken) : {};
|
|
869
|
+
const gatewayClient = gatewayUrl2 ? makeHttpGatewayClient(gatewayUrl2) : null;
|
|
870
|
+
const [promptResult, toolDocs] = await Promise.all([
|
|
871
|
+
getPrompt(promptId),
|
|
872
|
+
getToolDocs(gatewayClient).catch(() => "")
|
|
873
|
+
]);
|
|
874
|
+
const compilers = (promptBody?.vars?.compilers ?? []).join(", ");
|
|
875
|
+
const systemPrompt = compilePrompt(promptResult.prompt, {
|
|
876
|
+
compilers,
|
|
877
|
+
tool_docs: toolDocs
|
|
878
|
+
});
|
|
879
|
+
const apiKey = await getOpenRouterKey();
|
|
880
|
+
const provider = createOpenRouterProvider(apiKey);
|
|
881
|
+
const modelId = requestedModel ?? workspace.limits.maxModels[0] ?? "openrouter/auto";
|
|
882
|
+
const baseModel = provider(modelId);
|
|
883
|
+
const phClient = getPostHogClient();
|
|
884
|
+
const tracedModel = phClient && promptResult.source !== "code_fallback" ? withTracing(baseModel, phClient, {
|
|
885
|
+
posthogDistinctId: claims.sub,
|
|
886
|
+
posthogProperties: {
|
|
887
|
+
$ai_prompt_name: promptResult.name,
|
|
888
|
+
$ai_prompt_version: promptResult.version
|
|
889
|
+
}
|
|
890
|
+
}) : baseModel;
|
|
891
|
+
const model = wrapLanguageModel({
|
|
892
|
+
model: tracedModel,
|
|
893
|
+
middleware: retryAtStartMiddleware
|
|
894
|
+
});
|
|
895
|
+
const result = streamText({
|
|
896
|
+
model,
|
|
897
|
+
system: systemPrompt,
|
|
898
|
+
messages: await convertToModelMessages(messages),
|
|
899
|
+
stopWhen: stepCountIs(workspace.limits.maxToolSteps),
|
|
900
|
+
maxOutputTokens: workspace.limits.maxTokensPerRequest,
|
|
901
|
+
tools
|
|
902
|
+
});
|
|
903
|
+
return result.toUIMessageStreamResponse();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// src/routes/edit.ts
|
|
907
|
+
import { withTracing as withTracing2 } from "@posthog/ai";
|
|
908
|
+
import { streamText as streamText2 } from "ai";
|
|
909
|
+
import { Hono as Hono2 } from "hono";
|
|
910
|
+
import { z as z2 } from "zod";
|
|
911
|
+
var editBodySchema = z2.object({
|
|
912
|
+
code: z2.string(),
|
|
913
|
+
prompt: z2.string()
|
|
914
|
+
});
|
|
915
|
+
var MODEL_ID = "openrouter/auto";
|
|
916
|
+
var editRoute = new Hono2();
|
|
917
|
+
editRoute.post("/", async (c) => {
|
|
918
|
+
const body = await c.req.json().catch(() => null);
|
|
919
|
+
const parsed = editBodySchema.safeParse(body);
|
|
920
|
+
if (!parsed.success) {
|
|
921
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
922
|
+
}
|
|
923
|
+
const promptResult = await getPrompt(EDIT_PROMPT_ID);
|
|
924
|
+
const systemPrompt = compilePrompt(promptResult.prompt, {
|
|
925
|
+
code: parsed.data.code
|
|
926
|
+
});
|
|
927
|
+
const apiKey = await getOpenRouterKey();
|
|
928
|
+
const provider = createOpenRouterProvider(apiKey);
|
|
929
|
+
const baseModel = provider(MODEL_ID);
|
|
930
|
+
const phClient = getPostHogClient();
|
|
931
|
+
const model = phClient && promptResult.source !== "code_fallback" ? withTracing2(baseModel, phClient, {
|
|
932
|
+
posthogDistinctId: "chat-api",
|
|
933
|
+
posthogProperties: {
|
|
934
|
+
$ai_prompt_name: promptResult.name,
|
|
935
|
+
$ai_prompt_version: promptResult.version
|
|
936
|
+
}
|
|
937
|
+
}) : baseModel;
|
|
938
|
+
const result = streamText2({
|
|
939
|
+
model,
|
|
940
|
+
system: systemPrompt,
|
|
941
|
+
messages: [{ role: "user", content: parsed.data.prompt }]
|
|
942
|
+
});
|
|
943
|
+
return result.toTextStreamResponse();
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// src/routes/health.ts
|
|
947
|
+
import { Hono as Hono3 } from "hono";
|
|
948
|
+
var health = new Hono3();
|
|
949
|
+
health.get("/health", (c) => c.json({ status: "ok" }));
|
|
950
|
+
|
|
951
|
+
// src/routes/proxy.ts
|
|
952
|
+
import { Hono as Hono4 } from "hono";
|
|
953
|
+
var proxy = new Hono4();
|
|
954
|
+
proxy.post("/:ns/:proc{.*}", async (c) => {
|
|
955
|
+
const claims = c.get("claims");
|
|
956
|
+
const workspaceId = c.get("workspaceId");
|
|
957
|
+
const ns = c.req.param("ns");
|
|
958
|
+
const proc = c.req.param("proc");
|
|
959
|
+
const authHeader = c.req.header("Authorization");
|
|
960
|
+
const cognitoToken = authHeader.slice("Bearer ".length);
|
|
961
|
+
let sessionToken;
|
|
962
|
+
try {
|
|
963
|
+
const session = await getGatewaySession(claims, workspaceId, cognitoToken);
|
|
964
|
+
sessionToken = session.token;
|
|
965
|
+
} catch (err) {
|
|
966
|
+
if (err instanceof GatewaySessionError && err.status === 401) {
|
|
967
|
+
return c.json({ error: "Gateway session setup failed" }, 401);
|
|
968
|
+
}
|
|
969
|
+
return c.json({ error: "Failed to connect to gateway" }, 502);
|
|
970
|
+
}
|
|
971
|
+
let body = {};
|
|
972
|
+
try {
|
|
973
|
+
body = await c.req.json();
|
|
974
|
+
} catch {
|
|
975
|
+
}
|
|
976
|
+
const gatewayUrl2 = process.env["GATEWAY_URL"].replace(/\/$/, "");
|
|
977
|
+
const res = await fetch(`${gatewayUrl2}/tools/${ns}/${proc}`, {
|
|
978
|
+
method: "POST",
|
|
979
|
+
headers: {
|
|
980
|
+
"Content-Type": "application/json",
|
|
981
|
+
Authorization: `Bearer ${sessionToken}`
|
|
982
|
+
},
|
|
983
|
+
body: JSON.stringify(body)
|
|
984
|
+
});
|
|
985
|
+
if (res.status === 401) {
|
|
986
|
+
evictGatewaySession(claims.sub);
|
|
987
|
+
return c.json({ error: "Gateway authentication failed" }, 502);
|
|
988
|
+
}
|
|
989
|
+
const responseData = await res.json();
|
|
990
|
+
return c.json(responseData, res.status);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// src/routes/services.ts
|
|
994
|
+
import { Hono as Hono5 } from "hono";
|
|
995
|
+
var services = new Hono5();
|
|
996
|
+
services.get("/", async (c) => {
|
|
997
|
+
const claims = c.get("claims");
|
|
998
|
+
const workspaceId = c.get("workspaceId");
|
|
999
|
+
const authHeader = c.req.header("Authorization");
|
|
1000
|
+
const cognitoToken = authHeader.slice("Bearer ".length);
|
|
1001
|
+
let sessionToken;
|
|
1002
|
+
try {
|
|
1003
|
+
const session = await getGatewaySession(claims, workspaceId, cognitoToken);
|
|
1004
|
+
sessionToken = session.token;
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
if (err instanceof GatewaySessionError && err.status === 401) {
|
|
1007
|
+
return c.json({ error: "Gateway session setup failed" }, 401);
|
|
1008
|
+
}
|
|
1009
|
+
return c.json({ error: "Failed to connect to gateway" }, 502);
|
|
1010
|
+
}
|
|
1011
|
+
const gatewayUrl2 = process.env["GATEWAY_URL"].replace(/\/$/, "");
|
|
1012
|
+
const res = await fetch(`${gatewayUrl2}/tools`, {
|
|
1013
|
+
headers: { Authorization: `Bearer ${sessionToken}` }
|
|
1014
|
+
});
|
|
1015
|
+
if (res.status === 401) {
|
|
1016
|
+
evictGatewaySession(claims.sub);
|
|
1017
|
+
return c.json({ error: "Gateway authentication failed" }, 502);
|
|
1018
|
+
}
|
|
1019
|
+
if (!res.ok) {
|
|
1020
|
+
return c.json({ error: "Gateway tools fetch failed" }, 502);
|
|
1021
|
+
}
|
|
1022
|
+
const data = await res.json();
|
|
1023
|
+
const serviceList = data.tools.map((t) => ({
|
|
1024
|
+
namespace: t.provider,
|
|
1025
|
+
name: t.name,
|
|
1026
|
+
procedure: t.operation,
|
|
1027
|
+
description: t.description ?? "",
|
|
1028
|
+
parameters: t.inputSchema
|
|
1029
|
+
}));
|
|
1030
|
+
const namespaces = Array.from(new Set(data.tools.map((t) => t.provider)));
|
|
1031
|
+
return c.json({ namespaces, services: serviceList });
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// src/routes/workspaces.ts
|
|
1035
|
+
import { DynamoDBClient as DynamoDBClient4 } from "@aws-sdk/client-dynamodb";
|
|
1036
|
+
import { BatchGetCommand, DynamoDBDocumentClient as DynamoDBDocumentClient4 } from "@aws-sdk/lib-dynamodb";
|
|
1037
|
+
import { zValidator } from "@hono/zod-validator";
|
|
1038
|
+
import { Hono as Hono6 } from "hono";
|
|
1039
|
+
import { z as z3 } from "zod";
|
|
1040
|
+
var ddbClient4 = null;
|
|
1041
|
+
function getDdb4() {
|
|
1042
|
+
if (!ddbClient4) {
|
|
1043
|
+
ddbClient4 = DynamoDBDocumentClient4.from(
|
|
1044
|
+
new DynamoDBClient4({ region: process.env["AWS_REGION"] })
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
return ddbClient4;
|
|
1048
|
+
}
|
|
1049
|
+
async function batchGetWorkspaces(workspaceIds) {
|
|
1050
|
+
if (workspaceIds.length === 0) return [];
|
|
1051
|
+
const tableName2 = process.env["WORKSPACE_TABLE_NAME"];
|
|
1052
|
+
const result = await getDdb4().send(
|
|
1053
|
+
new BatchGetCommand({
|
|
1054
|
+
RequestItems: {
|
|
1055
|
+
[tableName2]: { Keys: workspaceIds.map((id) => ({ workspaceId: id })) }
|
|
1056
|
+
}
|
|
1057
|
+
})
|
|
1058
|
+
);
|
|
1059
|
+
return result.Responses?.[tableName2] ?? [];
|
|
1060
|
+
}
|
|
1061
|
+
var workspacesRoute = new Hono6();
|
|
1062
|
+
workspacesRoute.get("/", async (c) => {
|
|
1063
|
+
const claims = c.get("claims");
|
|
1064
|
+
const userSub = claims.sub;
|
|
1065
|
+
const [workspaceIds, activeWorkspaceId] = await Promise.all([
|
|
1066
|
+
listWorkspaceMemberships(userSub),
|
|
1067
|
+
getSessionWorkspaceId(userSub)
|
|
1068
|
+
]);
|
|
1069
|
+
const workspaces = await batchGetWorkspaces(workspaceIds);
|
|
1070
|
+
const effectiveActiveId = activeWorkspaceId && workspaceIds.includes(activeWorkspaceId) ? activeWorkspaceId : workspaceIds[0] ?? null;
|
|
1071
|
+
const sorted = workspaceIds.map((id) => workspaces.find((w) => w.workspaceId === id)).filter((w) => w !== void 0);
|
|
1072
|
+
return c.json({
|
|
1073
|
+
workspaces: sorted.map((w) => ({
|
|
1074
|
+
workspaceId: w.workspaceId,
|
|
1075
|
+
name: w.name,
|
|
1076
|
+
plan: w.plan,
|
|
1077
|
+
active: w.workspaceId === effectiveActiveId
|
|
1078
|
+
})),
|
|
1079
|
+
activeWorkspaceId: effectiveActiveId
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
var setActiveSchema = z3.object({
|
|
1083
|
+
workspaceId: z3.string().min(1)
|
|
1084
|
+
});
|
|
1085
|
+
workspacesRoute.put(
|
|
1086
|
+
"/active",
|
|
1087
|
+
zValidator("json", setActiveSchema),
|
|
1088
|
+
async (c) => {
|
|
1089
|
+
const claims = c.get("claims");
|
|
1090
|
+
const userSub = claims.sub;
|
|
1091
|
+
const { workspaceId } = c.req.valid("json");
|
|
1092
|
+
const workspaceIds = await listWorkspaceMemberships(userSub);
|
|
1093
|
+
if (!workspaceIds.includes(workspaceId)) {
|
|
1094
|
+
return c.json({ error: "Not a member of this workspace" }, 403);
|
|
1095
|
+
}
|
|
1096
|
+
await setSessionWorkspaceId(userSub, workspaceId);
|
|
1097
|
+
return c.json({ activeWorkspaceId: workspaceId });
|
|
1098
|
+
}
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
// src/app.ts
|
|
1102
|
+
function createChatApp() {
|
|
1103
|
+
const app2 = new Hono7();
|
|
1104
|
+
app2.route("/", health);
|
|
1105
|
+
const api = app2.basePath("/api");
|
|
1106
|
+
api.use(authMiddleware, workspaceMiddleware, planMiddleware);
|
|
1107
|
+
api.route("/chat", chatRoute);
|
|
1108
|
+
api.route("/edit", editRoute);
|
|
1109
|
+
api.route("/services", services);
|
|
1110
|
+
api.route("/proxy", proxy);
|
|
1111
|
+
api.route("/workspaces", workspacesRoute);
|
|
1112
|
+
return app2;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// src/env.ts
|
|
1116
|
+
import { z as z4 } from "zod";
|
|
1117
|
+
var envSchema = z4.object({
|
|
1118
|
+
NODE_ENV: z4.enum(["development", "production", "test"]).default("development"),
|
|
1119
|
+
PORT: z4.coerce.number().default(3001),
|
|
1120
|
+
COGNITO_USER_POOL_ID: z4.string().min(1),
|
|
1121
|
+
COGNITO_CLIENT_ID: z4.string().min(1),
|
|
1122
|
+
AWS_REGION: z4.string().default("us-east-1"),
|
|
1123
|
+
WORKSPACE_TABLE_NAME: z4.string().min(1),
|
|
1124
|
+
MEMBERSHIPS_TABLE_NAME: z4.string().min(1),
|
|
1125
|
+
USER_SESSIONS_TABLE_NAME: z4.string().min(1),
|
|
1126
|
+
OPENROUTER_SECRET_ARN: z4.string().min(1),
|
|
1127
|
+
GATEWAY_URL: z4.string().url(),
|
|
1128
|
+
// PostHog prompt management (optional — falls back to code prompts when absent)
|
|
1129
|
+
POSTHOG_PROJECT_API_KEY: z4.string().optional(),
|
|
1130
|
+
POSTHOG_PERSONAL_API_KEY: z4.string().optional(),
|
|
1131
|
+
POSTHOG_HOST: z4.string().default("https://us.posthog.com")
|
|
1132
|
+
});
|
|
1133
|
+
function parseEnv(raw) {
|
|
1134
|
+
return envSchema.parse(raw);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/lambda.ts
|
|
1138
|
+
import { streamHandle } from "hono/aws-lambda";
|
|
1139
|
+
var env = parseEnv(process.env);
|
|
1140
|
+
initPostHog(env);
|
|
1141
|
+
var app = createChatApp();
|
|
1142
|
+
var handler = streamHandle(app);
|
|
1143
|
+
|
|
1144
|
+
// src/index.ts
|
|
1145
|
+
var env2 = parseEnv(process.env);
|
|
1146
|
+
initPostHog(env2);
|
|
1147
|
+
if (env2.NODE_ENV !== "test") {
|
|
1148
|
+
const app2 = createChatApp();
|
|
1149
|
+
serve({ fetch: app2.fetch, port: env2.PORT }, () => {
|
|
1150
|
+
console.log(`Chat API listening on :${env2.PORT}`);
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
export {
|
|
1154
|
+
createChatApp,
|
|
1155
|
+
handler,
|
|
1156
|
+
initPostHog
|
|
1157
|
+
};
|
|
1158
|
+
//# sourceMappingURL=index.js.map
|