@aprovan/stitchery 0.1.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/.turbo/turbo-build.log +24 -0
- package/LICENSE +373 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1224 -0
- package/dist/cli.js.map +1 -0
- package/dist/index-DNQY1UAP.d.ts +195 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1143 -0
- package/dist/index.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +1139 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +40 -0
- package/src/cli.ts +116 -0
- package/src/index.ts +16 -0
- package/src/prompts.ts +326 -0
- package/src/server/index.ts +365 -0
- package/src/server/local-packages.ts +91 -0
- package/src/server/routes.ts +122 -0
- package/src/server/services.ts +382 -0
- package/src/server/vfs-routes.ts +142 -0
- package/src/types.ts +59 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +15 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import { createMCPClient } from '@ai-sdk/mcp';
|
|
3
|
+
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';
|
|
4
|
+
import { createUtcpBackend } from '@aprovan/patchwork-utcp';
|
|
5
|
+
import { jsonSchema, type Tool } from 'ai';
|
|
6
|
+
import type { ServerConfig, McpServerConfig } from '../types.js';
|
|
7
|
+
import { handleChat, handleEdit, type RouteContext } from './routes.js';
|
|
8
|
+
import { handleLocalPackages } from './local-packages.js';
|
|
9
|
+
import { handleVFS, type VFSContext } from './vfs-routes.js';
|
|
10
|
+
import { ServiceRegistry, generateServicesPrompt } from './services.js';
|
|
11
|
+
|
|
12
|
+
export interface StitcheryServer {
|
|
13
|
+
server: Server;
|
|
14
|
+
registry: ServiceRegistry;
|
|
15
|
+
start(): Promise<{ port: number; host: string }>;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function initMcpTools(
|
|
20
|
+
configs: McpServerConfig[],
|
|
21
|
+
registry: ServiceRegistry,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
for (const config of configs) {
|
|
24
|
+
const client = await createMCPClient({
|
|
25
|
+
transport: new Experimental_StdioMCPTransport({
|
|
26
|
+
command: config.command,
|
|
27
|
+
args: config.args,
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
// Use MCP server name as namespace for all tools from this server
|
|
31
|
+
registry.registerTools(await client.tools(), config.name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const searchServicesSchema = {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
query: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description:
|
|
41
|
+
'Natural language description of what you want to do (e.g., "get weather forecast", "list github repos")',
|
|
42
|
+
},
|
|
43
|
+
namespace: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
description:
|
|
46
|
+
'Filter results to a specific service namespace (e.g., "weather", "github")',
|
|
47
|
+
},
|
|
48
|
+
tool_name: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Get detailed info about a specific tool by name',
|
|
51
|
+
},
|
|
52
|
+
limit: {
|
|
53
|
+
type: 'number',
|
|
54
|
+
description: 'Maximum number of results to return',
|
|
55
|
+
default: 10,
|
|
56
|
+
},
|
|
57
|
+
include_interfaces: {
|
|
58
|
+
type: 'boolean',
|
|
59
|
+
description: 'Include TypeScript interface definitions in results',
|
|
60
|
+
default: true,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
interface SearchServicesArgs {
|
|
66
|
+
query?: string;
|
|
67
|
+
namespace?: string;
|
|
68
|
+
tool_name?: string;
|
|
69
|
+
limit?: number;
|
|
70
|
+
include_interfaces?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create the search_services tool for LLM use
|
|
75
|
+
*/
|
|
76
|
+
function createSearchServicesTool(registry: ServiceRegistry): Tool {
|
|
77
|
+
return {
|
|
78
|
+
description: `Search for available services/tools. Use this to discover what APIs are available for widgets to call.
|
|
79
|
+
|
|
80
|
+
Returns matching services with their TypeScript interfaces. Use when:
|
|
81
|
+
- You need to find a service to accomplish a task
|
|
82
|
+
- You want to explore available APIs in a namespace
|
|
83
|
+
- You need the exact interface/parameters for a service call`,
|
|
84
|
+
inputSchema: jsonSchema<SearchServicesArgs>(searchServicesSchema),
|
|
85
|
+
execute: async (args: SearchServicesArgs) => {
|
|
86
|
+
// If requesting specific tool info
|
|
87
|
+
if (args.tool_name) {
|
|
88
|
+
const info = registry.getToolInfo(args.tool_name);
|
|
89
|
+
if (!info) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: `Tool '${args.tool_name}' not found`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { success: true, tool: info };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Search for tools
|
|
99
|
+
const results = registry.searchServices({
|
|
100
|
+
query: args.query,
|
|
101
|
+
namespace: args.namespace,
|
|
102
|
+
limit: args.limit ?? 10,
|
|
103
|
+
includeInterfaces: args.include_interfaces ?? true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
success: true,
|
|
108
|
+
count: results.length,
|
|
109
|
+
tools: results,
|
|
110
|
+
namespaces: registry.getNamespaces(),
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseBody<T>(req: import('node:http').IncomingMessage): Promise<T> {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
let body = '';
|
|
119
|
+
req.on('data', (chunk) => (body += chunk));
|
|
120
|
+
req.on('end', () => {
|
|
121
|
+
try {
|
|
122
|
+
resolve(JSON.parse(body));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
reject(err);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
req.on('error', reject);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function createStitcheryServer(
|
|
132
|
+
config: Partial<ServerConfig> = {},
|
|
133
|
+
): Promise<StitcheryServer> {
|
|
134
|
+
const {
|
|
135
|
+
port = 6434,
|
|
136
|
+
host = '127.0.0.1',
|
|
137
|
+
copilotProxyUrl = 'http://127.0.0.1:6433/v1',
|
|
138
|
+
localPackages = {},
|
|
139
|
+
mcpServers = [],
|
|
140
|
+
utcp,
|
|
141
|
+
vfsDir,
|
|
142
|
+
vfsUsePaths = false,
|
|
143
|
+
verbose = false,
|
|
144
|
+
} = config;
|
|
145
|
+
|
|
146
|
+
const log = verbose
|
|
147
|
+
? (...args: unknown[]) => console.log('[stitchery]', ...args)
|
|
148
|
+
: () => {};
|
|
149
|
+
|
|
150
|
+
// Create service registry
|
|
151
|
+
const registry = new ServiceRegistry();
|
|
152
|
+
|
|
153
|
+
log('Initializing MCP tools...');
|
|
154
|
+
await initMcpTools(mcpServers, registry);
|
|
155
|
+
log(`Loaded ${registry.size} tools from ${mcpServers.length} MCP servers`);
|
|
156
|
+
|
|
157
|
+
// Initialize UTCP backend if config provided
|
|
158
|
+
if (utcp) {
|
|
159
|
+
log('Initializing UTCP backend...');
|
|
160
|
+
log('UTCP config:', JSON.stringify(utcp, null, 2));
|
|
161
|
+
try {
|
|
162
|
+
// Cast to unknown since createUtcpBackend uses UtcpClientConfigSerializer to validate
|
|
163
|
+
const { backend, toolInfos } = await createUtcpBackend(
|
|
164
|
+
utcp as unknown as Parameters<typeof createUtcpBackend>[0],
|
|
165
|
+
utcp.cwd,
|
|
166
|
+
);
|
|
167
|
+
registry.registerBackend(backend, toolInfos);
|
|
168
|
+
log(
|
|
169
|
+
`Registered UTCP backend with ${toolInfos.length} tools:`,
|
|
170
|
+
toolInfos.map((t) => t.name).join(', '),
|
|
171
|
+
);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error('[stitchery] Failed to initialize UTCP backend:', err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
log('Local packages:', localPackages);
|
|
178
|
+
|
|
179
|
+
// Create internal tools (search_services, etc.)
|
|
180
|
+
const internalTools = {
|
|
181
|
+
search_services: createSearchServicesTool(registry),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Combine MCP tools with internal tools
|
|
185
|
+
const allTools = { ...registry.getTools(), ...internalTools };
|
|
186
|
+
|
|
187
|
+
const routeCtx: RouteContext = {
|
|
188
|
+
copilotProxyUrl,
|
|
189
|
+
tools: allTools,
|
|
190
|
+
registry,
|
|
191
|
+
servicesPrompt: generateServicesPrompt(registry),
|
|
192
|
+
log,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const localPkgCtx = { localPackages, log };
|
|
196
|
+
|
|
197
|
+
const vfsCtx: VFSContext | null = vfsDir
|
|
198
|
+
? { rootDir: vfsDir, usePaths: vfsUsePaths, log }
|
|
199
|
+
: null;
|
|
200
|
+
|
|
201
|
+
const server = createServer(async (req, res) => {
|
|
202
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
203
|
+
res.setHeader(
|
|
204
|
+
'Access-Control-Allow-Methods',
|
|
205
|
+
'GET, POST, PUT, DELETE, HEAD, OPTIONS',
|
|
206
|
+
);
|
|
207
|
+
res.setHeader(
|
|
208
|
+
'Access-Control-Allow-Headers',
|
|
209
|
+
'Content-Type, Authorization',
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (req.method === 'OPTIONS') {
|
|
213
|
+
res.writeHead(204);
|
|
214
|
+
res.end();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const url = req.url || '/';
|
|
219
|
+
log(`${req.method} ${url}`);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (handleLocalPackages(req, res, localPkgCtx)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (vfsCtx && handleVFS(req, res, vfsCtx)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (url === '/api/chat' && req.method === 'POST') {
|
|
231
|
+
await handleChat(req, res, routeCtx);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (url === '/api/edit' && req.method === 'POST') {
|
|
236
|
+
await handleEdit(req, res, routeCtx);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Service proxy endpoint for widgets
|
|
241
|
+
const proxyMatch = url.match(/^\/api\/proxy\/([^/]+)\/(.+)$/);
|
|
242
|
+
if (proxyMatch && req.method === 'POST') {
|
|
243
|
+
const [, namespace, procedure] = proxyMatch;
|
|
244
|
+
try {
|
|
245
|
+
const body = await parseBody<{ args?: unknown }>(req);
|
|
246
|
+
const result = await registry.call(
|
|
247
|
+
namespace!,
|
|
248
|
+
procedure!,
|
|
249
|
+
body.args ?? {},
|
|
250
|
+
);
|
|
251
|
+
res.setHeader('Content-Type', 'application/json');
|
|
252
|
+
res.writeHead(200);
|
|
253
|
+
res.end(JSON.stringify(result));
|
|
254
|
+
} catch (err) {
|
|
255
|
+
log('Proxy error:', err);
|
|
256
|
+
res.setHeader('Content-Type', 'application/json');
|
|
257
|
+
res.writeHead(500);
|
|
258
|
+
res.end(
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
error: err instanceof Error ? err.message : 'Service call failed',
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Services search endpoint (POST with body for complex queries)
|
|
268
|
+
if (url === '/api/services/search' && req.method === 'POST') {
|
|
269
|
+
const body = await parseBody<{
|
|
270
|
+
query?: string;
|
|
271
|
+
namespace?: string;
|
|
272
|
+
tool_name?: string;
|
|
273
|
+
limit?: number;
|
|
274
|
+
include_interfaces?: boolean;
|
|
275
|
+
}>(req);
|
|
276
|
+
|
|
277
|
+
res.setHeader('Content-Type', 'application/json');
|
|
278
|
+
res.writeHead(200);
|
|
279
|
+
|
|
280
|
+
if (body.tool_name) {
|
|
281
|
+
const info = registry.getToolInfo(body.tool_name);
|
|
282
|
+
if (!info) {
|
|
283
|
+
res.end(
|
|
284
|
+
JSON.stringify({
|
|
285
|
+
success: false,
|
|
286
|
+
error: `Tool '${body.tool_name}' not found`,
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
res.end(JSON.stringify({ success: true, tool: info }));
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const results = registry.searchServices({
|
|
296
|
+
query: body.query,
|
|
297
|
+
namespace: body.namespace,
|
|
298
|
+
limit: body.limit ?? 20,
|
|
299
|
+
includeInterfaces: body.include_interfaces ?? false,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
res.end(
|
|
303
|
+
JSON.stringify({
|
|
304
|
+
success: true,
|
|
305
|
+
count: results.length,
|
|
306
|
+
tools: results,
|
|
307
|
+
namespaces: registry.getNamespaces(),
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Services metadata endpoint
|
|
314
|
+
if (url === '/api/services' && req.method === 'GET') {
|
|
315
|
+
res.setHeader('Content-Type', 'application/json');
|
|
316
|
+
res.writeHead(200);
|
|
317
|
+
res.end(
|
|
318
|
+
JSON.stringify({
|
|
319
|
+
namespaces: registry.getNamespaces(),
|
|
320
|
+
services: registry.getServiceInfo(),
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (url === '/health' || url === '/') {
|
|
327
|
+
res.setHeader('Content-Type', 'application/json');
|
|
328
|
+
res.writeHead(200);
|
|
329
|
+
res.end(JSON.stringify({ status: 'ok', service: 'stitchery' }));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
res.writeHead(404);
|
|
334
|
+
res.end(`Not found: ${url}`);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
log('Error:', err);
|
|
337
|
+
res.writeHead(500);
|
|
338
|
+
res.end(err instanceof Error ? err.message : 'Internal server error');
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
server,
|
|
344
|
+
registry,
|
|
345
|
+
|
|
346
|
+
async start() {
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
server.on('error', reject);
|
|
349
|
+
server.listen(port, host, () => {
|
|
350
|
+
log(`Server listening on http://${host}:${port}`);
|
|
351
|
+
resolve({ port, host });
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
async stop() {
|
|
357
|
+
return new Promise((resolve, reject) => {
|
|
358
|
+
server.close((err) => {
|
|
359
|
+
if (err) reject(err);
|
|
360
|
+
else resolve();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export interface LocalPackagesContext {
|
|
6
|
+
localPackages: Record<string, string>;
|
|
7
|
+
log: (...args: unknown[]) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function handleLocalPackages(
|
|
11
|
+
req: IncomingMessage,
|
|
12
|
+
res: ServerResponse,
|
|
13
|
+
ctx: LocalPackagesContext,
|
|
14
|
+
): boolean {
|
|
15
|
+
const rawUrl = req.url || '';
|
|
16
|
+
|
|
17
|
+
// Only handle /_local-packages routes
|
|
18
|
+
if (!rawUrl.startsWith('/_local-packages')) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const urlWithoutPrefix = rawUrl.replace('/_local-packages', '');
|
|
23
|
+
|
|
24
|
+
// Strip query string (bundlers add ?import to dynamic imports)
|
|
25
|
+
const url = urlWithoutPrefix.split('?')[0] || '';
|
|
26
|
+
|
|
27
|
+
// Parse the package name from URL (handles scoped packages like @scope/name)
|
|
28
|
+
const match = url.match(/^\/@([^/]+)\/([^/@]+)(.*)$/);
|
|
29
|
+
if (!match) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const [, scope, name, restPath] = match;
|
|
34
|
+
const packageName = `@${scope}/${name}`;
|
|
35
|
+
const localPath = ctx.localPackages[packageName];
|
|
36
|
+
|
|
37
|
+
if (!localPath) {
|
|
38
|
+
res.writeHead(404);
|
|
39
|
+
res.end(`Package ${packageName} not found in local overrides`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Determine what file to serve
|
|
44
|
+
const rest = restPath || '';
|
|
45
|
+
let filePath: string;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
if (rest === '/package.json') {
|
|
49
|
+
filePath = path.join(localPath, 'package.json');
|
|
50
|
+
} else if (rest === '' || rest === '/') {
|
|
51
|
+
const pkgJson = JSON.parse(
|
|
52
|
+
fs.readFileSync(path.join(localPath, 'package.json'), 'utf-8'),
|
|
53
|
+
);
|
|
54
|
+
const mainEntry = pkgJson.main || 'dist/index.js';
|
|
55
|
+
filePath = path.join(localPath, mainEntry);
|
|
56
|
+
} else {
|
|
57
|
+
const normalizedPath = rest.startsWith('/') ? rest.slice(1) : rest;
|
|
58
|
+
const distPath = path.join(localPath, 'dist', normalizedPath);
|
|
59
|
+
const rootPath = path.join(localPath, normalizedPath);
|
|
60
|
+
filePath = fs.existsSync(distPath) ? distPath : rootPath;
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
ctx.log('Error resolving file path:', err);
|
|
64
|
+
res.writeHead(500);
|
|
65
|
+
res.end(`Error resolving path for ${packageName}: ${err}`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
ctx.log(`Serving ${filePath}`);
|
|
71
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
72
|
+
const ext = path.extname(filePath);
|
|
73
|
+
const contentType =
|
|
74
|
+
ext === '.json'
|
|
75
|
+
? 'application/json'
|
|
76
|
+
: ext === '.js'
|
|
77
|
+
? 'application/javascript'
|
|
78
|
+
: ext === '.ts'
|
|
79
|
+
? 'application/typescript'
|
|
80
|
+
: 'text/plain';
|
|
81
|
+
res.setHeader('Content-Type', contentType);
|
|
82
|
+
res.writeHead(200);
|
|
83
|
+
res.end(content);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
ctx.log('Error serving file:', filePath, err);
|
|
86
|
+
res.writeHead(404);
|
|
87
|
+
res.end(`File not found: ${filePath}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
3
|
+
import {
|
|
4
|
+
streamText,
|
|
5
|
+
convertToModelMessages,
|
|
6
|
+
stepCountIs,
|
|
7
|
+
type UIMessage,
|
|
8
|
+
type Tool,
|
|
9
|
+
} from 'ai';
|
|
10
|
+
import { PATCHWORK_PROMPT, EDIT_PROMPT } from '../prompts.js';
|
|
11
|
+
import type { ServiceRegistry } from './services.js';
|
|
12
|
+
|
|
13
|
+
export interface RouteContext {
|
|
14
|
+
copilotProxyUrl: string;
|
|
15
|
+
tools: Record<string, Tool>;
|
|
16
|
+
registry: ServiceRegistry;
|
|
17
|
+
servicesPrompt: string;
|
|
18
|
+
log: (...args: unknown[]) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseBody<T>(req: IncomingMessage): Promise<T> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let body = '';
|
|
24
|
+
req.on('data', (chunk) => (body += chunk));
|
|
25
|
+
req.on('end', () => {
|
|
26
|
+
try {
|
|
27
|
+
resolve(JSON.parse(body));
|
|
28
|
+
} catch (err) {
|
|
29
|
+
reject(err);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
req.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function handleChat(
|
|
37
|
+
req: IncomingMessage,
|
|
38
|
+
res: ServerResponse,
|
|
39
|
+
ctx: RouteContext,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const {
|
|
42
|
+
messages,
|
|
43
|
+
metadata,
|
|
44
|
+
}: {
|
|
45
|
+
messages: UIMessage[];
|
|
46
|
+
metadata?: { patchwork?: { compilers?: string[] } };
|
|
47
|
+
} = await parseBody(req);
|
|
48
|
+
|
|
49
|
+
const normalizedMessages = messages.map((msg) => ({
|
|
50
|
+
...msg,
|
|
51
|
+
parts: msg.parts ?? [{ type: 'text' as const, text: '' }],
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const provider = createOpenAICompatible({
|
|
55
|
+
name: 'copilot-proxy',
|
|
56
|
+
baseURL: ctx.copilotProxyUrl,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const result = streamText({
|
|
60
|
+
model: provider('claude-sonnet-4'),
|
|
61
|
+
system: `---\npatchwork:\n compilers: ${
|
|
62
|
+
(metadata?.patchwork?.compilers ?? []).join(',') ?? '[]'
|
|
63
|
+
}\n services: ${ctx.registry
|
|
64
|
+
.getNamespaces()
|
|
65
|
+
.join(',')}\n---\n\n${PATCHWORK_PROMPT}\n\n${ctx.servicesPrompt}`,
|
|
66
|
+
messages: await convertToModelMessages(normalizedMessages),
|
|
67
|
+
stopWhen: stepCountIs(5),
|
|
68
|
+
tools: ctx.tools,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const response = result.toUIMessageStreamResponse();
|
|
72
|
+
response.headers.forEach((value: string, key: string) =>
|
|
73
|
+
res.setHeader(key, value),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!response.body) {
|
|
77
|
+
res.end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const reader = response.body.getReader();
|
|
82
|
+
const pump = async () => {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) {
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
res.write(value);
|
|
89
|
+
await pump();
|
|
90
|
+
};
|
|
91
|
+
await pump();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function handleEdit(
|
|
95
|
+
req: IncomingMessage,
|
|
96
|
+
res: ServerResponse,
|
|
97
|
+
ctx: RouteContext,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const { code, prompt }: { code: string; prompt: string } = await parseBody(
|
|
100
|
+
req,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const provider = createOpenAICompatible({
|
|
104
|
+
name: 'copilot-proxy',
|
|
105
|
+
baseURL: ctx.copilotProxyUrl,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = streamText({
|
|
109
|
+
model: provider('claude-opus-4.5'),
|
|
110
|
+
system: `Current component code:\n\`\`\`tsx\n${code}\n\`\`\`\n\n${EDIT_PROMPT}`,
|
|
111
|
+
messages: [{ role: 'user', content: prompt }],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
115
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
116
|
+
res.writeHead(200);
|
|
117
|
+
|
|
118
|
+
for await (const chunk of result.textStream) {
|
|
119
|
+
res.write(chunk);
|
|
120
|
+
}
|
|
121
|
+
res.end();
|
|
122
|
+
}
|