@omnivoice/page-agent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@omnivoice/page-agent",
3
3
  "private": false,
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/esm/page-agent.js",
7
7
  "types": "./dist/esm/PageAgent.d.ts",
@@ -13,7 +13,8 @@
13
13
  }
14
14
  },
15
15
  "files": [
16
- "dist/"
16
+ "dist/",
17
+ "server/"
17
18
  ],
18
19
  "description": "GUI agent for web applications - add intelligent automation to any webpage with a single script",
19
20
  "keywords": [
@@ -39,10 +40,10 @@
39
40
  "postpublish": "node ../../scripts/post-publish.js"
40
41
  },
41
42
  "dependencies": {
42
- "@omnivoice/core": "0.1.0",
43
- "@omnivoice/llms": "0.1.0",
44
- "@omnivoice/page-controller": "0.1.0",
45
- "@omnivoice/ui": "0.1.0",
43
+ "@omnivoice/core": "0.2.0",
44
+ "@omnivoice/llms": "0.2.0",
45
+ "@omnivoice/page-controller": "0.2.0",
46
+ "@omnivoice/ui": "0.2.0",
46
47
  "chalk": "^6.0.0"
47
48
  },
48
49
  "peerDependencies": {
@@ -56,5 +57,8 @@
56
57
  "url": "git+https://github.com/SorPuti/page-agent-fork.git",
57
58
  "directory": "packages/page-agent"
58
59
  },
60
+ "bin": {
61
+ "omnivoice-agent-gateway": "server/agent-gateway.mjs"
62
+ },
59
63
  "module": "./dist/esm/page-agent.js"
60
64
  }
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FORK: gateway do agente — a casa da chave de API. O front-end NUNCA vê a chave: ele fala com
4
+ * este gateway (mesma origem do site do projeto) e o gateway injeta a chave ao chamar o modelo.
5
+ *
6
+ * Por que: colocar `apiKey` no HTML/JS expõe a chave a qualquer um que abrir o devtools. O
7
+ * padrão correto é: segredo só no servidor. Cada projeto roda o SEU gateway com a SUA config
8
+ * (modelo, URL do provedor, chave, voz), então o controle fica com o projeto, não com a página.
9
+ *
10
+ * Config (prioridade: env > arquivo JSON):
11
+ * AGENT_BASE_URL Endpoint OpenAI-compatible upstream. ex.: https://api.openai.com/v1
12
+ * AGENT_API_KEY Chave do provedor (SÓ aqui).
13
+ * AGENT_MODEL Modelo a usar. ex.: gpt-4o-mini
14
+ * AGENT_LANGUAGE pt-BR | en-US | zh-CN (default pt-BR)
15
+ * AGENT_MAX_STEPS Máx. de passos por tarefa (default 30)
16
+ * AGENT_VOICE_INPUT true/false — permite ditado no widget (default true)
17
+ * AGENT_VOICE_OUTPUT true/false — agente fala o resultado (default false)
18
+ * AGENT_VOICE_LANG Idioma da voz (default = AGENT_LANGUAGE)
19
+ * AGENT_PORT Porta do gateway (default 8790)
20
+ * AGENT_HOST Bind (default 127.0.0.1 — local; use 0.0.0.0 só atrás de proxy seguro)
21
+ * AGENT_CONFIG Caminho p/ um agent.config.json com os mesmos campos (case-insensitive).
22
+ *
23
+ * Endpoints:
24
+ * GET /config → config PÚBLICA (sem chave) p/ o widget montar.
25
+ * POST /v1/chat/completions → proxy p/ ${AGENT_BASE_URL}/chat/completions, injetando a chave.
26
+ * GET /health → { ok, configured, model }
27
+ *
28
+ * Uso:
29
+ * AGENT_API_KEY=sk-… AGENT_BASE_URL=https://api.openai.com/v1 AGENT_MODEL=gpt-4o-mini \
30
+ * node agent-gateway.mjs
31
+ * Ou: npm i @omnivoice/page-agent && npx omnivoice-agent-gateway (bin do pacote)
32
+ *
33
+ * Zero dependências (usa fetch global do Node 18+).
34
+ */
35
+ import { createServer } from 'node:http'
36
+ import { existsSync, readFileSync } from 'node:fs'
37
+ import { resolve } from 'node:path'
38
+
39
+ // ---- Config: env > arquivo JSON > default ----
40
+ function loadFileConfig() {
41
+ const p = process.env.AGENT_CONFIG
42
+ if (!p) return {}
43
+ const abs = resolve(process.cwd(), p)
44
+ if (!existsSync(abs)) {
45
+ console.warn(`[gateway] AGENT_CONFIG aponta p/ arquivo inexistente: ${abs}`)
46
+ return {}
47
+ }
48
+ try {
49
+ return JSON.parse(readFileSync(abs, 'utf8'))
50
+ } catch (e) {
51
+ console.warn(`[gateway] AGENT_CONFIG inválido (${abs}): ${e.message}`)
52
+ return {}
53
+ }
54
+ }
55
+
56
+ function envBool(v, def) {
57
+ if (v === undefined || v === '') return def
58
+ return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase())
59
+ }
60
+
61
+ const fileCfg = loadFileConfig()
62
+ const pick = (envKey, fileKey, def) => {
63
+ const fromEnv = process.env[envKey]
64
+ if (fromEnv !== undefined && fromEnv !== '') return fromEnv
65
+ const fromFile = fileCfg[fileKey]
66
+ if (fromFile !== undefined && fromFile !== null && fromFile !== '') return fromFile
67
+ return def
68
+ }
69
+
70
+ const config = {
71
+ baseURL: String(pick('AGENT_BASE_URL', 'baseURL', '')).replace(/\/+$/, ''),
72
+ apiKey: String(pick('AGENT_API_KEY', 'apiKey', '')),
73
+ model: String(pick('AGENT_MODEL', 'model', 'gpt-4o-mini')),
74
+ language: String(pick('AGENT_LANGUAGE', 'language', 'pt-BR')),
75
+ maxSteps: Number(pick('AGENT_MAX_STEPS', 'maxSteps', 30)) || 30,
76
+ voice: {
77
+ input: envBool(pick('AGENT_VOICE_INPUT', 'voiceInput', 'true'), true),
78
+ output: envBool(pick('AGENT_VOICE_OUTPUT', 'voiceOutput', 'false'), false),
79
+ lang: String(pick('AGENT_VOICE_LANG', 'voiceLang', '')) || undefined,
80
+ },
81
+ promptForNextTask: envBool(pick('AGENT_PROMPT_NEXT', 'promptForNextTask', 'true'), true),
82
+ port: Number(pick('AGENT_PORT', 'port', 8790)) || 8790,
83
+ host: String(pick('AGENT_HOST', 'host', '127.0.0.1')),
84
+ }
85
+
86
+ // ---- Helpers ----
87
+ function sendJson(res, status, obj) {
88
+ const body = JSON.stringify(obj)
89
+ res.writeHead(status, {
90
+ 'content-type': 'application/json; charset=utf-8',
91
+ 'content-length': Buffer.byteLength(body),
92
+ // Sem cache da config: o projeto pode trocar modelo/voz e querer efeito imediato.
93
+ 'cache-control': 'no-store',
94
+ })
95
+ res.end(body)
96
+ }
97
+
98
+ // Config pública (o que pode ir pro front). A chave NUNCA sai daqui.
99
+ function publicConfig() {
100
+ return {
101
+ model: config.model,
102
+ language: config.language,
103
+ maxSteps: config.maxSteps,
104
+ promptForNextTask: config.promptForNextTask,
105
+ voice: {
106
+ input: config.voice.input,
107
+ output: config.voice.output,
108
+ ...(config.voice.lang && { lang: config.voice.lang }),
109
+ },
110
+ }
111
+ }
112
+
113
+ // ---- Proxy de chat/completions ----
114
+ async function proxyCompletions(req, res) {
115
+ if (!config.baseURL) {
116
+ return sendJson(res, 503, { error: 'AGENT_BASE_URL não configurado no gateway' })
117
+ }
118
+ if (!config.apiKey) {
119
+ return sendJson(res, 503, { error: 'AGENT_API_KEY não configurada no gateway' })
120
+ }
121
+
122
+ let raw
123
+ try {
124
+ raw = await readBody(req)
125
+ } catch (e) {
126
+ return sendJson(res, 400, { error: `Corpo da request inválido: ${e.message}` })
127
+ }
128
+ let body
129
+ try {
130
+ body = raw ? JSON.parse(raw) : {}
131
+ } catch {
132
+ return sendJson(res, 400, { error: 'Corpo não é JSON válido' })
133
+ }
134
+
135
+ // O gateway é a autoridade do modelo: força o modelo configurado (o front não escolhe).
136
+ body.model = config.model
137
+
138
+ // Cabeçalhos: injeta a chave do servidor e DESCARTA qualquer Authorization do client,
139
+ // para que uma página comprometida não consiga usar outra chave/conta.
140
+ const headers = { 'content-type': 'application/json' }
141
+ if (config.apiKey) headers.authorization = `Bearer ${config.apiKey}`
142
+
143
+ let upstream
144
+ try {
145
+ upstream = await fetch(`${config.baseURL}/chat/completions`, {
146
+ method: 'POST',
147
+ headers,
148
+ body: JSON.stringify(body),
149
+ })
150
+ } catch (e) {
151
+ console.error('[gateway] falha ao chamar upstream:', e.message)
152
+ return sendJson(res, 502, { error: `Falha ao contatar o provedor: ${e.message}` })
153
+ }
154
+
155
+ // Encaminha o corpo (stream ou JSON) e o status, sem vazar headers do provedor.
156
+ const text = await upstream.text()
157
+ res.writeHead(upstream.status, {
158
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8',
159
+ 'cache-control': 'no-store',
160
+ })
161
+ res.end(text)
162
+ }
163
+
164
+ // Lê o corpo da request com limite (evita estouro de memória com payload gigante).
165
+ const MAX_BODY = 2 * 1024 * 1024
166
+ function readBody(req) {
167
+ return new Promise((resolveP, rejectP) => {
168
+ let size = 0
169
+ const chunks = []
170
+ req.on('data', c => {
171
+ size += c.length
172
+ if (size > MAX_BODY) {
173
+ rejectP(new Error('payload muito grande'))
174
+ req.destroy()
175
+ return
176
+ }
177
+ chunks.push(c)
178
+ })
179
+ req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
180
+ req.on('error', rejectP)
181
+ })
182
+ }
183
+
184
+ // ---- Servidor ----
185
+ const server = createServer(async (req, res) => {
186
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
187
+ const path = url.pathname
188
+
189
+ try {
190
+ if (req.method === 'GET' && path === '/config') {
191
+ return sendJson(res, 200, publicConfig())
192
+ }
193
+ if (req.method === 'GET' && path === '/health') {
194
+ return sendJson(res, 200, {
195
+ ok: true,
196
+ configured: Boolean(config.apiKey && config.baseURL),
197
+ model: config.model,
198
+ })
199
+ }
200
+ if (req.method === 'POST' && path === '/v1/chat/completions') {
201
+ return await proxyCompletions(req, res)
202
+ }
203
+ return sendJson(res, 404, { error: `Rota não encontrada: ${req.method} ${path}` })
204
+ } catch (e) {
205
+ console.error('[gateway] erro não tratado:', e)
206
+ if (!res.headersSent) sendJson(res, 500, { error: 'Erro interno do gateway' })
207
+ }
208
+ })
209
+
210
+ server.listen(config.port, config.host, () => {
211
+ console.log(`[gateway] agente ouvindo em http://${config.host}:${config.port}`)
212
+ console.log(`[gateway] model=${config.model} language=${config.language} voice=${JSON.stringify(config.voice)}`)
213
+ console.log(`[gateway] chave configurada: ${config.apiKey ? 'sim (mantida no servidor)' : 'NÃO — defina AGENT_API_KEY'}`)
214
+ console.log(`[gateway] upstream: ${config.baseURL || '(não definido)'}`)
215
+ })