@env-hopper/backend-core 2.0.1-alpha → 2.0.1-alpha.1

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.
Files changed (66) hide show
  1. package/dist/index.d.ts +1584 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +1806 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +26 -11
  6. package/prisma/migrations/20250526183023_init/migration.sql +71 -0
  7. package/prisma/migrations/migration_lock.toml +3 -0
  8. package/prisma/schema.prisma +121 -0
  9. package/src/db/client.ts +34 -0
  10. package/src/db/index.ts +17 -0
  11. package/src/db/syncAppCatalog.ts +67 -0
  12. package/src/db/tableSyncMagazine.ts +22 -0
  13. package/src/db/tableSyncPrismaAdapter.ts +202 -0
  14. package/src/index.ts +96 -3
  15. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  16. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  17. package/src/modules/appCatalog/service.ts +79 -0
  18. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +113 -0
  19. package/src/modules/assets/assetRestController.ts +309 -0
  20. package/src/modules/assets/assetUtils.ts +81 -0
  21. package/src/modules/assets/screenshotRestController.ts +195 -0
  22. package/src/modules/assets/screenshotRouter.ts +116 -0
  23. package/src/modules/assets/syncAssets.ts +261 -0
  24. package/src/modules/auth/auth.ts +51 -0
  25. package/src/modules/auth/authProviders.ts +108 -0
  26. package/src/modules/auth/authRouter.ts +77 -0
  27. package/src/modules/auth/authorizationUtils.ts +114 -0
  28. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  29. package/src/modules/icons/iconRestController.ts +190 -0
  30. package/src/modules/icons/iconRouter.ts +157 -0
  31. package/src/modules/icons/iconService.ts +73 -0
  32. package/src/server/controller.ts +102 -29
  33. package/src/server/ehStaticControllerContract.ts +8 -1
  34. package/src/server/ehTrpcContext.ts +0 -6
  35. package/src/types/backend/api.ts +1 -14
  36. package/src/types/backend/companySpecificBackend.ts +17 -0
  37. package/src/types/common/appCatalogTypes.ts +167 -0
  38. package/src/types/common/dataRootTypes.ts +72 -10
  39. package/src/types/index.ts +2 -0
  40. package/dist/esm/__tests__/dummy.test.d.ts +0 -1
  41. package/dist/esm/index.d.ts +0 -7
  42. package/dist/esm/index.js +0 -9
  43. package/dist/esm/index.js.map +0 -1
  44. package/dist/esm/server/controller.d.ts +0 -32
  45. package/dist/esm/server/controller.js +0 -35
  46. package/dist/esm/server/controller.js.map +0 -1
  47. package/dist/esm/server/db.d.ts +0 -2
  48. package/dist/esm/server/ehStaticControllerContract.d.ts +0 -9
  49. package/dist/esm/server/ehStaticControllerContract.js +0 -12
  50. package/dist/esm/server/ehStaticControllerContract.js.map +0 -1
  51. package/dist/esm/server/ehTrpcContext.d.ts +0 -8
  52. package/dist/esm/server/ehTrpcContext.js +0 -11
  53. package/dist/esm/server/ehTrpcContext.js.map +0 -1
  54. package/dist/esm/types/backend/api.d.ts +0 -71
  55. package/dist/esm/types/backend/common.d.ts +0 -9
  56. package/dist/esm/types/backend/dataSources.d.ts +0 -20
  57. package/dist/esm/types/backend/deployments.d.ts +0 -34
  58. package/dist/esm/types/common/app/appTypes.d.ts +0 -12
  59. package/dist/esm/types/common/app/ui/appUiTypes.d.ts +0 -10
  60. package/dist/esm/types/common/appCatalogTypes.d.ts +0 -16
  61. package/dist/esm/types/common/dataRootTypes.d.ts +0 -32
  62. package/dist/esm/types/common/env/envTypes.d.ts +0 -6
  63. package/dist/esm/types/common/resourceTypes.d.ts +0 -8
  64. package/dist/esm/types/common/sharedTypes.d.ts +0 -4
  65. package/dist/esm/types/index.d.ts +0 -11
  66. package/src/server/db.ts +0 -4
@@ -0,0 +1,67 @@
1
+ import type { AppForCatalog } from '../types/common/appCatalogTypes'
2
+ import { getDbClient } from './client'
3
+ import { TABLE_SYNC_MAGAZINE } from './tableSyncMagazine'
4
+ import { tableSyncPrisma } from './tableSyncPrismaAdapter'
5
+
6
+ export interface SyncAppCatalogResult {
7
+ created: number
8
+ updated: number
9
+ deleted: number
10
+ total: number
11
+ }
12
+
13
+ /**
14
+ * Syncs app catalog data to the database using table sync.
15
+ * This will create new apps, update existing ones, and delete any that are no longer in the input.
16
+ *
17
+ * Note: Call connectDb() before and disconnectDb() after if running in a script.
18
+ */
19
+ export async function syncAppCatalog(
20
+ apps: Array<AppForCatalog>,
21
+ ): Promise<SyncAppCatalogResult> {
22
+ const prisma = getDbClient()
23
+
24
+ const sync = tableSyncPrisma({
25
+ prisma,
26
+ ...TABLE_SYNC_MAGAZINE.DbAppForCatalog,
27
+ })
28
+
29
+ // Transform AppForCatalog to DbAppForCatalog format
30
+ const dbApps = apps.map((app) => {
31
+ const slug =
32
+ app.slug ||
33
+ app.displayName
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, '-')
36
+ .replace(/^-+|-+$/g, '')
37
+
38
+ return {
39
+ slug,
40
+ displayName: app.displayName,
41
+ description: app.description,
42
+ access: app.access,
43
+ teams: app.teams ?? [],
44
+ roles: app.roles ?? null,
45
+ approverName: app.approver?.name ?? null,
46
+ approverEmail: app.approver?.email ?? null,
47
+ notes: app.notes ?? null,
48
+ tags: app.tags ?? [],
49
+ appUrl: app.appUrl ?? null,
50
+ links: app.links ?? null,
51
+ iconName: app.iconName ?? null,
52
+ screenshotIds: app.screenshotIds ?? [],
53
+ }
54
+ })
55
+
56
+ const result = await sync.sync(dbApps)
57
+
58
+ // Get actual synced data to calculate stats
59
+ const actual = result.getActual()
60
+
61
+ return {
62
+ created: actual.length - apps.length + (apps.length - actual.length),
63
+ updated: 0, // TableSync doesn't expose this directly
64
+ deleted: 0, // TableSync doesn't expose this directly
65
+ total: actual.length,
66
+ }
67
+ }
@@ -0,0 +1,22 @@
1
+ import type { Prisma } from '@prisma/client'
2
+ import type { ObjectKeys, ScalarKeys } from './tableSyncPrismaAdapter'
3
+
4
+ interface CommonSyncTableInfo<TPrismaModelName extends Prisma.ModelName> {
5
+ prismaModelName: TPrismaModelName
6
+ uniqColumns: Array<ScalarKeys<TPrismaModelName>>
7
+ relationColumns?: Array<ObjectKeys<TPrismaModelName>>
8
+ }
9
+
10
+ type TableSyncMagazineType = Partial<{
11
+ [key in Prisma.ModelName]: CommonSyncTableInfo<key>
12
+ }>
13
+
14
+ export const TABLE_SYNC_MAGAZINE = {
15
+ DbAppForCatalog: {
16
+ prismaModelName: 'DbAppForCatalog',
17
+ uniqColumns: ['slug'],
18
+ },
19
+ } as const satisfies TableSyncMagazineType
20
+
21
+ export type TableSyncMagazine = typeof TABLE_SYNC_MAGAZINE
22
+ export type TableSyncMagazineModelNameKey = keyof TableSyncMagazine
@@ -0,0 +1,202 @@
1
+ import { tableSync } from '@env-hopper/table-sync'
2
+ import type { Prisma, PrismaClient } from '@prisma/client'
3
+ import type * as runtime from '@prisma/client/runtime/library'
4
+ import { mapValues, omit, pick } from 'radashi'
5
+
6
+ export type ScalarKeys<TPrismaModelName extends Prisma.ModelName> =
7
+ keyof Prisma.TypeMap['model'][TPrismaModelName]['payload']['scalars']
8
+ export type ObjectKeys<TPrismaModelName extends Prisma.ModelName> =
9
+ keyof Prisma.TypeMap['model'][TPrismaModelName]['payload']['objects']
10
+
11
+ export type ScalarFilter<TPrismaModelName extends Prisma.ModelName> = Partial<
12
+ Prisma.TypeMap['model'][TPrismaModelName]['payload']['scalars']
13
+ >
14
+
15
+ export type GetOperationFns<TModel extends Prisma.ModelName> = {
16
+ [TOperation in keyof Prisma.TypeMap['model']['DbAppForCatalog']['operations']]: (
17
+ args: Prisma.TypeMap['model'][TModel]['operations'][TOperation]['args'],
18
+ ) => Promise<
19
+ Prisma.TypeMap['model'][TModel]['operations'][TOperation]['result']
20
+ >
21
+ }
22
+
23
+ export interface TableSyncParamsPrisma<
24
+ TPrismaClient extends PrismaClient,
25
+ TPrismaModelName extends Prisma.ModelName,
26
+ TUniqColumns extends ReadonlyArray<ScalarKeys<TPrismaModelName>>,
27
+ TRelationColumns extends ReadonlyArray<ObjectKeys<TPrismaModelName>>,
28
+ > {
29
+ prisma: TPrismaClient
30
+ prismaModelName: TPrismaModelName
31
+ uniqColumns: TUniqColumns
32
+ relationColumns?: TRelationColumns
33
+ where?: ScalarFilter<TPrismaModelName>
34
+ upsertOnly?: boolean
35
+ }
36
+
37
+ function getPrismaModelOperations<
38
+ TPrismaClient extends Omit<PrismaClient, runtime.ITXClientDenyList>,
39
+ TPrismaModelName extends Prisma.ModelName,
40
+ >(prisma: TPrismaClient, prismaModelName: TPrismaModelName) {
41
+ const key = (prismaModelName.slice(0, 1).toLowerCase() +
42
+ prismaModelName.slice(1)) as keyof TPrismaClient
43
+ return prisma[key] as GetOperationFns<TPrismaModelName>
44
+ }
45
+
46
+ export type MakeTFromPrismaModel<TPrismaModelName extends Prisma.ModelName> =
47
+ NonNullable<
48
+ Prisma.TypeMap['model'][TPrismaModelName]['operations']['findUnique']['result']
49
+ >
50
+
51
+ export function tableSyncPrisma<
52
+ TPrismaClient extends PrismaClient,
53
+ TPrismaModelName extends Prisma.ModelName,
54
+ TUniqColumns extends ReadonlyArray<ScalarKeys<TPrismaModelName>>,
55
+ TRelationColumns extends ReadonlyArray<ObjectKeys<TPrismaModelName>>,
56
+ TId extends ScalarKeys<TPrismaModelName> & (string | number) = 'id',
57
+ >(
58
+ params: TableSyncParamsPrisma<
59
+ TPrismaClient,
60
+ TPrismaModelName,
61
+ TUniqColumns,
62
+ TRelationColumns
63
+ >,
64
+ ) {
65
+ const {
66
+ prisma,
67
+ prismaModelName,
68
+ uniqColumns,
69
+ where: whereGlobal,
70
+ upsertOnly,
71
+ } = params
72
+ const prismOperations = getPrismaModelOperations(prisma, prismaModelName)
73
+
74
+ // @ts-expect-error This is too difficult for me to come up with right types
75
+ return tableSync<MakeTFromPrismaModel<TPrismaModelName>, TUniqColumns, TId>({
76
+ id: 'id' as TId,
77
+ uniqColumns,
78
+ readAll: async () => {
79
+ const findManyArgs = whereGlobal
80
+ ? {
81
+ where: whereGlobal,
82
+ }
83
+ : {}
84
+ return (await prismOperations.findMany(findManyArgs)) as Array<
85
+ MakeTFromPrismaModel<TPrismaModelName>
86
+ >
87
+ },
88
+ writeAll: async (createData, update, deleteIds) => {
89
+ const prismaUniqKey = params.uniqColumns.join('_')
90
+ const relationColumnList =
91
+ params.relationColumns ?? ([] as Array<ObjectKeys<TPrismaModelName>>)
92
+
93
+ return prisma.$transaction(async (tx) => {
94
+ const txOps = getPrismaModelOperations(tx, prismaModelName)
95
+ for (const { data, where } of update) {
96
+ const uniqKeyWhere =
97
+ Object.keys(where).length > 1
98
+ ? {
99
+ [prismaUniqKey]: where,
100
+ }
101
+ : where
102
+
103
+ const dataScalar = omit(data, relationColumnList)
104
+ const dataRelations = mapValues(
105
+ pick(data, relationColumnList),
106
+ (value) => {
107
+ return {
108
+ set: value,
109
+ }
110
+ },
111
+ )
112
+
113
+ // @ts-expect-error This is too difficult for me to come up with right types
114
+ await txOps.update({
115
+ data: { ...dataScalar, ...dataRelations },
116
+ where: { ...uniqKeyWhere },
117
+ })
118
+ }
119
+
120
+ if (upsertOnly !== true) {
121
+ // @ts-expect-error This is too difficult for me to come up with right types
122
+ await txOps.deleteMany({
123
+ where: {
124
+ id: {
125
+ in: deleteIds,
126
+ },
127
+ },
128
+ })
129
+ }
130
+
131
+ // Validate uniqueness of uniqColumns before creating records
132
+ const createDataMapped = createData.map((data) => {
133
+ // @ts-expect-error This is too difficult for me to come up with right types
134
+ const dataScalar = omit(data, relationColumnList)
135
+
136
+ // @ts-expect-error This is too difficult for me to come up with right types
137
+ const onlyRelationColumns = pick(data, relationColumnList)
138
+ const dataRelations = mapValues(onlyRelationColumns, (value) => {
139
+ return {
140
+ connect: value,
141
+ }
142
+ })
143
+
144
+ return { ...dataScalar, ...dataRelations }
145
+ })
146
+
147
+ // Check for duplicates in the data to be created
148
+ if (createDataMapped.length > 0) {
149
+ const uniqKeysInCreate = new Set<string>()
150
+ const duplicateKeys: Array<string> = []
151
+
152
+ for (const data of createDataMapped) {
153
+ const keyParts = params.uniqColumns.map((col) => {
154
+ const value = data[col as keyof typeof data]
155
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive: unique columns may be nullable in schemas
156
+ return value === null || value === undefined
157
+ ? 'null'
158
+ : String(value)
159
+ })
160
+ const key = keyParts.join(':')
161
+
162
+ if (uniqKeysInCreate.has(key)) {
163
+ duplicateKeys.push(key)
164
+ } else {
165
+ uniqKeysInCreate.add(key)
166
+ }
167
+ }
168
+
169
+ if (duplicateKeys.length > 0) {
170
+ const uniqColumnsStr = params.uniqColumns.join(', ')
171
+ throw new Error(
172
+ `Duplicate unique key values found in data to be created. ` +
173
+ `Model: ${prismaModelName}, Unique columns: [${uniqColumnsStr}], ` +
174
+ `Duplicate keys: [${duplicateKeys.join(', ')}]`,
175
+ )
176
+ }
177
+ }
178
+
179
+ const results: Array<MakeTFromPrismaModel<TPrismaModelName>> = []
180
+
181
+ if (relationColumnList.length === 0) {
182
+ // @ts-expect-error This is too difficult for me to come up with right types
183
+ const batchResult = await txOps.createManyAndReturn({
184
+ data: createDataMapped,
185
+ })
186
+
187
+ results.push(...batchResult)
188
+ } else {
189
+ for (const dataMappedElement of createDataMapped) {
190
+ // @ts-expect-error too difficult for me
191
+ const newVar = await txOps.create({
192
+ data: dataMappedElement,
193
+ })
194
+ results.push(newVar as MakeTFromPrismaModel<TPrismaModelName>)
195
+ }
196
+ }
197
+
198
+ return results
199
+ })
200
+ },
201
+ })
202
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // common
2
2
 
3
- export { trpcRouter } from './server/controller'
3
+ export { createTrpcRouter } from './server/controller'
4
4
  export type { TRPCRouter } from './server/controller'
5
5
  export { createEhTrpcContext } from './server/ehTrpcContext'
6
6
  export type {
7
- EhTrpcContext,
8
- EhTrpcContextOptions,
7
+ EhTrpcContext,
8
+ EhTrpcContextOptions
9
9
  } from './server/ehTrpcContext'
10
10
 
11
11
  export { staticControllerContract } from './server/ehStaticControllerContract'
@@ -15,4 +15,97 @@ export type { EhStaticControllerContract } from './server/ehStaticControllerCont
15
15
 
16
16
  // backend-only
17
17
 
18
+ export type { AppForCatalog } from './types/common/appCatalogTypes'
18
19
  export * from './types/index'
20
+
21
+ // Auth
22
+ export {
23
+ createAuth,
24
+ type AuthConfig,
25
+ type BetterAuth
26
+ } from './modules/auth/auth'
27
+
28
+ export {
29
+ registerAuthRoutes
30
+ } from './modules/auth/registerAuthRoutes'
31
+
32
+ export {
33
+ createAuthRouter,
34
+ type AuthRouter
35
+ } from './modules/auth/authRouter'
36
+
37
+ export {
38
+ getAuthProvidersFromEnv,
39
+ getAuthPluginsFromEnv,
40
+ validateAuthConfig
41
+ } from './modules/auth/authProviders'
42
+
43
+ export {
44
+ getUserGroups,
45
+ isMemberOfAnyGroup,
46
+ isMemberOfAllGroups,
47
+ getAdminGroupsFromEnv,
48
+ isAdmin,
49
+ requireAdmin,
50
+ requireGroups,
51
+ type UserWithGroups
52
+ } from './modules/auth/authorizationUtils'
53
+
54
+ // Admin
55
+ export {
56
+ createAdminChatHandler,
57
+ tool,
58
+ type AdminChatHandlerOptions
59
+ } from './modules/admin/chat/createAdminChatHandler'
60
+
61
+ export {
62
+ createDatabaseTools,
63
+ createPrismaDatabaseClient, DEFAULT_ADMIN_SYSTEM_PROMPT, type DatabaseClient
64
+ } from './modules/admin/chat/createDatabaseTools'
65
+
66
+ // Icon management
67
+ export {
68
+ registerIconRestController,
69
+ type IconRestControllerConfig
70
+ } from './modules/icons/iconRestController'
71
+
72
+ export {
73
+ getAssetByName,
74
+ upsertIcon,
75
+ upsertIcons,
76
+ type UpsertIconInput
77
+ } from './modules/icons/iconService'
78
+
79
+ // Asset management (universal for icons, screenshots, etc.)
80
+ export {
81
+ registerAssetRestController,
82
+ type AssetRestControllerConfig
83
+ } from './modules/assets/assetRestController'
84
+
85
+ export {
86
+ registerScreenshotRestController,
87
+ type ScreenshotRestControllerConfig
88
+ } from './modules/assets/screenshotRestController'
89
+
90
+ export {
91
+ createScreenshotRouter
92
+ } from './modules/assets/screenshotRouter'
93
+
94
+ export {
95
+ syncAssets,
96
+ type SyncAssetsConfig
97
+ } from './modules/assets/syncAssets'
98
+
99
+ // App Catalog Admin
100
+ export {
101
+ createAppCatalogAdminRouter
102
+ } from './modules/appCatalogAdmin/appCatalogAdminRouter'
103
+
104
+ // Database utilities
105
+ export {
106
+ connectDb,
107
+ disconnectDb, getDbClient, syncAppCatalog, TABLE_SYNC_MAGAZINE, tableSyncPrisma, type MakeTFromPrismaModel, type ObjectKeys,
108
+ type ScalarFilter, type ScalarKeys, type SyncAppCatalogResult, type TableSyncMagazine,
109
+ type TableSyncMagazineModelNameKey, type TableSyncParamsPrisma
110
+ } from './db'
111
+
@@ -0,0 +1,152 @@
1
+ import { stepCountIs, streamText, tool } from 'ai'
2
+ import type { LanguageModel, Tool } from 'ai'
3
+
4
+ import type { Request, Response } from 'express'
5
+
6
+ export interface AdminChatHandlerOptions {
7
+ /** The AI model to use (from @ai-sdk/openai, @ai-sdk/anthropic, etc.) */
8
+ model: LanguageModel
9
+ /** System prompt for the AI assistant */
10
+ systemPrompt?: string
11
+ /** Tools available to the AI assistant */
12
+ tools?: Record<string, Tool>
13
+ /**
14
+ * Optional function to validate configuration before processing requests.
15
+ * Should throw an error if configuration is invalid (e.g., missing API key).
16
+ * @example
17
+ * validateConfig: () => {
18
+ * if (!process.env.OPENAI_API_KEY) {
19
+ * throw new Error('OPENAI_API_KEY is not configured')
20
+ * }
21
+ * }
22
+ */
23
+ validateConfig?: () => void
24
+ }
25
+
26
+ interface TextPart {
27
+ type: 'text'
28
+ text: string
29
+ }
30
+
31
+ interface UIMessageInput {
32
+ role: 'user' | 'assistant' | 'system'
33
+ content?: string
34
+ parts?: Array<TextPart | { type: string }>
35
+ }
36
+
37
+ interface CoreMessage {
38
+ role: 'user' | 'assistant' | 'system'
39
+ content: string
40
+ }
41
+
42
+ function convertToCoreMessages(
43
+ messages: Array<UIMessageInput>,
44
+ ): Array<CoreMessage> {
45
+ return messages.map((msg) => {
46
+ if (msg.content) {
47
+ return { role: msg.role, content: msg.content }
48
+ }
49
+ // Extract text from parts array (AI SDK v3 format)
50
+ const textContent =
51
+ msg.parts
52
+ ?.filter((part): part is TextPart => part.type === 'text')
53
+ .map((part) => part.text)
54
+ .join('') ?? ''
55
+ return { role: msg.role, content: textContent }
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Creates an Express handler for the admin chat endpoint.
61
+ *
62
+ * Usage in thin wrappers:
63
+ *
64
+ * ```typescript
65
+ * // With OpenAI
66
+ * import { openai } from '@ai-sdk/openai'
67
+ * app.post('/api/admin/chat', createAdminChatHandler({
68
+ * model: openai('gpt-4o-mini'),
69
+ * }))
70
+ *
71
+ * // With Claude
72
+ * import { anthropic } from '@ai-sdk/anthropic'
73
+ * app.post('/api/admin/chat', createAdminChatHandler({
74
+ * model: anthropic('claude-sonnet-4-20250514'),
75
+ * }))
76
+ * ```
77
+ */
78
+ export function createAdminChatHandler(options: AdminChatHandlerOptions) {
79
+ const {
80
+ model,
81
+ systemPrompt = 'You are a helpful admin assistant for the Env Hopper application. Help users manage apps, data sources, and MCP server configurations.',
82
+ tools = {},
83
+ validateConfig,
84
+ } = options
85
+
86
+ return async (req: Request, res: Response) => {
87
+ try {
88
+ // Validate configuration if validator provided
89
+ if (validateConfig) {
90
+ validateConfig()
91
+ }
92
+
93
+ const { messages } = req.body as { messages: Array<UIMessageInput> }
94
+ const coreMessages = convertToCoreMessages(messages)
95
+
96
+ console.log(
97
+ '[Admin Chat] Received messages:',
98
+ JSON.stringify(coreMessages, null, 2),
99
+ )
100
+ console.log('[Admin Chat] Available tools:', Object.keys(tools))
101
+
102
+ const result = streamText({
103
+ model,
104
+ system: systemPrompt,
105
+ messages: coreMessages,
106
+ tools,
107
+ // Allow up to 5 steps so the model can call tools and then generate a response
108
+ stopWhen: stepCountIs(5),
109
+ onFinish: (event) => {
110
+ console.log('[Admin Chat] Finished:', {
111
+ finishReason: event.finishReason,
112
+ usage: event.usage,
113
+ hasText: !!event.text,
114
+ textLength: event.text.length,
115
+ })
116
+ },
117
+ })
118
+
119
+ // Use UI message stream response which is compatible with AI SDK React hooks
120
+ const response = result.toUIMessageStreamResponse()
121
+
122
+ // Copy headers from the response
123
+ response.headers.forEach((value, key) => {
124
+ res.setHeader(key, value)
125
+ })
126
+
127
+ // Pipe the stream to the response
128
+ if (response.body) {
129
+ const reader = response.body.getReader()
130
+ const pump = async (): Promise<void> => {
131
+ const { done, value } = await reader.read()
132
+ if (done) {
133
+ res.end()
134
+ return
135
+ }
136
+ res.write(value)
137
+ return pump()
138
+ }
139
+ await pump()
140
+ } else {
141
+ console.error('[Admin Chat] No response body')
142
+ res.status(500).json({ error: 'No response from AI model' })
143
+ }
144
+ } catch (error) {
145
+ console.error('[Admin Chat] Error:', error)
146
+ res.status(500).json({ error: 'Failed to process chat request' })
147
+ }
148
+ }
149
+ }
150
+
151
+ // Re-export tool helper for convenience
152
+ export { tool }