@omnivoice/page-agent 0.2.0 → 0.3.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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Sessão de voz: gerencia o contexto compartilhado, enfileira inputs,
3
+ * emite eventos públicos e coordena alternância entre voz e chat.
4
+ *
5
+ * Desenho:
6
+ * - Estado mutável fica tudo aqui (contexto, histórico, turno).
7
+ * - Um dono por estado: só VoiceSession muda VoiceContext.
8
+ * - Eventos públicos são propagados via AsyncIterator.
9
+ * - Bridge e controlador observam; nunca escrevem direto.
10
+ * - Controle opcional de mutação (padrão: true / ativado).
11
+ * Pode ser desativado para modo read-only (debug/auditoria).
12
+ */
13
+
14
+ import { EventEmitter } from "node:events";
15
+ import type {
16
+ VoiceContext,
17
+ VoiceEvent,
18
+ VoiceSnapshot,
19
+ InputChannel,
20
+ TurnState,
21
+ Message,
22
+ SpeechMode,
23
+ } from "./types.ts";
24
+ import type { UserInput, VoiceSessionReader } from "./bridge.ts";
25
+ import type { VoiceEngine } from "./types.ts";
26
+ import type { VoiceSettings } from "./config.ts";
27
+
28
+ /**
29
+ * Sessão de voz: o coração do sistema.
30
+ * Implementa VoiceSessionReader para o bridge.
31
+ */
32
+ export class VoiceSession implements VoiceSessionReader {
33
+ readonly sessionId: string;
34
+ readonly context: VoiceContext;
35
+
36
+ private engine: VoiceEngine | null = null;
37
+ private settings: VoiceSettings;
38
+ private inputQueue: UserInput[] = [];
39
+ private eventEmitter: EventEmitter = new EventEmitter();
40
+ private isClosed = false;
41
+
42
+ /** Flag para ativar/desativar mutação de estado. Padrão: true (ativado). */
43
+ private allowMutations = true;
44
+
45
+ constructor(settings: VoiceSettings, allowMutations: boolean = true) {
46
+ this.settings = settings;
47
+ this.sessionId = settings.sessionId;
48
+ this.allowMutations = allowMutations;
49
+
50
+ this.context = {
51
+ mode: settings.speechMode,
52
+ channel: "voice", // começa com voz
53
+ turn: "idle",
54
+ sessionId: this.sessionId,
55
+ conversationHistory: [],
56
+ muted: !settings.mic,
57
+ lastTurnTime: undefined,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Abre a sessão: inicializa a engine, começa a escutar eventos.
63
+ */
64
+ async abrir(engine: VoiceEngine): Promise<void> {
65
+ if (this.engine) throw new Error("sessão já está aberta");
66
+
67
+ this.engine = engine;
68
+ this.isClosed = false;
69
+
70
+ await engine.enter();
71
+
72
+ // Emite evento "pronto"
73
+ this.emitPublic({
74
+ kind: "session.ready",
75
+ data: this.snapshot(),
76
+ });
77
+
78
+ // Loop de eventos da engine: transforma em públicos (se aplicável)
79
+ // e processa inputs enfileirados.
80
+ this.processEngineEvents();
81
+ }
82
+
83
+ /**
84
+ * Fecha a sessão.
85
+ */
86
+ async fechar(): Promise<void> {
87
+ this.isClosed = true;
88
+
89
+ if (this.engine) {
90
+ try {
91
+ await this.engine.exit();
92
+ } catch (err) {
93
+ console.error("[VoiceSession.fechar]", err);
94
+ }
95
+ this.engine = null;
96
+ }
97
+
98
+ this.eventEmitter.removeAllListeners();
99
+ }
100
+
101
+ /**
102
+ * Snapshot atual para o painel.
103
+ */
104
+ snapshot(): VoiceSnapshot {
105
+ return {
106
+ mode: this.context.mode,
107
+ channel: this.context.channel,
108
+ state: this.context.turn,
109
+ muted: this.context.muted,
110
+ sessionId: this.sessionId,
111
+ messageCount: this.context.conversationHistory.length,
112
+ lastMessageTime: this.context.lastTurnTime,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Habilita/desabilita mutação de estado.
118
+ * Padrão: true (ativado). false = modo auditoria (read-only).
119
+ */
120
+ setMutationsAllowed(allowed: boolean): void {
121
+ this.allowMutations = allowed;
122
+ }
123
+
124
+ /**
125
+ * Enfileira entrada do usuário (voz transcrita ou texto).
126
+ * Sem efeito se mutações desabilitadas.
127
+ */
128
+ enqueueInput(input: UserInput): void {
129
+ if (!this.allowMutations) return;
130
+
131
+ this.inputQueue.push(input);
132
+
133
+ // Sinaliza ao loop que há nova entrada
134
+ this.processNextInput();
135
+ }
136
+
137
+ /**
138
+ * Muta/desmuta o microfone.
139
+ * Sem efeito se mutações desabilitadas.
140
+ */
141
+ setMuted(muted: boolean): void {
142
+ if (!this.allowMutations) return;
143
+
144
+ this.context.muted = muted;
145
+ if (this.engine) {
146
+ this.engine.setMuted(muted);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Itera eventos públicos até fechar.
152
+ */
153
+ async *events(): AsyncIterable<VoiceEvent> {
154
+ const iterator = this.eventEmitter;
155
+
156
+ while (!this.isClosed) {
157
+ const event: VoiceEvent = await new Promise((resolve) => {
158
+ iterator.once("public-event", (ev: VoiceEvent) => resolve(ev));
159
+ if (this.isClosed) resolve({ kind: "error", data: { message: "sessão fechada", code: "CLOSED" } });
160
+ });
161
+
162
+ yield event;
163
+
164
+ if (event.kind === "error") break;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Processa entrada do fila.
170
+ * Atualiza histórico, emite evento ao bridge, passa ao agente.
171
+ * Sem efeito se mutações desabilitadas.
172
+ */
173
+ private async processNextInput(): Promise<void> {
174
+ if (!this.allowMutations || this.inputQueue.length === 0) return;
175
+
176
+ const input = this.inputQueue.shift()!;
177
+
178
+ // Atualiza turno
179
+ this.context.turn = "listening";
180
+ this.context.channel = input.channel;
181
+ this.context.lastTurnTime = input.timestamp;
182
+
183
+ // Registra no histórico
184
+ this.context.conversationHistory.push({
185
+ role: "user",
186
+ content: input.utterance,
187
+ timestamp: input.timestamp,
188
+ channel: input.channel,
189
+ confidence: input.confidence,
190
+ });
191
+
192
+ // Emite evento público
193
+ this.emitPublic({
194
+ kind: "turn.heard",
195
+ data: {
196
+ text: input.utterance,
197
+ confidence: input.confidence,
198
+ },
199
+ });
200
+
201
+ // Marca turno como "processando" (o agente vai responder)
202
+ this.context.turn = "processing";
203
+ }
204
+
205
+ /**
206
+ * Processa eventos da engine (Local ou Realtime) e transforma em públicos.
207
+ * Roda em background enquanto a sessão está aberta.
208
+ */
209
+ private async processEngineEvents(): Promise<void> {
210
+ if (!this.engine) return;
211
+
212
+ try {
213
+ for await (const engineEvent of this.engine.events()) {
214
+ if (this.isClosed) break;
215
+
216
+ // Transforma evento internal em público (se aplicável)
217
+ this.handleEngineEvent(engineEvent);
218
+ }
219
+ } catch (err) {
220
+ this.emitPublic({
221
+ kind: "error",
222
+ data: {
223
+ message: err instanceof Error ? err.message : "erro desconhecido",
224
+ code: "ENGINE_ERROR",
225
+ },
226
+ });
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Transforma evento da engine em público.
232
+ */
233
+ private handleEngineEvent(event: VoiceEvent): void {
234
+ // Apenas certos eventos são públicos; ver types.ts
235
+ if (
236
+ [
237
+ "session.ready",
238
+ "user.started",
239
+ "turn.heard",
240
+ "agent.started",
241
+ "agent.finished",
242
+ "sound.started",
243
+ "speaker.idle",
244
+ "error",
245
+ ].includes(event.kind)
246
+ ) {
247
+ this.emitPublic(event);
248
+ }
249
+
250
+ // Registra resposta do agente no histórico
251
+ if (event.kind === "agent.finished") {
252
+ const { text } = event.data;
253
+ this.context.conversationHistory.push({
254
+ role: "assistant",
255
+ content: text,
256
+ timestamp: Date.now(),
257
+ channel: this.context.channel,
258
+ });
259
+ this.context.turn = "idle";
260
+ }
261
+
262
+ // Atualiza turno conforme eventos
263
+ if (event.kind === "agent.started") {
264
+ this.context.turn = "speaking";
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Emite evento público (para bridge e painel).
270
+ */
271
+ private emitPublic(event: VoiceEvent): void {
272
+ this.eventEmitter.emit("public-event", event);
273
+ }
274
+
275
+ /**
276
+ * Alterna entre voz e chat.
277
+ * O histórico é preservado; apenas a origem do próximo input muda.
278
+ * Sem efeito se mutações desabilitadas.
279
+ */
280
+ switchToChannel(channel: InputChannel): void {
281
+ if (!this.allowMutations) return;
282
+ if (this.context.channel === channel) return;
283
+
284
+ const from = this.context.channel;
285
+ this.context.channel = channel;
286
+
287
+ this.emitPublic({
288
+ kind: "channel.switched",
289
+ data: { from, to: channel },
290
+ });
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Factory: cria e abre uma sessão de voz com a engine apropriada.
296
+ *
297
+ * Usa o settings.speechMode para escolher Local ou Realtime,
298
+ * e carrega a engine correspondente.
299
+ */
300
+ export async function createVoiceSession(settings: VoiceSettings): Promise<VoiceSession> {
301
+ const session = new VoiceSession(settings);
302
+
303
+ const { createEngine } = await import("./engines.ts");
304
+ const engine = await createEngine(settings);
305
+
306
+ await session.abrir(engine);
307
+
308
+ return session;
309
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Types compartilhados para a integração de voz.
3
+ * Baseado no pattern Conversation + Engine de NoStopPageTest.
4
+ */
5
+
6
+ /** Modo de fala: local (modelos no device) ou realtime (OpenAI full-duplex). */
7
+ export type SpeechMode = "local" | "realtime";
8
+
9
+ /** Canal ativo de entrada: voz (microfone) ou chat (texto). */
10
+ export type InputChannel = "voice" | "chat";
11
+
12
+ /** Tipo de efeito observável após ação. */
13
+ export type EffectKind = "notification" | "navigation" | "loading";
14
+
15
+ /** Resultado da observação de efeito. */
16
+ export type EffectOutcome =
17
+ | { status: "observed"; kind: EffectKind; detail: string }
18
+ | { status: "unknown"; detail: string };
19
+
20
+ /** Uma mensagem no histórico de conversa. */
21
+ export type Message = {
22
+ role: "user" | "assistant";
23
+ content: string;
24
+ timestamp: number; // milliseconds since epoch
25
+ channel: InputChannel; // voz ou chat
26
+ confidence?: number; // 0-1, para speech-to-text
27
+ };
28
+
29
+ /** Estado de um turno: agente escutando, processando, falando. */
30
+ export type TurnState = "idle" | "listening" | "processing" | "speaking";
31
+
32
+ /** Snapshot do estado de voz para o painel. */
33
+ export type VoiceSnapshot = {
34
+ mode: SpeechMode;
35
+ channel: InputChannel;
36
+ state: TurnState;
37
+ muted: boolean;
38
+ sessionId: string;
39
+ messageCount: number;
40
+ lastMessageTime?: number;
41
+ error?: string;
42
+ };
43
+
44
+ /** Estado compartilhado entre voz e navegação. */
45
+ export type VoiceContext = {
46
+ mode: SpeechMode;
47
+ channel: InputChannel;
48
+ turn: TurnState;
49
+ sessionId: string;
50
+ conversationHistory: Message[];
51
+ muted: boolean;
52
+ lastTurnTime?: number;
53
+ };
54
+
55
+ /** Evento emitido pela sessão de voz. */
56
+ export type VoiceEvent =
57
+ | { kind: "session.ready"; data: VoiceSnapshot }
58
+ | { kind: "user.started"; data: { channel: InputChannel } }
59
+ | { kind: "turn.heard"; data: { text: string; confidence?: number } }
60
+ | { kind: "agent.started"; data: { turn: TurnState } }
61
+ | { kind: "agent.finished"; data: { text: string } }
62
+ | { kind: "sound.started"; data: object }
63
+ | { kind: "speaker.idle"; data: object }
64
+ | { kind: "channel.switched"; data: { from: InputChannel; to: InputChannel } }
65
+ | { kind: "error"; data: { message: string; code: string } };
66
+
67
+ /** Capacidades oferecidas pela engine de voz. */
68
+ export const TEXT_INPUT = "text_input";
69
+ export const MUTE = "mute";
70
+ export const REPLAY = "replay";
71
+
72
+ export type Capability = typeof TEXT_INPUT | typeof MUTE | typeof REPLAY | string;
73
+
74
+ /**
75
+ * Protocol para uma engine de voz (Local ou Realtime).
76
+ * Baseado no EngineLike de NoStopPageTest.
77
+ */
78
+ export interface VoiceEngine {
79
+ readonly capabilities: ReadonlySet<Capability>;
80
+
81
+ /** Abre a engine (inicializa modelos, conexões, etc). */
82
+ enter(): Promise<void>;
83
+
84
+ /** Fecha a engine. */
85
+ exit(exc?: Error): Promise<void>;
86
+
87
+ /** Snapshot atual para o painel. */
88
+ snapshot(): VoiceSnapshot;
89
+
90
+ /** Muta ou desmuta o microfone. */
91
+ setMuted(muted: boolean): void;
92
+
93
+ /** Submete texto como se tivesse sido ouvido (se TEXT_INPUT). */
94
+ submit(text: string): void;
95
+
96
+ /** Itera eventos públicos até fecha. */
97
+ events(): AsyncIterable<VoiceEvent>;
98
+ }
package/LICENSE DELETED
@@ -1,22 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 SimonLuvRamen
4
- Copyright (c) 2026 Alibaba Group Holding Limited
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
@@ -1,160 +0,0 @@
1
- import { AgentConfig } from '@omnivoice/core';
2
- import { ExecutionResult } from '@omnivoice/core';
3
- import { PageAgentCore } from '@omnivoice/core';
4
- import { PageControllerConfig } from '@omnivoice/page-controller';
5
- import { Panel } from '@omnivoice/ui';
6
- import { PanelConfig } from '@omnivoice/ui';
7
-
8
- export declare function createVoice(options?: VoiceOptions): Voice;
9
-
10
- export declare function mountWidget(options?: WidgetOptions): Widget;
11
-
12
- export declare class PageAgent extends PageAgentCore {
13
- panel: Panel;
14
- constructor(config: PageAgentConfig);
15
- }
16
-
17
- export declare type PageAgentConfig = AgentConfig & PageControllerConfig & Omit<PanelConfig, 'language'>;
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
-
148
-
149
- export * from "@omnivoice/core";
150
-
151
- export { }
152
-
153
-
154
-
155
- declare global {
156
- interface Window {
157
- pageAgent?: PageAgent
158
- PageAgent: typeof PageAgent
159
- }
160
- }