@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/README.md +69 -3
- package/dist/esm/PageAgent.d.ts +134 -0
- package/dist/esm/page-agent.js +397 -1
- package/dist/esm/page-agent.js.map +1 -1
- package/dist/iife/page-agent.demo.js +4 -4
- package/package.json +10 -6
- package/server/agent-gateway.mjs +215 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Page Agent — fork `@omnivoice`
|
|
2
2
|
|
|
3
|
-
> **Fork publicado como [`@omnivoice/*`](https://www.npmjs.com/org/omnivoice) (v0.
|
|
3
|
+
> **Fork publicado como [`@omnivoice/*`](https://www.npmjs.com/org/omnivoice) (v0.2.0), a partir do
|
|
4
4
|
> [page-agent](https://github.com/alibaba/page-agent) upstream `v1.12.4` (commit `9eb6b66`), MIT.**
|
|
5
5
|
|
|
6
6
|
## O que este fork muda
|
|
@@ -27,7 +27,73 @@ lista o conjunto completo). As principais:
|
|
|
27
27
|
| `@omnivoice/page-controller` | Operações de DOM e interação com elementos |
|
|
28
28
|
| `@omnivoice/ui` | Componentes de UI (painel e i18n) |
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Widget central + voz (v0.2.0)
|
|
31
|
+
|
|
32
|
+
**Um único ponto de integração** para qualquer projeto: `mountWidget(config)` centraliza
|
|
33
|
+
agente + painel + voz + configuração num objeto só. O agente é criado **lazy** (a página não
|
|
34
|
+
paga startup até o widget abrir), e a voz (reconhecimento + síntese, pt-BR) usa a Web Speech API
|
|
35
|
+
nativa — degrada graciosamente se o navegador não tiver mic/voz.
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { mountWidget } from "@omnivoice/page-agent";
|
|
39
|
+
|
|
40
|
+
const w = mountWidget({
|
|
41
|
+
// config central do LLM (um lugar só)
|
|
42
|
+
model: "gpt-4o-mini",
|
|
43
|
+
baseURL: "https://api.exemplo.com/v1",
|
|
44
|
+
apiKey: "sk-...",
|
|
45
|
+
language: "pt-BR",
|
|
46
|
+
voice: { input: true, output: true, speakOnComplete: true }, // fala o resultado
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// handle: w.run(task) · w.stop() · w.show()/hide()/toggle() · w.setConfig(patch) · w.unmount()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Ou via CDN (IIFE) — desligue o auto-init do demo com `data-auto-init="false"`:
|
|
53
|
+
|
|
54
|
+
```html
|
|
55
|
+
<script src="https://unpkg.com/@omnivoice/page-agent@0.2.0/dist/iife/page-agent.demo.js"
|
|
56
|
+
data-auto-init="false"></script>
|
|
57
|
+
<script>
|
|
58
|
+
const w = PageAgent.mountWidget({ model: "...", baseURL: "...", apiKey: "...",
|
|
59
|
+
voice: { input: true, output: true } });
|
|
60
|
+
</script>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
O widget injeta um **launcher** (botão flutuante, mobile-friendly, com safe-area) e um
|
|
64
|
+
**botão de microfone** ao lado do campo de tarefa. Sem assets externos, arquivo único e
|
|
65
|
+
cacheável via CDN versionada — pensado para mobile/desktop e rede limitada.
|
|
66
|
+
|
|
67
|
+
### Gateway — a chave fica no servidor, não no front
|
|
68
|
+
|
|
69
|
+
Colocar `apiKey` no HTML/JS expõe a chave a qualquer um que abrir o devtools. O padrão: o front
|
|
70
|
+
fala com um **gateway do projeto** (mesma origem do site), que injeta a chave ao chamar o modelo
|
|
71
|
+
e **controla a config** (modelo, URL do provedor, voz) por servidor. Cada projeto roda o seu
|
|
72
|
+
gateway com a sua config.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# No projeto (ex.: NoStopPageTest). A chave vive SÓ no ambiente do servidor.
|
|
76
|
+
AGENT_BASE_URL=https://api.openai.com/v1 \
|
|
77
|
+
AGENT_API_KEY=sk-… \
|
|
78
|
+
AGENT_MODEL=gpt-4o-mini \
|
|
79
|
+
AGENT_VOICE_OUTPUT=true \
|
|
80
|
+
npx omnivoice-agent-gateway # bin do pacote @omnivoice/page-agent
|
|
81
|
+
# → GET /config (config pública, SEM chave) · POST /v1/chat/completions (proxy p/ o provedor)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
// No front: só aponta pro gateway. Sem chave, sem modelo — o servidor decide.
|
|
86
|
+
PageAgent.mountWidget({
|
|
87
|
+
baseURL: "http://127.0.0.1:8790/v1",
|
|
88
|
+
configUrl: "http://127.0.0.1:8790/config",
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
O gateway **força o modelo** configurado (o front não escolhe) e **descarta qualquer
|
|
93
|
+
`Authorization` vinda do client** — uma página comprometida não usa outra chave/conta.
|
|
94
|
+
Config por env **ou** `AGENT_CONFIG=agent.config.json`. Zero dependências (Node 18+).
|
|
95
|
+
|
|
96
|
+
## Uso (classe)
|
|
31
97
|
|
|
32
98
|
```bash
|
|
33
99
|
npm install @omnivoice/page-agent
|
|
@@ -40,7 +106,7 @@ import { PageAgent } from "@omnivoice/page-agent";
|
|
|
40
106
|
Ou single-file via CDN (exposto como `window.PageAgent`):
|
|
41
107
|
|
|
42
108
|
```html
|
|
43
|
-
<script src="https://unpkg.com/@omnivoice/page-agent@0.
|
|
109
|
+
<script src="https://unpkg.com/@omnivoice/page-agent@0.2.0/dist/iife/page-agent.demo.js"></script>
|
|
44
110
|
```
|
|
45
111
|
|
|
46
112
|
## Como publicar
|
package/dist/esm/PageAgent.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { AgentConfig } from '@omnivoice/core';
|
|
2
|
+
import { ExecutionResult } from '@omnivoice/core';
|
|
2
3
|
import { PageAgentCore } from '@omnivoice/core';
|
|
3
4
|
import { PageControllerConfig } from '@omnivoice/page-controller';
|
|
4
5
|
import { Panel } from '@omnivoice/ui';
|
|
5
6
|
import { PanelConfig } from '@omnivoice/ui';
|
|
6
7
|
|
|
8
|
+
export declare function createVoice(options?: VoiceOptions): Voice;
|
|
9
|
+
|
|
10
|
+
export declare function mountWidget(options?: WidgetOptions): Widget;
|
|
11
|
+
|
|
7
12
|
export declare class PageAgent extends PageAgentCore {
|
|
8
13
|
panel: Panel;
|
|
9
14
|
constructor(config: PageAgentConfig);
|
|
@@ -11,6 +16,135 @@ export declare class PageAgent extends PageAgentCore {
|
|
|
11
16
|
|
|
12
17
|
export declare type PageAgentConfig = AgentConfig & PageControllerConfig & Omit<PanelConfig, 'language'>;
|
|
13
18
|
|
|
19
|
+
export declare interface Voice {
|
|
20
|
+
/** O que o navegador realmente oferece. A UI usa isto para mostrar/ocultar controles. */
|
|
21
|
+
readonly supported: {
|
|
22
|
+
input: boolean;
|
|
23
|
+
output: boolean;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Escuta uma vez e resolve com a transcrição final. Rejeita em erro real; resolve com ""
|
|
27
|
+
* quando o usuário não fala nada ('no-speech') — não é falha, é "nada foi dito".
|
|
28
|
+
*/
|
|
29
|
+
listen(): Promise<string>;
|
|
30
|
+
/** Interrompe a escuta em andamento. */
|
|
31
|
+
stopListening(): void;
|
|
32
|
+
/** Fala um texto. `interrupt` corta o que estiver sendo dito antes de começar. */
|
|
33
|
+
speak(text: string, opts?: {
|
|
34
|
+
interrupt?: boolean;
|
|
35
|
+
}): void;
|
|
36
|
+
/** Para a síntese em andamento. */
|
|
37
|
+
stopSpeaking(): void;
|
|
38
|
+
/** Observa transições de estado (a UI mostra o indicador de "ouvindo/falando"). */
|
|
39
|
+
onStatus(cb: (status: VoiceStatus) => void): () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export declare interface VoiceOptions {
|
|
43
|
+
/** Idioma BCP-47 do reconhecimento e da síntese. @default 'pt-BR' */
|
|
44
|
+
lang?: string;
|
|
45
|
+
/** Habilita o reconhecimento de fala (botão de microfone). @default true */
|
|
46
|
+
input?: boolean;
|
|
47
|
+
/** Habilita a síntese de fala (agente fala o resultado). @default false */
|
|
48
|
+
output?: boolean;
|
|
49
|
+
/** URI de uma voz específica do speechSynthesis (deixa o módulo escolher quando ausente). */
|
|
50
|
+
voiceURI?: string;
|
|
51
|
+
/** Velocidade da síntese (0.1–10). @default 1 */
|
|
52
|
+
rate?: number;
|
|
53
|
+
/** Tom da síntese (0–2). @default 1 */
|
|
54
|
+
pitch?: number;
|
|
55
|
+
/** Trava de segurança para `listen()`: ms até rejeitar se o reconhecimento não terminar. @default 30000 */
|
|
56
|
+
listenTimeoutMs?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* FORK: módulo de voz do widget. Zero dependências — usa apenas a Web Speech API nativa do
|
|
61
|
+
* navegador (reconhecimento + síntese), o que mantém o bundle leve e funciona offline/sem rede
|
|
62
|
+
* além das chamadas do próprio agente.
|
|
63
|
+
*
|
|
64
|
+
* Filosofia (regras do laboratório):
|
|
65
|
+
* - Degrada sem quebrar: se o navegador não tem mic nem síntese, `supported` informa e os
|
|
66
|
+
* métodos viram no-op silenciosos — a UI decide esconder os botões, o agente segue funcionando.
|
|
67
|
+
* - Espera por condição, nunca por relógio: `listen()` resolve quando chega o resultado final;
|
|
68
|
+
* o timeout é só uma trava de segurança contra um reconhecimento que "prende" a UI.
|
|
69
|
+
* - Nada de rede própria: o reconhecimento/síntese são do navegador (Chrome/Edge/Safari enviam
|
|
70
|
+
* apenas o áudio necessário para o serviço de voz do SO; em rede limitada isso é mínimo).
|
|
71
|
+
*/
|
|
72
|
+
declare type VoiceStatus = 'idle' | 'listening' | 'speaking';
|
|
73
|
+
|
|
74
|
+
export declare interface Widget {
|
|
75
|
+
/** Agente centralizado (mesma instância que o painel controla). `null` até a primeira abertura. */
|
|
76
|
+
readonly agent: PageAgent | null;
|
|
77
|
+
/** Voz centralizada (reconhecimento + síntese). `null` até a primeira abertura. */
|
|
78
|
+
readonly voice: Voice | null;
|
|
79
|
+
/** Posição do botão-lançador. */
|
|
80
|
+
readonly launcher: HTMLElement;
|
|
81
|
+
/** Garante config (busca o `configUrl`) + voz + agente prontos. Idempotente; devolve o agente. */
|
|
82
|
+
ready(): Promise<PageAgent>;
|
|
83
|
+
/** Executa uma tarefa programaticamente. */
|
|
84
|
+
run(task: string): Promise<ExecutionResult>;
|
|
85
|
+
/** Para a tarefa em andamento (o agente segue reutilizável). */
|
|
86
|
+
stop(): Promise<void>;
|
|
87
|
+
/** Abre o painel (propaga erro de config). `hide()`/`toggle()` para fechar/alternar. */
|
|
88
|
+
show(): Promise<void>;
|
|
89
|
+
hide(): void;
|
|
90
|
+
toggle(): void;
|
|
91
|
+
/** Atualiza config em runtime (recria o agente quando muda algo de LLM/agente). */
|
|
92
|
+
setConfig(patch: Partial<WidgetOptions>): void;
|
|
93
|
+
/** Remove launcher, painel e voz da página. */
|
|
94
|
+
unmount(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export declare interface WidgetOptions {
|
|
98
|
+
/** Modelo. Ex.: "gpt-4o-mini". */
|
|
99
|
+
model?: string;
|
|
100
|
+
/** Endpoint OpenAI-compatible. */
|
|
101
|
+
baseURL?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Chave da API. Só para uso local/sem gateway (ex.: laboratório). Em produção, prefira
|
|
104
|
+
* `configUrl` + gateway: a chave fica no servidor e não aparece no front-end.
|
|
105
|
+
*/
|
|
106
|
+
apiKey?: string;
|
|
107
|
+
/**
|
|
108
|
+
* URL de uma config pública servida pelo gateway do projeto (ex.: "/api/agent/config").
|
|
109
|
+
* O widget busca model/idioma/passos/voz DE LÁ — o front nunca precisa da chave. O gateway
|
|
110
|
+
* é a autoridade: ele injeta a `apiKey` no servidor e força o modelo configurado.
|
|
111
|
+
*/
|
|
112
|
+
configUrl?: string;
|
|
113
|
+
/** Customiza o corpo da request (providers específicos). */
|
|
114
|
+
transformRequestBody?: PageAgentConfig['transformRequestBody'];
|
|
115
|
+
/** `fetch` custom (proxy, credentials, baixa latência). */
|
|
116
|
+
customFetch?: PageAgentConfig['customFetch'];
|
|
117
|
+
maxRetries?: number;
|
|
118
|
+
/** Idioma da interface e do raciocínio. @default 'pt-BR' */
|
|
119
|
+
language?: PageAgentConfig['language'];
|
|
120
|
+
/** Máximo de passos por tarefa. @default 30 */
|
|
121
|
+
maxSteps?: number;
|
|
122
|
+
/** Instruções globais/por página. */
|
|
123
|
+
instructions?: PageAgentConfig['instructions'];
|
|
124
|
+
/** Ferramentas customizadas. */
|
|
125
|
+
customTools?: PageAgentConfig['customTools'];
|
|
126
|
+
/** Máscula sobre a página durante a execução. @default true */
|
|
127
|
+
enableMask?: boolean;
|
|
128
|
+
/** Expansão da viewport ao enumerar elementos. @default 0 (só o que está visível) */
|
|
129
|
+
viewportExpansion?: number;
|
|
130
|
+
/** Pergunta pela próxima tarefa ao concluir. @default true */
|
|
131
|
+
promptForNextTask?: boolean;
|
|
132
|
+
/** Posição do botão-lançador. @default 'bottom-right' */
|
|
133
|
+
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
134
|
+
/** Rótulo acessível do botão-lançador. @default 'Abrir assistente da página' */
|
|
135
|
+
launcherLabel?: string;
|
|
136
|
+
/** Começa com o painel aberto (em vez de só o botão). @default false */
|
|
137
|
+
open?: boolean;
|
|
138
|
+
voice?: WidgetVoiceOptions;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export declare interface WidgetVoiceOptions extends VoiceOptions {
|
|
142
|
+
/** Fala o resultado quando a tarefa termina (requer `output: true`). @default true */
|
|
143
|
+
speakOnComplete?: boolean;
|
|
144
|
+
/** Fala o próximo objetivo a cada passo (mais falante; útil para mãos-livres). @default false */
|
|
145
|
+
speakSteps?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
14
148
|
|
|
15
149
|
export * from "@omnivoice/core";
|
|
16
150
|
|
package/dist/esm/page-agent.js
CHANGED
|
@@ -1,7 +1,403 @@
|
|
|
1
|
+
(function() { try { if (typeof document != "undefined") { var elementStyle = document.createElement("style"); elementStyle.appendChild(document.createTextNode("/* FORK: estilos do widget central (launcher + microfone). Injetados via CSS-in-JS — sem\n request de CSS separada, sem fontes externas. Pensado para mobile (alvo de toque 48px,\n safe-area) e para não brigar com a página host (z-index alto, cor própria). */\n\n.page-agent-widget-launcher {\n position: fixed;\n z-index: 2147483630;\n width: 48px;\n height: 48px;\n padding: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: 50%;\n background: #111827;\n color: #f9fafb;\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);\n cursor: pointer;\n transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;\n -webkit-tap-highlight-color: transparent;\n}\n\n/* Safe-area em telas com notch/barras: o botão não fica preso na borda. */\n.page-agent-widget-launcher {\n right: calc(20px + env(safe-area-inset-right, 0px));\n bottom: calc(20px + env(safe-area-inset-bottom, 0px));\n}\n\n.page-agent-widget-launcher:hover {\n transform: translateY(-1px);\n box-shadow: 0 6px 18px rgba(0, 0, 0, 0.34);\n background: #1f2937;\n}\n\n.page-agent-widget-launcher:active {\n transform: translateY(0) scale(0.97);\n}\n\n.page-agent-widget-launcher:focus-visible {\n outline: 3px solid #60a5fa;\n outline-offset: 2px;\n}\n\n.page-agent-widget-launcher svg {\n display: block;\n}\n\n/* Botão de microfone, injetado ao lado do input de tarefa do painel. */\n.page-agent-voice-mic {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 34px;\n height: 34px;\n flex: 0 0 auto;\n margin-right: 8px;\n padding: 0;\n border: 1px solid rgba(148, 163, 184, 0.4);\n border-radius: 8px;\n background: rgba(148, 163, 184, 0.12);\n color: inherit;\n cursor: pointer;\n transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;\n -webkit-tap-highlight-color: transparent;\n}\n\n.page-agent-voice-mic:hover {\n background: rgba(148, 163, 184, 0.22);\n border-color: rgba(148, 163, 184, 0.6);\n}\n\n.page-agent-voice-mic:active {\n transform: scale(0.96);\n}\n\n.page-agent-voice-mic:focus-visible {\n outline: 2px solid #60a5fa;\n outline-offset: 1px;\n}\n\n.page-agent-voice-mic.is-listening {\n background: rgba(239, 68, 68, 0.16);\n border-color: rgba(239, 68, 68, 0.6);\n animation: page-agent-voice-pulse 1.4s ease-in-out infinite;\n}\n\n@keyframes page-agent-voice-pulse {\n 0%,\n 100% {\n box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.35);\n }\n 50% {\n box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .page-agent-voice-mic.is-listening {\n animation: none;\n }\n}")); document.head.appendChild(elementStyle); } } catch (e) { console.error("vite-plugin-css-injected-by-js", e); }})();
|
|
1
2
|
import { PageAgentCore } from "@omnivoice/core";
|
|
2
3
|
import { PageController } from "@omnivoice/page-controller";
|
|
3
4
|
import { Panel } from "@omnivoice/ui";
|
|
4
5
|
export * from "@omnivoice/core";
|
|
6
|
+
//#region src/voice.ts
|
|
7
|
+
function getRecognitionCtor() {
|
|
8
|
+
const w = globalThis;
|
|
9
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
10
|
+
}
|
|
11
|
+
var STATUS_LISTENING = "listening";
|
|
12
|
+
var STATUS_SPEAKING = "speaking";
|
|
13
|
+
var STATUS_IDLE = "idle";
|
|
14
|
+
function createVoice(options = {}) {
|
|
15
|
+
const lang = options.lang ?? "pt-BR";
|
|
16
|
+
const rate = options.rate ?? 1;
|
|
17
|
+
const pitch = options.pitch ?? 1;
|
|
18
|
+
const inputEnabled = options.input ?? true;
|
|
19
|
+
const outputEnabled = options.output ?? false;
|
|
20
|
+
const listenTimeoutMs = options.listenTimeoutMs ?? 3e4;
|
|
21
|
+
const Recognition = inputEnabled ? getRecognitionCtor() : null;
|
|
22
|
+
const synth = typeof window !== "undefined" ? window.speechSynthesis : void 0;
|
|
23
|
+
const supported = {
|
|
24
|
+
input: Boolean(inputEnabled && Recognition),
|
|
25
|
+
output: Boolean(outputEnabled && synth)
|
|
26
|
+
};
|
|
27
|
+
let status = STATUS_IDLE;
|
|
28
|
+
const statusListeners = /* @__PURE__ */ new Set();
|
|
29
|
+
function setStatus(next) {
|
|
30
|
+
if (status === next) return;
|
|
31
|
+
status = next;
|
|
32
|
+
statusListeners.forEach((cb) => {
|
|
33
|
+
try {
|
|
34
|
+
cb(next);
|
|
35
|
+
} catch {}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
let activeRecognition = null;
|
|
39
|
+
let currentVoice = null;
|
|
40
|
+
if (synth) {
|
|
41
|
+
const pickVoice = () => {
|
|
42
|
+
if (options.voiceURI) currentVoice = synth.getVoices().find((v) => v.voiceURI === options.voiceURI) ?? null;
|
|
43
|
+
else {
|
|
44
|
+
const voices = synth.getVoices();
|
|
45
|
+
const prefix = lang.split("-")[0].toLowerCase();
|
|
46
|
+
currentVoice = voices.find((v) => v.lang?.toLowerCase() === lang.toLowerCase()) ?? voices.find((v) => v.lang?.toLowerCase().startsWith(prefix)) ?? null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
pickVoice();
|
|
50
|
+
synth.onvoiceschanged = pickVoice;
|
|
51
|
+
}
|
|
52
|
+
function speak(text, opts) {
|
|
53
|
+
if (!synth || !outputEnabled) return;
|
|
54
|
+
const clean = (text ?? "").trim();
|
|
55
|
+
if (!clean) return;
|
|
56
|
+
if (opts?.interrupt !== false) synth.cancel();
|
|
57
|
+
const utterance = new SpeechSynthesisUtterance(clean);
|
|
58
|
+
utterance.lang = lang;
|
|
59
|
+
utterance.rate = rate;
|
|
60
|
+
utterance.pitch = pitch;
|
|
61
|
+
if (currentVoice) utterance.voice = currentVoice;
|
|
62
|
+
utterance.onend = () => setStatus(STATUS_IDLE);
|
|
63
|
+
utterance.onerror = () => setStatus(STATUS_IDLE);
|
|
64
|
+
setStatus(STATUS_SPEAKING);
|
|
65
|
+
synth.speak(utterance);
|
|
66
|
+
}
|
|
67
|
+
function listen() {
|
|
68
|
+
if (!Recognition) return Promise.reject(/* @__PURE__ */ new Error("Voz de entrada não suportada neste navegador"));
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const recognition = new Recognition();
|
|
71
|
+
activeRecognition = recognition;
|
|
72
|
+
let finalText = "";
|
|
73
|
+
let settled = false;
|
|
74
|
+
let timer;
|
|
75
|
+
const finish = (fn) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
if (timer) clearTimeout(timer);
|
|
79
|
+
if (activeRecognition === recognition) activeRecognition = null;
|
|
80
|
+
setStatus(STATUS_IDLE);
|
|
81
|
+
fn();
|
|
82
|
+
};
|
|
83
|
+
timer = setTimeout(() => {
|
|
84
|
+
try {
|
|
85
|
+
recognition.stop();
|
|
86
|
+
} catch {}
|
|
87
|
+
finish(() => reject(/* @__PURE__ */ new Error("Tempo de escuta esgotado")));
|
|
88
|
+
}, listenTimeoutMs);
|
|
89
|
+
recognition.lang = lang;
|
|
90
|
+
recognition.continuous = false;
|
|
91
|
+
recognition.interimResults = true;
|
|
92
|
+
recognition.maxAlternatives = 1;
|
|
93
|
+
recognition.onstart = () => setStatus(STATUS_LISTENING);
|
|
94
|
+
recognition.onresult = (event) => {
|
|
95
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
96
|
+
const result = event.results[i];
|
|
97
|
+
if (result?.isFinal) finalText = result[0]?.transcript ?? finalText;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
recognition.onerror = (event) => {
|
|
101
|
+
const code = event?.error;
|
|
102
|
+
if (code === "no-speech" || code === "aborted") {
|
|
103
|
+
finish(() => resolve(finalText.trim()));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
finish(() => reject(/* @__PURE__ */ new Error(`Erro de reconhecimento de voz: ${code ?? "desconhecido"}`)));
|
|
107
|
+
};
|
|
108
|
+
recognition.onend = () => {
|
|
109
|
+
finish(() => resolve(finalText.trim()));
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
recognition.start();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
finish(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
supported,
|
|
120
|
+
listen,
|
|
121
|
+
stopListening() {
|
|
122
|
+
try {
|
|
123
|
+
activeRecognition?.stop();
|
|
124
|
+
} catch {}
|
|
125
|
+
activeRecognition = null;
|
|
126
|
+
setStatus(STATUS_IDLE);
|
|
127
|
+
},
|
|
128
|
+
speak,
|
|
129
|
+
stopSpeaking() {
|
|
130
|
+
if (!synth) return;
|
|
131
|
+
synth.cancel();
|
|
132
|
+
setStatus(STATUS_IDLE);
|
|
133
|
+
},
|
|
134
|
+
onStatus(cb) {
|
|
135
|
+
statusListeners.add(cb);
|
|
136
|
+
return () => statusListeners.delete(cb);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/widget.ts
|
|
142
|
+
/**
|
|
143
|
+
* FORK: widget central do page-agent. É o ÚNICO ponto de integração para um projeto host:
|
|
144
|
+
* uma chamada `mountWidget(config)` centraliza agente + painel + voz + config, com defaults
|
|
145
|
+
* sensatos, criação lazy do agente (a página não paga startup até o widget abrir) e degradação
|
|
146
|
+
* graciosa de voz. Pensado para mobile/desktop e rede limitada/offline: arquivo único, sem
|
|
147
|
+
* assets externos, agente criado sob demanda.
|
|
148
|
+
*
|
|
149
|
+
* Uso em projeto host (npm):
|
|
150
|
+
* import { mountWidget } from "@omnivoice/page-agent";
|
|
151
|
+
* const w = mountWidget({ model, baseURL, apiKey, voice: { input: true, output: true } });
|
|
152
|
+
*
|
|
153
|
+
* Uso via CDN (IIFE):
|
|
154
|
+
* <script src=".../page-agent.demo.js" data-auto-init="false"><\/script>
|
|
155
|
+
* <script>PageAgent.mountWidget({ model, baseURL, apiKey })<\/script>
|
|
156
|
+
*/
|
|
157
|
+
var DEFAULTS = {
|
|
158
|
+
language: "pt-BR",
|
|
159
|
+
maxSteps: 30,
|
|
160
|
+
enableMask: true,
|
|
161
|
+
viewportExpansion: 0,
|
|
162
|
+
promptForNextTask: true,
|
|
163
|
+
position: "bottom-right",
|
|
164
|
+
launcherLabel: "Abrir assistente da página",
|
|
165
|
+
open: false
|
|
166
|
+
};
|
|
167
|
+
var LAUNCHER_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 3l1.9 4.6L18.5 9l-4.6 1.9L12 15.5l-1.9-4.6L5.5 9l4.6-1.4L12 3z\"/><path d=\"M19 14l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8.8-2z\"/></svg>";
|
|
168
|
+
var MIC_ICON = "<svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\"/><path d=\"M5 11a7 7 0 0 0 14 0M12 18v3\"/></svg>";
|
|
169
|
+
function positionCss(position) {
|
|
170
|
+
switch (position) {
|
|
171
|
+
case "bottom-left": return {
|
|
172
|
+
bottom: "20px",
|
|
173
|
+
left: "20px",
|
|
174
|
+
right: "auto",
|
|
175
|
+
top: "auto"
|
|
176
|
+
};
|
|
177
|
+
case "top-right": return {
|
|
178
|
+
top: "20px",
|
|
179
|
+
right: "20px",
|
|
180
|
+
bottom: "auto",
|
|
181
|
+
left: "auto"
|
|
182
|
+
};
|
|
183
|
+
case "top-left": return {
|
|
184
|
+
top: "20px",
|
|
185
|
+
left: "20px",
|
|
186
|
+
bottom: "auto",
|
|
187
|
+
right: "auto"
|
|
188
|
+
};
|
|
189
|
+
default: return {
|
|
190
|
+
bottom: "20px",
|
|
191
|
+
right: "20px",
|
|
192
|
+
top: "auto",
|
|
193
|
+
left: "auto"
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function mountWidget(options = {}) {
|
|
198
|
+
const position = options.position ?? DEFAULTS.position;
|
|
199
|
+
const launcherLabel = options.launcherLabel ?? DEFAULTS.launcherLabel;
|
|
200
|
+
const open = options.open ?? DEFAULTS.open;
|
|
201
|
+
let agent = null;
|
|
202
|
+
let voice = null;
|
|
203
|
+
let micButton = null;
|
|
204
|
+
let listening = false;
|
|
205
|
+
let configPromise = null;
|
|
206
|
+
async function ensureConfig() {
|
|
207
|
+
if (configPromise) return configPromise;
|
|
208
|
+
if (!options.configUrl) return;
|
|
209
|
+
configPromise = (async () => {
|
|
210
|
+
const res = await fetch(options.configUrl, { headers: { accept: "application/json" } });
|
|
211
|
+
if (!res.ok) throw new Error(`mountWidget: configUrl respondeu ${res.status} — o gateway está no ar?`);
|
|
212
|
+
const server = await res.json();
|
|
213
|
+
for (const key of [
|
|
214
|
+
"model",
|
|
215
|
+
"language",
|
|
216
|
+
"maxSteps",
|
|
217
|
+
"promptForNextTask",
|
|
218
|
+
"instructions",
|
|
219
|
+
"voice"
|
|
220
|
+
]) {
|
|
221
|
+
const value = server[key];
|
|
222
|
+
if (value !== void 0 && value !== null) options[key] = value;
|
|
223
|
+
}
|
|
224
|
+
})();
|
|
225
|
+
return configPromise;
|
|
226
|
+
}
|
|
227
|
+
function ensureVoice() {
|
|
228
|
+
if (!voice) voice = createVoice(options.voice ?? {});
|
|
229
|
+
return voice;
|
|
230
|
+
}
|
|
231
|
+
async function ensureAgent() {
|
|
232
|
+
if (agent && !agent.disposed) return agent;
|
|
233
|
+
await ensureConfig();
|
|
234
|
+
const v = ensureVoice();
|
|
235
|
+
const model = options.model;
|
|
236
|
+
const baseURL = options.baseURL;
|
|
237
|
+
if (!model || !baseURL) throw new Error(options.configUrl ? "mountWidget: o gateway não enviou `model` e/ou falta `baseURL` no front. Confira AGENT_MODEL no gateway e a baseURL do widget." : "mountWidget: configure `model` e `baseURL` (config central do LLM) antes de abrir o agente.");
|
|
238
|
+
agent = new PageAgent({
|
|
239
|
+
model,
|
|
240
|
+
baseURL,
|
|
241
|
+
apiKey: options.apiKey,
|
|
242
|
+
transformRequestBody: options.transformRequestBody,
|
|
243
|
+
customFetch: options.customFetch,
|
|
244
|
+
maxRetries: options.maxRetries,
|
|
245
|
+
language: options.language ?? DEFAULTS.language,
|
|
246
|
+
maxSteps: options.maxSteps ?? DEFAULTS.maxSteps,
|
|
247
|
+
enableMask: options.enableMask ?? DEFAULTS.enableMask,
|
|
248
|
+
viewportExpansion: options.viewportExpansion ?? DEFAULTS.viewportExpansion,
|
|
249
|
+
instructions: options.instructions,
|
|
250
|
+
customTools: options.customTools,
|
|
251
|
+
promptForNextTask: options.promptForNextTask ?? DEFAULTS.promptForNextTask
|
|
252
|
+
});
|
|
253
|
+
wireVoiceOutput(agent, v);
|
|
254
|
+
injectMicButton(agent, v);
|
|
255
|
+
return agent;
|
|
256
|
+
}
|
|
257
|
+
function wireVoiceOutput(a, v) {
|
|
258
|
+
const speakOnComplete = options.voice?.speakOnComplete ?? true;
|
|
259
|
+
const speakSteps = options.voice?.speakSteps ?? false;
|
|
260
|
+
const onStatus = () => {
|
|
261
|
+
const status = a.status;
|
|
262
|
+
const result = a.lastResult;
|
|
263
|
+
if (status === "completed" || status === "error") {
|
|
264
|
+
if (speakOnComplete && v.supported.output) {
|
|
265
|
+
if (status === "error") v.speak("A tarefa encontrou um problema e foi parada.");
|
|
266
|
+
else if (result?.success) v.speak("Tarefa concluída com sucesso.");
|
|
267
|
+
else v.speak("A tarefa terminou, mas o resultado não foi confirmado.");
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (speakSteps && status === "running" && v.supported.output) {
|
|
272
|
+
const goal = [...a.history].reverse().find((h) => h.type === "step")?.reflection?.next_goal;
|
|
273
|
+
if (goal) v.speak(goal, { interrupt: true });
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
a.addEventListener("statuschange", onStatus);
|
|
277
|
+
a.addEventListener("activity", (event) => {
|
|
278
|
+
const detail = event.detail;
|
|
279
|
+
if (detail?.type === "error" && detail.message && v.supported.output) v.speak(detail.message, { interrupt: true });
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function injectMicButton(a, v) {
|
|
283
|
+
const wrapper = a.panel.wrapper;
|
|
284
|
+
const input = wrapper.querySelector("input");
|
|
285
|
+
if (!input || !v.supported.input) return;
|
|
286
|
+
if (wrapper.querySelector("[data-page-agent-voice=\"mic\"]")) return;
|
|
287
|
+
if (micButton) micButton.remove();
|
|
288
|
+
micButton = document.createElement("button");
|
|
289
|
+
micButton.type = "button";
|
|
290
|
+
micButton.className = "page-agent-voice-mic";
|
|
291
|
+
micButton.setAttribute("data-page-agent-voice", "mic");
|
|
292
|
+
micButton.setAttribute("aria-label", "Ditar tarefa por voz");
|
|
293
|
+
micButton.title = "Ditar tarefa por voz";
|
|
294
|
+
micButton.innerHTML = MIC_ICON;
|
|
295
|
+
micButton.style.display = "inline-flex";
|
|
296
|
+
(input.parentElement ?? wrapper).insertBefore(micButton, input);
|
|
297
|
+
v.onStatus((s) => {
|
|
298
|
+
micButton?.classList.toggle("is-listening", s === "listening");
|
|
299
|
+
});
|
|
300
|
+
micButton.addEventListener("click", async () => {
|
|
301
|
+
if (listening) {
|
|
302
|
+
v.stopListening();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
listening = true;
|
|
306
|
+
try {
|
|
307
|
+
const transcript = await v.listen();
|
|
308
|
+
if (transcript) {
|
|
309
|
+
input.value = transcript;
|
|
310
|
+
input.dispatchEvent(new KeyboardEvent("keydown", {
|
|
311
|
+
key: "Enter",
|
|
312
|
+
bubbles: true
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
input.setAttribute("placeholder", error instanceof Error ? error.message : "Não foi possível ouvir; digite a tarefa.");
|
|
317
|
+
setTimeout(() => input.removeAttribute("placeholder"), 6e3);
|
|
318
|
+
} finally {
|
|
319
|
+
listening = false;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
const launcher = document.createElement("button");
|
|
324
|
+
launcher.type = "button";
|
|
325
|
+
launcher.className = "page-agent-widget-launcher";
|
|
326
|
+
launcher.setAttribute("aria-label", launcherLabel);
|
|
327
|
+
launcher.title = launcherLabel;
|
|
328
|
+
launcher.innerHTML = LAUNCHER_ICON;
|
|
329
|
+
const pos = positionCss(position);
|
|
330
|
+
Object.assign(launcher.style, pos);
|
|
331
|
+
let panelVisible = false;
|
|
332
|
+
async function openPanel(silent = false) {
|
|
333
|
+
try {
|
|
334
|
+
(await ensureAgent()).panel.show();
|
|
335
|
+
panelVisible = true;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (silent) console.error("[page-agent-widget] não abriu o agente:", error);
|
|
338
|
+
else throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function closePanel() {
|
|
342
|
+
agent?.panel.hide?.();
|
|
343
|
+
panelVisible = false;
|
|
344
|
+
}
|
|
345
|
+
launcher.addEventListener("click", () => {
|
|
346
|
+
if (panelVisible) closePanel();
|
|
347
|
+
else openPanel(true);
|
|
348
|
+
});
|
|
349
|
+
document.body.appendChild(launcher);
|
|
350
|
+
if (open) openPanel(true);
|
|
351
|
+
return {
|
|
352
|
+
get agent() {
|
|
353
|
+
return agent;
|
|
354
|
+
},
|
|
355
|
+
get voice() {
|
|
356
|
+
return voice;
|
|
357
|
+
},
|
|
358
|
+
launcher,
|
|
359
|
+
ready() {
|
|
360
|
+
return ensureAgent();
|
|
361
|
+
},
|
|
362
|
+
async run(task) {
|
|
363
|
+
return await (await ensureAgent()).execute(task);
|
|
364
|
+
},
|
|
365
|
+
async stop() {
|
|
366
|
+
if (agent) await agent.stop();
|
|
367
|
+
},
|
|
368
|
+
show() {
|
|
369
|
+
return openPanel(false);
|
|
370
|
+
},
|
|
371
|
+
hide() {
|
|
372
|
+
closePanel();
|
|
373
|
+
},
|
|
374
|
+
toggle() {
|
|
375
|
+
launcher.click();
|
|
376
|
+
},
|
|
377
|
+
setConfig(patch) {
|
|
378
|
+
Object.assign(options, patch);
|
|
379
|
+
if (agent) {
|
|
380
|
+
agent.dispose();
|
|
381
|
+
agent = null;
|
|
382
|
+
}
|
|
383
|
+
if (panelVisible) {
|
|
384
|
+
panelVisible = false;
|
|
385
|
+
openPanel(true);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
unmount() {
|
|
389
|
+
voice?.stopSpeaking();
|
|
390
|
+
voice?.stopListening();
|
|
391
|
+
agent?.dispose();
|
|
392
|
+
agent = null;
|
|
393
|
+
voice = null;
|
|
394
|
+
micButton?.remove();
|
|
395
|
+
micButton = null;
|
|
396
|
+
launcher.remove();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
//#endregion
|
|
5
401
|
//#region src/PageAgent.ts
|
|
6
402
|
/**
|
|
7
403
|
* Copyright (C) 2025 Alibaba Group Holding Limited
|
|
@@ -25,6 +421,6 @@ var PageAgent = class extends PageAgentCore {
|
|
|
25
421
|
}
|
|
26
422
|
};
|
|
27
423
|
//#endregion
|
|
28
|
-
export { PageAgent };
|
|
424
|
+
export { PageAgent, createVoice, mountWidget };
|
|
29
425
|
|
|
30
426
|
//# sourceMappingURL=page-agent.js.map
|